memcg v2 统一层级:彻底消除回收与OOM的扩展性瓶颈

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

背景:这个问题从哪里来

在 memcg v1 中,回收和 OOM 的行为是“本地优先”的:当某个 cgroup 达到 hard limit 时,直接在该 cgroup 及其后代内发起直接回收,OOM 也只杀掉该 cgroup 内的进程。这种做法在高密度容器场景下导致两个痛点:

  1. 全局回收的可扩展性差:系统全局内存不足时,全局回收(kswapd 或 direct reclaim)需要遍历所有 memcg 的 LRU 列表。随着 memcg 数量增长(数据中心几十万个),线性扫描成为瓶颈。
  2. OOM 层级决策不灵活:v1 的 OOM 只在一个 memcg 内“就地”选杀进程,无法利用父 cgroup 的语义——例如,在 CPU 和 I/O 资源都已经统一管理的 v2 中,应当允许 OOM 在更宽的范围(如整个 subtree)内选择 victim,并支持 root cgroup 介入。

memcg v2 的统一层级设计为此提供了新的基础设施。本文聚焦于三个关键演化:

  • 回收路径:引入 Memcg LRU(mm/memcontrol.c 中的 mem_cgroup_lruvec)和 MGLRU(多代 LRU)来提升回收的可扩展性。
  • OOM 层级选择:通过 mem_cgroup_get_oom_group() 实现“范围可配置”的杀进程策略。
  • 压力统计memory.events 和 PSI 机制在统一层级下按 cgroup 层级累积,提供细粒度压力信号。

核心机制与设计思路

1. Memcg LRU:从“每个 memcg 独立 LRU”到“全局层级的 LRU of LRUs”

传统回收时,每个 mem_cgroup 内部维护自己的 lruvec,全局回收需要遍历所有 memcg。Memcg LRU 将 per-node 的所有 memcg 组织成一个 LRU 链表(memcg_lru),回收器按顺序“弹出”一个 memcg,然后在其 lruvec 中回收页面。本质上是一个 LRU of LRUs

1
2
3
4
5
6
7
┌─────────────────────────────────────────────┐
│ Node 0 的 Memcg LRU │
│ │
│ head ──> memcg_A ──> memcg_B ──> memcg_C │
│ │ │ │ ← per-memcg 的 folio LRU
│ lruvec lruvec lruvec │
└─────────────────────────────────────────────┘

每个 memcg 在 memcg_lru 中的位置由页面的访问活跃度驱动。MGLRU(include/linux/mmzone.h)进一步优化:将 memcg 本身分成不同代(generation),每次回收只扫描某个代内的一个随机 bin,大幅减少锁竞争。

1
2
3
4
5
6
7
8
9
10
11
12
flowchart LR
subgraph memcg_lru
A[memcg_A] --> B[memcg_B] --> C[memcg_C]
end
subgraph per_memcg_folio_lru
A_lru[memcg_A lruvec] --> folio1[folio] --> folio2[folio]
B_lru[memcg_B lruvec] --> folio3[folio]
C_lru[memcg_C lruvec] --> folio4[folio] --> folio5[folio]
end
A --> A_lru
B --> B_lru
C --> C_lru

为什么这样设计:让全局回收器不必在大量的小 memcg 间跳跃,而是按“冷热程度”遍历 memcg 集合,减少无用扫描。同时层级感依然保留:当回收某个 memcg 内的页面时,仍然尊重该 memcg 自己的软限制和保护。

2. 保护计算:mem_cgroup_calculate_protection()

回收并非盲目进行。memcg v2 引入 memory.min 和 memory.low,分别提供绝对保护和相对保护。当系统内存压力达到一定程度时,回收器需要决定哪些 memcg 可以牺牲。mem_cgroup_calculate_protection() 计算每个 memcg 在当前 root 下的保护阈值(effective_protection)。

1
2
3
4
5
6
7
// mm/memcontrol.c:5101
void mem_cgroup_calculate_protection(struct mem_cgroup *root,
struct mem_cgroup *memcg)
{
// ... 根据 memory.min / memory.low 及递归保护标志计算
page_counter_calculate_protection(...);
}

计算结果存储在 memcg->memory.lowmemcg->memory.minusageeffective_protection 中。回收时,try_charge_memcg 会检查是否已经超过保护线:如果超出保护线,该 memcg 才允许被回收。

层级性:递归保护标志 CGRP_ROOT_MEMORY_RECURSIVE_PROT 控制保护是否从 root 向下覆盖。若开启,子 cgroup 的保护值是其自身的 low/min 与父保护值的交集,而非简单的累计。

3. OOM 层级选择算法:mem_cgroup_get_oom_group()

OOM 触发时,内核需要决定“谁被杀死”。v2 通过 memory.oom.group 文件控制是否将 OOM 限制在触发 cgroup 内,还是允许向上传播到祖先。mem_cgroup_get_oom_group() 实现该决策:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// mm/memcontrol.c:1947
struct mem_cgroup *mem_cgroup_get_oom_group(struct task_struct *victim,
struct mem_cgroup *oom_domain)
{
// 仅在 cgroup v2 下工作
if (!cgroup_subsys_on_dfl(memory_cgrp_subsys))
return NULL;

// 从 victim 的 memcg 开始向上查找
for (; memcg; memcg = parent_mem_cgroup(memcg)) {
// 如果某个祖先设置了 memory.oom.group,则该祖先成为 kill 的目标组
// 具体实现略
}
// 兜底:返回 oom_domain 或 root
return oom_group;
}

算法核心伪代码:

1
2
3
4
5
6
输入:victim_task, oom_domain (触发 OOM 的 memcg)
输出:应被整体杀死的 cgroup(作为组)

1. 从 victim_task->mm 获取所在 memcg (通过 mem_cgroup_from_task)
2. 向上遍历祖先,检查是否满足 oom_group 条件(如 memory.oom.group 为 1)
3. 若找到,返回该祖先;否则返回 oom_domain(不跨越层级)

这种设计允许用户配置:一个容器内某个进程 OOM 后,是整个容器被杀,还是只杀那个进程。层级选择突破了 v1 的“死锁在本地”的局限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
flowchart TD
A["root (/)"]
B["parent (/myapp)"]
C["child (/myapp/web)"]
D["grandchild (/myapp/web/worker)"]
E["OOM 触发: worker 进程"]
F["mem_cgroup_get_oom_group"]
G["检查 /myapp/web 是否有 oom.group?"]
H["检查 /myapp 是否有 oom.group?"]
I["检查 root 是否有 oom.group?"]
J["最终决策: 杀 ???"]

E --> F
F --> G
G -->|无| H
H -->|有| J
H -->|无| I
I -->|通常有| J
G -->|有| J

4. memory.events 与 PSI 统计

memcg v2 提供 memory.events 文件记录层级事件(如 low, high, max 超限次数、OOM 次数)。每个事件在触发时向本 cgroup 和所有祖先的计数器累加(通过递归 memcg_event 机制)。PSI(Pressure Stall Information)则独立于 memcg,但 memory.pressure 文件(v1)在 v2 中可通过 cgroup 的 PSI 接口 (cgroup.pressure) 获取,它按 cgroup 层级累加任务 stalled 时间。内核在任务被内存回收阻塞时更新 PSI 状态。

这些事件和压力信号共同用于判断层级内存健康状态,例如:若 memory.eventsmax 计数快速上升,说明该 cgroup 频繁达到 hard limit,需要增大 limit 或减少内存分配。

关键代码路径

try_charge_memcg(非直接引用,但原理常见)

当进程分配匿名页或 page cache 时,try_charge_memcg 被调用。它检查当前 memcg 的 memory.current 是否超过 memory.maxmemory.high。若超过 high,启动异步回收(通过 mem_cgroup_reclaim 或发起 kswapd);若超过 max,直接同步回收,若回收失败则触发 OOM。

mem_cgroup_reclaim

mem_cgroup_reclaim 最终调用 shrink_lruvecshrink_list。在 MGLRU 开启时,它通过 lru_gen_shrink_lruvec 遍历当前 memcg 的 folio LRU 并回收冷页。

mem_cgroup_oom_control

mem_cgroup_oom_controlmemory.oom_control 文件)在 v2 中已简化:它只报告 OOM 是否被 kill enabled,但不再像 v1 那样提供 under_oom 等字段。新的事件机制通过 memory.events 提供:每次 OOM kill 发生后,递增 oom_kill 计数。

memory.events 的累加逻辑

memcg_event 函数(mm/memcontrol.c)在事件发生时,自底向上更新所有祖先的 memcg_vmstats_percpu->events 数组。每次读取 memory.events 文件时,汇总各 CPU 的计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sequenceDiagram
participant Process
participant try_charge_memcg
participant mem_cgroup_reclaim
participant OOM if needed
Process->>try_charge_memcg: 分配内存
try_charge_memcg->>try_charge_memcg: 检查 current > max?
alt 超过 max
try_charge_memcg->>mem_cgroup_reclaim: 同步回收
mem_cgroup_reclaim->>MGLRU: lru_gen_shrink_lruvec
alt 回收不足
try_charge_memcg->>OOM: 触发 mem_cgroup_out_of_memory
OOM->>mem_cgroup_get_oom_group: 确定杀哪个组
OOM->>oom_kill_process: 杀进程
OOM->>memcg_event: 记录 OOM kill 事件
end
end

与 Android/手机的关联

(本文 patch 原文及提供的源码片段中未包含 Android 特定信息,故不展开。但通用关联参考:Android 在 memcg 上使用 Memory cgroup 控制 App 内存,v2 的层级回收和 OOM group 可用于实现“App 组 OOM 策略”,即当一个 App 子进程 OOM 后,kill 整个 App 进程组,而非单个任务。此机制在 Android 中已通过 memory.oom.group=1 实现,但不在本文源码范围内。)

延伸阅读

  • Memcg LRU 设计文档:Documentation/mm/multigen_lru.rst(src1)
  • 保护计算源码:mm/memcontrol.c:5101 mem_cgroup_calculate_protection
  • OOM 组选择源码:mm/memcontrol.c:1947 mem_cgroup_get_oom_group
  • memcg v2 事件与压力统计:可查看 mm/memcontrol.cmemcg_event 实现;PSI 部分见 kernel/sched/psi.c
  • 相关内核 commit:引入 Memcg LRU 的补丁系列(如 mm/mglru 系列),可在 lore.kernel.org 搜索 “Memcg LRU” 获取

本文为「memcg v2 统一层级」系列第 3 部分。下期(第 4 部分)将讨论 swap 与 cgroup 的绑定、以及 memcg v2 的回收优先权调度。