统一层级终结 memcg 资源泄漏:从混乱到精确

源码版本:本文所有源码引用均基于 6e845bcb,不同内核版本/分支的行号可能不同。


背景:这个问题从哪里来

memcg v1 的层级混乱

在 cgroup v1 时代,内存控制器(memcg)允许用户任意创建嵌套层级。每个 cgroup 目录下都可以挂载 memory 子系统,形成一棵多叉树。这种设计看似灵活,却带来了两个根本性问题:

  1. 资源统计重复与泄漏
    每个子节点独立维护自己的 memory.usage_in_bytes,而父节点的统计值等于子节点之和。但内核在页面回收、软限制、oom 判定中需要反复向上遍历整棵树,计算 “total” 值。更严重的是,mem_cgroup 结构体中为每个层级都分配了独立的 page_counter(如 memorymemswkmem),但子节点被删除后,其统计值并不会从父节点的 page_counter 中减去——因为父节点只知道子节点 “存在”,却不知道子节点何时释放。这导致资源泄漏:即使所有子任务都已退出,父节点依然保留着已消失子节点的累加值,造成统计飘移。

  2. 配置粒度混乱
    用户可以在任意层级设置 memory.limit_in_bytes,但内核的回收/限流逻辑只读取当前任务所在层级的限制,不会向上检查。这导致一个任务可能被其祖先的限制所限制,而另一条路径上的任务却可以超额使用。维护多层级限制的一致性极其困难,且容易出现死锁(例如父子互等对方 OOM)。

内存资源泄漏的典型例子

考虑以下场景:用户创建 /sys/fs/cgroup/memory/A/B,在 B 中运行大量内存密集型进程,然后在进程退出后删除 B。在 v1 中,Amemory.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_countermemsw 等),并且通过 memcg->css.cgroup 与 cgroup 子系统绑定。v2 中,mem_cgroup 结构体被精简,核心变化如下:

  • page_counter 替换 res_counter
    page_counter 是一个原子计数器,支持 chargeunchargecancel 等操作,且能够自动更新父节点的计数器(通过 page_counter_chargeparent 指针)。这消除了 v1 中手动向上更新的 bug。

  • 统一使用 memoryswap 作为唯一计数器
    v1 中的 kmemmemswtcpmem 等分离计数器在 v2 中不再独立存在。内核 kvmalloc 通过 memcg->memory 统一管理。

  • 新增 vmstatsevents 字段
    用于快速访问统计和事件,避免每次读取都遍历层级。

include/linux/memcontrol.h:1(片段)可以看到 mem_cgroup 的成员包括:

1
2
3
4
5
6
7
8
9
10
11
12
struct mem_cgroup {
struct page_counter memory; // 内存使用量
struct page_counter swap; // 交换使用量
#ifdef CONFIG_MEMCG_KMEM
struct page_counter kmem; // v1 兼容,v2 中仍保留但仅在 root 使用
#endif
/* memory.events */
atomic_long_t memory_events[MEMCG_NR_MEMORY_EVENTS];
atomic_long_t memory_events_local[MEMCG_NR_MEMORY_EVENTS];
struct memcg_vmstats *vmstats;
...
};

page_counter 的层级感知

page_counter 结构体如下(来自常见内核源码,非本次提供,但作为辅助理解):

1
2
3
4
5
struct page_counter {
atomic_long_t usage; // 当前计数
unsigned long max; // 硬上限
struct page_counter *parent; // 父计数器,用于层级更新
};

page_counter_charge(memcg->memory, nr_pages) 被调用时,它不仅增加当前计数器的 usage,还会沿着 parent 指针向上更新所有祖先的计数。这是通过读取 memcg->css.parent 并遍历实现的。这使得统计一致且原子。

关键分配/销毁函数的演进

mem_cgroup_css_alloc() (mm/memcontrol.c:4155) 负责创建一个新 memcg 的数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static struct cgroup_subsys_state * __ref
mem_cgroup_css_alloc(struct cgroup_subsys_state *parent_css)
{
struct mem_cgroup *parent = mem_cgroup_from_css(parent_css);
struct mem_cgroup *memcg, *old_memcg;
bool memcg_on_dfl = cgroup_subsys_on_dfl(memory_cgrp_subsys);

old_memcg = set_active_memcg(...); // 设置当前 task 的 memcg 以正确计数
memcg = mem_cgroup_alloc(); // 分配零化的 mem_cgroup 结构体
...
// 初始化 page_counter: 设置最大值为 PAGE_COUNTER_MAX (无限制)
page_counter_init(&memcg->memory, parent ? &parent->memory : NULL);
page_counter_init(&memcg->swap, parent ? &parent->swap : NULL);
...
// v1 特有: 分配 events_percpu (mm/memcontrol-v1.c:2250)
if (memcg_on_dfl) {
// v2 不需要 events_percpu
} else {
memcg1_alloc_events(memcg);
}
...
return &memcg->css;
}

在 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
2
3
4
5
6
7
8
9
10
11
12
cgroup hierarchy (v2 only leaf tasks)

root_mem_cgroup (css: root)
+-- page_counter memory (usage=...)
parent=NULL

+-- A (css: internal, no tasks)
+-- page_counter memory
parent -> root.memory (自动传播)
+-- B (leaf, tasks)
+-- page_counter memory
parent -> A.memory

Mermaid 图:相同内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
classDiagram
class RootMemCGroup {
+page_counter memory
+page_counter swap
}
class InternalCGroupA {
+page_counter memory
+page_counter swap
}
class LeafCGroupB {
+page_counter memory
+page_counter swap
+tasks
}
RootMemCGroup <--> InternalCGroupA: css parent
InternalCGroupA <--> LeafCGroupB: css parent
RootMemCGroup --> "parent=NULL"
InternalCGroupA ..> RootMemCGroup: memory.parent
LeafCGroupB ..> InternalCGroupA: memory.parent

初始化与销毁流程

mem_cgroup_css_alloc() 核心步骤

  1. 获取 parent_css 对应的 mem_cgroup(可能为 NULL,即根)。
  2. 分配 mem_cgroup 结构体(通过 kmem_cache_alloc)。
  3. 初始化各级 page_counter,并设置 parent 指针。
  4. 根据 memcg_on_dfl 决定是否分配 v1 特有字段(如 events_percpu)。
  5. 返回 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
2
3
4
5
get_mem_cgroup_from_current()        // mm/memcontrol.c:1154
-> mem_cgroup_from_task(current) // mm/memcontrol.c:1079
-> task_css(p, memory_cgrp_id) // 返回 css,兼容 v1/v2
-> css_tryget(&memcg->css) // 增加引用计数
-> return memcg

v2 中,当前任务必属于一个叶子 cgroup,因此这条路径始终返回唯一的 memcg,没有歧义。

2. 内存分配时的 charge

1
2
3
4
charge (如 __memcg_kmem_charge)
-> page_counter_charge(&memcg->memory, nr_pages)
-> 原子增加 usage
-> 若 parent 存在,递归调用 page_counter_charge(parent, nr_pages)

3. 创建新 cgroup:mem_cgroup_css_alloc()

全链路:

1
2
3
4
cgroup_mkdir() -> cgroup_apply_control() -> css_create()
-> cgroup_apply_control_enable()
-> offline_css() / online_css()
-> 最终调用 mem_cgroup_css_alloc(parent_css)

mem_cgroup_css_alloc 内(mm/memcontrol.c:4155):

1
2
3
4
5
6
7
8
9
10
11
12
static struct cgroup_subsys_state * __ref
mem_cgroup_css_alloc(struct cgroup_subsys_state *parent_css) {
// ...
memcg = mem_cgroup_alloc(); // 分配零化结构体
// ...
page_counter_init(&memcg->memory, // 初始化 memory 计数器
parent ? &parent->memory : NULL);
page_counter_init(&memcg->swap,
parent ? &parent->swap : NULL);
// 如果 v1,还需 memcg1_alloc_events (mm/memcontrol-v1.c:2250)
return &memcg->css;
}

4. 销毁 cgroup:mem_cgroup_css_free()

1
2
3
4
5
6
7
css_free_rwork_fn() -> css_free_work_fn() -> mem_cgroup_css_free(css)
-> mem_cgroup_id_remove(memcg)
-> cancel_work_sync(&memcg->high_work)
-> mem_cgroup_uncharge_after_rm(memcg) // 确保残留 charge 被清理(v2 中通常为空)
-> free_mem_cgroup_per_node_info(memcg)
-> page_counter_cancel(&memcg->memory, ...) // 如果仍然有残留,保证父节点正确
-> kmem_cache_free(memcg_cachep, memcg)

注意page_counter_cancel 在 v2 中通常不需要,因为所有任务在退出时已经 uncharge 完毕。但在一些极端路径(如 cgroup 内有未释放的页面缓存,内核会先强制回收再释放 cgroup),此函数作为一个安全网。

5. 保护计算:mem_cgroup_calculate_protection()

当启用 memory 保护(memory.low/memory.min)时,内核需要计算每个受保护的 cgroup 的可回收值。函数位于 mm/memcontrol.c:5101

1
2
3
4
5
6
7
8
void mem_cgroup_calculate_protection(struct mem_cgroup *root,
struct mem_cgroup *memcg) {
bool recursive_protection =
cgrp_dfl_root.flags & CGRP_ROOT_MEMORY_RECURSIVE_PROT;
// ...
page_counter_calculate_protection(&root->memory, &memcg->memory,
recursive_protection);
}

这个函数展示了 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(源码片段中未给出,但值得自行阅读)。