memcg v1 核心缺陷与 v2 统一层级改进解析

memcg v1:第一代内核内存隔离方案

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

前置知识

阅读本文前,建议先阅读本系列:

本文假设读者已经理解:cgroup 是什么、hierarchy 和 subsystem 的关系、css_set 的基本概念。如果对 folio、page、VMA、page table 等基础概念不熟悉,请自行补充。

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

2014 年,Linux 内核引入了 memcg(memory cgroup)v1,这是第一个在用户空间层面实现内存隔离的方案。它的诞生背景很简单:多租户服务器场景下,一个“吵闹的邻居”(noisy neighbor)进程可以耗尽系统内存,导致其他进程被 OOM killer 随机杀死。云厂商需要一种机制来限制每个容器能使用的最大内存量,并在接近上限时优雅地回收内存,而不是让内核随机选择 victim。

Android 生态也在同一时期面临类似问题:前台应用和后台服务之间缺乏内存资源保护,后台服务大量消耗内存会导致前台应用被频繁杀掉。memcg v1 的出现为 Android LMK(Low Memory Killer)和后来的用户空间 cgroup 管理提供了基础。

然而,memcg v1 的设计并非完美。它基于 cgroup v1 多 hierarchy 架构,每个资源 subsystem(memory、cpu、blkio 等)都有独立的树状结构。这种灵活性带来了严重的计费混乱和管理复杂性。同时,memcg v1 的计费路径性能较差,软限制(soft limit)形同虚设,最终推动社区设计了 memcg v2。理解 v1 的缺陷,才能明白 v2 改进的缘由。

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

cgroup v1 的 hierarchy:每个 subsystem 可以独立挂载到不同目录,形成不同的树。例如,/sys/fs/cgroup/memory 是 memory 子系统的树,/sys/fs/cgroup/cpu 是 cpu 子系统的树。一个进程可以同时在多个树的叶节点中,这意味着内存限制的树和控制 CPU 限制的树可能完全不对齐——同一个进程在两个树中有不同的 parent,导致资源回收策略混乱。

memcg(memory cgroup):每个 cgroup 节点对应一个 mem_cgroup 结构体,负责跟踪该组及其后代的内存使用量。v1 中,mem_cgroup 使用 res_counter 记录每个资源类型(memory、memsw、kmem)的用量。

res_counter:一个简单的资源计数器和限制器,提供 res_counter_chargeres_counter_uncharge 接口。它包含两个主要字段:limit(硬上限)和 usage(当前已用量),并支持 soft_limit(软限制)字段。但软限制只在 reclaim 时作为参考,不强制。

page_counter:v2 中替代 res_counter 的升级版,支持层级保护(min/low)、更精确的加速计算。v1 沿用 res_counter,这是性能瓶颈之一。

soft limit:v1 中,当内存使用超过 soft limit 时,内核会尝试回收该 cgroup 的内存,但不保证在超过时立即回收。它只是一个“提示”,实际是否回收取决于全局 reclaim 的压力。

charge:当进程分配内存(如 page fault、文件页缓存)时,需要“记账”到所属的 memcg。这个过程叫 charge。如果超过 hard limit,charge 会失败,导致分配被拒绝(如 OOM kill 或返回 ENOMEM)。

核心机制详解

设计思路:为什么是层级树 + res_counter?

最简单的内存隔离方案是:给每个 cgroup 一个计数器,当分配内存时检查是否超过 limit,超过则拒绝。但只这样做会带来问题:cgroup 的子节点继承父节点的限制,如果父节点允许 2GB,子节点允许 1GB,而子节点用满 1GB 时,父节点还能用 1GB,这没问题。但如果父节点先被一个非子节点的进程用完了 2GB,子节点即使只有 1GB 限制,也无法再分配内存——因为父节点已经触及了全局限制?不,内核是按 cgroup 计费的,父节点用满 2GB 只会影响父节点自身的后续分配,不会直接影响子节点。然而,回收时,内核需要知道应该先回收哪个 cgroup 的内存。层级树的意义就在于:reclaim 可以向上遍历,对整个子树进行压力均衡。

res_counter 的设计很简单:一个原子变量 usage,一个 limit,加一个 spinlock 保护的 usage_in_hierarchy(用于统计整个子树用量)。charge 时先检查当前 usage + 新页数 <= limit,再更新 usage。如果超过 limit,触发 res_counter_charge 返回 -ENOMEM。这个设计没有考虑 NUMA 亲和性、也没有低延迟的加速路径(如提前检查水位线),导致 v1 的 charge 路径非常慢。

mem_cgroup v1 核心数据结构

mem_cgroup 在 v1 中(定义在 include/linux/memcontrol.h)包含多个 res_counter 成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct mem_cgroup {
struct cgroup_subsys_state css; // 嵌入到 cgroup 框架
struct res_counter res; // memory 限制(用户态可见 memory.limit_in_bytes)
struct res_counter memsw; // memory+swap 限制(memory.memsw.limit_in_bytes)
struct res_counter kmem; // 内核内存限制(memory.kmem.limit_in_bytes)
struct res_counter tcpmem; // TCP 内存限制(已废弃)
// …… 还有用于 soft limit 的字段
unsigned long soft_limit;
// 层级统计
struct mem_cgroup *parent; // 父节点
// 用于 charge 的 per-zone 统计
struct mem_cgroup_stat_cpu __percpu *stat;
// …
};

每个 res_counter 又是一个结构体:

1
2
3
4
5
6
7
8
9
struct res_counter {
unsigned long long limit; // 硬限制
unsigned long long soft_limit; // 软限制
unsigned long long usage; // 当前使用量
unsigned long long max_usage; // 历史峰值
struct res_counter *parent; // 指向父 res_counter(用于层级统计)
spinlock_t lock; // 保护 usage_in_hierarchy
unsigned long long usage_in_hierarchy; // 包括子孙的合计用量
};

注意:usage_in_hierarchy 是递归累加的,每次 charge/uncharge 时,需要向上遍历到根更新所有祖先的 usage_in_hierarchy。这个操作需要获取 lock,是 v1 性能瓶颈之一。

计费路径:mem_cgroup_charge → res_counter_charge

当进程访问缺页或分配文件页时,内核会调用:

1
2
3
4
5
6
7
8
9
10
11
12
do_anonymous_page / do_swap_page / add_to_page_cache_lru
└── mem_cgroup_charge()
└── try_charge()
├── 获取当前进程的 memcg (mem_cgroup_from_task)
├── 检查是否超过软限制(若超过,标记要回收)
├── 更新 per-memcg 统计(mem_cgroup_update_page_stat)
├── res_counter_charge(&memcg->res, nr_pages) // 主计费
│ ├── spin_lock_irqsave(&res->lock, flags)
│ ├── 检查 usage + nr_pages > limit? 若超过返回 -ENOMEM
│ ├── 更新 res->usage += nr_pages
│ └── 向上递归更新 usage_in_hierarchy(加锁、写回)
└── 若 res_counter_charge 失败,触发 OOM

res_counter_charge 的递归更新是 O(depth) 且每次都要获取锁,深度大时非常慢。而且,每次 charge 都需要做一次 usage + nr_pages > limit 检查,没有缓存或批处理。

软限制为何形同虚设

软限制的设计初衷:当 cgroup 内存使用超过 soft limit 时,内核在全局 reclaim 中会“优先”回收该 cgroup 的内存。实现方式:在 mem_cgroup_soft_reclaim 中遍历所有超过 soft limit 的 cgroup,尝试回收一定量的页。但问题是:

  1. 优先级不足:软限制只是在最近已用 limit 的 cgroup 列表(soft_limit_tree)中标记,reclaim 顺序由 LRU 和 shrinker 驱动,软限制的 cgroup 并不保证会被优先回收。
  2. 回收粒度大:软限制回收只会扫描该 cgroup 的 LRU,但全局 reclaim 可能先选择其他 cgroup 的页,因为 LRU 是全局的(per-node、per-zone 共享,不按 cgroup 分区)。
  3. 缺乏反馈:内核不会因为一个 cgroup 超过了软限制而立即触发回收,只有在系统内存不足时才会尝试。这导致软限制几乎没有实际约束力,用户误解为“软限制等于警告线”,但实际行为是“大部分情况下无效果”。

社区随后在 v2 中用 memory.high(硬限制的减速区)和 memory.max(硬限制)替代了软限制,并提供 memory.low(最小保护)和 memory.min(硬保护)来做隔离。

多 hierarchy 带来的计费混乱

cgroup v1 允许每个 subsystem 独立构建 hierarchy,例如:

1
2
3
4
5
     /cgroup
/ \
memory cpu
/ \
A B

进程可以同时加入 memory 树的 A 节点和 cpu 树的 B 节点。这意味着:

  • 当内存 reclaim 时,内核需要根据 memory 树来选择 victim(如 A 节点)。
  • 但 CPU 限制由 cpu 树的 B 节点控制,进程可能因为 CPU 限制而变慢,但它仍然大量消耗内存,系统却无法通过限制 CPU 来抑制内存增长——两个树的控制逻辑是独立的。

更严重的问题是:memory cgroup 的回收策略是基于 memory 树的层级,但进程可能属于不同 parent 的 memory 和 cpu cgroup。 例如,父进程在 memory 树根节点,子进程在叶子节点 A,而它们共享同一个 cpu 树节点。这种情况下的资源竞争无法通过单棵树来解决。

此外,v1 中每个 memcg 都有自己的 mem_cgroup_per_zone 统计,但 LRU 是 per-node、per-zone 的全局链表,没有按 memcg 分区。这意味着内核需要遍历所有 LRU 页,检查每个页属于哪个 memcg,才能决定回收哪个 cgroup 的页。这种遍历性能很差,且容易受到其他 cgroup 页的污染。

v1 的根本性缺陷

  1. 性能差res_counter 每次 charge 都要加锁递归,且缺乏批处理,在高速网络、大数据场景下成为瓶颈。
  2. 软限制无约束力:无法用作“内存警告阈值”,影响用户空间监控。
  3. 多 hierarchy 导致计费逻辑复杂:同一个进程可能在多个树中,但内存归属只按 memory 树,导致其他 subsystem 的控制无法与内存协调。
  4. LRU 不分 cgroup:全局 LRU 导致 reclaim 需要扫描大量不属于目标 cgroup 的页,效率低下。
  5. kmem 计费不完整:内核内存(如 slab、socket 缓冲区)在 v1 中需要单独配置 memory.kmem.limit_in_bytes,且实现有漏洞(如某些内核对象未计入),导致通过内核内存绕过限制。
  6. 缺乏层级保护:v1 只有 hard limit,没有保证某个 cgroup 的最小可用内存(如 v2 的 memory.min)。父节点可以独占所有内存,子节点得不到任何保障。

正是因为这些缺陷,内核社区在 2016 年引入了 memcg v2,彻底重构了计费、回收和保护机制。

关键代码路径

以下代码路径均基于 mm/memcontrol.c

mem_cgroup_charge → try_charge → res_counter_charge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// mm/memcontrol.c
int mem_cgroup_charge(struct page *page, struct mm_struct *mm, gfp_t gfp_mask)
{
struct mem_cgroup *memcg;
int ret;

if (mem_cgroup_disabled())
return 0;

memcg = get_mem_cgroup_from_mm(mm); // 获取目标 memcg
if (!memcg)
return 0;

ret = try_charge(memcg, gfp_mask, 1 << compound_order(page));
// 如果成功,将 page 关联到 memcg
if (!ret)
commit_charge(page, memcg);

css_put(&memcg->css);
return ret;
}

try_charge 的简化流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static int try_charge(struct mem_cgroup *memcg, gfp_t gfp_mask,
unsigned int nr_pages)
{
struct res_counter *fail_res;
int ret;

// 第一步:快速检查是否超过软限制(只做标记)
if (memcg->soft_limit && memcg->res.usage > memcg->soft_limit)
memcg_check_events(memcg, true); // 标记需要软reclaim

// 第二步:主计费
ret = res_counter_charge(&memcg->res, nr_pages, &fail_res);
if (ret)
goto nomem;

// 第三步:如果 memory+swap 也有限制,再计费 memsw
if (do_swap_account)
ret = res_counter_charge(&memcg->memsw, nr_pages, &fail_res);
// 若失败则回滚 memory 的计费

// 更新 per-cpu 统计
// …
return 0;

nomem:
// 触发 OOM 或直接返回 -ENOMEM
if (mem_cgroup_oom(memcg, gfp_mask, get_order(nr_pages * PAGE_SIZE)))
return 0; // OOM kill 后重试
return -ENOMEM;
}

res_counter_charge 的实现位于 kernel/res_counter.c(已在 v2 中废弃):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int res_counter_charge(struct res_counter *counter, unsigned long val,
struct res_counter **limit_fail_at)
{
unsigned long flags;
struct res_counter *c;
int ret = 0;

local_irq_save(flags);
for (c = counter; c != NULL; c = c->parent) {
spin_lock(&c->lock);
if (c->usage + val > c->limit) { // 硬限制检查
ret = -ENOMEM;
*limit_fail_at = c;
} else {
c->usage += val;
}
c->usage_in_hierarchy += val; // 向上传播
spin_unlock(&c->lock);
if (ret) break; // 一旦失败,停止传播
}
if (ret) {
// 回滚已更新的祖先
for (; c != counter; c = c->parent) {
spin_lock(&c->lock);
c->usage -= val;
c->usage_in_hierarchy -= val;
spin_unlock(&c->lock);
}
}
local_irq_restore(flags);
return ret;
}

注意:每次 charge 都要从当前节点递归到根,每个节点都要加锁 spin_lock、更新两个字段。如果层级深(如容器嵌套多层),开销线性增加。这在高并发分配(如网络包处理)中是灾难性的。

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

为什么不用全局计数器?

最简单的方案是:每个 cgroup 一个计数器,charge 时只检查自己的计数器,不涉及祖先。但这种方案无法实现“递归限制”——父节点不能限制子孙的总和,子节点可以无限制地使用内存,只要每个单独不超。这违反了隔离本意。层级传播是必要的,但 v1 用了锁保护的递归更新,性能差。

为什么不用 per-cpu 计数器?

v2 的 page_counter 使用了 per-cpu 缓存来降低锁争用。v1 时代的设计者可能认为层级传播的原子操作已经够用,但忽略了高并发场景。

软限制为何不被强化?

软限制本意是提供一个“建议性”的限制,用于全局 reclaim 的优先级调整。但实现上,它没有触发立即回收,也没有与 OOM 关联。社区讨论过“软限制超过后强制回收”的方案,但担心对突发内存使用的误判。直到 v2,才用 memory.high 来提供“减速而不是刹停”的机制。

为什么保留多 hierarchy?

cgroup v1 多 hierarchy 是设计哲学:subsystem 应该独立,以便在不同场景下灵活组合。但事实证明,资源隔离需要跨子系统的协调,多 hierarchy 带来的复杂性远大于灵活性。v2 的决定是:一个 hierarchy 管理所有 subsystem,所有进程只能属于同一个 cgroup 节点,这样内存、CPU、IO 的控制可以协同。

小结

  • memcg v1 诞生于多租户隔离需求,使用 res_counter 实现层级计费,但每次 charge 都需要递归加锁更新 usage_in_hierarchy,性能差,不适用于高并发场景。
  • 软限制(soft limit) 是一个无约束力的“提示”,内核不会主动回收超过软限制的内存,导致用户在需要内存预警时只能依赖用户空间监控,造成运营困难。
  • 多 hierarchy 导致资源控制树不对齐,内存和 CPU 限制无法协调,且 LRU 不按 cgroup 分区,reclaim 需要全局扫描,效率低下。
  • 根本缺陷:性能瓶颈、软限制无效、无层级保护、kmem 计费不完整、LRU 回收与 cgroup 脱节。这些缺陷直接催生了 memcg v2 的全面重构。

如果你正在阅读旧的内核代码或遇到 memcg v1 系统,理解这些设计选择可以帮助你判断瓶颈在哪里:如果 charge 延迟高,可以关注 res_counter 的锁;如果软限制不生效,请使用硬限制并配合用户空间监控;如果希望更完善的内存隔离,推荐升级到 memcg v2。