memory.max 与 memory.high:硬限与软节流的精确博弈
源码版本:本文所有源码引用均基于
c425609d。
前置知识
阅读本文前,建议先阅读本系列:
- Part 0:读懂 memcg 之前:你必须掌握的 cgroup 基础
- Part 1:memcg v1 到 v2:缺陷剖析与设计演进
- Part 2:搞懂 memcg 内存隔离,先吃透这 4 个核心数据结构
- Part 3:记账全路径:一次内存分配如何被 memcg 精确追踪
背景:为什么需要了解这个
memcg v2 用两道防线管控内存:memory.high 是第一道压力控制线,负责在超出软阈值后触发 reclaim/throttle;如果 cgroup 继续增长并触及已配置的 finite memory.max,后续 charge 才进入 hard-limit 路径,由回收、重试和 memcg OOM 兜底。简化理解:一个负责"减速",一个负责"熔断"。
想象一个云平台上的容器:某个应用出现内存泄漏,如果只有硬限制(memory.max),应用持续增长到 hard limit 后会进入回收、重试和 memcg OOM 流程;在回收无法把用量压回限制以下时,OOM killer 可能杀死进程,导致服务中断。但 memory.high 在接近上限时先让进程变慢、触发回收,给系统时间自救,往往能避免被杀。
在 Android 手机上,内存天生紧张。Android 系统级低内存决策主要依赖 lmkd(Low Memory Killer Daemon)结合 PSI 和 oom_score_adj;当 OEM 或系统将前台应用、后台应用映射到 memcg v2 层级并配置 memory.high 时,high 能提供额外的"先回收、再限速"渐进式压力控制,让分配变慢但不直接杀进程。
许多内存优化工程师只把 memory.max 当作"上限",把 memory.high 当作"警告水位",但内核的设计远比这精妙——它包含一套完整的层级 overage 计算、延迟惩罚和回收协作机制。理解两者的精确边界,才能真正调优 cgroup 内存策略。
基础概念:读懂本文需要知道的
- throttle: 限速。指让进程主动睡眠一段时间,从而降低分配频率。
- PSI (Pressure Stall Information): 内核统计内存/IO 压力的指标,
psi_memstall_enter/leave用于记录进程因内存不足而停滞的时间。 - TIF_NOTIFY_RESUME: 内核通用机制,标记进程返回用户态前需执行回调(信号投递、seccomp、rseq 等都用它)。memcg 用它延迟执行 memory.high 的节流惩罚,避免在内核关键路径中插入睡眠。
- OOM killer: 当内存耗尽时,内核选择杀死一个进程来释放内存。
- PF_MEMALLOC: 进程标志,表示当前执行上下文处于内核内存回收路径(kswapd 永久持有,直接回收期间临时设置)。
核心机制详解
1. memory.max:硬上限与 OOM 边界
memory.max 是 memcg 的"安全阀"。当本次 charge 会使 usage 超过 memory.max 时,page_counter_try_charge 失败并回滚本次尝试;随后根据 gfp 语义进入回收、重试、OOM 或 force charge 路径。
page_counter_try_charge的内部逻辑(乐观 add→检查→回滚)已在 Part 3 详细讲解。以下是try_charge_memcg的精简版(省略了变量声明、v1 兼容分支、__GFP_NORETRY/__GFP_RETRY_MAYFAIL 等细节,非连续行用...隔开),重点标注 memory.max 的作用点。
1 | /* mm/memcontrol.c(精简,非连续行用 ... 隔开) */ |
memory.max 的"硬"体现在:可阻塞路径会一直回收+重试直到 OOM kill。不可阻塞路径(如 GFP_ATOMIC)无法执行回收,在 nomem 处根据 gfp 标志决定是 force charge 还是返回 -ENOMEM——这与 memory.high 的"变慢但继续"有本质区别。特例:PF_MEMALLOC、__GFP_HIGH、__GFP_NOFAIL 等场景会通过 force 路径临时超限记账,以保证回收路径或高优先级分配向前推进。
kmem opt-out → opt-in 的历史背景:PF_MEMALLOC 这个 check 是 2016 年(89a2848381b5)加入的,针对的是 btrfs 在回收路径里触发 kmem 递归的栈溢出。彼时内核 kmem 计费是 opt-out 模式——只要 cgroup 开启了 memory.kmem.limit_in_bytes,任务的所有 slab 分配都自动计入 memcg,除非显式用 memcg_kmem_skip_account 跳过;btrfs 的 alloc_extent_state() 没有跳过,被计费后触发回收,无限递归。现代内核改为 opt-in 模式——slab 分配默认不进 memcg 计费,只有显式带 __GFP_ACCOUNT(即 GFP_KERNEL_ACCOUNT)的分配才被计费;btrfs 那条路径没有 __GFP_ACCOUNT,不再被计费,递归不会发生。PF_MEMALLOC check 作为安全网保留至今。
2. memory.high:渐进 throttle
memory.high 不是阻断,而是在 charge 成功后,批量检查是否超限。超限后,进程不会被立即杀死,而是被标记为需要 throttle,在后续某个时机执行睡眠惩罚。
设计思路:high 超限不阻止分配(分配已成功),只在事后施加惩罚。实际执行惩罚的函数是 __mem_cgroup_handle_over_high(),由两条路径触发:
- 异步路径(主路径):每次 stock miss 过高时,done_restock 阶段通过
set_notify_resume设置TIF_NOTIFY_RESUME,进程返回用户态时调用。 - 同步路径(兜底):若进程在尚未返回用户态的同一段内核路径中再次 stock miss 过高,
memcg_nr_pages_over_high超过 64 后立即就地调用,防止长时间停留内核导致过度超限。
路径一:done_restock 阶段(mm/memcontrol.c,charge 成功后执行):
1 | do { |
throttle 执行:__mem_cgroup_handle_over_high(mm/memcontrol.c,精简,非连续行用 ... 隔开):
两条路径最终都调用此函数,执行针对 memory.high 的回收与睡眠惩罚:
1 | void __mem_cgroup_handle_over_high(gfp_t gfp_mask) |
路径二:同步触发条件(mm/memcontrol.c,紧接 done_restock 之后):
若进程尚未返回用户态,TIF_NOTIFY_RESUME 无法触发,内核额外增加一道就地调用的兜底检查:
1 | if (current->memcg_nr_pages_over_high > MEMCG_CHARGE_BATCH && |
> 64的门槛决定同步路径至少需要两次 stock miss:第一次累积到 64,条件64 > 64为假;第二次累积到 128,条件为真。典型场景:read()读未缓存的大文件,readahead 循环连续调用filemap_add_folio,每 ~64 页触发一次 stock miss,不返回用户态。PF_MEMALLOC是内存回收路径的标志,不 throttle 回收者本身(防止死锁)。gfpflags_allow_blocking确保分配上下文可阻塞,中断上下文(GFP_ATOMIC)被此条件完全关闭。
惩罚计算:penalty_jiffies = r² × 64 × HZ
__mem_cgroup_handle_over_high 调用 mem_find_max_overage 获得层级最大 overage,再传给 calculate_high_delay 算出睡眠时长。两个函数的精简源码(mm/memcontrol.c):
1 | /* mem_find_max_overage / swap_find_max_overage:遍历层级(不含 root),取各节点 overage 最大值 */ |
设真实超限比例 r = (usage - high) / high,overage = r × 220(即超限量左移 20 位再除以 high,放大精度以便整数运算)。代入惩罚公式:
penalty = overage2 × HZ >> 34
= (r × 220)2 × HZ >> 34
= r2 × 240 × HZ / 234
= r2 × 64 × HZ
右移 34 位消掉了 240 中的 234,残留因子 64(= 26)。MEMCG_DELAY_SCALING_SHIFT=14 是经验值,commit 原注:“just happens to be a number that produces a reasonable delay curve”(恰好能产生合理延迟曲线的一个数),并非精确推导出的数学系数。
上式得到的是"每次满批量 charge 的惩罚基准"。calculate_high_delay 最后还乘以 nr_pages / MEMCG_CHARGE_BATCH:若本轮只超出了 1 页(nr_pages=1),惩罚缩至基准的 1/64;若攒满 64 页(nr_pages=64,即一个完整 batch),惩罚按基准全额执行。这样少量分配不会被过重惩罚,持续大批量分配才承受完整代价。
内核原始提交(0e4b01df8659,Chris Down)给出了 memory.high=100MB、HZ=250 时的完整惩罚表:
| 实际用量 | 超出量 | 每次分配延迟 |
|---|---|---|
| 100M | 0 | 0 ms |
| 101M | 1M | 6 ms |
| 102M | 2M | 25 ms |
| 103M | 3M | 57 ms |
| 105M | 5M | 159 ms |
| 110M | 10M | 639 ms |
| 115M | 15M | 1439 ms |
| ≥118M | ≥18M | 2000 ms(封顶) |
用公式验证 101M(r = 1/100 = 0.01):0.01² × 64 × 250 = 1.6 jiffies ≈ 6ms ✓
平方曲线的效果:轻度超限(1M)几乎无感,严重超限(18M)迅速触及 2 秒封顶(MEMCG_MAX_HIGH_DELAY_JIFFIES = 2*HZ)——允许短暂突发,禁止持续超额。mem_find_max_overage 取整条层级路径最大 overage,子 cgroup 的惩罚受最超限祖先驱动,体现层级压力传导。
3. 用户态写入到内核生效的完整路径
写入 memory.max 或 memory.high 文件时,分别调用 memory_max_write 和 memory_high_write(都在 mm/memcontrol.c)。
memory_max_write(mm/memcontrol.c,精简,非连续行用 ... 隔开):
1 | unsigned int nr_reclaims = MAX_RECLAIM_RETRIES; /* 回收最大重试次数,耗尽后触发 OOM */ |
memory_high_write(mm/memcontrol.c,精简,非连续行用 ... 隔开):
写入新值后若当前用量已超过新 high,cgroup 立刻处于超限状态,但后续只有新分配触发 done_restock 才会开始 throttle——可能要等很久。因此写入进程主动驱动一轮回收,把用量压到新 high 以下。这与 memory_max_write 同一思路:谁降低了限制,谁负责初始执行。区别在于力度:memory_max_write 回收失败会触发 OOM;memory_high_write 耗尽重试后直接放弃,不触发 OOM——soft 限制允许超限存在,靠后续 throttle 持续施压。
1 | page_counter_set_high(&memcg->memory, high); |
为什么 memory_max_write 用 xchg,memory_high_write 用 WRITE_ONCE?
两者的差异源于语义要求不同:
memory.max 是硬限制,page_counter_try_charge() 在每次 charge 时都与它比较。try_charge 的模式是"先原子增加 usage(带全内存屏障),再读 max 比较";xchg 将新 max 原子写入并在前后各插一道全内存屏障,保证两侧的读写有序:不会出现某 CPU 已按新 max 放行了 charge、而写端随后读到的 usage 仍是旧值的竞态窗口。这是顺序约束,而非"立即广播给所有 CPU"。
memory.max 的类型是普通 unsigned long 字段,因此这里使用 xchg();如果对象是 atomic_t,才会使用 atomic_xchg() 类接口。两者在本文关心的层面——原子替换并提供顺序约束——效果相同,区别仅在 C 类型(arm64 上都生成 LDAXR/STLXR + DMB ISH 或 LSE 的 SWPAL)。
memory.high 是软参考值,写端在 memory_high_write,读端在任意 CPU 的 done_restock,两者之间没有锁。WRITE_ONCE 的实现是 *(volatile typeof(x) *)&(x) = (val),volatile 转型对编译器有三点约束:
- 防止死存储消除:编译器若发现写完
counter->high后本函数不再读它,可能直接删除这行;volatile强制写入必须真实落地到内存 - 防止写合并:多次赋值不会被合并成只保留最后一个值
- 向 KCSAN 声明有意竞争:内核并发 sanitizer 会把没有
WRITE_ONCE/READ_ONCE标注的并发访问报告为未声明竞争;加上标注表示"已审查的无锁访问"
WRITE_ONCE 不产生硬件内存屏障,不保证其他 CPU 立即看到新值。而 memory.high 只是 throttle 参考值,即使写入期间被读到旧值,也仅影响惩罚力度,不会导致内存超限,所以 WRITE_ONCE 足矣——它比 xchg 便宜得多。
关键代码路径
路径1:memory.max hard-limit 路径(每次 charge)
1 | mem_cgroup_charge() |
路径2:memory.high throttle(异步/同步)
1 | try_charge_memcg() 成功后 |
路径3:写入 memory.max
1 | memory_max_write() |
路径4:写入 memory.high
1 | memory_high_write() |
设计演进:为什么不是更简单的方案
为什么不让 memory.high 直接阻止 charge? 早期内核确实考虑过类似 v1 的软限制,但发现一旦阻止 charge,就会导致分配回退到更慢路径,甚至死锁(例如 printk 分配内存时被阻塞)。所以 v2 选择让 charge 成功,事后通过 throttle 减速——这对应用程序是透明的(它只感觉到变慢,不会收到分配失败)。
为什么惩罚公式用 overage² 而不是线性? 线性方案对轻度超限过度惩罚,平方曲线则在低超限时几乎无感、高超限时迅速加重——更符合"允许短暂突发,禁止持续超额"的意图。以 memory.high=100M、HZ=250 为例,假设线性方案校准到与平方曲线在 105M 处相同(penalty = r × 3180ms,3180 = 159ms / 0.05,纯假设对比),两条曲线差异如下:
| 用量 | 超限比例 r | 假设线性惩罚 | 实际平方惩罚 |
|---|---|---|---|
| 101M | 0.01 | 32ms | 6ms |
| 105M | 0.05 | 159ms | 159ms(基准) |
| 110M | 0.10 | 318ms | 639ms |
| ≥118M | ≥0.18 | 572ms | 2000ms(封顶) |
轻度超限(101M)时线性已惩罚 32ms,平方只有 6ms;严重超限(110M+)时平方是线性的 2–3 倍,更快触及封顶。线性方案要么对突发过苛,要么对持续超额太宽松,平方曲线同时解决了两端。
为什么 throttle 不是立即执行,而是累积到 64 页? 避免每次分配都检查 high。对许多小额 page charge 而言,若每次分配都立即检查并 throttle 会增加热路径成本;按 MEMCG_CHARGE_BATCH 聚合后处理可以降低检查频率。MEMCG_CHARGE_BATCH 是 64 页(以 4KB 页为例约 256KB,16KB 页环境下约 1MB)。批量累积也使内核可以集中执行回收,提高效率。
小结
- memory.max 是 OOM 边界:普通 charge 在
page_counter_try_charge失败后进入回收→重试→OOM kill 流程,但不是数学意义上永不突破的封顶值——PF_MEMALLOC、__GFP_HIGH、__GFP_NOFAIL等场景会 force charge 临时超限,以保证回收路径或高优先级分配向前推进。 - memory.high 是渐进 throttle:charge 成功后在
done_restock标记超限,返回用户态或累积超 64 页时执行睡眠惩罚(r² × 64 × HZ),小超限几乎无感,大超限上限 2 秒,回收失败不触发 OOM。 - 两者的边界:memory.high 允许短暂突发,通过 reclaim/throttle 惩罚持续超额;memory.max 是 hard-limit/OOM 边界,charge 触及后进入回收→重试→OOM 流程。工程调参时,memory.high 应低于 memory.max 并留出缓冲,初始比例依 workload 而定,再结合 memory.events.high/max/oom、PSI 和应用延迟迭代调整,不存在通用的固定比例。
- 层级 overage 传播:
mem_find_max_overage取整条路径最大 overage,子 cgroup 的惩罚受最超限祖先驱动。 - 写入语义差异:
memory_max_write用xchg原子更新 hard limit,并由写入者同步触发 reclaim/OOM 使新限制尽快收敛;xchg的关键作用是与并发 charge 保持顺序一致性,不是"立即广播给所有 CPU"。memory_high_write用WRITE_ONCE更新 soft threshold,只触发同步 reclaim,不触发 OOM。
两者关键差异对比:
| memory.max | memory.high | |
|---|---|---|
| 检查时机 | charge 失败时 | charge 成功后 |
| 对分配影响 | 回收/重试/OOM;高优先级分配可 force 超限 | 放行,随后睡眠惩罚 |
| 回收失败 | 通常进入 OOM | charge→throttle;写入→放弃不 OOM |
| 写入方式 | xchg(全屏障) | WRITE_ONCE(软参考) |
| 角色 | 安全阀 | 第一道防线 |
下一篇预告 · Part 5:软保护——memory.min/low 与 protection 计算
memory.max 和 memory.high 解决的是"不能超"和"超了要减速"的问题,但全局内存压力下,如何保证一个 cgroup 不被过度回收?这是 memory.min/memory.low 要解决的问题。
下一篇将深入 mem_cgroup_calculate_protection()——它如何在层级树中按比例分配保护额度(emin/elow),以及保护值如何影响 vmscan 的 LRU 回收候选页选取。