上一篇讲了 memcg 如何把全局内存资源划分成层级预算。当预算用尽且回收无力时,最后一道防线是 OOM killer——通过终止进程来释放内存。这不是内存管理的常规路径,而是所有正常手段都失败后的兜底。

核心问题可以压成一句话:

OOM killer 是多轮分配、回收、压缩、写回、swap 都无法满足请求后的兜底路径,不是内存管理的常规目标。

问题从哪里来

内存分配失败的处理有一个基本问题:内核不能简单地对调用者返回"分配失败"。很多内核代码路径不检查分配失败(GFP_KERNEL 分配假设不会失败),即使返回错误,用户空间进程通常也没有合理的 fallback 逻辑。

所以内核的策略是:在返回失败之前,尽可能通过各种手段释放内存。如果所有手段都用尽仍然无法满足分配请求,最后才走 OOM kill——选择一个进程杀掉以释放它占用的内存。

到达 OOM kill 之前的完整路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__alloc_pages() 分配请求
→ 检查 zone 水位线:有空闲页? → 成功返回
→ 唤醒 kswapd 后台回收
→ 进入 __alloc_pages_slowpath()
→ direct reclaim (同步回收本进程上下文)
→ direct compaction (碎片整理)
→ 回收后重试分配
→ 仍然失败? 考虑 OOM
→ 检查是否允许 OOM (__GFP_NOFAIL? __GFP_NORETRY?)
→ 调用 out_of_memory()
→ 选择 victim
→ 发送 SIGKILL
→ 等待 victim 退出释放内存
→ 重试分配

这个路径说明:OOM kill 发生时,系统已经经历了多轮回收和压缩尝试。如果在日志中看到 OOM,首先应该追问:回收为什么失败?是所有页面都不可回收(pinned/mlocked/unevictable),还是回收速度跟不上分配速度?

overcommit:为什么分配成功不代表有物理内存

Linux 默认启用内存 overcommit。mmapmalloc 返回成功只意味着虚拟地址空间被预留,不保证有物理页支持。物理页在首次访问(page fault)时才分配。

overcommit 策略通过 /proc/sys/vm/overcommit_memory 控制:

行为
0(默认) 启发式 overcommit:明显不合理的请求被拒绝
1 永远允许 overcommit:所有 mmap 都成功
2 严格模式:总承诺量不超过 swap + RAM × overcommit_ratio

在默认模式下,一个进程可以 malloc(100GB) 成功(虚拟地址空间被映射),但实际使用时如果物理内存不够就会触发 OOM。这就是"分配成功 ≠ 有内存"的根源。

overcommit 的合理性:fork 后大多数子进程立即 exec(地址空间被替换),如果 fork 时强制预留物理内存,绝大多数系统无法正常工作。COW 使得 fork 不需要实际复制页面,overcommit 使得这些未触碰的页面不需要物理预留。

victim 选择:oom_score

OOM killer 不是随机杀进程。它用一个评分机制选择"最合适"的 victim:

oom_score/proc/<pid>/oom_score):内核计算的 OOM 分数,范围 0-1000+。分数越高越可能被选为 victim。基础分数与进程的 RSS(常驻内存集)成正比——使用内存越多,分数越高。

oom_score_adj/proc/<pid>/oom_score_adj):管理员可调的偏移量,范围 -1000 到 1000。设为 -1000 表示永不被 OOM kill(进程免死)。设为正值表示该进程更容易被选中。

1
2
3
4
5
6
最终选择逻辑 (简化):
for each process:
points = RSS / total_ram * 1000 (基础分:按内存占比)
points += oom_score_adj (管理员调整)
if points == -1000: skip (免死)
选择 points 最高的进程作为 victim

实际实现(oom_badness())更复杂:考虑子进程共享页面、硬件资源预留、特权进程等因素。但核心逻辑是:杀掉释放最多内存的进程——因为 OOM kill 的目标是尽快恢复可用内存。

global OOM 与 memcg OOM

两种 OOM 触发场景有本质区别:

维度 global OOM memcg OOM
触发条件 系统全局物理内存 + swap 耗尽 cgroup 达到 memory.max
victim 范围 全局所有进程(除 oom_score_adj=-1000) 只限该 cgroup 内的进程
前提 所有 zone 的 kswapd 回收都失败 该 cgroup 的 per-memcg 回收失败
信号 dmesg “Out of memory” dmesg “Memory cgroup out of memory”
宿主状态 真的没有可用内存 宿主可能仍有大量空闲内存

memcg OOM 是容器环境中最常见的 OOM 类型。它表示容器的预算用完,不代表物理机出了问题。排查方向完全不同:global OOM 要看全局内存使用和回收效率,memcg OOM 要看容器的 memory.max 设置是否合理以及容器内的内存使用模式。

OOM 日志怎么读

OOM 触发时,内核向 dmesg 输出一段结构化日志。关键字段:

1
2
3
4
5
6
[timestamp] container-B invoked oom-killer:
gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[timestamp] oom_kill_constraint=CONSTRAINT_MEMCG
[timestamp] Memory cgroup out of memory: Killed process 12345 (my-app)
total-vm:2048000kB, anon-rss:512000kB, file-rss:24000kB, shmem-rss:0kB
[timestamp] oom_reaper: reaped process 12345 (my-app), now anon-rss:0kB ...

各字段含义:

字段 含义 诊断价值
gfp_mask 触发 OOM 的分配请求的标志 说明是什么类型的分配失败
order 请求的页面阶 order=0 表示连单页都分配不了;高阶可能是碎片问题
oom_score_adj 触发分配的进程的 adj 值 非 victim,是触发者
constraint OOM 约束类型 CONSTRAINT_MEMCG=容器限制,CONSTRAINT_NONE=全局
total-vm victim 的虚拟内存总量
anon-rss victim 的匿名页 RSS 被杀后释放的主要内存来源
file-rss victim 的文件映射 RSS page cache,可能被回收

日志中还包含系统内存状态快照(MemFree、Active、Inactive、Slab 等),这些信息帮助还原 OOM 发生时的完整现场。

读 OOM 日志的正确顺序:

  1. constraint → 确定是全局还是 memcg OOM
  2. order → 0 表示真缺页,高阶可能只是碎片
  3. 内存状态快照 → 回收是否真的无计可施
  4. victim 选择 → 为什么选了这个进程

防御性配置

几种降低 OOM 影响的策略:

调整 oom_score_adj:为关键服务设置负值(如 -500 到 -999),使其不容易被选为 victim。但设为 -1000(免死)要慎重——如果所有重要进程都免死,OOM killer 可能杀掉不相关的系统进程。

1
2
3
4
5
6
# 保护关键服务
echo -500 > /proc/$(pidof my-critical-service)/oom_score_adj

# systemd 服务配置
# [Service]
# OOMScoreAdjust=-500

合理设置 memcg 限制memory.highmemory.max 更优先使用——前者通过节流避免 OOM,后者直接杀进程。

禁用 overcommit(模式 2):在已知工作集大小的环境中,严格模式可以让分配在虚拟地址阶段就失败,避免后续 OOM。但这大幅降低了内存利用率。

earlyoom / systemd-oomd:用户空间 OOM daemon 在内存压力达到阈值时主动杀进程,比内核 OOM killer 更早介入、决策更灵活(可以根据服务优先级、重启策略等选择目标)。

模式提炼:不可恢复的分配失败触发终止以释放资源

1
unrecoverable allocation failure -> choose victim -> free resources by termination
阶段 动作 失败后
正常分配 从 free_area 取页 → 唤醒 kswapd
后台回收 kswapd 扫描 LRU → direct reclaim
同步回收 当前进程回收页面 → compaction
碎片整理 迁移可移动页 → retry
重试仍然失败 → OOM kill
OOM kill SIGKILL victim → oom_reaper 回收

这个逐步升级的策略在系统设计中是经典模式:先用轻量级方法(缓存淘汰),再用重量级方法(同步 I/O、GC STW),最后用破坏性方法(终止连接、丢弃请求)。对应分布式系统中的 circuit breaker 三态:closed → half-open → open。

一个最小实验:在 cgroup 中安全触发 OOM

这个实验在受限 cgroup 中触发 memcg OOM,不影响宿主系统。

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
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <signal.h>

static volatile int got_signal = 0;
static void handler(int sig) { got_signal = sig; }

int main(void) {
long page_size = sysconf(_SC_PAGESIZE);

signal(SIGTERM, handler);

printf("PID: %d\n", getpid());
printf("oom_score: ");
fflush(stdout);
char cmd[64];
snprintf(cmd, sizeof(cmd), "cat /proc/%d/oom_score", getpid());
if (system(cmd) != 0) { /* ignore */ }

printf("\nAllocating memory in 8MB chunks until OOM...\n");
printf("(Run in a cgroup with memory.max set to observe memcg OOM)\n\n");

size_t chunk = 8UL * 1024 * 1024;
size_t total = 0;
int round = 0;

while (!got_signal) {
char *buf = mmap(NULL, chunk, PROT_READ | PROT_WRITE,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
if (buf == MAP_FAILED) {
printf(" mmap failed at round %d (total %zu MB)\n",
round, total / (1024*1024));
break;
}

for (size_t off = 0; off < chunk; off += (size_t)page_size)
buf[off] = 1;

total += chunk;
round++;
printf(" round %d: +8MB, total %zu MB\n", round, total / (1024*1024));
}

if (got_signal)
printf("\nReceived signal %d before OOM\n", got_signal);

return 0;
}

编译与运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cc -Wall -Wextra -O2 oom_demo.c -o oom_demo

# 方法 1: 用 systemd-run 创建受限 cgroup
systemd-run --user --scope -p MemoryMax=64M ./oom_demo

# 方法 2: 手动创建 cgroup
sudo mkdir -p /sys/fs/cgroup/test-oom
echo 67108864 | sudo tee /sys/fs/cgroup/test-oom/memory.max
echo $$ | sudo tee /sys/fs/cgroup/test-oom/cgroup.procs
./oom_demo

# 观察 OOM 日志
sudo dmesg | tail -30

# 查看 cgroup events
cat /sys/fs/cgroup/test-oom/memory.events

# 清理
sudo rmdir /sys/fs/cgroup/test-oom 2>/dev/null

完成实验后清理:

1
rm -f oom_demo oom_demo.c

读取实验结果

实验预期行为:

程序在 ~56-64MB 时被杀:前几轮分配顺利。接近 memory.max 时内核开始 per-memcg 回收。由于所有页面都是刚 touch 的匿名页(无法回收),回收失败后触发 memcg OOM,进程收到 SIGKILL。

dmesg 输出

  • Memory cgroup out of memory: Killed process <pid> (oom_demo)
  • oom_kill_constraint=CONSTRAINT_MEMCG
  • anon-rss 接近 memory.max 的值

memory.eventsoomoom_kill 计数器各增加 1。

关键观察:

  • 进程不是在"第 8 轮 malloc 时"被杀——是在 page fault 时被杀。mmap 本身几乎总是成功的(只是预留虚拟地址),OOM 发生在 touch 内存(触发物理分配)的时刻。
  • 如果给 cgroup 设置 memory.swap.max=0,OOM 来得更快——因为匿名页无处可去。如果允许 swap,内核会先 swap out 再 OOM。
  • oom_score 随 RSS 增长而增加。在单进程 cgroup 中,该进程必然是 victim(没有其他选择)。

模式提炼:OOM 是症状,不是原因

1
2
3
4
5
OOM observed → ask: why did reclaim fail?
- all pages pinned/mlocked? → memory leak or excessive mlock
- memcg limit too low? → raise limit or optimize workload
- swap full? → add swap or reduce anonymous pages
- fragmentation? → order>0 failure, check compaction

看到 OOM 日志时,正确的反应不是"内存不够了",而是追溯回收失败的原因。OOM 是回收链路末端的事件,它的存在意味着前面的每个环节都已经尝试过且失败了。

源码锚点

入口 读它的目的
mm/oom_kill.c out_of_memory():OOM 决策入口
mm/oom_kill.c oom_badness():victim 评分函数
mm/oom_kill.c oom_reaper:异步回收被杀进程的内存
mm/memcontrol.c mem_cgroup_oom():memcg OOM 触发路径
mm/page_alloc.c __alloc_pages_slowpath():分配失败到 OOM 的路径
include/linux/oom.h OOM 约束类型、标志定义

oom_badness() 时可以带着一个问题:如果两个进程的 RSS 相同且 oom_score_adj 相同,内核用什么规则打破平局?

研究生迁移表

Linux 概念 一般系统设计
OOM killer 断路器 open 状态(circuit breaker trip)
oom_score 优先级队列中的权重
oom_score_adj 管理员配置的服务优先级
overcommit 超售(航空/云计算/连接池)
memcg OOM per-tenant 熔断(不影响其他租户)
oom_reaper 异步资源清理(GC finalizer)
earlyoom/systemd-oomd 主动熔断(proactive circuit breaker)
OOM 日志 事后分析 artifact(post-mortem trace)

OOM 的核心模式——“正常回收手段用尽后通过破坏性操作恢复系统”——在分布式系统中对应 load shedding(过载丢弃请求)、Kubernetes OOMKilled(pod 重启)、数据库连接池满时 reject 新连接。区别在于粒度:Linux OOM 杀进程,K8s 重启 pod,数据库拒绝连接。

常见误解

第一个误解是认为 OOM killer 随机杀进程。实际上 victim 选择完全基于 oom_badness() 评分——RSS 最大且 oom_score_adj 最高的进程最可能被选中。可以通过 /proc/<pid>/oom_score 预判谁会被杀。

第二个误解是认为 OOM 意味着 malloc 返回 NULL。在 overcommit 模式下,malloc(通过 mmap)几乎不会返回 NULL。OOM 发生在 page fault 时——此时内核直接发 SIGKILL,进程没有机会处理。用户空间代码检查 malloc 返回值在 Linux 上几乎不能防御 OOM。

第三个误解是认为 oom_score_adj=-1000 是万能保护。如果系统中所有重要进程都设为 -1000,OOM killer 只能杀 init 或 kernel thread——这通常导致系统挂起而不是恢复。应该只对真正关键的少数进程使用 -1000。

第四个误解是认为增加 swap 能"解决"OOM。swap 推迟 OOM 的发生(匿名页可以换出),但如果内存泄漏是根因,swap 只是延长了到达 OOM 的时间。同时大量 swap 活动导致系统极度缓慢(thrashing),此时进程虽然活着但几乎无法响应。

第五个误解是认为容器中看到 OOM 就要给宿主加内存。memcg OOM 是局部事件——可能只需要调高容器的 memory.max,或者优化容器内应用的内存使用。宿主物理内存可能仍然充裕。

练习

第一,查看当前系统的 overcommit 配置:cat /proc/sys/vm/overcommit_memory/proc/sys/vm/overcommit_ratio。用 /proc/meminfo 中的 CommitLimitCommitted_AS 计算当前的承诺比例。

第二,列出系统中 oom_score 最高的 10 个进程:ps -eo pid,comm,rss --sort=-rss | head,然后对比 /proc/<pid>/oom_score。验证分数是否与 RSS 大致成正比。

第三,按文中实验在一个 64MB 的 cgroup 中触发 OOM。对比 memory.events 在 OOM 前后的变化。用 dmesg 找到 OOM 日志,标出 constraint、victim RSS 和 gfp_mask。

第四,阅读 mm/oom_kill.cselect_bad_process() 的实现。列出哪些进程会被跳过不杀(除了 oom_score_adj=-1000 外还有哪些条件)。

第五,配置 systemd-oomd(如果系统支持)或研究其配置:它如何在内核 OOM 之前介入?它使用什么指标(PSI memory pressure)判断何时杀进程?与内核 OOM killer 相比,用户空间 OOM daemon 的优势和局限是什么?

系列导航

参考资料