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
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/* mm/memcontrol.c(精简,非连续行用 ... 隔开) */
static int try_charge_memcg(struct mem_cgroup *memcg, gfp_t gfp_mask,
unsigned int nr_pages)
{
unsigned int batch = max(MEMCG_CHARGE_BATCH, nr_pages);
int nr_retries = MAX_RECLAIM_RETRIES;
bool drained = false;
...

retry:
/* ① stock 快速路径:命中则 0 次全局原子操作,直接返回(见 Part 3) */
if (consume_stock(memcg, nr_pages))
return 0;

...

/* ② 向 page_counter 申请额度;memory.max 在这里检查
* page_counter_try_charge() 内部:usage += batch,若 usage > max 则回滚,
* 设 *counter = 超限节点,返回 false */
if (page_counter_try_charge(&memcg->memory, batch, &counter))
goto done_restock; /* 成功:进入 memory.high 检查阶段 */

/* ③ charge 失败(超过 memory.max):开始重试序列 */

if (batch > nr_pages) { batch = nr_pages; goto retry; } /* 降批量重试 */

/* 防止 memcg 直接回收路径内的 kmem 分配无限递归(89a2848381b5):
* 早期内核中,btrfs_releasepage() 在回收路径里分配 slab 对象,
* 该对象被 kmem 计费到 memcg,再次触发直接回收,导致栈溢出。
* 现代内核 kmem opt-in,该具体路径已不再触发,此 check 作为
* 安全网保留。触发本次 charge 的"原始进程"首次到达此处时
* PF_MEMALLOC 未设置,正常往下走触发 try_to_free_mem_cgroup_pages。 */
if (unlikely(current->flags & PF_MEMALLOC))
goto force;

/* 已在 OOM 流程中,不再回收,直接失败 */
if (unlikely(task_in_memcg_oom(current)))
goto nomem;

/* 不带 __GFP_DIRECT_RECLAIM 的分配无法执行直接回收,跳转至 nomem。
* 典型代表:GFP_ATOMIC(__GFP_HIGH | __GFP_KSWAPD_RECLAIM)、
* GFP_NOWAIT(__GFP_KSWAPD_RECLAIM | __GFP_NOWARN)。
* 两者都会进 nomem,但结局不同:
* GFP_ATOMIC 含 __GFP_HIGH → nomem 处转入 force 路径,允许临时超限记账
* GFP_NOWAIT 不含 __GFP_HIGH → nomem 处直接返回 -ENOMEM */
if (!gfpflags_allow_blocking(gfp_mask))
goto nomem;

/* 记录 memory.events 的 max 计数器(用户可读 memory.events 文件) */
__memcg_memory_event(mem_over_limit, MEMCG_MAX, allow_spinning);

/* 触发回收,尝试把用量压回 max 以下 */
nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit, nr_pages,
gfp_mask, reclaim_options, NULL);

/* mem_cgroup_margin = memory.max - usage(v2 下如此;v1 还会和 memsw 取小)
* 回收后剩余空间已够本次 charge,直接重试;否则继续往下走 drain/OOM */
if (mem_cgroup_margin(mem_over_limit) >= nr_pages) goto retry;

/* 回收后仍超限:刷 per-cpu stock(避免因缓存未归还而误判超限),再重试一次 */
if (!drained) { drain_all_stock(mem_over_limit); drained = true; goto retry; }

...

if (nr_retries--) goto retry; /* 最多重试 MAX_RECLAIM_RETRIES 次 */

...

/* 回收彻底无效,触发 OOM killer
* mem_cgroup_oom() 返回 bool:
* true = out_of_memory() 成功选出 victim 并发出 kill 信号
* → 内存即将释放,reset 重试次数再试一次
* false = OOM 无法推进(大页 order 过大 / 找不到可杀进程等)
* → fall through 到 nomem,返回 -ENOMEM */
if (mem_cgroup_oom(mem_over_limit, gfp_mask,
get_order(nr_pages * PAGE_SIZE))) {
passed_oom = true;
nr_retries = MAX_RECLAIM_RETRIES;
goto retry;
}

nomem:
/* __GFP_NOFAIL/__GFP_HIGH:不允许失败,走 force 路径 */
if (!(gfp_mask & (__GFP_NOFAIL | __GFP_HIGH)))
return -ENOMEM; /* 普通失败出口:分配返回 NULL */

force:
/* __GFP_NOFAIL:调用方保证分配最终会成功,不允许失败。
* __GFP_HIGH:分配对系统向前推进至关重要(如 GFP_ATOMIC),
* 拒绝可能造成系统级死锁,故允许超越 cgroup 限额。
* 两者都强制记账,usage 暂时超过 max。 */
page_counter_charge(&memcg->memory, nr_pages);
...
return 0;

done_restock:
/* charge 成功后:检查 memory.high,决定是否 throttle(见下节) */
...
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
do {
bool mem_high = page_counter_read(&memcg->memory) >
READ_ONCE(memcg->memory.high);
bool swap_high = page_counter_read(&memcg->swap) >
READ_ONCE(memcg->swap.high);

if (!in_task()) {
/* 中断上下文:不执行节流惩罚,仅通过 work queue 触发异步回收 */
if (mem_high) schedule_work(&memcg->high_work);
continue;
}
if (mem_high || swap_high) {
current->memcg_nr_pages_over_high += batch;
set_notify_resume(current); /* 设置 TIF_NOTIFY_RESUME,返回用户态时执行 throttle */
break;
}
} while ((memcg = parent_mem_cgroup(memcg)));
/* charge 本身已成功,本次分配不受影响 */

throttle 执行:__mem_cgroup_handle_over_highmm/memcontrol.c,精简,非连续行用 ... 隔开):

两条路径最终都调用此函数,执行针对 memory.high 的回收与睡眠惩罚:

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
29
30
31
32
33
34
35
void __mem_cgroup_handle_over_high(gfp_t gfp_mask)
{
unsigned int nr_pages = current->memcg_nr_pages_over_high;
memcg = get_mem_cgroup_from_mm(current->mm);
current->memcg_nr_pages_over_high = 0; /* 立即清零,避免重复计费 */

retry_reclaim:
if (task_is_dying()) goto out; /* 退出中的进程不再强制回收 */

/* 先尝试回收:首次回收 nr_pages 页,重试时只回收 SWAP_CLUSTER_MAX */
nr_reclaimed = reclaim_high(memcg,
in_retry ? SWAP_CLUSTER_MAX : nr_pages, gfp_mask);

/* 计算惩罚(memory + swap 两路相加):
* mem_find_max_overage 遍历层级取最大 overage,
* calculate_high_delay 将 overage^2 * HZ >> 34 换算为 jiffies */
penalty_jiffies = calculate_high_delay(memcg, nr_pages,
mem_find_max_overage(memcg));
penalty_jiffies += calculate_high_delay(memcg, nr_pages,
swap_find_max_overage(memcg));
penalty_jiffies = min(penalty_jiffies, MEMCG_MAX_HIGH_DELAY_JIFFIES); /* 上限 2s */

if (penalty_jiffies <= HZ / 100) goto out; /* < 10ms 不值得睡,直接跳过 */

/* 回收有进展(nr_reclaimed > 0)或还有重试次数(nr_retries-- > 0),继续重试;
* 两者均耗尽才落到下面的睡眠惩罚 */
if (nr_reclaimed || nr_retries--) { in_retry = true; goto retry_reclaim; }

/* 回收无效,睡眠惩罚 */
psi_memstall_enter(&pflags);
schedule_timeout_killable(penalty_jiffies); /* TASK_KILLABLE,致命信号可被杀 */
psi_memstall_leave(&pflags);
out:
css_put(&memcg->css);
}

路径二:同步触发条件mm/memcontrol.c,紧接 done_restock 之后):

若进程尚未返回用户态,TIF_NOTIFY_RESUME 无法触发,内核额外增加一道就地调用的兜底检查:

1
2
3
4
if (current->memcg_nr_pages_over_high > MEMCG_CHARGE_BATCH &&
!(current->flags & PF_MEMALLOC) &&
gfpflags_allow_blocking(gfp_mask))
__mem_cgroup_handle_over_high(gfp_mask);
  • > 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
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/* mem_find_max_overage / swap_find_max_overage:遍历层级(不含 root),取各节点 overage 最大值 */
static u64 mem_find_max_overage(struct mem_cgroup *memcg)
{
u64 overage, max_overage = 0;
do {
overage = calculate_overage(page_counter_read(&memcg->memory),
READ_ONCE(memcg->memory.high));
max_overage = max(overage, max_overage);
} while ((memcg = parent_mem_cgroup(memcg)) && !mem_cgroup_is_root(memcg));
return max_overage;
}

static u64 swap_find_max_overage(struct mem_cgroup *memcg)
{
u64 overage, max_overage = 0;
do {
overage = calculate_overage(page_counter_read(&memcg->swap),
READ_ONCE(memcg->swap.high));
if (overage)
/* MEMCG_HIGH 已在 reclaim_high() 中记录;swap 无对应的 reclaim_swap_high,
* 此处是检测 swap.high 超限的唯一入口,所以在这里记录事件 */
memcg_memory_event(memcg, MEMCG_SWAP_HIGH);
max_overage = max(overage, max_overage);
} while ((memcg = parent_mem_cgroup(memcg)) && !mem_cgroup_is_root(memcg));
return max_overage;
}

/* calculate_overage:归一化超限量并放大精度 */
static u64 calculate_overage(unsigned long usage, unsigned long high)
{
if (usage <= high) return 0;
u64 overage = usage - high;
overage <<= MEMCG_DELAY_PRECISION_SHIFT; /* 左移 20 位,保留整数精度 */
return div64_u64(overage, high); /* 除以 high,得归一化比例 */
}

/* calculate_high_delay:将 overage 转为睡眠 jiffies */
static unsigned long calculate_high_delay(struct mem_cgroup *memcg,
unsigned int nr_pages, u64 max_overage)
{
if (!max_overage) return 0;
unsigned long penalty_jiffies = max_overage * max_overage * HZ;
penalty_jiffies >>= MEMCG_DELAY_PRECISION_SHIFT; /* 右移 20 */
penalty_jiffies >>= MEMCG_DELAY_SCALING_SHIFT; /* 右移 14,合计右移 34 */
return penalty_jiffies * nr_pages / MEMCG_CHARGE_BATCH;
}

设真实超限比例 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.maxmemory.high 文件时,分别调用 memory_max_writememory_high_write(都在 mm/memcontrol.c)。

memory_max_writemm/memcontrol.c,精简,非连续行用 ... 隔开):

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
29
unsigned int nr_reclaims = MAX_RECLAIM_RETRIES; /* 回收最大重试次数,耗尽后触发 OOM */
bool drained = false; /* 标记 drain_all_stock 是否已执行过(整个循环只做一次) */
...

xchg(&memcg->memory.max, max);
/* 原子替换 max;xchg 提供前后 full memory barriers,
* 用于与并发 page_counter_try_charge() 的 usage/max 检查保持顺序一致性 */

if (of->file->f_flags & O_NONBLOCK)
goto out; /* 非阻塞模式:写入后直接返回,不等待回收 */

for (;;) {
unsigned long nr_pages = page_counter_read(&memcg->memory);
if (nr_pages <= max) break; /* 用量已降到新 max 以下,结束 */
if (signal_pending(current)) break; /* 收到信号,提前退出 */

if (!drained) {
drain_all_stock(memcg); /* 第一轮:先刷 per-CPU stock 确保计数精确,再判断是否真超限 */
drained = true; continue;
}
if (nr_reclaims) {
try_to_free_mem_cgroup_pages(memcg, nr_pages - max, ...);
nr_reclaims--; continue; /* 每次回收后重新检查,最多重试 MAX_RECLAIM_RETRIES 次 */
}
/* drain + 多轮回收后仍超限,触发 OOM kill */
memcg_memory_event(memcg, MEMCG_OOM);
if (!mem_cgroup_out_of_memory(memcg, GFP_KERNEL, 0))
break; /* OOM 无法推进(找不到可杀进程等),放弃 */
}

memory_high_writemm/memcontrol.c,精简,非连续行用 ... 隔开):

写入新值后若当前用量已超过新 high,cgroup 立刻处于超限状态,但后续只有新分配触发 done_restock 才会开始 throttle——可能要等很久。因此写入进程主动驱动一轮回收,把用量压到新 high 以下。这与 memory_max_write 同一思路:谁降低了限制,谁负责初始执行。区别在于力度:memory_max_write 回收失败会触发 OOM;memory_high_write 耗尽重试后直接放弃,不触发 OOM——soft 限制允许超限存在,靠后续 throttle 持续施压。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
page_counter_set_high(&memcg->memory, high);
/* page_counter_set_high 内部:WRITE_ONCE(counter->high, nr_pages) */

if (of->file->f_flags & O_NONBLOCK)
goto out;

for (;;) {
unsigned long nr_pages = page_counter_read(&memcg->memory);
if (nr_pages <= high) break;
if (signal_pending(current)) break;

if (!drained) { drain_all_stock(memcg); drained = true; continue; }

reclaimed = try_to_free_mem_cgroup_pages(memcg, nr_pages - high, ...);
if (!reclaimed && !nr_retries--)
break; /* 回收连续无进展,放弃(不触发 OOM) */
}

为什么 memory_max_writexchgmemory_high_writeWRITE_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
2
3
4
5
6
7
8
9
10
11
12
13
mem_cgroup_charge()
-> __mem_cgroup_charge()
-> charge_memcg()
-> try_charge_memcg()
-> page_counter_try_charge(&memcg->memory, nr_pages, &fail)
(若失败)
-> 降 batch 重试
-> if (!gfpflags_allow_blocking) goto nomem // 无 __GFP_DIRECT_RECLAIM,不能直接回收;含 __GFP_HIGH(如 GFP_ATOMIC)→ force,否则 → -ENOMEM
-> __memcg_memory_event(mem_over_limit, MEMCG_MAX)
-> try_to_free_mem_cgroup_pages()
-> drain_all_stock()
-> retry 循环(MAX_RECLAIM_RETRIES 次)
-> 最终 mem_cgroup_oom() -> OOM kill

路径2:memory.high throttle(异步/同步)

1
2
3
4
5
6
7
8
9
10
11
12
13
try_charge_memcg() 成功后
-> done_restock:
-> 遍历层级,若当前 usage > memory.high:
-> 进程上下文: current->memcg_nr_pages_over_high += batch
set_notify_resume(current)
-> 中断上下文: schedule_work(&memcg->high_work)
-> 若累积超过 MEMCG_CHARGE_BATCH 且可阻塞:
-> __mem_cgroup_handle_over_high(gfp_mask)
-> reclaim_high(memcg, nr_pages, gfp_mask)
-> mem_find_max_overage(memcg) // 计算层级最大 overage
-> calculate_high_delay() -> penalty_jiffies
-> schedule_timeout_killable(penalty_jiffies)
-> 返回用户态时 (TIF_NOTIFY_RESUME) 也会调用 handle_over_high

路径3:写入 memory.max

1
2
3
4
5
6
7
8
memory_max_write()
-> page_counter_memparse(buf, "max", &max)
-> xchg(&memcg->memory.max, max)
-> 若当前 usage > max:
drain_all_stock(memcg)
try_to_free_mem_cgroup_pages() 循环
memcg_memory_event(memcg, MEMCG_OOM)
mem_cgroup_out_of_memory()

路径4:写入 memory.high

1
2
3
4
5
6
7
memory_high_write()
-> page_counter_memparse(buf, "max", &high)
-> page_counter_set_high(&memcg->memory, high) // WRITE_ONCE
-> 若当前 usage > high:
drain_all_stock(memcg)
try_to_free_mem_cgroup_pages() 循环(最多 MAX_RECLAIM_RETRIES 次)
// 回收失败直接放弃,不触发 OOM

设计演进:为什么不是更简单的方案

为什么不让 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)。批量累积也使内核可以集中执行回收,提高效率。

小结

  1. memory.max 是 OOM 边界:普通 charge 在 page_counter_try_charge 失败后进入回收→重试→OOM kill 流程,但不是数学意义上永不突破的封顶值——PF_MEMALLOC、__GFP_HIGH__GFP_NOFAIL 等场景会 force charge 临时超限,以保证回收路径或高优先级分配向前推进。
  2. memory.high 是渐进 throttle:charge 成功后在 done_restock 标记超限,返回用户态或累积超 64 页时执行睡眠惩罚(r² × 64 × HZ),小超限几乎无感,大超限上限 2 秒,回收失败不触发 OOM。
  3. 两者的边界:memory.high 允许短暂突发,通过 reclaim/throttle 惩罚持续超额;memory.max 是 hard-limit/OOM 边界,charge 触及后进入回收→重试→OOM 流程。工程调参时,memory.high 应低于 memory.max 并留出缓冲,初始比例依 workload 而定,再结合 memory.events.high/max/oom、PSI 和应用延迟迭代调整,不存在通用的固定比例。
  4. 层级 overage 传播mem_find_max_overage 取整条路径最大 overage,子 cgroup 的惩罚受最超限祖先驱动。
  5. 写入语义差异memory_max_writexchg 原子更新 hard limit,并由写入者同步触发 reclaim/OOM 使新限制尽快收敛;xchg 的关键作用是与并发 charge 保持顺序一致性,不是"立即广播给所有 CPU"。memory_high_writeWRITE_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 回收候选页选取。