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_charge 和 res_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 | struct mem_cgroup { |
每个 res_counter 又是一个结构体:
1 | struct res_counter { |
注意:usage_in_hierarchy 是递归累加的,每次 charge/uncharge 时,需要向上遍历到根更新所有祖先的 usage_in_hierarchy。这个操作需要获取 lock,是 v1 性能瓶颈之一。
计费路径:mem_cgroup_charge → res_counter_charge
当进程访问缺页或分配文件页时,内核会调用:
1 | do_anonymous_page / do_swap_page / add_to_page_cache_lru |
res_counter_charge 的递归更新是 O(depth) 且每次都要获取锁,深度大时非常慢。而且,每次 charge 都需要做一次 usage + nr_pages > limit 检查,没有缓存或批处理。
软限制为何形同虚设
软限制的设计初衷:当 cgroup 内存使用超过 soft limit 时,内核在全局 reclaim 中会“优先”回收该 cgroup 的内存。实现方式:在 mem_cgroup_soft_reclaim 中遍历所有超过 soft limit 的 cgroup,尝试回收一定量的页。但问题是:
- 优先级不足:软限制只是在最近已用 limit 的 cgroup 列表(
soft_limit_tree)中标记,reclaim 顺序由 LRU 和 shrinker 驱动,软限制的 cgroup 并不保证会被优先回收。 - 回收粒度大:软限制回收只会扫描该 cgroup 的 LRU,但全局 reclaim 可能先选择其他 cgroup 的页,因为 LRU 是全局的(per-node、per-zone 共享,不按 cgroup 分区)。
- 缺乏反馈:内核不会因为一个 cgroup 超过了软限制而立即触发回收,只有在系统内存不足时才会尝试。这导致软限制几乎没有实际约束力,用户误解为“软限制等于警告线”,但实际行为是“大部分情况下无效果”。
社区随后在 v2 中用 memory.high(硬限制的减速区)和 memory.max(硬限制)替代了软限制,并提供 memory.low(最小保护)和 memory.min(硬保护)来做隔离。
多 hierarchy 带来的计费混乱
cgroup v1 允许每个 subsystem 独立构建 hierarchy,例如:
1 | /cgroup |
进程可以同时加入 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 的根本性缺陷
- 性能差:
res_counter每次 charge 都要加锁递归,且缺乏批处理,在高速网络、大数据场景下成为瓶颈。 - 软限制无约束力:无法用作“内存警告阈值”,影响用户空间监控。
- 多 hierarchy 导致计费逻辑复杂:同一个进程可能在多个树中,但内存归属只按 memory 树,导致其他 subsystem 的控制无法与内存协调。
- LRU 不分 cgroup:全局 LRU 导致 reclaim 需要扫描大量不属于目标 cgroup 的页,效率低下。
- kmem 计费不完整:内核内存(如 slab、socket 缓冲区)在 v1 中需要单独配置
memory.kmem.limit_in_bytes,且实现有漏洞(如某些内核对象未计入),导致通过内核内存绕过限制。 - 缺乏层级保护:v1 只有 hard limit,没有保证某个 cgroup 的最小可用内存(如 v2 的
memory.min)。父节点可以独占所有内存,子节点得不到任何保障。
正是因为这些缺陷,内核社区在 2016 年引入了 memcg v2,彻底重构了计费、回收和保护机制。
关键代码路径
以下代码路径均基于 mm/memcontrol.c。
mem_cgroup_charge → try_charge → res_counter_charge
1 | // mm/memcontrol.c |
try_charge 的简化流程:
1 | static int try_charge(struct mem_cgroup *memcg, gfp_t gfp_mask, |
res_counter_charge 的实现位于 kernel/res_counter.c(已在 v2 中废弃):
1 | int res_counter_charge(struct res_counter *counter, unsigned long val, |
注意:每次 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。