从 memory.max 写入到 OOM 的完整调用链:无锁设计与性能优化
本文是「memcg v2 统一层级」系列第 4/4 部分,聚焦于用户态写入
memory.max限制后,内核如何依次经过计费检查、层级回收、OOM 判定的全过程。
源码版本:所有引用基于linux-next 6e845bcb,不同内核版本/分支的行号可能不同。
注意:本系列前三部分已介绍 memcg v2 的诞生背景、层级继承与资源控制、回收与 OOM 策略,本文不再重复。
背景:为什么需要理解这条调用链
在 Android 手机或容器场景中,系统管理员(或 LMKD)通过写入 memory.max 动态调整进程的内存上限。这个简单的写入动作背后,触发了一条横跨 cgroup 文件系统、页计数子系统、内存回收模块和 OOM 处理器的复杂路径。理解这条路径,能帮助开发者定位以下问题:
- 为何调整
memory.max后,进程并未立即被 kill? - 何时触发内存回收?回收失败后 OOM killer 如何被调用?
- 层级结构下,回收与 OOM 如何在不同父/子 cgroup 之间传导?
本文以 mem_cgroup_write_max() 为入口,逐函数解析直到 mem_cgroup_oom_control() 的完整链路,并揭示其中与 try_charge / reclaim / oom 相关的关键设计。
核心流程总览
ASCII 示意图
1 | 用户态写入 memory.max |
Mermaid 流程图
1 | flowchart TD |
设计要点:
memory.max的写入并不直接导致回收或 OOM,它只修改了一个上限值。实际的压力在后续内存分配时体现——page_counter_try_charge()会在每次增加计数时检查是否超出新上限。- 这种“事后检查”的设计避免了在写入时同步回收大量内存,将压力分散到后续的分配路径上,减少写入延迟。
关键代码路径解读
1. 入口:mem_cgroup_write_max()
位置:mm/memcontrol-v1.c? 在 v2 中统一为 memory_max_write(或 mem_cgroup_write_max,取决于内核版本)。
功能:将用户写入的数值转化为 page_counter_set_max(),更新 memcg->memory.max。
源码类似实现(参考 src11 中的 mem_cgroup_css_reset 相关逻辑):
1 | // 来自 src11 (mm/memcontrol.c:4358) 的类似模式 |
写入时,如果新值小于当前使用量,未来任何试图增加计数的操作都会失败,从而触发回收或 OOM。
2. 计费失败:page_counter_try_charge()
位置:include/linux/page_counter.h 或 mm/page_counter.c。
逻辑:尝试增加 counter->usage 并检查是否超过 counter->max。若超过则回滚并返回 -ENOMEM。
调用时机:内存分配路径中(如 mem_cgroup_charge())会先调用 page_counter_try_charge() 尝试计费。
3. 输入回收循环:try_charge_memcg()
位置:mm/memcontrol.c(约 2600 行附近)。
核心作用:当 page_counter_try_charge 失败后,try_charge_memcg 会:
- 调用
mem_cgroup_reclaim()尝试回收本 cgroup 及其祖先的内存。 - 若回收后仍无法满足,则进入 OOM 路径。
层级回收:回收时并不是只回收当前 cgroup,而是沿着 cgroup 树向上遍历,在每个层级尝试回收(因为父 cgroup 可能拥有共享页面或全局 LRU)。
保护机制:mem_cgroup_calculate_protection()(src8)在回收前计算软保护,避免过度回收受保护的进程。
4. 回收函数:mem_cgroup_reclaim()
位置:mm/memcontrol.c(约 2500 行)。
实现:它包装了直接内存回收调用(try_to_free_mem_cgroup_pages()),该函数遍历 memcg 的 LRU 列表,尝试回收匿名页和文件页。
注意:在 v2 层级结构下,回收的目标可以是任意层级,但 try_charge 通常从当前 memcg 开始,并向上到根。如果父 memcg 的限额更小,回收父级可能释放属于子级的页面(通过全局 LRU)。
5. OOM 触发的门卫:mem_cgroup_oom_control()
位置:mm/memcontrol.c(约 3100 行)。
功能:决定是否执行 OOM kill。逻辑如下:
- 检查当前 memcg 是否已处于 OOM 锁定状态(避免并发 OOM);
- 调用
memcg1_oom_prepare()(src5)获取锁定并唤醒等待者; - 确定 OOM 受害组:使用
mem_cgroup_get_oom_group()(src7)根据memory.oom_group设置决定 kill 哪个 cgroup(v2 特有); - 最终调用
out_of_memory()杀死选中进程; - 完成后调用
memcg1_oom_finish()(src12)解锁。
关键数据结构:memcg->memory_events[MEMCG_OOM] 在 OOM 发生时递增,用户可通过 memory.events 观察到 OOM 次数。
6. 辅助函数:受害者组的选择
mem_cgroup_get_oom_group()(src7):
- 如果未设置
oom_group,则默认 kill 触发 OOM 的 cgroup 中的某个进程; - 如果设置了,则向父级找第一个设置了
oom_group的 memcg,把整个组当作一个 kill 目标(例如容器场景下杀掉整个容器)。
1 | // src7 精简 |
与 Android/手机的关联
本部分基于真实的 Android 内核源码行为,与上述 patch 无直接引用,但可与此系列前几部分的设计一致。
Android LMKD 的协同
- LMKD 通过
write()到memory.max或memory.high触发内存压力。 - 当
page_counter_try_charge()失败导致try_charge_memcg()调用进入回收时,LMKD 可以监听memory.events中的high、max事件,提前终止低优先级的进程,避免真正的 OOM kill。 mem_cgroup_oom_control()中的oom_group机制被 Google 用于容器(或 Work Profile)场景:当一个子 cgroup(如 App 进程)OOM 时,可以配置为杀掉整个 Work Profile,防止单个进程拖垮系统。
移动设备上的低延迟要求
memory.max写入不应因同步回收而阻塞写者。如上所述,写入只修改限制,压力分散到后续分配,符合移动端要求快速响应的特性。- 回收路径中的
mem_cgroup_calculate_protection()确保前台应用(受保护)不会被轻易回收,这是通过memory.min和memory.low实现的(已在第 2 部分讨论)。
延伸阅读
- 完整内核源码:
mm/memcontrol.c,重点关注try_charge_memcg()、mem_cgroup_reclaim()、mem_cgroup_oom_control()函数。 src7和src8中的mem_cgroup_get_oom_group()和mem_cgroup_calculate_protection()是理解层级回收与 OOM 组的关键。- Linux 内核文档:
Documentation/admin-guide/cgroup-v2.rst中关于 memory.max、oom_group 的描述。 - 相关 patch 系列:memcg v2 统一层级的 LWN 文章(lwn.net/Articles/806096)。
注意:本文分析中引用的
mm/memcontrol.c函数名与行号基于linux-next 6e845bcb版本,若使用其他内核版本需参考对应源码。