memcg v1 到 v2:缺陷剖析与设计演进
以下是根据审阅反馈逐条修复后的完整文章。已修正所有指出的问题,未改动其他内容。
memcg v1 到 v2:缺陷剖析与设计演进
源码版本:本文所有源码引用均基于 linux-next 6e845bcb,不同内核版本行号可能不同。
前置知识
阅读本文前,建议先阅读本系列:
- Part 0:读懂 memcg 之前:你必须掌握的 cgroup 基础
其它背景知识:了解 cgroup v1 的多层级结构与 v2 的统一层级设计,有助于理解 memcg v1 的缺陷根源。文中的 memcg(memory cgroup)指的是控制内存使用的 cgroup 子系统,它与 cgroup 框架(v1/v2)的设计紧密耦合。
背景:为什么需要了解这个
想象一下,你在云主机上部署了多个容器,或者在同一台手机上运行着几十个应用。某个应用突然疯狂分配内存(比如 Chrome 浏览器加载了 50 个标签页),然后整个系统变得极度卡顿,其他应用被 OOM(Out-Of-Memory)杀掉,甚至系统直接崩溃重启。
这就是经典的 “noisy neighbor”(吵闹邻居) 问题:一个进程的行为影响了同机其他进程。2008 年,社区引入了 memcg(memory cgroup),核心目标就是为每组进程划定内存围墙,让每个"邻居"在自己的围墙内活动,互不干扰。
memcg 最初在 cgroup v1 框架下实现,从 2008 年至今积累了大量设计债务——你可能会想,既然有上限控制,为什么还经常听到内存超限导致的 OOM kill?为什么设置了软限制,某些容器照样被回收?为什么进程迁移后,它的内存计费还留在原来的 cgroup 里?这些都是 v1 设计缺陷的体现。
cgroup v2 在 2016 年合并进内核,提供了一个统一的层级结构,社区借此机会重写了内存控制器,形成了 memcg v2。作为手机厂商或云厂商的内存优化工程师,理解这 2008 → 2016 → 2025 的演进逻辑,才能真正掌握 memcg 的用法:v2 保留了什么、改变了什么、哪些你熟悉的 v1 概念其实已经被埋葬了。
基础概念:读懂本文需要知道的
在深入缺陷之前,先铺几条基础路线:
cgroup 与 subsystem:cgroup(control group)是内核提供的资源隔离机制,你把一组进程放入一个 cgroup,就可以控制它们能使用多少 CPU、内存、IO 等。对应的控制模块称为 subsystem,比如 memory(memcg)控制内存,cpu 控制 CPU 时间。
page_counter 计数器:memcg 用 page_counter 结构体来统计内存用量、设置上限。它本质上是一个原子变量 + 一个上限值,can_charge 检查不超限就 try_charge 成功,否则触发回收或 OOM。
默认层级(default hierarchy):cgroup v2 只有一棵树,所有 subsystem 都挂在同一棵树上——这就是"统一层级"的含义。v1 允许每棵树挂不同的 subsystem,管理极其混乱。
folio 与 page:folio 是内核 5.x 引入的新概念,可以理解为一个"结构化的页面集合"(比如 2 MB 的大页就是一个 order-9 folio)。本文中说"页面"时,泛指一个内存块,读者可以按 struct page 或 struct folio 理解,不影响主逻辑。
charge(计费):当一个进程分配内存时,内核要把这个内存页"记到"该进程所属的 memcg 账上,这个过程叫做 charge。如果记账后该 memcg 的 total_usage 超过了上限,内核就会触发回收或 OOM。
核心机制详解
缺陷一:memsw 耦合——你想限制 swap,v1却要你把RAM也一起限制
问题:v1 引入了 memory.memsw.limit_in_bytes 接口,控制的是 RAM 用量 + swap 用量之和。也就是说,你无法只限制一个 cgroup 的 swap 使用量而不碰它的 RAM 上限。
为什么会有这么别扭的设计?让我们看看 try_charge() 函数在 v1 下的行为:当 do_memsw_account() 返回 true(v1 下为 true)时,每次 charge 会同时更新两个 page_counter:
memcg->memory记录 RAM 用量memcg->memsw记录 RAM + swap 用量
也就是说,一次物理页面分配(无论是 RAM 还是 swap),被双重记账了。mm/memcontrol.c 中 do_memsw_account() 的实现很简单:返回 !cgroup_subsys_on_dfl(memory_cgrp_subsys)——v2 下为 false,v1 下为 true。
这种设计的初衷是防止"用 swap 绕过内存限制":如果只限制 RAM,那进程可以把大量数据 swap 出去,仍然会占用 swap 空间,这同样是一种资源滥用。所以社区直接一刀切:把 RAM 和 swap 绑在一起限制。
问题在于:这个语义完全不直观。假设你的手机上有 8 GB RAM 和 4 GB swap,你对微信这个 cgroup 设置了 memory.limit_in_bytes = 2G 和 memory.memsw.limit_in_bytes = 3G。微信的 RAM 上限仍然是 2 GB,并没有被放宽;但 swap 可以用到 1 GB(因为 3G - 2G = 1G),总使用量(RAM+swap)可达 3 GB。你想让微信最多用 500 MB swap,但 memsw 接口不让你单独设 swap 上限——你只能通过降低 RAM 限制来间接压缩 swap 可用空间,这非常别扭。
v2 的解法:废掉 memsw,拆成两个独立的接口:
memory.max:RAM 硬上限memory.swap.max:swap 硬上限
这两个 counters 完全解耦,互不干扰。Documentation/admin-guide/cgroup-v2.rst 中明确指出:“The combined memory+swap accounting and limiting is replaced by real control over swap space.” 每次 charge 时,RAM 只更新 memory 计数器,swap 只更新 swap 计数器,两个上限分别检查。管理员可以自由组合:RAM 给 2 GB、swap 给 500 MB,清清楚楚。
缺陷二:无渐进限速——从"正常"到"OOM kill"只有一堵墙
问题:v1 的 memory.limit_in_bytes 是一堵硬墙。使用量超过上限时,内核直接触发 OOM kill——没有任何缓冲区间,没有任何预警机制。
等一下,v1 不是有 memory.soft_limit_in_bytes(软限制)吗?软限制确实提供了"软墙",但它的问题是:分配进程感知不到压力。当 usage 超过软限制时,内核只是在后台(kswapd)尝试回收,但对当前正在分配内存的进程来说,它依然全力分配,直到撞到硬墙被 kill。
用术语来说:v1 缺少"限速"(throttle)机制。你想要的是一个"渐进减速":当 cgroup 接近上限时,让分配过程变得越来越慢,而不是突然被 kill。
v2 的解法:引入 memory.high,并在 mem_cgroup_handle_over_high() 中实现了惩罚性延迟:
1 | 惩罚程度 (penalty_jiffies) |
公式为 penalty_jiffies = (overage * overage) / (high * high) * HZ。注意这里是 overage² / high² × HZ——超限越多,惩罚呈二次方增长。分配进程被 schedule_timeout_killable() 强制睡眠一会儿再醒来,继续尝试分配。
这就形成了三级梯度:
memory.high以下:全力分配,不受干扰memory.high~memory.max:进程被 throttle,越接近 max 越慢memory.max以上:触发强制回收,回收失败则 OOM kill
这种设计借鉴了 TCP 拥塞控制的思路:penalty_jiffies = overage² / high² * HZ 类似于 AIMD(加法增、乘性减)中的加性增长、乘性减速——超限越多,惩罚越重,自然地让进程减速。
缺陷三:无保护机制——强势邻居可以随意挤占弱势邻居
问题:v1 只有上限控制(软/硬),没有下限保护。所谓"保护"是指:在全局内存压力下,保证某个 cgroup 的已有页面不被回收。
假设你的手机上跑着微信(前台)和一堆后台应用。内存紧张时,v1 对所有 cgroup 一视同仁地回收,后台应用的页面可能会被回收,微信的页面也可能会被回收——这显然不合理。前台应用的用户体验需要得到保护,不应该因为后台应用的缓存被回收而导致自己也被回收。
page_counter_init() 中有一个关键字段 protection_support,v1 下始终为 false。这意味着 page_counter_try_charge() 不会计算任何保护值,所有 cgroup 在回收面前完全平等。
v2 的解法:引入 memory.min(硬保护下限)和 memory.low(软保护下限),在 mm/page_counter.c 和 mm/memcontrol.c 中实现了完整的保护体系。
当 protection_support = true 时,page_counter_try_charge() 每次减少(uncharge)时会调用 propagate_protected_usage(),在层级树中传播保护上限/保护下限,计算每个 cgroup 的 emin(effective min)和 elow(effective low)。
1 | root(usage=10G) |
回收时的优先级:
- usage < min(emin)的 cgroup:通常不被回收
- min < usage < low(elow)的 cgroup:尽量不回收
- usage > low 的 cgroup:可以被回收
注意 min 和 low 在层级树中会叠加:如果一个 cgroup 的 min 被其子节点都消耗完了,子节点就没有保护了。这种传播机制保证了保护值是"尽力而为但有限"的——系统不会为了防止某个 cgroup 被回收而导致其他 cgroup 爆出无内存可用。
缺陷四:共享 page cache 记账不准 + 进程迁移计费漂移
问题 4a:共享 page cache——谁先访问谁买单
页缓存(page cache)是文件在内存中的缓存。当多个来自不同 cgroup 的进程读同一个文件时,哪个 cgroup 应该为这些缓存付费?
v1/v2 的选择是:第一个使用这个 folio 的进程的 cgroup 买单。filemap_add_folio()(mm/filemap.c)在插入 folio 到 page cache 时调用 mem_cgroup_charge(folio, NULL, gfp),其中 mm 参数为 NULL。get_mem_cgroup_from_mm(NULL)(mm/memcontrol.c)的回退逻辑:先查 active_memcg()(通常未设置),再回退到 current->mm,即当前分配进程的 memcg。
这意味着:假设 cgroup A 的进程先读了某个文件,cgroup A 的页缓存计费增加了。10 分钟后,cgroup B 的进程读同一个文件,它可以直接使用已经在缓存中的页面,不用付费。cgroup B 的计费没变,但确实"借用"了 cgroup A 的缓存。
v2 并没有从根本上改变这一规则——这是单一页缓存归属的结构性约束:一个物理页面不可能同时属于多个 cgroup。但 v2 通过 memory.min/memory.low 提供了补偿手段:如果你在 v1 中因为过度共享而被挤占,在 v2 中至少可以保护关键 cgroup 不被过度回收。
问题 4b:进程迁移——计费不跟人走
把进程从一个 cgroup 迁移到另一个 cgroup,它已经占用的内存(比如 anonymous pages,匿名页)会怎么办?理想情况下,计费应该跟着进程走。但 v1 下,这些页面仍留在原 cgroup。
v1 提供了一个 move_charge_at_immigrate 接口来解决这个问题,但写入处理函数(mm/memcontrol-v1.c)对任何非零值返回 -EINVAL,并输出 deprecation warning——实质上这个功能在 v1 中已被废弃,不再建议使用。
为什么废弃?原因很实际:迁移内存计费是一个在释放时重新指向的操作,涉及大量页面锁、复杂的内存屏障、交叉引用更新,极容易引入死锁和竞争。代码中禁止该功能的 commit 所写的原因很直白:它被证明是有问题的,不再建议使用。
v2 的解法:v2 不再提供 move_charge_at_immigrate。它通过"统一层级"从根本上减少了"需要迁移 cgroup"的场景:在 v1 中,你可能会因为想切换 CPU 控制权而把进程从一个 cgroup 移到另一个,因为不同 cgroup 树上的 subsystem 是独立的。但 v2 下所有 subsystem 都在同一棵树上,你改变进程的 CPU 控制时,不需要动它的内存控制——它们属于同一个 cgroup 树上的节点。如果不得不迁移(比如容器重启),v2 承认"已分配的内存计费不会迁移"这一现实,让管理员通过 memory.min/memory.low 在目标 cgroup 设置保护。
缺陷五:软限制形同虚设
问题:v1 的 memory.soft_limit_in_bytes 从设计上就是一团乱麻。
软限制的实现放在 memcg1_soft_limit_reclaim()(mm/memcontrol-v1.c)中。它维护一个全局的 per-node soft_limit_tree 红黑树,按超限量排序。只有设置了软限制的 cgroup 才会被加入这棵树。kswapd 或直接回收时会调用此函数,尝试回收超限最多的 cgroup 的页面。
三个致命缺陷:
-
即使大部分 cgroup 没有设置软限制,软限制回收逻辑仍需遍历软限制树中的所有 cgroup(这些 cgroup 都设置了软限制),这是一个不必要的开销——特别是当设置了软限制的 cgroup 数量很多时。未设置软限制的 cgroup 不会出现在树中,因此不会遍历它们。
-
所有 cgroup 在同一个红黑树里,不区分层级:一个 cgroup 的软限制与它的子 cgroup 的软限制之间没有传播关系。如果你设置了一个父 cgroup 的软限制为 2 GB,它的三个子 cgroup 分别设了 1 GB,父级软限制实际上得不到保障。
-
MGLRU(Multi-Gen LRU,多代 LRU)开启后,软限制直接被跳过:
memcg1_soft_limit_reclaim()如果检测到 MGLRU 开启,直接返回 0,不回收任何页面。因为 MGLRU 有更精细的 per-memcg 代际管理(lru_gen_rotate_memcg()),软限制的全局红黑树机制与它完全不兼容。
Documentation/admin-guide/cgroup-v2.rst 给出的评价很严厉:软限制"回收过于激进,引入大量分配延迟,有时适得其反"。
v2 的解法:memory.low 取代软限制。low 提供了明确的层级语义:在层级树中,父级 low 值会按比例分配给子级,作为"尽力不回收"的保护线。它的行为是可预测的:低于 low 时优先不被回收,高于 low 时可能被回收——不会像软限制那样出现"回收过于激进"的情况。
soft_limit_in_bytes 已经被标记为 deprecated。
关键代码路径
v1 memsw 双计费路径
1 | try_charge() (mm/memcontrol.c) |
v1 下一次 allocate 被两次记账。
v2 memory.high 限速路径
1 | mem_cgroup_handle_over_high() (mm/memcontrol.c) |
v2 protection_support 逻辑
1 | page_counter_try_charge() (mm/page_counter.c) |
设计演进:为什么不是更简单的方案
有人可能会问:为什么 v1 不一开始就用两个独立的计数器(RAM 和 swap),而非要用 memsw 耦合?原因在于最初的设想是"用 memsw 防止 swap 占用膨胀,绕过 RAM 限制"。这个设想的出发点是对的(防止滥用 swap),但在实践中造成了更多困惑。
另一个可能的方案是:保留 memsw,但允许单独设置 swap 上限,比如 memory.memsw.swap.max。为什么没这样做?因为 memsw 的语义是"总和",再细分反而会增加混乱。v2 直接砍掉 memsw,引入 memory.swap.max 是最干净的解决方案。
关于 memory.high 的惩罚公式 penalty_jiffies = overage² / high² * HZ,为什么是二次方而不是线性?线性惩罚在压力上升时不够敏感——如果延迟随超限线性增长,超限 10% 时惩罚很轻,而超限 20% 时惩罚只翻倍,但实际内存压力可能是爆炸性增长的。二次方惩罚意味着超限 20% 时的惩罚是超限 10% 时的四倍,能更快地遏制住内存分配暴涨。
关于保护机制,为什么不是"简单地禁止回收低于 min 的页面"?因为内核无法确保"不回收"——页面驱逐是全局的,没有一个全局锁可以保证不跨 cgroup 回收。v2 的保护实际上是"回收器在面临多个候选页面时优先选择没有保护的 cgroup"。这是概率性的,但通过层级传播算法,可以做到足够精确。
小结
- memsw 耦合被 v2 拆解为两个独立的接口
memory.max和memory.swap.max,RAM 和 swap 的限制终于解耦了——你终于可以单独限制 swap 而不影响 RAM。 - v1 只有 hard limit 这一堵墙,v2 用
memory.high实现了渐进限速(penalty_jiffies = overage² / high² * HZ),形成"high → max → OOM"三级梯度,分配越猛惩罚越重。 - v1 没有保护机制,v2 引入
memory.min(硬保护)和memory.low(软保护),通过propagate_protected_usage()在层级树中传播保护值,关键 cgroup 在内存紧张时优先不被回收(但不能绝对保证不被回收)。 - 共享 page cache 的"第一次使用者付费"规则在 v2 中没有改变,但 v2 通过
memory.min/memory.low提供了补偿手段;v2 不再提供move_charge_at_immigrate,通过统一层级从根本上减少了迁移场景。 - v1 的软限制(soft_limit_in_bytes)已被 deprecated,v2 用
memory.low提供基于层级的软保护,支持子树委托,在 MGLRU 下也能正常工作。
对于在手机厂商或云厂商工作的你来说,理解这些缺陷和演进,意味着:当你在做内存优化时(比如设置微信的最小保护、限制后台应用的 swap 使用、为前台容器设置渐进限速),应当使用 v2 的四个接口,而不是去碰那些 deprecated 的 v1 接口。v2 是社区的"重新设计",它保留了 v1 的基础记账能力,去掉了设计债务。