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
2
mount -t cgroup -o cpu none /sys/fs/cgroup/cpu
mount -t cgroup -o memory none /sys/fs/cgroup/memory

这样 cpumemory 分别在不同目录下形成各自的树。这意味着一个进程可能同时属于两个不同的 cgroup(一个在 cpu 树,一个在 memory 树)。这听起来灵活,但带来了几个缺陷:

  1. 复杂度爆炸:每个进程需要记录多个 hierarchy 中的 cgroup 关系,css_set 的交叉组合导致管理困难。
  2. 资源控制器间的关联丢失:例如 memory 和 blkio 的协同控制(如“如果内存超限,则限制 I/O”)变得困难,因为两个子系统不在同一树上。
  3. 使用混乱:用户容易忘记挂载了哪些 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
              +---------------------+
| cgroup_root | (每个 hierarchy 一个)
+---------------------+
/ \
+-----------+ +-----------+
| cgroup | ... | cgroup | (根的子组)
+-----------+ +-----------+
/ \ / \
+-------+ +-------+ +-------+ +-------+
| cg | | cg | | cg | | cg | ... (子 cgroup)
+-------+ +-------+ +-------+ +-------+
| | | |
v v v v
+-----------------------------------------------+
| css_set(每个 task 一个指针) |
| 包含:cgroup_subsys_state *[] (按 subsystem ID) |
+-----------------------------------------------+
|
v
+-----------------------------+
| task_struct |
| -> css_set *cgroups | (指向所属的 css_set)
+-----------------------------+
  • 每个 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()。它的主要工作:

  1. 创建一个 struct cgroup_root(代表 hierarchy)。
  2. 分配根 cgroup(root->cgrp)。
  3. 对于每个在 cgroup_dfl_kernel_subsys 中注册的 subsystem,调用 cgroup_create() 创建对应的 cgroup_subsys_state,并挂在根 cgroup 下。
  4. 将根 cgroup 的目录挂载到文件系统指定路径。

cgroup_create()

用于在已有 cgroup 下创建新的子 cgroup。调用链通常是用户 mkdir 一个目录时,VFS 回调 cgroup_mkdir() -> cgroup_create()

cgroup_create() 完成:

  1. 分配 struct cgroup 结构体。
  2. 设置父子关系(parent 指针,添加到父的 children 链表)。
  3. 针对每个 subsystem,如果父组已启用该子系统,则调用该 subsystem 的 css_alloc() 回调创建子组的 cgroup_subsys_state
  4. 创建 cgroup 文件系统目录和默认的控制文件(如 cgroup.procs, memory.current 等)。

关键代码路径

以下路径基于 linux-next 6e845bcb,行号因版本可能不同,只给出文件路径:

  1. 挂载 hierarchykernel/cgroup/cgroup.c -> cgroup_mount() -> cgroup_setup_root()
  2. 创建子 cgroupkernel/cgroup/cgroup.c -> cgroup_mkdir() -> cgroup_create()
  3. 移动进程:写入 cgroup.procs 文件触发 cgroup_attach_task(),它会更新进程的 css_set
  4. 初始化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 的数据结构与核心工作原理。