cgroup v1到v2:统一层级如何终结资源混乱

cgroup 基础:从入门到 v1/v2 对比

源码版本:本文所有源码引用均基于 linux-next 6e845bcb,不同内核版本行号可能不同。下文中的行号为近似值,仅用于定位函数所在文件,实际行号请以对应版本为准。

前置知识

无(本文是系列入口,读者需有基础 Linux 命令行经验,了解进程、资源限制等基本概念)。

背景:为什么需要了解这个

想象你在一台服务器上运行多个 Web 应用,或者在一部手机上同时跑几十个 App。如果没有资源隔离,一个应用的内存泄漏可能拖垮整个系统;一个 CPU 密集的进程可能抢占所有时间片,导致其他进程响应缓慢。这就是资源控制(resource control)要解决的问题。

cgroup(control group,控制组)正是 Linux 内核提供的一组机制,用于将进程按层次分组,并对其进行资源限制、统计和优先级的设置。它被广泛应用于:

  • 容器技术(如 Docker、LXC):每个容器对应一个或多个 cgroup,确保容器间的资源隔离。
  • 系统资源管理(如 systemd):将服务分成独立的控制组,便于监控和限制。
  • Android 内存管理lmkd(low memory killer)利用 memory cgroup 监控每个 App 的内存使用,并在内存紧缺时杀死最耗内存的进程。

要深入理解后续的 memcg(memory cgroup)v2 统一层级,必须先掌握 cgroup 的基本概念和 v1/v2 的区别。没有 cgroup,资源管理就是“无政府状态”;有了 cgroup,系统才能实现精细化的“法治”。

基础概念:读懂本文需要知道的

  • cgroup:一个通过虚拟文件系统暴露的进程分组。每个 cgroup 是一个目录,目录内的文件是控制接口(如 memory.limit_in_bytes),目录下的 tasks 文件包含本组的所有进程 PID。

  • subsystem(子系统):也叫 resource controller(资源控制器)。每个子系统负责一类资源的管理,例如:

    • cpu:限制 CPU 时间片。
    • memory:限制内存使用量。
    • blkio:限制块设备 I/O。
    • pids:限制进程数。
      一个 cgroup 可以关联多个子系统。
  • hierarchy(层级树):cgroup 组织成树状结构,每个节点是一个 cgroup,根节点(root cgroup)默认创建。父 cgroup 可以包含多个子 cgroup,子 cgroup 继承父 cgroup 的资源限制。关键在于:在 v1 中,每个子系统可以拥有独立的 hierarchy(多树);在 v2 中,所有子系统共享一个统一的 hierarchy(单树)。

  • css_set (struct css_set):内核中连接 task 和 cgroup 的核心数据结构。每个 task 属于一个 css_set,而 css_set 指向一组 cgroup_subsys_state(每个子系统一个)。首次出现解释css_set 相当于一张映射表,告诉内核“当前进程受哪些 cgroup 的哪些限制”。

  • 挂载(mount)cgroup 文件系统:通过 mount -t cgroup 命令将 cgroup 文件系统挂载到某个目录。v1 中每个挂载点仅关联一个 subsystem(或一组 subsystem),形成独立 hierarchy;v2 中只能挂载一个 cgroup2 文件系统,且所有子系统都在同一棵树上。

核心机制详解

设计思路:从 v1 的多树到 v2 的单树

为什么 v1 设计成多 hierarchy?
最初的设计者认为,不同资源控制器之间是正交的。比如 CPU 和内存的层级结构可能不同:一个管理员希望按 CPU 核心数量组织分组,另一个希望按内存节点组织。v1 允许每个子系统(或子系统组合)挂载到不同目录,形成彼此独立的树,最大程度提供灵活性。

但 v1 带来了严重的复杂性问题:

  • 一个任务可以同时属于多个不同的 cgroup(来自不同 hierarchy),这使得资源统计和协调变得困难。例如,要获得一个任务的总内存使用,需要跨多个树聚合,开销巨大。
  • 不同 hierarchy 之间无法共享控制策略,例如不能在一个 hierarchy 中同时限制 CPU 和内存(需要分别挂载两个树,再通过额外脚本同步)。
  • 用户空间需要管理多棵树,容易出错,且容器化场景中不支持“单一 pod”的资源组概念。

v2 统一 hierarchy 应运而生
将所有子系统绑定到同一棵树,每个 cgroup 节点包含所有子系统的控制接口。任务只能属于一个 cgroup(不能同时属于多个不同的 hierarchy),任务迁移时所有子系统的状态同步迁移。这大大简化了资源管理逻辑,也使容器编排(如 Kubernetes 中的 Pod)能自然地对应一个 cgroup。

核心数据结构与 ASCII 图

v1 的多 hierarchy 示意:

1
2
3
4
5
6
7
/sys/fs/cgroup/cpu/            /sys/fs/cgroup/memory/
| |
root_cgroup (cpu) root_cgroup (mem)
/ | \ / | \
A1 A2 A3 B1 B2 B3
/ /
A1a B1a
  • 进程 P1 可以同时属于 cpu/A1amemory/B1(从不同树),导致 P1 的 css_set 包含来自两棵树的 cgroup_subsys_state
  • 这种交叉关系使得内核需要维护复杂的 css_set 链表。

v2 的统一 hierarchy 示意:

1
2
3
4
5
6
7
  /sys/fs/cgroup/ (unified)
|
root_cgroup
/ | \
A B C
/ \ |
A1 A2 B1
  • 进程 P1 只能属于一个节点(例如 B/B1),该节点同时包含 cpumemoryblkio 等所有子系统的控制文件。
  • css_set 只需对应一个 cgroup 即可,不再需要跨树聚合。

基本操作(用户视角)

v1 挂载

1
2
mount -t cgroup -o cpu,cpuacct none /sys/fs/cgroup/cpu
mount -t cgroup -o memory none /sys/fs/cgroup/memory

每个挂载点形成独立 hierarchy。创建子 cgroup 直接 mkdir /sys/fs/cgroup/cpu/mygroup,向 tasks 文件写入 PID 即可将进程加入。写控制文件如 echo 1000000 > /sys/fs/cgroup/memory/mygroup/memory.limit_in_bytes

v2 挂载

1
mount -t cgroup2 none /sys/fs/cgroup

所有子系统出现在同一个目录下。创建和操作类似:mkdir /sys/fs/cgroup/mygroup,然后修改 memory.maxcpu.max 等文件。cgroup.procs 文件写入线程组 ID(TGID)或 PID 即可加入。

关键代码路径

以下分析两个内核入口函数,了解内核如何创建和管理 hierarchy。

1. cgroup_setup_root() —— 初始化 hierarchy 根节点

位置kernel/cgroup/cgroup.c(约 5500 行)

作用:在挂载 cgroup 文件系统时被调用,创建一个新的 hierarchy(根 cgroup)。v1 中每一次 mount -t cgroup 都会调用一次,v2 中只在首次挂载时调用。

主要步骤

  1. 解析挂载选项(-o 指定的子系统列表,v1 中指定;v2 中固定为所有已启用的子系统)。
  2. 分配并初始化一个 struct cgroup_root,该结构体代表一个 hierarchy 的根。
  3. 调用 cgroup_alloc_control() 为每个选中的 subsystem 分配 cgroup_subsys_state(初始为根 cgroup 的 state)。
  4. 创建根 cgroup 目录(对应根节点)。
  5. 调用 kernfs_create_root() 创建 kernfs 文件系统的根目录,之后通过 cgroup_populate_dir() 填充默认的控制文件(如 cgroup.procstasks、以及各子系统的接口文件)。

为什么这样设计?
内核通过 cgroup_root 来管理 hierarchy 的全局信息(如子系统列表、挂载点路径等)。cgroup_setup_root() 的设计体现了“挂载即创建根”的语义,与 UNIX 文件系统的 mount 操作一致。

2. cgroup_create() —— 创建一个子 cgroup

位置kernel/cgroup/cgroup.c(约 3200 行)

作用:当用户在 cgroup 目录下 mkdir 时,内核 VFS 回调触发此函数,创建一个新的 cgroup 节点。

主要步骤

  1. mkdir 系统调用传入的 dentry(目录项)和父 cgroup 对象,调用 cgroup_kn_set_live() 确保 kernfs 节点已激活。
  2. 分配并初始化 struct cgroup(每个 cgroup 节点在内核中的表示)。
  3. 继承父 cgroup 的子系统状态:对每个子系统,调用 css_create()css_alloc() 创建子级的 cgroup_subsys_state,并从父级复制默认限制(例如 memory.limit_in_bytes 继承父级的值)。
  4. 将新 cgroup 加入父级的子节点链表,并通过 kernfs 系统创建对应的目录及控制文件。
  5. 最后调用 cgroup_propagate_cgroup_settings() 将父级的一些配置(如冻结状态)传播到子级。

设计关键:继承机制使得资源限制可以沿树传递。父 cgroup 的限额会自动成为子 cgroup 的默认值,但子 cgroup 可以显式覆盖。这保证了资源池的分层管理——你可以在根级设置总限额,然后在子级细分。

设计演进:为什么不是更简单的方案

有人可能会问:为什么不一开始就设计成统一 hierarchy?为什么要搞出 v1 那么复杂的多树?

历史原因

  • cgroup 的早期实现(2006 年左右)主要针对单资源控制,比如 CPU 分组(cpusets)。当时并不需要跨子系统协作,每个子系统独立管理自己的树更简单。
  • 随着容器技术兴起,人们发现跨子系统协同限制非常重要(比如:一个容器同时限制 CPU 和内存)。v1 的“多树”本质上是早期设计没预见到的需求。

为什么不直接抛弃 v1 转向 v2

  • v2 是一次重大重构,需要所有子系统同时适配(例如 memory 子系统的计费单位从 page 改为 folio,接口大规模改变)。为保持向后兼容,v1 代码被保留(通过 CONFIG_CGROUP_V1 编译选项),但内核社区已明确 v2 是未来。
  • 手机场景(Android)也逐步向 v2 迁移:Android 10+ 开始默认使用 cgroup v2,因为统一层级更便于 lmkd 做全局内存压力判断(利用 memory.pressure 文件)。

有没有更简单的方案?

  • 最简单的方案当然是单树(v2 就是),但受限于历史,只能通过版本迭代完成。
  • 另一种极端是所有子系统合并成一个超级 controller?但那样灵活性太差,且每个子系统实现复杂,解耦性不好。当前 v2 的设计:单树 + 可选子系统(通过 CONFIG 和挂载时的 -o 选项控制子系统的启用)已经是灵活性和复杂性的较好平衡。

小结

  • cgroup 是 Linux 内核进程分组和资源隔离的核心机制,通过文件系统接口暴露,任何进程都可以被放入一个 cgroup 并受其限制。
  • v1 使用多 hierarchy:每个子系统(或子系统组)可挂载独立树,任务可以同时加入多棵树。优点灵活,缺点配置复杂、跨树管理困难,导致资源统计和协调开销大。
  • v2 采用统一 hierarchy:所有子系统共享一棵树,任务只能属于一个 cgroup。简化了用户接口和内核实现,是容器化时代的推荐方案。
  • 关键数据结构struct cgroup_root 表示 hierarchy 根,struct cgroup 表示节点,struct css_set 连接 task 和子系统状态。cgroup_setup_root()cgroup_create() 是内核创建 hierarchy 和子 cgroup 的入口函数。
  • 设计演进源于实际需求:v1 的多树是早期产物,v2 的统一层级是应对容器和云原生场景的优化。Android 手机的内存管理也在向 v2 迁移,以利用统一层级下的内存压力监测功能。

掌握了 cgroup 基础,下一篇文章我们将正式进入 memcg v2 统一层级,看 memory 子系统如何在统一 hierarchy 下工作。