Skip to content

Latest commit

 

History

History
2068 lines (1626 loc) · 81.8 KB

File metadata and controls

2068 lines (1626 loc) · 81.8 KB

Linux Namespace 与 cgroup v2:内核资源隔离机制深度解析

基于 Linux Kernel 主线源码逐行分析 核心文件:include/linux/nsproxy.hinclude/linux/cgroup-defs.hinclude/linux/pid_namespace.hkernel/nsproxy.ckernel/pid_namespace.ckernel/cgroup/cgroup.ckernel/cgroup/pids.cmm/memcontrol.c


目录

  1. 宏观架构:容器的内核基础
  2. Namespace 总论:nsproxy 与 COW 语义
  3. 八种 Namespace 类型深度解析
  4. PID Namespace 深度剖析
  5. Mount Namespace 与挂载点传播
  6. 系统调用实现路径:clone/unshare/setns
  7. /proc/pid/ns/ 文件绑定机制
  8. cgroup v2 总体架构
  9. 三核心结构体:cgroup、css_set、cgroup_subsys_state
  10. css_set 哈希表与 task 绑定
  11. cgroup_fork 进程继承流程
  12. memcg 内存子系统深度解析
  13. cpu 子系统:CFS bandwidth 与硬限制
  14. io 子系统:blkcg 与权重/速率控制
  15. pids 子系统:进程数量限制
  16. cgroup 冻结机制
  17. 锁机制与并发安全
  18. 总结:设计哲学与工程取舍

1. 宏观架构:容器的内核基础

Linux 容器技术从本质上看,是对两个互补的内核机制的组合运用:

+---------------------------------------------------------------------+
|                        用户空间容器                                  |
|              Docker / Podman / containerd / LXC                      |
+----------------------------+----------------------------------------+
                             | 调用
+----------------------------v----------------------------------------+
|                      系统调用层                                       |
|         clone(2)  unshare(2)  setns(2)  mount(2)                     |
+------+--------------------------------------------+-----------------+
       | 隔离"看到什么"                               | 限制"能用多少"
+------v-----------------------+        +------------v---------------+
|      namespace 子系统        |        |      cgroup v2 子系统       |
|                              |        |                            |
|  mnt / pid / net / uts       |        |  memory / cpu / io / pids  |
|  ipc / user / time /         |        |  cpuset / rdma / hugetlb   |
|  cgroup                      |        |  ...                       |
|                              |        |                            |
|  struct nsproxy              |        |  struct cgroup             |
|  struct pid_namespace        |        |  struct css_set            |
|  struct mnt_namespace        |        |  struct cgroup_subsys_state|
+------------------------------+        +----------------------------+

设计哲学的核心区别:

  • Namespace 的设计目标是"视图隔离":每个 namespace 相当于为进程提供一个独立的虚拟世界,进程无法感知到 namespace 边界之外的资源存在。这是一种"欺骗"性质的机制。
  • cgroup 的设计目标是"资源配给":cgroup 并不隐藏资源,而是精确地控制进程组能获得的资源份额,超出限额时触发限流、OOM 或拒绝请求。这是一种"管控"性质的机制。

两者正交互补,叠加后实现了完整的容器语义。

+--------------------------------------------------------------------+
|                  task_struct(进程控制块)                           |
|                                                                    |
|  +---------------------------+   +-----------------------------+  |
|  | struct nsproxy *nsproxy   |   | struct css_set __rcu *cgroups  |
|  | (namespace 视图)          |   | struct list_head cg_list    |  |
|  +---------------------------+   +-----------------------------+  |
|         |                                   |                    |
|         v                                   v                    |
|  各 ns 结构体                       css_set -> 各 subsys state   |
+--------------------------------------------------------------------+

2. Namespace 总论:nsproxy 与 COW 语义

2.1 nsproxy 结构体

所有 namespace 指针聚合在一个代理结构体 nsproxy 中。这是一个非常重要的设计决策:

// include/linux/nsproxy.h:32
struct nsproxy {
    refcount_t count;
    struct uts_namespace *uts_ns;
    struct ipc_namespace *ipc_ns;
    struct mnt_namespace *mnt_ns;
    struct pid_namespace *pid_ns_for_children;  // 注意:不是"当前"PID ns
    struct net            *net_ns;
    struct time_namespace *time_ns;
    struct time_namespace *time_ns_for_children;
    struct cgroup_namespace *cgroup_ns;
};

几个关键的设计细节值得深究:

为什么用代理而不是把指针直接放进 task_struct?

历史原因是渐进式设计:namespace 特性是在不同内核版本中逐步添加的。使用 nsproxy 作为间接层,可以让共享同一套 namespace 的任务(线程组成员、父子进程等)直接共享同一个 nsproxy 对象,通过引用计数管理生命周期,避免每个 task 都持有所有 namespace 的单独引用。

pid_ns_for_children 的语义:

注意 pid_ns_for_children 存储的是"子进程将会使用的 PID namespace",而不是"当前进程的 PID namespace"。当前进程自己的 PID namespace 需要通过 task_active_pid_ns() 获取。这个区分是必要的,因为 unshare(CLONE_NEWPID) 不会立即影响调用者本身,只影响之后 fork() 创建的子进程。

同样地,time_ns_for_children 也有这种"延迟生效"语义。

2.2 COW 语义详解

nsproxy 实现了严格的写时复制(Copy-on-Write)语义,这是其最核心的特性:

进程 A 持有 nsproxy_1(count=2)
       |
       +-- 进程 B(线程,共享所有 ns)
       |
       +-- 调用 unshare(CLONE_NEWUTS)
                |
                v
          复制 nsproxy_1 -> nsproxy_2(count=1)
          nsproxy_1.count--
          nsproxy_2.uts_ns = 新建 uts_namespace
          其余字段复制原指针(引用计数++)
          进程 A 的 tsk->nsproxy = nsproxy_2

在内核中,这个 COW 过程发生在 create_new_namespaces() 函数中:

// kernel/nsproxy.c:87
static struct nsproxy *create_new_namespaces(u64 flags,
    struct task_struct *tsk, struct user_namespace *user_ns,
    struct fs_struct *new_fs)
{
    struct nsproxy *new_nsp;
    int err;

    new_nsp = create_nsproxy();  // 从 slab 分配新的 nsproxy
    if (!new_nsp)
        return ERR_PTR(-ENOMEM);

    // 对每种 ns 调用对应的 copy_xxx_ns()
    // 如果 flags 中没有对应的 CLONE_NEW* 标志,
    // copy_xxx_ns() 仅增加引用计数并返回原指针
    new_nsp->mnt_ns = copy_mnt_ns(flags, tsk->nsproxy->mnt_ns, user_ns, new_fs);
    new_nsp->uts_ns = copy_utsname(flags, user_ns, tsk->nsproxy->uts_ns);
    new_nsp->ipc_ns = copy_ipcs(flags, user_ns, tsk->nsproxy->ipc_ns);
    new_nsp->pid_ns_for_children =
        copy_pid_ns(flags, user_ns, tsk->nsproxy->pid_ns_for_children);
    new_nsp->cgroup_ns = copy_cgroup_ns(flags, user_ns,
                                        tsk->nsproxy->cgroup_ns);
    new_nsp->net_ns = copy_net_ns(flags, user_ns, tsk->nsproxy->net_ns);
    new_nsp->time_ns_for_children = copy_time_ns(flags, user_ns,
                                tsk->nsproxy->time_ns_for_children);
    new_nsp->time_ns = get_time_ns(tsk->nsproxy->time_ns);  // 当前 time ns 不变
    ...
}

出错路径设计kernel/nsproxy.c:146):采用 goto 链式回滚,从后往前释放已创建的 namespace,是典型的 Linux 内核错误处理模式。

2.3 nsproxy 的访问规则

include/linux/nsproxy.h:69 的注释明确了三条并发访问规则:

  1. 只有当前任务可以修改 tsk->nsproxy 指针或 nsproxy 内部指针,且必须持有 task_lock
  2. 读取当前任务的 namespace 无需加锁(直接解引用)。
  3. 读取其他任务的 namespace 时,需要 task_lock(task) 保护,并检查 nsproxy != NULL(NULL 表示进程处于 zombie 状态)。
// include/linux/nsproxy.h:107
static inline void put_nsproxy(struct nsproxy *ns)
{
    if (refcount_dec_and_test(&ns->count))
        deactivate_nsproxy(ns);  // 引用计数降为 0 时,释放所有 ns 引用
}

2.4 初始 nsproxy

系统启动时的初始 nsproxy(kernel/nsproxy.c:33):

struct nsproxy init_nsproxy = {
    .count               = REFCOUNT_INIT(1),
    .uts_ns              = &init_uts_ns,
    .ipc_ns              = &init_ipc_ns,
    .mnt_ns              = NULL,           // 在 VFS 初始化时设置
    .pid_ns_for_children = &init_pid_ns,
    .net_ns              = &init_net,
    .cgroup_ns           = &init_cgroup_ns,
    .time_ns             = &init_time_ns,
    .time_ns_for_children = &init_time_ns,
};

3. 八种 Namespace 类型深度解析

3.1 类型全览

+-------------------------------------------------------------------+
|              Linux 8 种 Namespace 类型对比                         |
+----------+------------------+-------------+---------------------+
| 名称      | CLONE 标志       | 隔离内容    | 引入版本             |
+----------+------------------+-------------+---------------------+
| mnt      | CLONE_NEWNS      | 挂载点树    | 2.4.19 (2002)       |
| uts      | CLONE_NEWUTS     | 主机名/域名 | 2.6.19 (2006)       |
| ipc      | CLONE_NEWIPC     | POSIX IPC   | 2.6.19 (2006)       |
| pid      | CLONE_NEWPID     | PID 空间    | 2.6.24 (2008)       |
| net      | CLONE_NEWNET     | 网络栈      | 2.6.24 (2008)       |
| user     | CLONE_NEWUSER    | UID/GID映射 | 3.8   (2013)        |
| cgroup   | CLONE_NEWCGROUP  | cgroup 视图 | 4.6   (2016)        |
| time     | CLONE_NEWTIME    | 系统时钟偏移 | 5.6   (2020)       |
+----------+------------------+-------------+---------------------+

每种 namespace 对应的核心结构体:

namespace 核心结构体 存放位置
mnt struct mnt_namespace fs/mount.h
uts struct uts_namespace include/linux/utsname.h
ipc struct ipc_namespace include/linux/ipc_namespace.h
pid struct pid_namespace include/linux/pid_namespace.h
net struct net include/net/net_namespace.h
user struct user_namespace include/linux/user_namespace.h
cgroup struct cgroup_namespace include/linux/cgroup_namespace.h
time struct time_namespace include/linux/time_namespace.h

所有这些结构体都嵌入了一个公共基类 struct ns_commoninclude/linux/ns/ns_common_types.h:110):

struct ns_common {
    struct {
        refcount_t __ns_ref;    // 主引用计数(内存生命周期)
    } ____cacheline_aligned_in_smp;
    u32 ns_type;                // CLONE_NEW* 标志值
    struct dentry *stashed;     // VFS 层缓存的 dentry
    const struct proc_ns_operations *ops;  // ns 操作函数表
    unsigned int inum;          // inode number(对应 /proc/pid/ns/ 文件)
    union {
        struct ns_tree;         // namespace 树节点
        struct rcu_head ns_rcu; // RCU 释放
    };
};

双层引用计数设计include/linux/ns/ns_common_types.h:43)是一个值得关注的新设计:

  • __ns_ref(refcount_t):控制内存生命周期,降为 0 时释放结构体内存。
  • __ns_ref_active(atomic_t):控制 userspace 可见性,降为 0 时 namespace 从树结构中消失,不能再通过 /proc/pid/ns/ 发现,但内存可能尚未释放。

这种设计允许"inactive but alive"状态:一个 namespace 可以因持有其凭证的文件的存在而保持内存存活,但对用户空间不可见。include/linux/ns/ns_common_types.h:63 中的注释描述了这个完整的状态机:

Active (__ns_ref_active > 0):  可见、可被 /proc/pid/ns/ 访问
    |
    v (最后一个使用 ns 的 task 退出)
Inactive (__ns_ref_active == 0, __ns_ref > 0): 不可见但内存存活
    |
    v (最后一个 __ns_ref 释放)
Destroyed (__ns_ref == 0): 内存释放

3.2 UTS Namespace

UTS(UNIX Time-sharing System)namespace 隔离主机名和 NIS 域名,隔离的是 uname() 系统调用返回的结果以及 sethostname() 等调用的作用域:

struct uts_namespace {
    struct new_utsname name;    // 包含 nodename、domainname 等字段
    struct user_namespace *user_ns;
    struct ucounts *ucounts;
    struct ns_common ns;
} __randomize_layout;

容器需要 UTS ns 来设置自己的主机名,否则修改主机名会影响宿主机。这是容器 identity 的基础——容器内的进程看到的 hostname 是容器名,而不是宿主机名。

3.3 IPC Namespace

IPC namespace 隔离 POSIX 消息队列、System V 消息队列、共享内存、信号量。copy_namespaces() 中有一个重要的约束(kernel/nsproxy.c:191):

if ((flags & (CLONE_NEWIPC | CLONE_SYSVSEM)) ==
    (CLONE_NEWIPC | CLONE_SYSVSEM))
    return -EINVAL;

不能同时指定 CLONE_NEWIPCCLONE_SYSVSEM,因为新的 IPC namespace 不再与父进程共享 semaphore undo list,二者语义冲突。这是内核中罕见的"组合标志非法"检查,体现了 IPC 子系统的状态管理复杂性。

3.4 Net Namespace

Net namespace 是所有 namespace 中最"重量级"的,它完整隔离:网络接口(eth0、lo 等在不同 ns 中是独立的)、IPv4/IPv6 路由表、iptables/nftables 规则、TCP/UDP 套接字、/proc/net/ 目录内容、sysctl net.* 参数。

每个 net namespace 创建时都会创建独立的 loopback 设备、默认路由表等,开销相对较大。这是为什么在性能敏感场景(如 Kubernetes sidecar 注入)有时会考虑是否独立 net ns 的原因。

3.5 User Namespace

User namespace 是最复杂的 namespace,它隔离 UID/GID 映射,允许容器内的"root"(UID 0)映射到宿主机上的非特权用户。

创建 user namespace 是唯一不需要 CAP_SYS_ADMIN 权限就能执行的 namespace 操作——这是实现非特权容器(rootless containers)的关键。

关键约束:user namespace 必须在其他 namespace 之前创建(CLONE_NEWUSER 必须最先处理),因为其他 namespace 的创建需要检查新用户 namespace 中的能力。

3.6 Time Namespace

Time namespace(Linux 5.6,2020 年引入)是最新的 namespace 类型,它允许进程看到与宿主机不同的 CLOCK_MONOTONICCLOCK_BOOTTIME 时间偏移:

struct time_namespace {
    struct user_namespace   *user_ns;
    struct ns_common        ns;
    struct timens_offsets   offsets;  // 时钟偏移量
    struct page             *vvar_page;  // vDSO 页面(加速时钟读取)
    bool                    frozen_offsets;  // 偏移量是否已固化
};

nsproxy 中有两个字段(time_nstime_ns_for_children)也遵循"延迟生效"语义——unshare(CLONE_NEWTIME) 后,调用者继续使用原来的 time ns,只有其后创建的子进程才进入新的 time ns。exec() 会触发将 time_ns_for_children 合并为 time_ns 的动作(kernel/nsproxy.c:276)。

3.7 Cgroup Namespace

Cgroup namespace 隔离的是进程能"看到"的 cgroup 层次结构。它并不隔离 cgroup 本身(子系统配额不因 cgroup ns 而改变),而是改变 /proc/self/cgroup 和 cgroupfs 的视角:

struct cgroup_namespace {
    struct ns_common ns;
    struct user_namespace   *user_ns;
    struct ucounts          *ucounts;
    struct css_set          *root_cset;  // 该 ns 视角中的"根"css_set
};

进入容器时创建 cgroup namespace,容器内的进程通过 /proc/self/cgroup 看到的路径是以自身所在 cgroup 为根的相对路径,而不是宿主机上的绝对路径。

kernel/cgroup/cgroup.c:6963 展示了 cgroup_post_fork() 如何在 CLONE_NEWCGROUP 时更新 root_cset

// kernel/cgroup/cgroup.c:6963
if (kargs->flags & CLONE_NEWCGROUP) {
    struct css_set *rcset = child->nsproxy->cgroup_ns->root_cset;
    get_css_set(cset);
    child->nsproxy->cgroup_ns->root_cset = cset;
    put_css_set(rcset);
}

4. PID Namespace 深度剖析

4.1 pid_namespace 结构体

// include/linux/pid_namespace.h:26
struct pid_namespace {
    struct idr idr;                   // IDR 树:nr -> struct pid 的映射
    struct rcu_head rcu;              // RCU 延迟释放
    unsigned int pid_allocated;       // 已分配的 PID 数量
    struct task_struct *child_reaper; // 类似 init 进程,孤儿进程的新父亲
    struct kmem_cache *pid_cachep;    // 本层 struct pid 的 slab 缓存
    unsigned int level;               // 嵌套深度(init_pid_ns.level = 0)
    int pid_max;                      // /proc/sys/kernel/pid_max
    struct pid_namespace *parent;     // 父 PID namespace
    struct user_namespace *user_ns;   // 关联的 user namespace
    struct ucounts *ucounts;          // 用户级计数(防止 ns 数量暴增)
    int reboot;                       // namespace 内 reboot(2) 行为
    struct ns_common ns;              // 公共 namespace 基础字段
    ...
} __randomize_layout;                 // 结构体随机化(安全特性)

child_reaper 字段是 PID namespace 最重要的语义保证:每个 PID namespace 都有一个"init"进程(PID 1),当 namespace 内的进程成为孤儿时,由 child_reaper 收养并等待其退出。当 child_reaper 本身退出时,内核会向该 namespace 内所有进程发送 SIGKILLzap_pid_ns_processes())。

level 字段用于嵌套层次(include/linux/pid_namespace.h:15MAX_PID_NS_LEVEL = 32):

init_pid_ns (level=0)
    +-- ns_level_1 (level=1)
            +-- ns_level_2 (level=2)
                    +-- ... 最深 MAX_PID_NS_LEVEL=32

最大嵌套深度 32 是为了限制 struct pidnumbers[] 数组大小——每层嵌套要在 struct pid 中多存储一个 upid,深度无限会导致 pid 对象无限增长。

4.2 struct pid 与多层 PID 映射

每个进程对应一个 struct pid 对象,其中存储了该进程在每一层 PID namespace 中的 PID 号:

// include/linux/pid.h:53
struct upid {
    int nr;                         // 在该 namespace 中的 PID 号
    struct pid_namespace *ns;       // 对应的 namespace
};

// include/linux/pid.h:58
struct pid {
    refcount_t count;
    unsigned int level;             // 所在 namespace 的最深层次
    spinlock_t lock;
    struct hlist_head tasks[PIDTYPE_MAX]; // 同一 PID 的所有 task 类型
    struct hlist_head inodes;       // pidfd 相关
    wait_queue_head_t wait_pidfd;
    struct rcu_head rcu;
    struct upid numbers[];          // 柔性数组,每层一个 upid
};

这个设计是 PID namespace 嵌套语义的核心。一个进程在不同层次的 namespace 中有不同的 PID 号:

+----------------------------------------------------------+
| 进程 X 的 struct pid(level=2,嵌套两层 PID ns)           |
|                                                          |
|  numbers[0]:  nr=1234, ns=init_pid_ns   (宿主机看到)     |
|  numbers[1]:  nr=42,   ns=container_ns  (容器内部看到)   |
|  numbers[2]:  nr=1,    ns=nested_ns     (嵌套容器内看到) |
+----------------------------------------------------------+

为什么 numbers[] 下标 0 对应最外层?

numbers[0] 对应最外层(level=0,即 init_pid_ns),numbers[level] 对应最内层。这样设计是因为当内核需要查询"在某个 ns 中的 PID"时,通过 ns->level 作为索引可以 O(1) 直接定位,无需遍历。

4.3 PID 分配:alloc_pid

alloc_pid() 是 PID namespace 中最关键的函数(kernel/pid.c:160),它在每一层 namespace 中分别分配 PID 号:

// kernel/pid.c:160
struct pid *alloc_pid(struct pid_namespace *ns, pid_t *arg_set_tid,
                      size_t arg_set_tid_size)
{
    struct pid *pid;
    struct pid_namespace *tmp;
    int i, nr;

    // 每个层级使用独立的 slab 缓存(大小按 level+1 个 upid 计算)
    pid = kmem_cache_alloc(ns->pid_cachep, GFP_KERNEL);
    pid->level = ns->level;

    // 在 pidmap_lock 保护下,对每一层 namespace 分配 PID 号
    idr_preload(GFP_KERNEL);
    spin_lock(&pidmap_lock);

    // kernel/pid.c:239: 从最内层向最外层逐层分配
    for (tmp = ns, i = ns->level; i >= 0;) {
        if (tid) {
            // CLONE_NEWPID + set_tid:指定特定 PID 号(checkpoint/restore 场景)
            nr = idr_alloc(&tmp->idr, NULL, tid, tid + 1, GFP_ATOMIC);
        } else {
            // 普通 fork:循环分配,从 RESERVED_PIDS(300) 开始
            // kernel/pid.c:258: idr_get_cursor 判断是否已绕回
            if (idr_get_cursor(&tmp->idr) > RESERVED_PIDS)
                pid_min = RESERVED_PIDS;
            nr = idr_alloc_cyclic(&tmp->idr, NULL, pid_min,
                                  pid_max[ns->level - i], GFP_ATOMIC);
        }
        pid->numbers[i].nr = nr;
        pid->numbers[i].ns = tmp;
        tmp = tmp->parent;
        i--;
    }
    spin_unlock(&pidmap_lock);
    idr_preload_end();

    // 填充 upid 并将 pid 指针插入每层 ns 的 IDR 树
    for (i = ns->level; i >= 0; i--) {
        struct upid *upid = &pid->numbers[i];
        idr_replace(&upid->ns->idr, pid, upid->nr);
    }
    ...
}

IDR(ID Radix Tree):Linux 使用 IDR 数据结构(基于 radix tree)实现从整数 ID 到指针的高效映射。每个 PID namespace 有自己的 IDR 实例(pid_namespace.idr),独立管理 PID 号分配。

每层 ns 的独立 kmem_cachekernel/pid_namespace.c:40):

static struct kmem_cache *create_pid_cachep(unsigned int level)
{
    unsigned int len = struct_size_t(struct pid, numbers, level + 1);
    // 为 level+1 个 upid 创建专用的 slab 缓存
    *pkc = kmem_cache_create(name, len, 0,
                             SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT, NULL);
    return *pkc;
}

每个嵌套层级的 struct pid 大小不同(因为 numbers[] 数组长度不同),所以为每个层级单独创建 slab 缓存,避免内存碎片,并利用 slab 对齐特性提升分配效率。

4.4 PID namespace 销毁

当 PID namespace 的 child_reaper(内部 init 进程)退出时,触发 zap_pid_ns_processes()kernel/pid_namespace.c:192):

void zap_pid_ns_processes(struct pid_namespace *pid_ns)
{
    // 1. 禁止新进程进入该 namespace
    disable_pid_allocation(pid_ns);

    // 2. 忽略 SIGCHLD,防止产生僵尸进程积压
    me->sighand->action[SIGCHLD - 1].sa.sa_handler = SIG_IGN;

    // 3. 向 namespace 内所有非本进程发送 SIGKILL
    rcu_read_lock();
    read_lock(&tasklist_lock);
    idr_for_each_entry_continue(&pid_ns->idr, pid, nr) {
        task = pid_task(pid, PIDTYPE_PID);
        if (task && !__fatal_signal_pending(task))
            group_send_sig_info(SIGKILL, SEND_SIG_PRIV, task, PIDTYPE_MAX);
    }
    read_unlock(&tasklist_lock);
    rcu_read_unlock();

    // 4. 循环 wait4 直到所有子进程退出
    do {
        rc = kernel_wait4(-1, NULL, __WALL, NULL);
    } while (rc != -ECHILD);

    // 5. 等待 pid_allocated 降到 init_pids
    for (;;) {
        set_current_state(TASK_INTERRUPTIBLE);
        if (pid_ns->pid_allocated == init_pids)
            break;
        schedule();
    }
    __set_current_state(TASK_RUNNING);
}

销毁过程体现了 PID namespace 的"自洽性":namespace 内部有完整的生命周期管理,内部 init 退出意味着整个 namespace 结束。注意步骤 5 中使用了 schedule() 主动让出 CPU,等待其他进程退出——这是一个不带超时的无限等待,依靠 free_pid() 在最后一个 pid 释放时唤醒 init 进程。

4.5 嵌套 PID namespace 的亲祖关系验证

setns() 进入 PID namespace 时,内核会验证目标 ns 必须是当前活跃 PID ns 的"后裔"(kernel/pid_namespace.c:401):

static int pidns_install(struct nsset *nsset, struct ns_common *ns)
{
    struct pid_namespace *active = task_active_pid_ns(current);
    struct pid_namespace *new = to_pid_ns(ns);

    // 只允许进入当前 ns 或其子孙 ns
    // 不允许逃逸到更外层(更浅层)的 ns
    if (!pidns_is_ancestor(new, active))
        return -EINVAL;
    ...
}

pidns_is_ancestor() 通过逐级向上遍历 parent 指针来判断(kernel/pid_namespace.c:389):

bool pidns_is_ancestor(struct pid_namespace *child,
                       struct pid_namespace *ancestor)
{
    struct pid_namespace *ns;

    if (child->level < ancestor->level)
        return false;  // 子孙的 level 必须 >= 祖先
    for (ns = child; ns->level > ancestor->level; ns = ns->parent)
        ;
    return ns == ancestor;
}

这个约束的安全意义:进程只能进入更深(更内层)的 PID namespace,不能"爬出"到外层 ns。这维护了一个关键不变量:一个进程一旦在某个 PID namespace 中运行,就无法通过 setns() 逃逸到其祖先 ns,防止容器内进程影响宿主机 PID 空间。


5. Mount Namespace 与挂载点传播

5.1 mnt_namespace 结构体

// fs/mount.h:11
struct mnt_namespace {
    struct ns_common        ns;
    struct mount            *root;          // 根挂载点
    struct {
        struct rb_root      mounts;         // 所有挂载点的红黑树
        struct rb_node      *mnt_last_node; // 最右(最新)挂载
        struct rb_node      *mnt_first_node;// 最左(最旧)挂载
    };
    struct user_namespace   *user_ns;
    struct ucounts          *ucounts;
    wait_queue_head_t       poll;           // 挂载事件通知
    u64                     seq_origin;     // 来源 ns 的序列号
    u64                     event;          // 挂载变更计数
    unsigned int            nr_mounts;      // 挂载点数量
    unsigned int            pending_mounts;
    refcount_t              passive;        // 非固定引用
    bool                    is_anon;        // 是否为匿名 ns
} __randomize_layout;

红黑树代替链表是较新的优化:早期 mount namespace 使用链表存储挂载点,在挂载点数量巨大时遍历代价高;现改用红黑树提升查找效率。mnt_last_nodemnt_first_node 缓存了最近最久的挂载点,加速有序遍历。

5.2 挂载点传播类型

Mount namespace 最复杂的特性是挂载点传播(propagation),这决定了在一个 namespace 中做的挂载/卸载操作是否会"传播"到其他 namespace:

挂载传播类型(mount propagation):

  shared(共享传播)
      与 peer 组双向同步挂载事件
      容器中最常用的外部卷挂载方式
      设置: mount --make-shared <mountpoint>

  slave(从属传播)
      仅从 master 接收传播,本 ns 内的挂载不向外传播
      设置: mount --make-slave <mountpoint>

  private(私有)
      不接收也不发送传播
      clone(CLONE_NEWNS) 后需手动设置
      设置: mount --make-private <mountpoint>

  unbindable(不可绑定)
      既不传播也不能被绑定挂载
      防止挂载点意外扩散
      设置: mount --make-unbindable <mountpoint>

为什么这个设计存在?

当 Docker 容器使用 -v /host/path:/container/path 挂载卷时,宿主机上的 /host/pathshared 挂载,容器内的挂载点是 slave:宿主机上在该路径新增的挂载会传播到容器内,但容器内的挂载操作不会影响宿主机。这恰恰是容器化应用的需求。

clone(CLONE_NEWNS) 创建新 mount namespace 时,继承的挂载点传播关系:新 ns 中的挂载点如果原来是 shared,则变为 slave(接收原来 peer 组的传播,但不向外传播)。这是安全默认值,防止容器内的挂载意外影响宿主机。

5.3 /proc/self/mountinfo

mount namespace 的状态通过 /proc/<pid>/mountinfo 暴露,格式包含传播类型信息:

36 35 98:0 /mnt1 /mnt2 rw,noatime master:1 - ext3 /dev/root rw
  ^  ^  ^    ^     ^       ^          ^
  |  |  | root    mountpoint opts   propagation
  | parent
 mountID

6. 系统调用实现路径:clone/unshare/setns

6.1 clone() 路径

clone(2) / fork(2) / vfork(2)
         |
         v
    kernel_clone()                       [kernel/fork.c]
         |
         +-- copy_process()
         |        |
         |        +-- copy_namespaces()  [kernel/nsproxy.c:167]
         |        |        |
         |        |        +-- 快路径:无 CLONE_NEW* 标志?
         |        |        |       get_nsproxy(old_ns); return 0  (O(1))
         |        |        |
         |        |        +-- 慢路径:权限检查 + create_new_namespaces()
         |        |                   逐个调用 copy_xxx_ns()
         |        |
         |        +-- cgroup_can_fork() [kernel/cgroup/cgroup.c:6829]
         |                 |
         |                 +-- cgroup_css_set_fork() -> 准备 css_set
         |                 +-- 各子系统 can_fork() 钩子
         |
         +-- wake_up_new_task()
                  |
                  +-- cgroup_post_fork()  [kernel/cgroup/cgroup.c:6889]
                           |
                           +-- css_set_move_task() -> 绑定子进程到 css_set
                           +-- 各子系统 fork() 钩子

copy_namespaces() 的快路径(kernel/nsproxy.c:173):如果 flags 中没有任何 CLONE_NEW* 标志,且 time_ns_for_children == time_ns(即 time namespace 没有待生效的变更),则直接增加原 nsproxy 的引用计数并返回,无需创建新 nsproxy:

// kernel/nsproxy.c:173
if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
                      CLONE_NEWPID | CLONE_NEWNET |
                      CLONE_NEWCGROUP | CLONE_NEWTIME)))) {
    if ((flags & CLONE_VM) ||
        likely(old_ns->time_ns_for_children == old_ns->time_ns)) {
        get_nsproxy(old_ns);  // 只增引用计数,O(1)
        return 0;
    }
}

这个快路径至关重要——普通 fork() 不使用 namespace 特性,必须保证零额外开销。

6.2 unshare() 路径

unshare(2) 是与 clone(2) 互补的接口:clone() 在创建新进程时分离,unshare() 让当前进程脱离某些 namespace:

unshare(2)
    |
    v
SYSCALL_DEFINE1(unshare, unsigned long, unshare_flags)    [kernel/fork.c]
    |
    +-- 1. 参数合法性检查 check_unshare_flags()
    |
    +-- 2. 可能需要新建 user namespace(必须最先处理)
    |       unshare_userns()
    |
    +-- 3. 创建新 nsproxy(含新的 ns 子集)
    |       unshare_nsproxy_namespaces()
    |           +-- create_new_namespaces()   [kernel/nsproxy.c:211]
    |
    +-- 4. 可能需要新建 fs_struct(针对 mnt ns)
    |       unshare_fs()
    |
    +-- 5. 原子切换
    |       switch_task_namespaces(current, new_nsp)  [kernel/nsproxy.c:237]
    |           +-- task_lock(p); p->nsproxy = new; task_unlock(p);
    |               put_nsproxy(old_ns);
    |
    +-- 6. 清理临时对象

switch_task_namespaces() 的实现非常简洁(kernel/nsproxy.c:237):

void switch_task_namespaces(struct task_struct *p, struct nsproxy *new)
{
    struct nsproxy *ns;

    might_sleep();

    if (new)
        nsproxy_ns_active_get(new);

    task_lock(p);       // 保证原子性
    ns = p->nsproxy;
    p->nsproxy = new;
    task_unlock(p);

    if (ns)
        put_nsproxy(ns);  // 减少旧 nsproxy 的引用计数
}

6.3 setns() 路径

setns(2) 允许进程加入已有的 namespace(通过文件描述符引用):

setns(fd, flags)          [kernel/nsproxy.c:563]
    |
    +-- 1. 获取 fd 指向的 ns_common
    |       proc_ns_file(fd) -> get_proc_ns(inode) -> ns_common
    |       或通过 pidfd -> validate_nsset()
    |
    +-- 2. 验证 flags 与 ns 类型匹配 check_setns_flags()
    |
    +-- 3. prepare_nsset() -> 基于当前 ns 状态创建 nsset 临时结构
    |       create_new_namespaces(0, ...)  // 先复制一份当前 ns
    |
    +-- 4. validate_ns() / validate_nsset()
    |       对每个请求的 ns 调用 ns->ops->install(nsset, ns)
    |       例如 pidns_install() 会检查 pidns_is_ancestor()
    |
    +-- 5. commit_nsset() -> 原子提交(不可回退点)
    |       switch_task_namespaces(me, nsset->nsproxy)
    |
    +-- 6. perf_event_namespaces() -> 通知 perf 事件子系统

"validate then commit"两阶段提交setns() 的核心设计(kernel/nsproxy.c:392)。validate_nsset() 只做检查,不做实际更改;只有全部 namespace 都验证通过后,才在 commit_nsset() 中一次性提交所有变更。这保证了原子性:不会出现"部分 namespace 切换成功"的中间状态。

6.4 权限模型

创建新 namespace 的权限检查(kernel/nsproxy.c:181):

} else if (!ns_capable(user_ns, CAP_SYS_ADMIN))
    return -EPERM;

例外:CLONE_NEWUSER。创建新 user namespace 不需要 CAP_SYS_ADMIN,任何进程都可以创建(受 /proc/sys/user/max_user_namespaces 限制)。这是允许非特权容器(rootless containers)存在的根本原因。

check_setns_flags() 函数(kernel/nsproxy.c:293)展示了各 namespace 的编译时配置依赖:

static int check_setns_flags(unsigned long flags)
{
    if (!flags || (flags & ~(CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
                             CLONE_NEWNET | CLONE_NEWTIME | CLONE_NEWUSER |
                             CLONE_NEWPID | CLONE_NEWCGROUP)))
        return -EINVAL;

#ifndef CONFIG_USER_NS
    if (flags & CLONE_NEWUSER)
        return -EINVAL;
#endif
    // ... 类似检查 CONFIG_PID_NS, CONFIG_UTS_NS 等
    return 0;
}

7. /proc/pid/ns/ 文件绑定机制

7.1 namespace 文件系统(nsfs)

每个 /proc/<pid>/ns/<type> 是一个特殊文件,它通过 nsfs(namespace filesystem)暴露:

/proc/1/ns/
+-- cgroup  -> cgroup:[4026531835]    # 括号内是 inode number
+-- ipc     -> ipc:[4026531839]
+-- mnt     -> mnt:[4026531840]
+-- net     -> net:[4026531992]
+-- pid     -> pid:[4026531836]
+-- pid_for_children -> pid:[4026531836]
+-- time    -> time:[4026531834]
+-- time_for_children -> time:[4026531834]
+-- user    -> user:[4026531837]
+-- uts     -> uts:[4026531838]

符号链接指向的目标<ns_type>:[inum] 格式的字符串,其中 inum 就是 ns_common.inum 字段。两个进程的同类 namespace 是否相同,只需比较这两个 inum 即可——这是 nsenterip netns exec 等工具判断 namespace 身份的依据。

7.2 绑定挂载保持 namespace 存活

即使持有某个 namespace 的所有进程都退出了,也可以通过 bind mount 让该 namespace"存活":

# 将 /proc/1234/ns/net 绑定挂载到一个固定路径
mount --bind /proc/1234/ns/net /run/netns/my_container

# 即使 PID 1234 退出,my_container net ns 仍然存在
# 可以通过 setns() 进入该 net ns
ip netns exec my_container_via_path bash

这个机制由 ns_common.stashed(缓存的 dentry)支持,内核在打开 /proc/<pid>/ns/<type> 时会创建对应的 dentry,通过 __ns_ref_active 活跃引用计数维持存活。bind mount 会持有活跃引用,保持 namespace 可被访问。

7.3 proc_ns_operations

每种 namespace 注册一套操作函数(以 PID namespace 为例,kernel/pid_namespace.c:450):

const struct proc_ns_operations pidns_operations = {
    .name       = "pid",
    .get        = pidns_get,          // 获取并引用 ns
    .put        = pidns_put,          // 释放引用
    .install    = pidns_install,      // setns() 时安装 ns
    .owner      = pidns_owner,        // 返回所属 user ns
    .get_parent = pidns_get_parent,   // 获取父 ns(受当前 ns 可见性限制)
};

// pid_for_children 是另一套操作,对应 nsproxy->pid_ns_for_children
const struct proc_ns_operations pidns_for_children_operations = {
    .name         = "pid_for_children",
    .real_ns_name = "pid",            // 文件系统中显示为 "pid" 类型
    .get          = pidns_for_children_get,
    .put          = pidns_put,
    .install      = pidns_install,
    .owner        = pidns_owner,
    .get_parent   = pidns_get_parent,
};

pidns_for_children_get() 的实现(kernel/pid_namespace.c:361)检查了 ns->child_reaper:如果 PID namespace 的 init 进程还未启动(child_reaper == NULL),则该 ns 对外不可见,返回 NULL。


8. cgroup v2 总体架构

8.1 v1 vs v2 的核心区别

cgroup v1 架构(已有设计问题):

  memory 层级: /sys/fs/cgroup/memory/
  cpu 层级:    /sys/fs/cgroup/cpu/
  blkio 层级:  /sys/fs/cgroup/blkio/

  问题:同一进程在不同子系统树中可以属于不同的 cgroup,
        导致策略冲突和管理复杂。

cgroup v2 架构(统一层级):

  /sys/fs/cgroup/                  <- 统一根(cgrp_dfl_root)
  +-- cgroup.controllers           <- 可用子系统列表
  +-- cgroup.subtree_control       <- 向子 cgroup 开放的子系统
  +-- memory.max                   <- 内存限制
  +-- cpu.weight                   <- CPU 权重
  +-- io.max                       <- IO 速率限制
  +-- app/                         <- 子 cgroup
       +-- cgroup.procs             <- 迁移进程到此 cgroup
       +-- memory.max               <- 独立的内存限制
       +-- worker/                 <- 更深层的子 cgroup

v2 的核心设计原则:统一层级(single hierarchy)。所有子系统共享同一棵 cgroup 树,一个进程在所有子系统中属于同一个 cgroup 节点(或其祖先)。这消除了 v1 中"进程在不同子系统树的不同位置"导致的管理矛盾。

8.2 cgroup 层次结构总览

cgroup_root(cgrp_dfl_root)
|   .kf_root -> kernfs 根
|   .cgrp    -> 根 cgroup 节点
|
+-- struct cgroup(根节点,level=0)
    |   .subsys[] -> 各子系统的 css(cgroup_subsys_state)
    |   .kn      -> kernfs 目录节点
    |   .self    -> 内嵌的 css(ss=NULL)
    |   .ancestors[0] -> 自身
    |
    +-- struct cgroup(level=1,如 /sys/fs/cgroup/app/)
    |   |   .level = 1
    |   |   .ancestors[0] = 根节点
    |   |   .ancestors[1] = 自身
    |   |
    |   +-- struct cgroup(level=2,如 /sys/fs/cgroup/app/worker/)
    |           .level = 2
    |           .ancestors[0] = 根节点
    |           .ancestors[1] = app 节点
    |           .ancestors[2] = 自身
    |
    +-- struct cgroup(level=1,如 /sys/fs/cgroup/system/)
            .level = 1

cgroup.ancestors[] 柔性数组(include/linux/cgroup-defs.h:629)存储从根到自身的所有祖先指针,用于 O(1) 判断 cgroup 之间的祖先关系——只需检查 a->ancestors[b->level] == b,无需遍历链表。


9. 三核心结构体:cgroup、css_set、cgroup_subsys_state

9.1 三者关系图

struct task_struct
+---------------------+
| cgroups -> css_set  |
| cg_list (链表节点)   |
+---------------------+
           |
           v
+----------------------------------------------------------+
|                     struct css_set                       |
|                                                          |
|  subsys[0] -> mem_css_A    -----> struct mem_cgroup      |
|  subsys[1] -> cpu_css_A    -----> struct task_group      |
|  subsys[2] -> io_css_A     -----> struct blkcg           |
|  subsys[3] -> pids_css_A   -----> struct pids_cgroup     |
|  ...                                                     |
|  dfl_cgrp -> struct cgroup(该 css_set 对应的默认 cgroup)|
|  tasks    -> task 链表                                   |
|  hlist    -> 哈希桶链接                                  |
+----------------------------------------------------------+
           |
           v
+----------------------------------------------------------+
|  struct cgroup_subsys_state(css,嵌入各子系统结构体)     |
|                                                          |
|  .cgroup -> 关联的 struct cgroup 节点                    |
|  .ss     -> 所属子系统(struct cgroup_subsys *)          |
|  .refcnt -> percpu 引用计数                              |
|  .parent -> 父 css                                       |
|  .children -> 子 css 链表                               |
|  .id     -> 子系统内唯一 ID                              |
|  .flags  -> CSS_ONLINE / CSS_DYING / CSS_RELEASED        |
+----------------------------------------------------------+
           |
           v
+----------------------------------------------------------+
|                     struct cgroup                        |
|                                                          |
|  .self         -> 内嵌 css(ss=NULL,代表 cgroup 自身)  |
|  .subsys[]     -> 各子系统的 css 指针(RCU 保护)         |
|  .kn           -> kernfs 节点(对应文件系统目录)         |
|  .level        -> 在层级树中的深度                       |
|  .ancestors[]  -> 祖先指针数组(柔性数组)               |
|  .freezer      -> 冻结状态                               |
|  .flags        -> CGRP_FREEZE / CGRP_FROZEN 等           |
|  .subtree_control -> 向子 cgroup 开放的子系统掩码         |
+----------------------------------------------------------+

9.2 cgroup_subsys_state(css)深度解析

css 是子系统与 cgroup 之间的"绑定点"(include/linux/cgroup-defs.h:179):

struct cgroup_subsys_state {
    struct cgroup *cgroup;          // PI(Public/Immutable):所属 cgroup
    struct cgroup_subsys *ss;       // PI:所属子系统(NULL 表示 cgroup 自身的 css)
    struct percpu_ref refcnt;       // percpu 引用计数(高并发 css_get/put)
    struct css_rstat_cpu __percpu *rstat_cpu;  // per-cpu 统计数据
    struct list_head sibling;       // 兄弟 css 链表
    struct list_head children;      // 子 css 链表
    int id;                         // 子系统内唯一 ID
    unsigned int flags;             // CSS_ONLINE / CSS_DYING / CSS_RELEASED 等
    u64 serial_nr;                  // 单调递增序列号(用于有序迭代)
    atomic_t online_cnt;            // 自身及子孙在线 css 数量
    struct work_struct destroy_work;// 异步销毁工作项
    struct cgroup_subsys_state *parent; // 父 css
    int nr_descendants;             // 可见后代数量
    struct cgroup_subsys_state *rstat_flush_next; // rstat 刷新链表
};

为什么使用 percpu_ref 而不是普通 refcount_t

css 在热路径中频繁 get/put:每次内存分配(memcg)、调度(cpu)、IO 操作(blkcg)都可能触发。percpu_ref 在正常模式下每个 CPU 维护独立计数,读写本地 CPU 的缓存行,完全无竞争;只在引用计数降为 0 时才进行全局原子操作。这使得 css 的引用操作开销接近零。

CSS_ONLINE / CSS_DYING 状态机:

初始化 --> CSS_ONLINE(css_online() 回调后置位)
              |
              v (rmdir 触发,开始离线流程)
           CSS_DYING(在线计数降为 0 时置位)
              |
              v (所有外部引用释放)
           CSS_RELEASED(css_released() 回调)--> 最终释放内存

serial_nr 保证了子 css 链表的有序性,迭代可以被中断和恢复——通过记录当前位置的 serial_nr,重新迭代时跳过已处理的 css,无需持有锁整个遍历过程。

9.3 cgroup 结构体关键字段

// include/linux/cgroup-defs.h:472
struct cgroup {
    struct cgroup_subsys_state self;    // 内嵌 css(ss=NULL)
    unsigned long flags;                // CGRP_FREEZE / CGRP_FROZEN 等
    int level;                          // 层级深度(根=0)
    int max_depth;                      // 允许的最大子树深度
    int nr_descendants;                 // 可见后代 cgroup 数量
    int nr_dying_descendants;           // 正在销毁的后代数量
    int max_descendants;                // 允许的最大后代数量

    // 任务计数(用于快速判断 cgroup 是否为空)
    int nr_populated_csets;             // 有任务的 css_set 数量
    int nr_populated_domain_children;
    int nr_populated_threaded_children;

    unsigned int kill_seq;              // cgroup.kill 序列号

    struct kernfs_node *kn;             // kernfs 目录节点
    struct cgroup_file procs_file;      // cgroup.procs 文件句柄
    struct cgroup_file events_file;     // cgroup.events 文件句柄

    u32 subtree_control;                // 子 cgroup 开放的子系统掩码
    u32 subtree_ss_mask;                // 实际有效的子系统掩码

    // 各子系统 css(RCU 保护,允许并发读)
    struct cgroup_subsys_state __rcu *subsys[CGROUP_SUBSYS_COUNT];

    struct cgroup_root *root;           // 所属层级根
    struct list_head cset_links;        // 关联的 css_set 链表
    struct list_head e_csets[CGROUP_SUBSYS_COUNT]; // 有效 css_set 链表

    struct cgroup *dom_cgrp;            // 域 cgroup(threaded 模式用)
    struct psi_group *psi;              // PSI 压力统计
    struct cgroup_bpf bpf;             // eBPF 程序
    struct cgroup_freezer_state freezer;// 冻结状态

    // 统计数据(per-cpu,懒传播)
    struct cgroup_rstat_base_cpu __percpu *rstat_base_cpu;
    CACHELINE_PADDING(_pad_);
    struct cgroup_base_stat last_bstat;
    struct cgroup_base_stat bstat;

    // 祖先数组(柔性数组,大小=level+1)
    union {
        DECLARE_FLEX_ARRAY(struct cgroup *, ancestors);
        struct {
            struct cgroup *_root_ancestor;
            DECLARE_FLEX_ARRAY(struct cgroup *, _low_ancestors);
        };
    };
};

e_csets[] 数组的作用:对于每个子系统 sside_csets[ssid] 链接了所有"有效使用该 cgroup 的 ssid 子系统 css"的 css_set。这是 v2 中"祖先 css 代理"机制的关键——当某个 cgroup 的某个子系统未启用时,其 css_set->subsys[ssid] 指向最近启用该子系统的祖先的 css,而 e_csets[ssid] 就追踪了所有这些代理关系。

CACHELINE_PADDING(_pad_) 字段include/linux/cgroup-defs.h:595):将频繁更新的 bstat 字段与只读的 rstat_base_cpu 指针隔离在不同缓存行,防止伪共享(false sharing)。这是典型的内核性能优化手段。

9.4 cgroup_subsys 操作函数表

每个子系统注册一组回调(include/linux/cgroup-defs.h:769):

struct cgroup_subsys {
    // css 生命周期
    struct cgroup_subsys_state *(*css_alloc)(struct cgroup_subsys_state *parent_css);
    int  (*css_online)(struct cgroup_subsys_state *css);
    void (*css_offline)(struct cgroup_subsys_state *css);
    void (*css_released)(struct cgroup_subsys_state *css);
    void (*css_free)(struct cgroup_subsys_state *css);
    void (*css_reset)(struct cgroup_subsys_state *css);
    void (*css_killed)(struct cgroup_subsys_state *css);
    void (*css_rstat_flush)(struct cgroup_subsys_state *css, int cpu);

    // task 迁移(三段式:检查、取消、提交)
    int  (*can_attach)(struct cgroup_taskset *tset);
    void (*cancel_attach)(struct cgroup_taskset *tset);
    void (*attach)(struct cgroup_taskset *tset);

    // fork/exit 钩子
    int  (*can_fork)(struct task_struct *task, struct css_set *cset);
    void (*cancel_fork)(struct task_struct *task, struct css_set *cset);
    void (*fork)(struct task_struct *task);
    void (*exit)(struct task_struct *task);
    void (*release)(struct task_struct *task);

    bool early_init:1;          // 是否在系统早期初始化
    bool implicit_on_dfl:1;     // 是否在 v2 中隐式启用(如 perf_event)
    bool threaded:1;            // 是否支持 threaded 模式

    int id;
    const char *name;
    const char *legacy_name;    // v1 中的名称(可能不同)

    struct idr css_idr;         // css ID 到 css 的映射
    struct list_head cfts;      // 控制文件类型列表

    struct cftype *dfl_cftypes;     // v2 接口文件
    struct cftype *legacy_cftypes;  // v1 接口文件

    unsigned int depends_on;    // 依赖的其他子系统掩码

    spinlock_t rstat_ss_lock;
    struct llist_head __percpu *lhead; // lockless 统计更新链表头
};

10. css_set 哈希表与 task 绑定

10.1 css_set 的作用

css_set 是一个关键的优化结构:它将"一个进程在所有子系统中的状态指针"聚合在一起,使得 fork()/exit() 时只需要对一个对象做引用计数操作,而不是对每个子系统分别操作。

设计目标:让共享同一套 cgroup 配置的进程共享同一个 css_set,减少内存占用和操作开销。

10.2 哈希表实现

// kernel/cgroup/cgroup.c:959
#define CSS_SET_HASH_BITS    7
static DEFINE_HASHTABLE(css_set_table, CSS_SET_HASH_BITS);

static unsigned long css_set_hash(struct cgroup_subsys_state **css)
{
    unsigned long key = 0UL;
    struct cgroup_subsys *ss;
    int i;

    for_each_subsys(ss, i)
        key += (unsigned long)css[i];   // 对所有 css 指针值求和
    key = (key >> 16) ^ key;            // 混合高低位

    return key;
}

这个哈希函数非常简单:对所有子系统的 css 指针值求和,然后做简单混淆。之所以能工作,是因为:

  1. css_set_table 只有 128(2^7)个桶——碰撞不是大问题,链表遍历代价低;
  2. 实际上系统中 css_set 的数量通常很少(等于"不同 cgroup 组合"的数量),远小于进程数量。

10.3 查找已有 css_set

迁移进程到新 cgroup 时,先查找是否已有匹配的 css_set(kernel/cgroup/cgroup.c:1101):

static struct css_set *find_existing_css_set(struct css_set *old_cset,
                            struct cgroup *cgrp,
                            struct cgroup_subsys_state **template)
{
    struct cgroup_root *root = cgrp->root;
    struct cgroup_subsys *ss;
    struct css_set *cset;
    unsigned long key;
    int i;

    // 构建目标 css 模板
    for_each_subsys(ss, i) {
        if (root->subsys_mask & (1 << i)) {
            // 目标 cgroup 所在层级的子系统:使用目标 cgroup 的 css
            template[i] = cgroup_e_css_by_mask(cgrp, ss);
        } else {
            // 其他层级的子系统:保持原 css_set 的 css 不变
            template[i] = old_cset->subsys[i];
        }
    }

    key = css_set_hash(template);
    hash_for_each_possible(css_set_table, cset, hlist, key) {
        if (!compare_css_sets(cset, old_cset, cgrp, template))
            continue;
        return cset;    // 找到完全匹配的 css_set
    }

    return NULL;        // 需要新建
}

10.4 创建新 css_set

find_existing_css_set() 返回 NULL 时,find_css_set() 创建新的 css_set(kernel/cgroup/cgroup.c:1244):

cset = kzalloc_obj(*cset);
// 初始化各链表...
memcpy(cset->subsys, template, sizeof(cset->subsys));

spin_lock_irq(&css_set_lock);
key = css_set_hash(cset->subsys);
hash_add(css_set_table, &cset->hlist, key);  // 加入哈希表

// 对 cset 引用的每个 css 增加引用计数
for_each_subsys(ss, ssid) {
    struct cgroup_subsys_state *css = cset->subsys[ssid];
    list_add_tail(&cset->e_cset_node[ssid],
                  &css->cgroup->e_csets[ssid]);
    css_get(css);
}
spin_unlock_irq(&css_set_lock);

10.5 task 与 css_set 的绑定

task_struct 中的两个关键字段(include/linux/sched.h:1321):

struct css_set __rcu *cgroups;  // 当前 css_set(RCU 保护)
struct list_head cg_list;       // 链接到 css_set->tasks 的节点

css_set_move_task() 是将进程从一个 css_set 移动到另一个的原子操作(kernel/cgroup/cgroup.c:919):

static void css_set_move_task(struct task_struct *task,
                              struct css_set *from_cset,
                              struct css_set *to_cset,
                              bool use_mg_tasks)
{
    lockdep_assert_held(&css_set_lock);

    if (to_cset && !css_set_populated(to_cset))
        css_set_update_populated(to_cset, true);

    if (from_cset) {
        list_del_init(&task->cg_list);  // 从旧 css_set 的 tasks 链表移除
        if (!css_set_populated(from_cset))
            css_set_update_populated(from_cset, false);
    }

    rcu_assign_pointer(task->cgroups, to_cset);  // 原子更新 task->cgroups

    if (to_cset) {
        // 添加到新 css_set 的 tasks 或 mg_tasks(迁移中)
        list_add_tail(&task->cg_list,
                      use_mg_tasks ? &to_cset->mg_tasks : &to_cset->tasks);
    }
}

rcu_assign_pointer 保证其他 CPU 通过 RCU 读取 task->cgroups 时能看到完整的赋值(包含必要的内存屏障)。


11. cgroup_fork 进程继承流程

11.1 fork 与 cgroup 的交互

fork() 创建子进程时,cgroup 子系统经历三个阶段:

fork() / clone()
    |
    +-- copy_process()
    |        |
    |        +-- cgroup_fork()          [阶段 1:早期初始化]
    |        |   kernel/cgroup/cgroup.c:6632
    |        |   临时绑定到 init_css_set
    |        |
    |        +-- ... 其他 copy_xxx() ...
    |        |
    |        +-- cgroup_can_fork()      [阶段 2:预检查 + 准备 css_set]
    |            kernel/cgroup/cgroup.c:6829
    |            确定目标 css_set,调用各子系统 can_fork()
    |
    +-- wake_up_new_task()
             |
             +-- cgroup_post_fork()    [阶段 3:最终绑定]
                 kernel/cgroup/cgroup.c:6889
                 正式加入 css_set,调用各子系统 fork()

11.2 阶段 1:cgroup_fork

// kernel/cgroup/cgroup.c:6632
void cgroup_fork(struct task_struct *child)
{
    // 临时绑定到 init_css_set,防止 css_set 引用为空
    RCU_INIT_POINTER(child->cgroups, &init_css_set);
    INIT_LIST_HEAD(&child->cg_list);
}

这一步非常简单:让子进程临时持有 init_css_set 的引用。这是因为后续步骤可能失败(内存不足、权限错误等),需要保证任何时刻 child->cgroups 都是有效指针。

11.3 阶段 2:cgroup_can_fork

// kernel/cgroup/cgroup.c:6829
int cgroup_can_fork(struct task_struct *child, struct kernel_clone_args *kargs)
{
    struct cgroup_subsys *ss;
    int i, j, ret;

    // 2a. 确定子进程的目标 css_set
    ret = cgroup_css_set_fork(kargs);
    if (ret) return ret;

    // 2b. 调用各子系统的 can_fork() 钩子(如 pids 子系统检查配额)
    do_each_subsys_mask(ss, i, have_canfork_callback) {
        ret = ss->can_fork(child, kargs->cset);
        if (ret) goto out_revert;
    } while_each_subsys_mask();

    return 0;

out_revert:
    // 按相反顺序调用 cancel_fork(),回滚已执行的检查
    for_each_subsys(ss, j) {
        if (j >= i) break;
        if (ss->cancel_fork)
            ss->cancel_fork(child, kargs->cset);
    }
    cgroup_css_set_put_fork(kargs);
    return ret;
}

cgroup_css_set_fork() 中的关键逻辑(kernel/cgroup/cgroup.c:6693):

// 持有 cgroup_threadgroup_rwsem(防止并发 cgroup 迁移)
cgroup_threadgroup_change_begin(current);

spin_lock_irq(&css_set_lock);
cset = task_css_set(current);   // 父进程的 css_set
get_css_set(cset);              // 增加引用计数

// 如果是 CLONE_INTO_CGROUP,则查找目标 css_set
if (kargs->flags & CLONE_INTO_CGROUP) {
    kargs->cset = find_css_set(cset, dst_cgrp);
} else {
    kargs->cset = cset;         // 默认继承父进程的 css_set
}

11.4 阶段 3:cgroup_post_fork

// kernel/cgroup/cgroup.c:6889
void cgroup_post_fork(struct task_struct *child,
                      struct kernel_clone_args *kargs)
    __releases(&cgroup_threadgroup_rwsem) __releases(&cgroup_mutex)
{
    struct css_set *cset = kargs->cset;
    ...

    spin_lock_irq(&css_set_lock);

    if (likely(child->pid)) {   // 非 PID 0(init task 不走此路径)
        cset->nr_tasks++;
        // 将子进程正式加入 css_set->tasks 链表
        css_set_move_task(child, NULL, cset, false);
    }

    // 检查目标 cgroup 是否被冻结(kernel/cgroup/cgroup.c:6924)
    if (!(child->flags & PF_KTHREAD)) {
        if (unlikely(test_bit(CGRP_FREEZE, &cgrp_flags))) {
            // 设置 JOBCTL_TRAP_FREEZE,子进程运行后自动进入冻结状态
            spin_lock(&child->sighand->siglock);
            child->jobctl |= JOBCTL_TRAP_FREEZE;
            spin_unlock(&child->sighand->siglock);
        }
        // 检查 cgroup 是否正在被 kill(kill_seq 不匹配)
        kill = kargs->kill_seq != cgrp_kill_seq;
    }

    spin_unlock_irq(&css_set_lock);

    // 调用各子系统的 fork() 钩子(在 css_set 绑定后调用,顺序重要)
    do_each_subsys_mask(ss, i, have_fork_callback) {
        ss->fork(child);
    } while_each_subsys_mask();

    // 如果这是 CLONE_NEWCGROUP,更新 cgroup namespace 的 root_cset
    if (kargs->flags & CLONE_NEWCGROUP) {
        struct css_set *rcset = child->nsproxy->cgroup_ns->root_cset;
        get_css_set(cset);
        child->nsproxy->cgroup_ns->root_cset = cset;
        put_css_set(rcset);
    }

    // 如果目标 cgroup 正在被 kill,立即向子进程发送 SIGKILL
    if (unlikely(kill))
        do_send_sig_info(SIGKILL, SEND_SIG_NOINFO, child, PIDTYPE_TGID);

    cgroup_css_set_put_fork(kargs);  // 释放锁和临时引用
}

关键设计问题:为什么要三个阶段?

  1. 阶段 1(cgroup_fork):在进程完全初始化之前,需要一个有效的 css_set 指针,否则后续代码可能解引用空指针。
  2. 阶段 2(cgroup_can_fork):需要在进程对外可见之前完成 css_set 的选择和子系统预检查(如 pids 子系统的配额检查)。如果在此阶段失败,fork 直接返回错误,子进程不会被创建。
  3. 阶段 3(cgroup_post_fork):在子进程已经有 PID 且即将被唤醒的时刻,将其正式加入 css_set 的 task 链表,并调用 fork() 钩子(如 pids 子系统在此增加计数)。注释明确说明 fork() 钩子必须在 css_set 绑定之后调用,否则进程状态变化和 css_set 记录之间会产生竞态。

12. memcg 内存子系统深度解析

12.1 内存计费流程

页面分配时的完整计费路径(mm/memcontrol.c:4755):

int __mem_cgroup_charge(struct folio *folio, struct mm_struct *mm, gfp_t gfp)
{
    struct mem_cgroup *memcg;
    int ret;

    // 1. 从 mm_struct 获取对应的 mem_cgroup
    memcg = get_mem_cgroup_from_mm(mm);

    // 2. 执行计费
    ret = charge_memcg(folio, memcg, gfp);
    css_put(&memcg->css);

    return ret;
}

static int charge_memcg(struct folio *folio, struct mem_cgroup *memcg, gfp_t gfp)
{
    // 3. 核心计费函数
    ret = try_charge(memcg, gfp, folio_nr_pages(folio));
    if (ret) goto out;

    // 4. 计费成功:增加 memcg 的 css 引用,提交计费
    css_get(&memcg->css);
    commit_charge(folio, memcg);  // 设置 folio->memcg_data = memcg
    memcg1_commit_charge(folio, memcg);  // v1 兼容
out:
    return ret;
}

12.2 try_charge_memcg 重试与 OOM 流程

// mm/memcontrol.c:2355
static int try_charge_memcg(struct mem_cgroup *memcg, gfp_t gfp_mask,
                             unsigned int nr_pages)
{
    unsigned int batch = max(MEMCG_CHARGE_BATCH, nr_pages);
    int nr_retries = MAX_RECLAIM_RETRIES;

retry:
    // 1. 快路径:从 per-cpu stock 消费(批量缓存,避免原子操作)
    if (consume_stock(memcg, nr_pages))
        return 0;

    // 2. 尝试原子地增加页面计数器
    if (page_counter_try_charge(&memcg->memory, batch, &counter))
        goto done_restock;

    // 3. 超出限额:找到触发限额的 memcg(可能是祖先)
    mem_over_limit = mem_cgroup_from_counter(counter, memory);

    // 4. 特殊情况处理
    if (unlikely(current->flags & PF_MEMALLOC))
        goto force;      // 内存分配上下文中,强制分配避免死锁
    if (unlikely(task_in_memcg_oom(current)))
        goto nomem;      // 已经在 OOM 中,不能再等待
    if (!gfpflags_allow_blocking(gfp_mask))
        goto nomem;      // 原子上下文,直接失败

    // 5. 触发内存回收
    nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit, nr_pages,
                                                gfp_mask, reclaim_options, NULL);
    if (mem_cgroup_margin(mem_over_limit) >= nr_pages)
        goto retry;

    // 6. 排干所有 CPU 的 stock 缓存
    if (!drained) {
        drain_all_stock(mem_over_limit);
        drained = true;
        goto retry;
    }

    // 7. 多次重试仍失败:触发 OOM
    if (mem_cgroup_oom(mem_over_limit, gfp_mask,
                       get_order(nr_pages * PAGE_SIZE))) {
        passed_oom = true;
        nr_retries = MAX_RECLAIM_RETRIES;
        goto retry;  // OOM killer 成功杀死进程后重试
    }
nomem:
    return -ENOMEM;
}

Stock 机制是 memcg 高性能的关键:每个 CPU 维护一个"stock",缓存 MEMCG_CHARGE_BATCH(默认 32)个页面的配额。大多数 folio 计费只需要操作本地 CPU 的 stock,完全无锁。只有 stock 耗尽或溢出时才需要原子操作更新全局 page_counter

12.3 memory.high 限流机制

memory.high 是软限制,超出时触发主动回收 + 延迟惩罚,而不是直接 OOM:

内存使用量
     |
     +-- memory.min    <- 保护线:低于此值时不回收本 cgroup 的页面
     |
     +-- memory.low    <- 保护线:尽量不回收本 cgroup 的页面
     |
     +-- memory.high   <- 软限制:超出时回收 + 限流(sleep)
     |                            触发 __mem_cgroup_handle_over_high()
     |
     +-- memory.max    <- 硬限制:超出时直接 OOM kill

超出 memory.high 后的惩罚计算(mm/memcontrol.c:2228):

static unsigned long calculate_high_delay(struct mem_cgroup *memcg,
                                          unsigned int nr_pages,
                                          u64 max_overage)
{
    u64 penalty_jiffies;

    // 超出越多,惩罚越重(二次函数关系)
    penalty_jiffies = max_overage * max_overage * HZ;
    penalty_jiffies >>= MEMCG_DELAY_PRECISION_SHIFT;
    penalty_jiffies = div64_u64(penalty_jiffies * nr_pages,
                                MEMCG_CHARGE_BATCH);

    return min(penalty_jiffies, (u64)MAX_SCHEDULE_TIMEOUT);
}

惩罚机制使超额使用的进程主动睡眠,给回收线程时间清理页面,从而在不触发 OOM 的情况下实现"弹性限制"。mm/memcontrol.c:2304 的注释说明了 memory.highmemory.max 的本质区别:

memory.high is breached and reclaim is unable to keep up. Throttle allocators proactively to slow down excessive growth.

这是产生"背压"(back pressure)而不是立即杀进程的设计哲学。

12.4 memcg OOM killer

memory.max 被突破且内存回收失败后,触发 memcg 范围内的 OOM killer:

mem_cgroup_oom()
    |
    +-- 检查是否允许在该上下文 OOM kill
    |
    +-- mem_cgroup_out_of_memory()
    |       |
    |       +-- out_of_memory()
    |               |
    |               +-- select_bad_process()  -> 在 memcg 子树内选择
    |                                           oom_score_adj 最高的进程
    |
    +-- 等待 OOM kill 完成(用于同步)

与全局 OOM killer 的区别:memcg OOM killer 只在该 cgroup 子树内选择牺牲进程,不影响整个系统。这是容器隔离的关键保证。

12.5 rstat 懒传播统计

memcg 统计数据采用懒传播(lazy propagation)设计(include/linux/cgroup-defs.h:390):

"When a stat gets updated, the css_rstat_cpu and its ancestors are linked into the updated tree. On the following read, propagation only considers and consumes the updated tree."

这意味着读取统计数据的代价正比于"自上次读取以来活跃的子树数量",而非总子树数量。在 cgroup 数量很多但活跃 cgroup 较少的场景(如大型 Kubernetes 集群中有许多休眠的 pod)中,这个优化极大降低了统计读取开销。


13. cpu 子系统:CFS bandwidth 与硬限制

13.1 cpu.weight(CFS 权重)

cpu.weight 实现 CFS(Completely Fair Scheduler)的比例共享,默认值为 100,范围 1-10000:

cgroup 树示例:
|   根 cgroup
+-- app/        cpu.weight = 200  -> 获得 2/3 的 CPU 时间
+-- system/     cpu.weight = 100  -> 获得 1/3 的 CPU 时间

每个 cgroup 对应一个 struct task_group(包含一个 sched_entity 和一个 cfs_rq)。权重值最终转化为 CFS 的 load.weight,影响虚拟时间(vruntime)的流逝速率。权重较高的 cgroup 的 vruntime 增长更慢,因此在 CFS 的最小堆中更靠前,获得更多调度机会。

13.2 cpu.max(带宽硬限制)

cpu.max 实现 CFS bandwidth control,格式为 $MAX $PERIOD

# 允许该 cgroup 在每个 100ms 周期内使用最多 50ms 的 CPU 时间
echo "50000 100000" > /sys/fs/cgroup/app/cpu.max

CFS bandwidth 实现原理:

CFS bandwidth 机制:

  bandwidth_timer(全局周期定时器,每个 period 触发一次)
      重新填充 runtime_remaining = max_bytes
      唤醒所有被节流的 cfs_rq

  运行时扣除:
      每个 cpu_bandwidth_slice(时间片,默认 5ms)
      当 cgroup 的 runtime_remaining 用尽时,节流该 cfs_rq

  throttled_cfs_rq:
      被节流的 cfs_rq 从 rq 中摘除
      task 无法被调度,直到 timer 触发补充配额

13.3 cpu.pressure(PSI 压力指标)

v2 cpu 子系统集成了 PSI(Pressure Stall Information),通过 cpu.pressure 文件暴露该 cgroup 的 CPU 压力指标。PSI 指标分为 some(部分任务等待)和 full(所有任务等待),分别提供 10s/60s/300s 窗口的平均值,用于监控和自动伸缩决策。


14. io 子系统:blkcg 与权重/速率控制

14.1 blkcg 架构

每个 (blkcg, 块设备) 组合对应一个 blkcg_gq(group queue),管理该 cgroup 对该设备的 IO 请求队列:

// include/linux/blk-cgroup.h
struct blkcg {
    struct cgroup_subsys_state  css;
    spinlock_t                  lock;
    struct radix_tree_root      blkg_tree;  // 设备 -> blkg 映射
    struct blkcg_gq             *blkg_hint; // 最近使用的 blkg 缓存
    struct hlist_head           blkg_list;
    struct blkcg_policy_data    *cpd[BLKCG_MAX_POLS]; // 各策略私有数据
    ...
};

14.2 io.weight(比例调度)

基于 BFQ(Budget Fair Queuing)调度器实现 IO 权重:

# 在所有使用 /dev/sda 的 cgroup 中,按权重比例分配 IO 带宽
echo "default 200" > /sys/fs/cgroup/app/io.weight  # 全局默认权重
echo "8:0 500" >> /sys/fs/cgroup/app/io.weight     # 对 /dev/sda 的特定权重

BFQ 在队列级别实现预算公平(budget fairness),根据 io.weight 分配 IO 请求数量,同时考虑设备延迟特性。

14.3 io.max(速率硬限制)

基于 throttle 机制实现速率限制:

# 格式:MAJ:MIN rbps=N wbps=N riops=N wiops=N
echo "8:0 rbps=10485760 wbps=10485760" > /sys/fs/cgroup/app/io.max
# 限制 /dev/sda 的读写各 10MB/s

throttle 实现:throtl_grp 维护令牌桶,超出速率的 IO 请求被暂时挂起在 throtl_service_queue 中,待令牌补充后再发出。

14.4 io.pressure

类似 cpu.pressure,io.pressure 暴露 IO 压力指标,可用于探测存储 IO 瓶颈并触发水平扩展或降级策略。


15. pids 子系统:进程数量限制

15.1 pids_cgroup 结构

// kernel/cgroup/pids.c:49
struct pids_cgroup {
    struct cgroup_subsys_state css;

    atomic64_t counter;     // 当前进程数(层级累计)
    atomic64_t limit;       // 最大进程数(PIDS_MAX 表示无限)
    int64_t    watermark;   // 历史峰值(pids.peak 文件展示)

    struct cgroup_file events_file;
    struct cgroup_file events_local_file;

    // 事件计数器(PIDCG_MAX:本 cgroup 触发限制;PIDCG_FORKFAIL:因祖先限制失败)
    atomic64_t events[NR_PIDCG_EVENTS];
    atomic64_t events_local[NR_PIDCG_EVENTS];
};

15.2 层级式计数

pids 子系统的计数是层级累计的:每次 fork 时,从叶节点到根逐级累加(kernel/cgroup/pids.c:166):

static int pids_try_charge(struct pids_cgroup *pids, int num, struct pids_cgroup **fail)
{
    struct pids_cgroup *p, *q;

    // 沿层级向上逐级尝试计费
    for (p = pids; parent_pids(p); p = parent_pids(p)) {
        int64_t new = atomic64_add_return(num, &p->counter);
        int64_t limit = atomic64_read(&p->limit);

        if (new > limit) {
            *fail = p;      // 记录触发限制的层级
            goto revert;
        }
        pids_update_watermark(p, new);
    }
    return 0;

revert:
    // 撤销已计费的层级(从叶到触发限制的层级)
    for (q = pids; q != p; q = parent_pids(q))
        pids_cancel(q, num);
    pids_cancel(p, num);
    return -EAGAIN;
}

设计亮点:层级计数使得父 cgroup 的 pids.current 始终是所有子孙 cgroup 进程数之和。这个设计既保证了层级限制的正确性,又避免了维护全局计数器的开销。atomic64 操作本身的高效性(x86 的 lock xadd 指令)保证了高并发 fork 场景下的性能。

15.3 fork 拦截

pids 子系统通过 can_fork() 钩子拦截 fork:

static int pids_can_fork(struct task_struct *task, struct css_set *cset)
{
    struct cgroup_subsys_state *css = cset->subsys[pids_cgrp_id];
    struct pids_cgroup *pids = css_pids(css);
    struct pids_cgroup *fail;
    int err;

    err = pids_try_charge(pids, 1, &fail);
    if (err) {
        // fork 超出限额,返回 -EAGAIN
        atomic64_inc(&fail->events[PIDCG_MAX]);
        atomic64_inc(&pids->events_local[PIDCG_FORKFAIL]);
        cgroup_file_notify(&fail->events_file);
        cgroup_file_notify(&pids->events_local_file);
    }
    return err;
}

返回 -EAGAIN(而非 -ENOMEM)是故意的:POSIX 规定 fork 失败可以重试(EAGAIN 语义),允许进程在资源暂时不足时稍后重试,而不是立即报错。区分 PIDCG_MAX(本 cgroup 触发)和 PIDCG_FORKFAIL(祖先触发)便于监控工具定位限制来源。


16. cgroup 冻结机制

16.1 冻结状态数据结构

// include/linux/cgroup-defs.h:436
struct cgroup_freezer_state {
    bool freeze;                    // 期望的冻结状态(用户写 cgroup.freeze=1)
    bool e_freeze;                  // 有效冻结状态(考虑祖先的状态)

    int nr_frozen_descendants;      // 冻结的后代 cgroup 数量
    int nr_frozen_tasks;            // 冻结的任务数量(含 SIGSTOPed 和 PTRACEd)

    seqcount_spinlock_t freeze_seq; // 保护时间戳一致性
    u64 freeze_start_nsec;          // 最近一次冻结请求的时间戳
    u64 frozen_nsec;                // 累计冻结时长
};

cgroup 冻结有两个标志位(include/linux/cgroup-defs.h:70):

CGRP_FREEZE  // 期望冻结:用户写入 cgroup.freeze = 1
CGRP_FROZEN  // 实际冻结:所有任务都已进入冻结状态

两者之间存在时间差:写入 cgroup.freeze = 1 后,内核需要逐个通知并等待 cgroup 内所有任务进入 TASK_STOPPED 状态,CGRP_FROZEN 才会置位。

16.2 冻结实现机制

冻结的核心是 JOBCTL_TRAP_FREEZE 标志和信号处理路径:

写入 cgroup.freeze = 1
    |
    v
freeze_cgroup(cgrp)
    |
    +-- 设置 CGRP_FREEZE 标志
    |
    +-- 遍历 cgroup 内所有任务
            |
            +-- 对每个任务设置 JOBCTL_TRAP_FREEZE
                kick_process() 发送 reschedule IPI
                    |
                    v
              任务在下一次信号检查点(get_signal())发现标志
              kernel/signal.c:161 的 recalc_sigpending_tsk() 检查到:
              if ((t->jobctl & (JOBCTL_PENDING_MASK | JOBCTL_TRAP_FREEZE)) || ...)
                    |
                    v
              do_freezer_trap()    [kernel/signal.c:2696]
                    |
                    +-- 检查只有 JOBCTL_TRAP_FREEZE 标志(无其他 trap)
                    +-- current->frozen = true
                    +-- cgroup_update_frozen()  <- 更新 CGRP_FROZEN 状态
                    +-- schedule()              <- 进入睡眠

do_freezer_trap() 的核心逻辑(kernel/signal.c:2704):

if ((current->jobctl & (JOBCTL_PENDING_MASK | JOBCTL_TRAP_FREEZE)) !=
     JOBCTL_TRAP_FREEZE) {
    // 还有其他 trap 标志,先处理它们,再来冻结
    spin_unlock_irq(&current->sighand->siglock);
    return;
}
// 只有 JOBCTL_TRAP_FREEZE:安全进入冻结
__refrigerator(true);   // frozen = true, then schedule()

16.3 fork 时继承冻结状态

当一个已冻结的 cgroup 内 fork 子进程时(kernel/cgroup/cgroup.c:6924):

if (unlikely(test_bit(CGRP_FREEZE, &cgrp_flags))) {
    spin_lock(&child->sighand->siglock);
    WARN_ON_ONCE(child->frozen);
    child->jobctl |= JOBCTL_TRAP_FREEZE;  // 子进程也会被冻结
    spin_unlock(&child->sighand->siglock);
}

子进程一旦被唤醒并运行到信号检查点,就会自动进入冻结状态,无需额外操作。

16.4 解冻流程

写入 cgroup.freeze = 0
    |
    v
unfreeze_cgroup(cgrp)
    |
    +-- 清除 CGRP_FREEZE 标志
    |
    +-- 遍历 cgroup 内所有冻结任务
            |
            +-- 清除 JOBCTL_TRAP_FREEZE 标志
                signal_wake_up() 唤醒任务
                    |
                    v
              任务从 schedule() 返回
              重新检查 jobctl,无 TRAP_FREEZE 标志
              current->frozen = false
              继续正常执行

16.5 实际应用:容器热迁移(CRIU)

冻结机制为 CRIU(Checkpoint/Restore In Userspace)提供了基础:

热迁移流程:
1. echo 1 > /sys/fs/cgroup/container/cgroup.freeze
   (等待 CGRP_FROZEN 置位,确认所有进程停止)
2. 转储容器状态(进程内存、文件描述符、网络状态等)
3. 在目标机器上恢复
   (含 PID namespace 中的精确 PID 恢复,通过 set_tid 实现)
4. echo 0 > /sys/fs/cgroup/container/cgroup.freeze
   (解冻,恢复运行)

冻结 + PID namespace 的 set_tid 机制合作,使得容器在源机器和目标机器上有完全相同的进程 PID,对容器内应用透明。


17. 锁机制与并发安全

17.1 cgroup 子系统的锁层次

cgroup 锁层次(从最高级到最低级):

  cgroup_mutex(struct mutex)
      保护:cgroup 层级结构变更、subsys 启用/禁用
      使用:mkdir/rmdir、echo > subtree_control、task 迁移
      定义:kernel/cgroup/cgroup.c:89

      |
      v
  cgroup_threadgroup_rwsem(struct percpu_rw_semaphore)
      保护:task 在 cgroup 间迁移时的线程组一致性
      写端:task 迁移、CLONE_INTO_CGROUP
      读端:fork、exec(防止迁移中途插入新线程)
      定义:kernel/cgroup/cgroup.c:116

      |
      v
  css_set_lock(spinlock_t)
      保护:task->cgroups 指针、css_set->tasks 链表
      使用:css_set_move_task、task fork/exit
      定义:kernel/cgroup/cgroup.c:90

cgroup_threadgroup_rwsem 的存在原因kernel/cgroup/cgroup.c:89 注释):

当把一个多线程进程迁移到新 cgroup 时,需要保证整个线程组同时迁移(cgroup 的原子迁移语义)。cgroup_threadgroup_rwsem 的写端在迁移开始时获取,阻止任何线程 fork 新子线程(fork 需要读端);迁移完成后释放写端,其他线程的 fork 才能继续。

CGRP_ROOT_FAVOR_DYNMODS 标志(include/linux/cgroup-defs.h:105)可以将全局 cgroup_threadgroup_rwsem 改为 per-threadgroup 的 rwsem,降低高并发场景下的锁竞争,但会增加 fork/exec 路径的开销。如 include/linux/cgroup-defs.h:90 注释所述:

"Alleviate the contention between fork, exec, exit operations and writing to cgroup.procs by taking a per threadgroup rwsem instead of the global cgroup_threadgroup_rwsem."

17.2 RCU 的使用

cgroup 子系统大量使用 RCU 保护读路径:

// task->cgroups 使用 RCU 读(无锁)
rcu_read_lock();
cset = task_css_set(task);  // rcu_dereference(task->cgroups)
// 使用 cset ...
rcu_read_unlock();

// cgroup->subsys[] 使用 RCU 保护
rcu_read_lock();
css = rcu_dereference(cgrp->subsys[ssid]);
// 使用 css ...
rcu_read_unlock();

css 的 percpu_ref 也内嵌了 RCU 语义:css_put() 最终通过 call_rcu() 延迟释放,保证在所有 RCU 读取完成后才释放内存。

17.3 Namespace 的并发访问规则

总结 include/linux/nsproxy.h:69 的访问规则:

访问当前进程的 ns:  直接解引用,无需锁
访问其他进程的 ns:  task_lock(task) + 检查 nsproxy != NULL + 增引用计数
修改 ns 指针:       task_lock(task) 保护赋值
nsproxy 自身引用:   refcount_t count(非 percpu,因为共享者不多)
namespace 对象引用: ns_common.__ns_ref(refcount_t)
namespace 活跃引用: ns_common.__ns_ref_active(atomic_t,控制可见性)

18. 总结:设计哲学与工程取舍

18.1 Namespace 设计的哲学

渐进式隔离是 Linux namespace 的核心设计哲学。与 Plan 9 等系统从头设计完整隔离不同,Linux namespace 是在已有内核基础上逐步添加的:

2002  mnt namespace     (Linux 2.4.19)  最小侵入性
2006  uts/ipc namespace (Linux 2.6.19)  快速扩展
2008  pid/net namespace (Linux 2.6.24)  核心隔离
2013  user namespace    (Linux 3.8)     解锁非特权容器
2016  cgroup namespace  (Linux 4.6)     完善容器视图隔离
2020  time namespace    (Linux 5.6)     补全最后一块拼图

nsproxy 的 COW 设计使得这种渐进扩展几乎不影响不使用 namespace 的普通进程路径(快路径只需要一次 get_nsproxy() 引用计数增加)。

18.2 cgroup v2 的设计哲学

统一层级 + 委托模型是 cgroup v2 的核心创新:

  1. 统一层级:消除 v1 中"同一进程在不同子系统树的不同位置"的歧义。
  2. 委托模型:通过 cgroup.subtree_control 精确控制哪些子系统开放给子 cgroup 管理,实现安全的多租户 cgroup 管理。
  3. 无内部任务约束:v2 中,一旦 cgroup 有子 cgroup,就不允许直接在其中放置任务(除 threaded 模式),强制形成清晰的树型结构。
  4. 线程模式(threaded):允许在同一 cgroup 内以线程粒度管理资源,而非仅进程粒度,满足特定工作负载需求。

18.3 关键工程取舍

设计选择 优势 代价
css_set 哈希共享 fork/exit 只需 O(1) 引用计数操作 需要哈希表查找
percpu_ref for css 热路径引用操作几乎无竞争 销毁时需等待所有 CPU RCU 周期
memcg stock 批量减少原子操作,接近零开销 配额可能有 ±32页 瞬时不精确
nsproxy COW 普通 fork 无需创建 nsproxy 需要精细的引用计数管理
pid_namespace IDR O(log n) PID 分配,内存紧凑 IDR 本身有一定复杂性
ancestors[] 数组 O(1) 祖先关系查询 每个 cgroup 创建时需额外分配数组
双层 ns 引用计数 精细区分内存生命周期和可见性 两套引用计数的维护复杂性
rstat 懒传播 统计读取代价正比于活跃 cgroup 数 首次读取需要全量传播,有抖动

18.4 未来方向

从代码中可以观察到几个正在演进的方向:

  1. 双层 ns 引用计数include/linux/ns/ns_common_types.h:43):__ns_ref__ns_ref_active 的分离使得 namespace "inactive but alive" 状态成为可能,为更精细的生命周期管理打基础,也支持 SIOCGSKNS ioctl 对非活跃 namespace 的复活机制。

  2. CGRP_ROOT_FAVOR_DYNMODSinclude/linux/cgroup-defs.h:105):per-threadgroup rwsem 降低高并发场景下 cgroup_threadgroup_rwsem 的竞争,适配 Kubernetes 等高密度容器场景中大量并发 fork 的需求。

  3. rstat 懒传播include/linux/cgroup-defs.h:389):统计数据只在"被更新的子树"中传播,使读统计的代价正比于活跃 cgroup 数量而非总数,适合大规模 cgroup 场景。

  4. PSI 集成cgroup.psi):将压力指标内嵌到 cgroup,为自动伸缩和资源调度提供细粒度反馈信号,是 Linux 向"可观测性优先"演进的体现。

  5. CLONE_INTO_CGROUP:允许 fork 时直接将子进程放入指定 cgroup,避免 fork 后再迁移的窗口期(迁移期间子进程处于错误的 cgroup),是容器运行时的重要需求。

+--------------------------------------------------------------------+
|                    关键源文件索引                                    |
+--------------------------------------------------------------------+
|  include/linux/nsproxy.h           nsproxy 结构体和 COW 语义        |
|  include/linux/ns/ns_common_types.h  ns_common 双层引用计数设计     |
|  include/linux/pid_namespace.h     pid_namespace 结构体             |
|  include/linux/pid.h               struct pid / struct upid         |
|  include/linux/cgroup-defs.h       cgroup/css/css_set 三核心结构体  |
|  kernel/nsproxy.c                  copy_namespaces/unshare/setns    |
|  kernel/pid_namespace.c            PID ns 创建/销毁/嵌套验证         |
|  kernel/pid.c                      alloc_pid 多层 PID 分配          |
|  kernel/cgroup/cgroup.c            css_set 哈希表、fork 三阶段      |
|  kernel/cgroup/pids.c              pids 子系统层级计费               |
|  mm/memcontrol.c                   memcg 计费/回收/OOM/high 限流    |
|  fs/mount.h                        mnt_namespace 结构体             |
|  kernel/signal.c                   cgroup 冻结 JOBCTL_TRAP_FREEZE   |
+--------------------------------------------------------------------+

由 Claude Code 分析生成