memcg v2 统一层级:彻底消除回收与OOM的扩展性瓶颈
源码版本:本文所有源码引用均基于 6e845bcb,不同内核版本/分支的行号可能不同。
背景:这个问题从哪里来
在 memcg v1 中,回收和 OOM 的行为是“本地优先”的:当某个 cgroup 达到 hard limit 时,直接在该 cgroup 及其后代内发起直接回收,OOM 也只杀掉该 cgroup 内的进程。这种做法在高密度容器场景下导致两个痛点:
- 全局回收的可扩展性差:系统全局内存不足时,全局回收(kswapd 或 direct reclaim)需要遍历所有 memcg 的 LRU 列表。随着 memcg 数量增长(数据中心几十万个),线性扫描成为瓶颈。
- 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 | ┌─────────────────────────────────────────────┐ |
每个 memcg 在 memcg_lru 中的位置由页面的访问活跃度驱动。MGLRU(include/linux/mmzone.h)进一步优化:将 memcg 本身分成不同代(generation),每次回收只扫描某个代内的一个随机 bin,大幅减少锁竞争。
1 | flowchart LR |
为什么这样设计:让全局回收器不必在大量的小 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 | // mm/memcontrol.c:5101 |
计算结果存储在 memcg->memory.low 和 memcg->memory.min 的 usage 和 effective_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 | // mm/memcontrol.c:1947 |
算法核心伪代码:
1 | 输入:victim_task, oom_domain (触发 OOM 的 memcg) |
这种设计允许用户配置:一个容器内某个进程 OOM 后,是整个容器被杀,还是只杀那个进程。层级选择突破了 v1 的“死锁在本地”的局限。
1 | flowchart TD |
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.events 中 max 计数快速上升,说明该 cgroup 频繁达到 hard limit,需要增大 limit 或减少内存分配。
关键代码路径
try_charge_memcg(非直接引用,但原理常见)
当进程分配匿名页或 page cache 时,try_charge_memcg 被调用。它检查当前 memcg 的 memory.current 是否超过 memory.max 或 memory.high。若超过 high,启动异步回收(通过 mem_cgroup_reclaim 或发起 kswapd);若超过 max,直接同步回收,若回收失败则触发 OOM。
mem_cgroup_reclaim
mem_cgroup_reclaim 最终调用 shrink_lruvec 或 shrink_list。在 MGLRU 开启时,它通过 lru_gen_shrink_lruvec 遍历当前 memcg 的 folio LRU 并回收冷页。
mem_cgroup_oom_control
mem_cgroup_oom_control(memory.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 | sequenceDiagram |
与 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:5101mem_cgroup_calculate_protection - OOM 组选择源码:
mm/memcontrol.c:1947mem_cgroup_get_oom_group - memcg v2 事件与压力统计:可查看
mm/memcontrol.c中memcg_event实现;PSI 部分见kernel/sched/psi.c - 相关内核 commit:引入 Memcg LRU 的补丁系列(如
mm/mglru系列),可在 lore.kernel.org 搜索 “Memcg LRU” 获取
本文为「memcg v2 统一层级」系列第 3 部分。下期(第 4 部分)将讨论 swap 与 cgroup 的绑定、以及 memcg v2 的回收优先权调度。