统一层级终结 memcg 资源泄漏:从混乱到精确
源码版本:本文所有源码引用均基于 6e845bcb,不同内核版本/分支的行号可能不同。
背景:这个问题从哪里来
memcg v1 的层级混乱
在 cgroup v1 时代,内存控制器(memcg)允许用户任意创建嵌套层级。每个 cgroup 目录下都可以挂载 memory 子系统,形成一棵多叉树。这种设计看似灵活,却带来了两个根本性问题:
-
资源统计重复与泄漏
每个子节点独立维护自己的memory.usage_in_bytes,而父节点的统计值等于子节点之和。但内核在页面回收、软限制、oom 判定中需要反复向上遍历整棵树,计算 “total” 值。更严重的是,mem_cgroup结构体中为每个层级都分配了独立的page_counter(如memory、memsw、kmem),但子节点被删除后,其统计值并不会从父节点的page_counter中减去——因为父节点只知道子节点 “存在”,却不知道子节点何时释放。这导致资源泄漏:即使所有子任务都已退出,父节点依然保留着已消失子节点的累加值,造成统计飘移。 -
配置粒度混乱
用户可以在任意层级设置memory.limit_in_bytes,但内核的回收/限流逻辑只读取当前任务所在层级的限制,不会向上检查。这导致一个任务可能被其祖先的限制所限制,而另一条路径上的任务却可以超额使用。维护多层级限制的一致性极其困难,且容易出现死锁(例如父子互等对方 OOM)。
内存资源泄漏的典型例子
考虑以下场景:用户创建 /sys/fs/cgroup/memory/A/B,在 B 中运行大量内存密集型进程,然后在进程退出后删除 B。在 v1 中,A 的 memory.usage_in_bytes 不会因为 B 的消失而减少,因为 B 被删除时其 page_counter 并未通知父节点。直到 A 也被删除,这些累积的值才随父结构体一起释放。
核心机制与设计思路
统一层级:只有叶子节点才是 “任务组”
cgroup v2(memory 子系统默认为 v2)强制了统一层级(unified hierarchy)原则:
- 只有叶子 cgroup 才能关联任务(进程/线程)。
- 内部节点(非叶子)不能附加任务,只能作为配置继承的路径。
- 所有的统计和限制只应用于叶子节点;内部节点的统计只用于展示,不影响内核行为。
这一设计彻底消除了 “跨层级统计泄漏” 的问题:因为子节点只可能是叶子,而叶子被删除时,其统计直接从它的直接父节点的 page_counter 中减去(通过 page_counter_cancel 自然完成)。父节点不再需要向上聚合多个子节点的累加值,因为所有任务的统计都直接归属于唯一的叶子 cgroup。
核心数据结构演进
mem_cgroup 结构体
v1 中的 mem_cgroup 包含大量冗余字段(如每个层级的 res_counter、memsw 等),并且通过 memcg->css.cgroup 与 cgroup 子系统绑定。v2 中,mem_cgroup 结构体被精简,核心变化如下:
-
page_counter 替换 res_counter
page_counter是一个原子计数器,支持charge、uncharge、cancel等操作,且能够自动更新父节点的计数器(通过page_counter_charge的parent指针)。这消除了 v1 中手动向上更新的 bug。 -
统一使用
memory和swap作为唯一计数器
v1 中的kmem、memsw、tcpmem等分离计数器在 v2 中不再独立存在。内核 kvmalloc 通过memcg->memory统一管理。 -
新增
vmstats和events字段
用于快速访问统计和事件,避免每次读取都遍历层级。
从 include/linux/memcontrol.h:1(片段)可以看到 mem_cgroup 的成员包括:
1 | struct mem_cgroup { |
page_counter 的层级感知
page_counter 结构体如下(来自常见内核源码,非本次提供,但作为辅助理解):
1 | struct page_counter { |
当 page_counter_charge(memcg->memory, nr_pages) 被调用时,它不仅增加当前计数器的 usage,还会沿着 parent 指针向上更新所有祖先的计数。这是通过读取 memcg->css.parent 并遍历实现的。这使得统计一致且原子。
关键分配/销毁函数的演进
mem_cgroup_css_alloc() (mm/memcontrol.c:4155) 负责创建一个新 memcg 的数据结构。
1 | static struct cgroup_subsys_state * __ref |
在 v2 中,page_counter_init 通过传递 parent 指针建立层级链。当后代销毁时,mem_cgroup_css_free() 会调用 page_counter_set_min/max 或直接释放,但父计数器的 usage 无需显式减去——因为叶子节点释放时,其 page_counter 中的 usage 已经在 uncharge 过程中被扣减,而父计数器的 usage 也因 parent 指针的传播同步更新。
ASCII 图:mem_cgroup 层级与 page_counter 关系
1 | cgroup hierarchy (v2 only leaf tasks) |
Mermaid 图:相同内容
1 | classDiagram |
初始化与销毁流程
mem_cgroup_css_alloc() 核心步骤
- 获取
parent_css对应的mem_cgroup(可能为 NULL,即根)。 - 分配
mem_cgroup结构体(通过kmem_cache_alloc)。 - 初始化各级
page_counter,并设置parent指针。 - 根据
memcg_on_dfl决定是否分配 v1 特有字段(如events_percpu)。 - 返回
css指针。
mem_cgroup_css_free() 销毁流程
对应 mem_cgroup_css_free() 负责清理。关键操作包括:
- 断开与父级的 page_counter 关联(实际上无需显式操作,因为 page_counter 本身不持有引用)。
- 释放
memcg->vmstats等动态分配的内存。 - 调用
mem_cgroup_id_remove()移除 ID 映射。 - 最终通过
rcu_work或直接kmem_cache_free释放结构体。
注意:在销毁过程中,不需要向上 “合计” 残留值,因为所有任务都已经在退出时通过 uncharge 清空了叶子节点的 usage,而父节点的 usage 已经在每次 charge/uncharge 时实时更新。因此,v2 中删除一个叶子节点后,父节点的统计立刻恢复原样(不加任何额外操作)。
关键代码路径
以下路径均引自信源中的实际行号。
1. 获取当前任务的 memcg
1 | get_mem_cgroup_from_current() // mm/memcontrol.c:1154 |
v2 中,当前任务必属于一个叶子 cgroup,因此这条路径始终返回唯一的 memcg,没有歧义。
2. 内存分配时的 charge
1 | charge (如 __memcg_kmem_charge) |
3. 创建新 cgroup:mem_cgroup_css_alloc()
全链路:
1 | cgroup_mkdir() -> cgroup_apply_control() -> css_create() |
在 mem_cgroup_css_alloc 内(mm/memcontrol.c:4155):
1 | static struct cgroup_subsys_state * __ref |
4. 销毁 cgroup:mem_cgroup_css_free()
1 | css_free_rwork_fn() -> css_free_work_fn() -> mem_cgroup_css_free(css) |
注意:page_counter_cancel 在 v2 中通常不需要,因为所有任务在退出时已经 uncharge 完毕。但在一些极端路径(如 cgroup 内有未释放的页面缓存,内核会先强制回收再释放 cgroup),此函数作为一个安全网。
5. 保护计算:mem_cgroup_calculate_protection()
当启用 memory 保护(memory.low/memory.min)时,内核需要计算每个受保护的 cgroup 的可回收值。函数位于 mm/memcontrol.c:5101:
1 | void mem_cgroup_calculate_protection(struct mem_cgroup *root, |
这个函数展示了 v2 如何利用 page_counter 的层级信息进行保护计算,而不需要手动遍历。
与 Android/手机的关联
(无真实技术依据,本节省略)
延伸阅读
- 本系列后续文章将分析 v2 中的
memory.low/memory.min实现、memory.reclaim接口、以及 oom 策略变化。 - 相关内核文档:
Documentation/admin-guide/cgroup-v2.rst(内核源码树内)。 - 初始引入统一层级的 commit(内核主线,可搜索 “unified hierarchy”)。
- 深入
page_counter实现:mm/page_counter.c(源码片段中未给出,但值得自行阅读)。