从 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
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
用户态写入 memory.max
|
v
mem_cgroup_write_max()
| 更新 page_counter->max
| 触发 try_charge 检查(通过 memory.max_store 间接调用?)
|
v (实际路径:后续内存分配时体现)
page_counter_try_charge()
| 比较 usage + nr_pages <= max ?
| 若失败:
v
try_charge_memcg()
| 尝试层级回收
| 调用 mem_cgroup_reclaim()
| |-> 逐 memcg 向上回收
| |-> 若回收后仍不足:
v v
mem_cgroup_oom_control()
| 检查 OOM 策略:
| - 如果 memory.oom_group 设置 (v2 特有)
| - 或者开启 memcg1_oom_prepare
| v
调用 mem_cgroup_get_oom_group()
| 确定最终受害组
|
v
OOM killer (out_of_memory)

Mermaid 流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
flowchart TD
A[用户态写入 memory.max] --> B[mem_cgroup_write_max]
B --> C[更新 page_counter.max 数值]
C --> D[后续内存分配时调用 page_counter_try_charge]
D --> E{count > max?}
E -- 成功 --> F[返回成功]
E -- 失败 --> G[进入 try_charge_memcg]
G --> H[调用 mem_cgroup_reclaim 进行层级回收]
H --> I[回收是否释放足够内存?]
I -- 是 --> F
I -- 否 --> J[触发 OOM 控制]
J --> K[mem_cgroup_oom_control]
K --> L{检查 oom_group 等策略}
L -- oom_group 启用 --> M[mem_cgroup_get_oom_group 确定受害组]
L -- 默认行为 --> N[调用 memcg1_oom_prepare/memcg1_oom_finish]
M --> O[调用 out_of_memory 杀死进程]
N --> O

设计要点

  • 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
2
3
4
5
6
// 来自 src11 (mm/memcontrol.c:4358) 的类似模式
static void mem_cgroup_css_reset(struct cgroup_subsys_state *css) {
struct mem_cgroup *memcg = mem_cgroup_from_css(css);
page_counter_set_max(&memcg->memory, PAGE_COUNTER_MAX);
// ...
}

写入时,如果新值小于当前使用量,未来任何试图增加计数的操作都会失败,从而触发回收或 OOM。

2. 计费失败:page_counter_try_charge()

位置include/linux/page_counter.hmm/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
2
3
4
5
// src7 精简
struct mem_cgroup *mem_cgroup_get_oom_group(struct task_struct *victim,
struct mem_cgroup *oom_domain) {
// 沿祖先向上找设置了 oom_group 的 memcg
}

与 Android/手机的关联

本部分基于真实的 Android 内核源码行为,与上述 patch 无直接引用,但可与此系列前几部分的设计一致。

Android LMKD 的协同

  • LMKD 通过 write()memory.maxmemory.high 触发内存压力。
  • page_counter_try_charge() 失败导致 try_charge_memcg() 调用进入回收时,LMKD 可以监听 memory.events 中的 highmax 事件,提前终止低优先级的进程,避免真正的 OOM kill。
  • mem_cgroup_oom_control() 中的 oom_group 机制被 Google 用于容器(或 Work Profile)场景:当一个子 cgroup(如 App 进程)OOM 时,可以配置为杀掉整个 Work Profile,防止单个进程拖垮系统。

移动设备上的低延迟要求

  • memory.max 写入不应因同步回收而阻塞写者。如上所述,写入只修改限制,压力分散到后续分配,符合移动端要求快速响应的特性。
  • 回收路径中的 mem_cgroup_calculate_protection() 确保前台应用(受保护)不会被轻易回收,这是通过 memory.minmemory.low 实现的(已在第 2 部分讨论)。

延伸阅读

  • 完整内核源码:mm/memcontrol.c,重点关注 try_charge_memcg()mem_cgroup_reclaim()mem_cgroup_oom_control() 函数。
  • src7src8 中的 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 版本,若使用其他内核版本需参考对应源码。