cgroup v2统一层级:告别v1多树混乱,驯服资源隔离
cgroup 基础:从入门到 v1/v2 对比
源码版本:本文所有源码引用均基于 linux-next 6e845bcb,不同内核版本代码行号可能不同。
前置知识
- 无(系列入口,读者需有基础 Linux 命令行经验,了解进程、内存、CPU 等基本概念)
背景:为什么需要了解这个
在多进程、多租户的操作系统环境中,资源争抢是常态。如果没有限制机制,一个进程可能吞噬所有 CPU、内存或 I/O 带宽,导致其他进程甚至整个系统崩溃。手机厂商希望控制后台 App 的内存使用,云厂商需要为每个容器设定硬限制和软限制。
传统的 nice 值或 ulimit 只能提供粗粒度的资源限制,且无法对进程组整体做隔离。Linux cgroup(control group)应运而生:它允许将任意进程集合到一个“控制组”,然后对这个组整体施加资源约束(内存上限、CPU 时间配额等),同时可以统计资源使用量。Android 利用 cgroup 对前台/后台进程设置不同的内存回收优先级;云服务商(如 Docker/Kubernetes)依赖 cgroup 实现容器资源隔离。
本文作为系列的开篇,从最基础的 cgroup 概念讲起,对比 v1 和 v2 两个版本的架构差异,并带你首次接触核心的初始化与创建代码。
基础概念:读懂本文需要知道的
cgroup(control group)是一种内核机制,允许将进程按层次组织,并对每个组施加资源控制。每个组可以挂载零个或多个 subsystem(资源控制器,如 memory、cpu、blkio 等)。子系统负责具体的资源管理,如 memory 子系统会为每个 cgroup 维护独立的内存使用计数和限制。
hierarchy(层级树)是 cgroup 的组织形式:所有 cgroup 构成一棵树,根节点通常由挂载时创建。一个子 cgroup 是父 cgroup 的细分,继承父组的资源约束设定。
css_set(cgroup_subsys_state set)是内核中的一个关键数据结构:每个进程(task_struct)关联一个 css_set,其中保存了该进程在每个已挂载 subsystem 中所属的 cgroup 的状态指针(cgroup_subsys_state *)。简单说,css_set 是进程与各 cgroup 之间的桥梁。
挂载点(mount point):cgroup 文件系统通过 mount -t cgroup 挂载到某个目录,挂载后该目录下会以目录层级的形式展示 cgroup 树,每个目录对应一个 cgroup,目录内包含 subsystem 的控制文件(如 memory.limit_in_bytes)。用户只需读写这些文件即可控制资源。
核心机制详解
设计思路:为什么需要层级?
假设我们只有“根组”和“进程组”两级,那么限制只能针对所有子进程整体,无法做更细粒度的隔离。通过层级树,我们可以把进程按“容器 → 服务 → 线程”的粒度嵌套,并且父组的限制自动成为子组的上限(例如父组内存上限 1G,子组上限 200M 生效时,子组实际可用不超过 min(父组, 子组) = 200M)。这种继承关系使管理变得自然。
v1 的多 hierarchy 问题
cgroup v1 允许同时挂载多个独立的 hierarchy,每个 hierarchy 可以关联任意选择的 subsystem。例如:
1 | mount -t cgroup -o cpu none /sys/fs/cgroup/cpu |
这样 cpu 和 memory 分别在不同目录下形成各自的树。这意味着一个进程可能同时属于两个不同的 cgroup(一个在 cpu 树,一个在 memory 树)。这听起来灵活,但带来了几个缺陷:
- 复杂度爆炸:每个进程需要记录多个 hierarchy 中的 cgroup 关系,
css_set的交叉组合导致管理困难。 - 资源控制器间的关联丢失:例如 memory 和 blkio 的协同控制(如“如果内存超限,则限制 I/O”)变得困难,因为两个子系统不在同一树上。
- 使用混乱:用户容易忘记挂载了哪些 hierarchy,或者错误地在一个 hierarchy 中挂载了互斥的 subsystem(如同时挂载 cpu 和 cpuset 会引发冲突)。
v2 的统一 hierarchy 改变
cgroup v2 专门解决了上述问题:所有子系统必须在同一个 hierarchy 中挂载(即统一 hierarchy)。你只能挂载一次 cgroup v2 根:
1 | mount -t cgroup2 none /sys/fs/cgroup |
然后通过在该目录下创建子目录来形成一棵树,所有受控的 subsystem 都作用于这棵树。每个进程只有一个祖先 cgroup(即它所属的目录)。子系统之间可以更好地协作(例如 memory 控制器可以与 io 控制器共享 cgroup 状态)。v2 还简化了控制文件接口(去掉了 tasks 文件,改为 cgroup.procs),并移除了许多争议特性。
核心数据结构关系
1 | +---------------------+ |
- 每个
cgroup内部有struct cgroup_subsys_state数组,每个 subsystem 对应一个结构体,保存该 cgroup 下的资源状态。 css_set保存一个cgroup_subsys_state指针数组,索引是 subsystem id。当进程被移入一个 cgroup 时,内核会更新其css_set,或者如果已经有相同组合的css_set存在,则复用(引用计数+1)。
关键初始化代码入口
cgroup_init() 在系统启动时初始化 cgroup 子系统(kernel/cgroup/cgroup.c)。之后通过挂载触发 cgroup_setup_root() 来建立 hierarchy 根。
cgroup_setup_root()
当用户执行 mount -t cgroup2 none /sys/fs/cgroup 时,VFS 调用 cgroup_mount(),最终进入 cgroup_setup_root()。它的主要工作:
- 创建一个
struct cgroup_root(代表 hierarchy)。 - 分配根 cgroup(
root->cgrp)。 - 对于每个在
cgroup_dfl_kernel_subsys中注册的 subsystem,调用cgroup_create()创建对应的cgroup_subsys_state,并挂在根 cgroup 下。 - 将根 cgroup 的目录挂载到文件系统指定路径。
cgroup_create()
用于在已有 cgroup 下创建新的子 cgroup。调用链通常是用户 mkdir 一个目录时,VFS 回调 cgroup_mkdir() -> cgroup_create()。
cgroup_create() 完成:
- 分配
struct cgroup结构体。 - 设置父子关系(
parent指针,添加到父的children链表)。 - 针对每个 subsystem,如果父组已启用该子系统,则调用该 subsystem 的
css_alloc()回调创建子组的cgroup_subsys_state。 - 创建 cgroup 文件系统目录和默认的控制文件(如
cgroup.procs,memory.current等)。
关键代码路径
以下路径基于 linux-next 6e845bcb,行号因版本可能不同,只给出文件路径:
- 挂载 hierarchy:
kernel/cgroup/cgroup.c->cgroup_mount()->cgroup_setup_root()。 - 创建子 cgroup:
kernel/cgroup/cgroup.c->cgroup_mkdir()->cgroup_create()。 - 移动进程:写入
cgroup.procs文件触发cgroup_attach_task(),它会更新进程的css_set。 - 初始化:
kernel/cgroup/cgroup.c->cgroup_init()-> 初始化css_set缓存、注册文件系统、调用各 subsystem 的css_alloc回调创建根状态。
设计演进:为什么不是更简单的方案
为什么不用“进程直接关联 subsystem 状态”?
最简单的想法:每个进程直接拥有多个资源计数器(memory 上限、cpu 时间),不用 cgroup。但这无法做到进程组级别的动态管理——你需要为每个进程单独设置,无法继承、无法批量调整。cgroup 的层级结构允许你修改父组策略自动影响所有子进程,这是最小管理成本的方案。
v1 的多 hierarchy 是历史的妥协
cgroup v1 设计时,各个 subsystem 的开发者出于独立维护的便利,倾向于各自管理自己的树。结果导致了前文所述的复杂性问题。社区在 v2 中果断放弃多 hierarchy,强制统一,尽管牺牲了部分历史兼容性,但大大降低了内核内部的复杂性,也使得用户 API 更加一致。
为什么保留 css_set 而不直接用 cgroup 指针?
每个任务需要记录它在这一 hierarchy 中属于哪个 cgroup,但一个任务可能同时属于多个 hierarchy 吗?v2 只有一个 hierarchy,所以理论上每个任务只需要一个 cgroup * 指针就够了。但 v2 为了向后兼容 v1 的部分代码,仍然保留了 css_set 结构。而且 css_set 还有另一个作用:当一个 cgroup 被删除但仍有任务在其中运行时(实际不会,v2 禁止了这种情况),或者多个任务拥有完全相同的 cgroup 组合时,共享同一 css_set 可以节省内存。v2 中每个任务仍然指向一个 css_set,但该 css_set 仅包含一个 hierarchy 的信息。
v2 中去掉了 tasks 文件
v1 中可写 tasks 文件来移动进程(按 TID),v2 改用 cgroup.procs(按 PGID/线程组)。这是因为 v2 认为应该对线程组整体操作(不能把线程组内的线程移到不同 cgroup),避免资源控制的不一致。这增加了语义清晰性,但也限制了细粒度线程级控制(已通过 cgroup.threads 文件在后续版本中弥补,但主流 v2 仍以线程组为单位)。
小结
- cgroup 是 Linux 的进程资源控制框架,通过层级树组织进程组,并挂载 subsystem 施加限制。
- v1 允许独立的多 hierarchy,导致复杂度高、控制器间协作困难;v2 强制统一 hierarchy,简化了模型,也更适合容器场景。
- 核心数据结构:
cgroup_root代表 hierarchy 根,cgroup代表一个节点,css_set关联任务与各 subsystem 状态。 - 基本操作:挂载后,通过创建子目录(
mkdir)创建 cgroup,通过读写控制文件(如memory.max)调整限制,通过写cgroup.procs移动进程。 - 入口函数:
cgroup_setup_root()用于初始化 hierarchy 根,cgroup_create()用于添加子节点,理解它们帮助我们迈入内核 cgroup 源码世界。
下一篇将进入内存子系统的具体实现——memcg v2 的数据结构与核心工作原理。