上一篇讲了 SLUB 如何在页之上管理小对象的分配。到此为止,所有讨论都假设一个全局的内存资源池——进程共享同一组物理页,回收和 OOM 是全局决策。容器化环境打破了这个假设:一个容器不应该消耗完整机器的内存,它的 OOM 不应该波及其他容器。

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

memcg 把全局 VM 策略投影到层级资源边界里,使回收、统计和 OOM 都带上 cgroup 语义。

问题从哪里来

传统 Linux 内存管理是全局视角:所有进程共享物理内存,kswapd 按全局水位线回收,OOM killer 从全局选 victim。这在单租户系统上没问题,但在多租户场景(容器、虚拟化、共享主机)上不够:

第一,隔离性缺失。一个行为异常的容器可以消耗所有可用内存,触发全局 OOM,导致无关容器的进程被杀。

第二,资源可预测性缺失。一个容器无法知道自己"还能用多少内存"——这取决于其他容器当前的使用情况。

第三,统计粒度缺失。管理员无法回答"这个服务用了多少内存"——/proc/meminfo 只有全局数据。

cgroup memory controller(memcg)解决这些问题:为每个 cgroup 建立独立的内存预算、独立的使用统计、独立的回收和 OOM 边界。

cgroup v2 memory controller 的接口

cgroup v2 的内存控制器通过文件系统接口暴露配置和统计。以一个 cgroup 路径 /sys/fs/cgroup/my-service/ 为例:

文件 读/写 含义
memory.current R 当前使用的内存(字节)
memory.max RW 硬上限,超过触发 OOM
memory.high RW 软上限,超过触发节流和加强回收
memory.low RW 最佳努力保护,低于此值不轻易回收
memory.min RW 硬保护,低于此值绝不回收
memory.events R 事件计数器(low、high、max、oom、oom_kill)
memory.stat R 详细分类统计
memory.pressure R PSI 内存压力指标

这些接口构成一个分层的内存管理模型:

1
2
3
4
5
memory.min ──── 硬保护:绝对不回收
memory.low ──── 软保护:尽量不回收(除非全局压力)
↕ 正常运行区间
memory.high ─── 软上限:超过则节流 + 加强回收
memory.max ──── 硬上限:超过则 OOM kill

memory.high 是推荐的主要控制手段——它不触发 OOM,而是通过节流进程的内存分配速度来施压,给回收机制留出时间。memory.max 是最后防线。

内存记账:什么被计入

memcg 追踪以下内存:

匿名页brkmmap(MAP_ANONYMOUS) 分配的内存。首次写入(page fault)时计入分配者所在的 cgroup。

page cache:文件读写产生的缓存页。首次进入 page cache 时计入触发 I/O 的 cgroup。

内核内存:slab 对象(dentry、inode 等)、页表、网络 buffer。通过 memory.stat 中的 kernelslab 字段可见。

共享内存和 tmpfsshmem 页面计入最先触发分配的 cgroup。

关键规则:内存跟随 cgroup,不跟随进程。如果进程 A 在 cgroup-X 中分配了内存,然后被迁移到 cgroup-Y,该内存仍然计入 cgroup-X。只有新分配的内存才计入新 cgroup。

1
2
3
4
5
6
7
8
9
memory.stat 的关键字段:
anon ← 匿名页 RSS
file ← page cache(文件映射)
shmem ← tmpfs / shared memory
kernel ← 内核数据结构
slab ← slab 分配器中的对象
sock ← TCP/UDP buffer
pgfault ← page fault 次数
pgmajfault ← major fault 次数

memcg 回收:局部压力,局部响应

没有 memcg 时,回收是全局的:kswapd 按 node 水位线扫描所有页面。有 memcg 后,回收增加了一个维度:当某个 cgroup 接近 memory.highmemory.max 时,回收只针对该 cgroup 拥有的页面。

1
2
3
4
5
6
7
8
9
全局回收 (kswapd):
触发条件: node 水位线低于 low watermark
扫描范围: node 内所有页面(所有 cgroup)
选择依据: LRU 位置、访问频率

memcg 回收:
触发条件: cgroup 使用量接近 memory.high 或 memory.max
扫描范围: 只扫描属于该 cgroup 的页面
选择依据: cgroup 内部的 LRU(per-memcg lruvec)

每个 memcg 维护自己的 lruvec——独立的活跃/不活跃链表。这意味着一个 cgroup 的回收不需要扫描其他 cgroup 的页面,也不会影响其他 cgroup 的热页面。

memory.lowmemory.min 在全局回收时生效:当 kswapd 进行全局扫描时,受 memory.low 保护的 cgroup 的页面被跳过(除非保护被过量承诺);受 memory.min 保护的页面在任何情况下都不被回收。

memcg OOM 与全局 OOM

当一个 cgroup 达到 memory.max 且回收无法释放足够内存时,memcg OOM 触发。与全局 OOM 的关键区别:

维度 memcg OOM 全局 OOM
触发条件 cgroup 达到 memory.max 系统全局内存耗尽
victim 范围 只杀该 cgroup 内的进程 全局选择
外部影响 不影响其他 cgroup 任何进程都可能被杀
memory.events oom、oom_kill 计数增加 不体现在 cgroup events

memory.oom.group 控制 OOM 行为:设为 1 时,OOM 杀死该 cgroup 内的所有进程(而不是只选一个 victim)。这对需要原子性终止的工作负载(如一组协作进程)更合适。

这就是为什么容器被 OOM kill 时宿主可能还有大量空闲内存——问题不在全局内存不足,而是该容器的 memory.max 限制被触及。

模式提炼:全局资源变成层级预算

1
global resource -> hierarchical budget -> local reclaim / local OOM
维度 全局视角 memcg 视角
可用内存 MemAvailable memory.max - memory.current
回收触发 node watermark cgroup 接近 high/max
回收范围 全 node LRU per-memcg lruvec
OOM 范围 全局 oom_score cgroup 内部
统计粒度 /proc/meminfo memory.stat per cgroup

这种"全局池拆分为局部预算"的模式在分布式系统中非常普遍:数据库连接池的 per-tenant 限制、网络 QoS 的 per-flow 带宽预算、云平台的 per-account quota。核心思路是:总资源有限时,通过预算划分实现隔离和公平性。

一个最小实验:在 cgroup 中观察内存限制

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

static long read_memcg_value(const char *filename) {
FILE *f = fopen(filename, "r");
if (!f) return -1;
char buf[64];
long val = -1;
if (fgets(buf, sizeof(buf), f)) {
if (strcmp(buf, "max\n") == 0) val = -2;
else val = atol(buf);
}
fclose(f);
return val;
}

static void show_memcg_status(void) {
char path[256];
/* 找到当前进程的 cgroup 路径 */
FILE *f = fopen("/proc/self/cgroup", "r");
if (!f) { printf(" Cannot read /proc/self/cgroup\n"); return; }
char line[256];
char cgpath[128] = "";
while (fgets(line, sizeof(line), f)) {
/* cgroup v2: "0::/path" */
if (strncmp(line, "0::", 3) == 0) {
char *p = line + 3;
p[strcspn(p, "\n")] = '\0';
snprintf(cgpath, sizeof(cgpath), "%s", p);
break;
}
}
fclose(f);

if (cgpath[0] == '\0') {
printf(" cgroup path not found\n");
return;
}
printf(" cgroup: %s\n", cgpath);

snprintf(path, sizeof(path), "/sys/fs/cgroup%s/memory.current", cgpath);
long current = read_memcg_value(path);

snprintf(path, sizeof(path), "/sys/fs/cgroup%s/memory.max", cgpath);
long max = read_memcg_value(path);

snprintf(path, sizeof(path), "/sys/fs/cgroup%s/memory.high", cgpath);
long high = read_memcg_value(path);

printf(" memory.current: %ld bytes (%.1f MB)\n",
current, current > 0 ? (double)current / (1024*1024) : 0);
if (max == -2) printf(" memory.max: max (unlimited)\n");
else printf(" memory.max: %ld bytes (%.1f MB)\n", max,
max > 0 ? (double)max / (1024*1024) : 0);
if (high == -2) printf(" memory.high: max (unlimited)\n");
else printf(" memory.high: %ld bytes (%.1f MB)\n", high,
high > 0 ? (double)high / (1024*1024) : 0);
}

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

printf("=== memcg demo ===\n\n");
printf("Before allocation:\n");
show_memcg_status();

/* 逐步分配内存,观察 memory.current 增长 */
size_t chunk = 16UL * 1024 * 1024; /* 16MB */
int rounds = 8;

printf("\nAllocating %d × %zu MB = %zu MB total:\n",
rounds, chunk / (1024*1024),
(size_t)rounds * chunk / (1024*1024));

for (int i = 0; i < rounds; i++) {
char *buf = mmap(NULL, chunk, PROT_READ | PROT_WRITE,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
if (buf == MAP_FAILED) {
printf(" round %d: mmap failed (likely hit memory.max)\n", i);
break;
}
/* touch pages to charge to memcg */
for (size_t off = 0; off < chunk; off += (size_t)page_size)
buf[off] = 1;
printf(" round %d: allocated +%zu MB, ", i, chunk / (1024*1024));
show_memcg_status();
}

printf("\nDone. If running in a limited cgroup, observe:\n");
printf(" - memory.current growing with each round\n");
printf(" - allocation failure when approaching memory.max\n");
printf(" - memory.events oom/oom_kill counters\n");

return 0;
}

编译与运行:

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

# 创建测试 cgroup(需要 root)
sudo mkdir -p /sys/fs/cgroup/test-memcg
echo $$ | sudo tee /sys/fs/cgroup/test-memcg/cgroup.procs

# 设置 64MB 限制
echo 67108864 | sudo tee /sys/fs/cgroup/test-memcg/memory.max

# 在该 cgroup 中运行
./memcg_demo

# 查看 OOM 事件
cat /sys/fs/cgroup/test-memcg/memory.events

# 清理
sudo rmdir /sys/fs/cgroup/test-memcg

如果不方便操作系统 cgroup,用 systemd-run 更简洁:

1
2
# 用 systemd-run 创建临时 cgroup 并限制内存
systemd-run --user --scope -p MemoryMax=64M ./memcg_demo

完成实验后清理:

1
rm -f memcg_demo memcg_demo.c

读取实验结果

在 64MB 限制的 cgroup 中运行时预期看到:

memory.current 递增:每轮分配 16MB 并 touch 后,memory.current 增加约 16MB。前 3-4 轮(共 48-64MB)应该成功。

接近 memory.max 时的行为:第 4 轮可能开始变慢(内核在尝试回收 cgroup 内的页面)。如果没有可回收页面(纯匿名页,无 swap),第 4 或第 5 轮的 mmap + touch 将触发 memcg OOM。

memory.events 变化

  • high:如果设置了 memory.high 且被触及,此计数器增加
  • max:接近 memory.max 时增加
  • oom:OOM 被触发时增加
  • oom_kill:进程实际被杀时增加

关键观察:

  • 程序被 OOM kill 时,dmesgjournalctl -k 会显示类似 Memory cgroup out of memory: Killed process <pid> 的消息。这与全局 OOM 的 Out of memory: Killed process 不同——前者明确指出是 cgroup 限制。
  • 宿主的 /proc/meminfoMemAvailable 可能仍然很大——cgroup OOM 是局部事件。
  • 如果系统有 swap 且 cgroup 没有禁用 swap(memory.swap.max),匿名页会先被 swap out 而不是直接 OOM。

模式提炼:限制不等于预留,保护不等于保证

1
2
3
memory.max = hard ceiling (triggers OOM, not reservation)
memory.min = hard floor (blocks reclaim, not guaranteed allocation)
over-commitment is possible at both ends

memory.max 不预留物理内存——它只在使用量到达时执行限制。所有 cgroup 的 memory.max 之和可以超过物理内存(超量承诺)。类似地,memory.min 的总和超过物理内存时,保护承诺无法全部兑现。这和航空公司超售机票是同一个模型:统计上不会所有人同时出现,但极端情况下需要有降级机制。

源码锚点

入口 读它的目的
mm/memcontrol.c memcg 核心:charge/uncharge、reclaim、OOM 触发
mm/vmscan.c shrink_lruvec():per-memcg 回收路径
mm/memcontrol.c mem_cgroup_oom():memcg OOM 判定和 kill
kernel/cgroup/cgroup.c cgroup v2 文件系统接口
include/linux/memcontrol.h struct mem_cgroup 定义

mem_cgroup_charge() 时可以带着一个问题:当一个 page fault 要把新页面 charge 到 memcg 但该 memcg 已达 memory.max 时,代码如何决定"先尝试回收"还是"直接 OOM"?

研究生迁移表

Linux 概念 一般系统设计
memcg memory.max per-tenant 资源配额
memcg memory.high 软限流(throttling、backpressure)
memcg memory.low/min 最低保障(reserved capacity)
per-memcg LRU per-tenant 缓存淘汰策略
memcg OOM 租户级别的熔断(circuit breaker)
memory.events per-tenant SLI 指标
cgroup hierarchy 组织层级(org → team → service)
memory charge/uncharge 资源使用计量(metering)

资源隔离的核心架构模式:全局池 + 层级预算 + 局部执行。Kubernetes 的 resource limits/requests 直接映射到 memcg 的 max/min。数据库的 per-query memory limit、网络的 per-flow rate limit、云平台的 per-account compute quota 都是同一个模式的实例。

常见误解

第一个误解是认为容器看到的 free 命令输出反映的是容器的可用内存。free 读取的是 /proc/meminfo——这是全局信息。容器内要看自己的可用内存,应该读 memory.currentmemory.max 的差值(或者使用 cgroup-aware 的工具)。

第二个误解是认为 memory.highmemory.max 只是"软限制"和"硬限制"的区别。memory.high 的机制是节流(throttle)——进程的内存分配被阻塞,直到回收释放出空间。这可能导致进程变慢但不会被杀。memory.max 才触发 OOM kill。两者的故障模式完全不同:前者是性能降级,后者是进程终止。

第三个误解是认为迁移进程到另一个 cgroup 会"移动"它的内存使用。不会。已分配的页面仍然计入原 cgroup。只有进程后续新分配的内存才计入新 cgroup。要真正移动内存使用,需要进程释放旧内存并重新分配。

第四个误解是认为 memory.min 保证进程能分配到那么多内存。memory.min 只保护已有的内存不被回收——它不保证分配一定成功。如果系统真的没有空闲物理页(所有内存都被 memory.min 保护),新分配仍然会失败。

第五个误解是把 cgroup v1 的 memory.limit_in_bytes 等同于 v2 的 memory.max。虽然功能类似,但 v2 的内存控制器在层级语义、统计精度和回收策略上有本质改进。v2 的 memory.high 在 v1 中没有直接对应物。生产系统应优先使用 cgroup v2。

练习

第一,用 systemd-run --user --scope -p MemoryMax=32M bash 创建一个 32MB 限制的 shell。在其中运行 cat /sys/fs/cgroup/$(cat /proc/self/cgroup | grep ^0:: | cut -d: -f3)/memory.current 观察基础内存使用。然后运行 head -c 50M /dev/urandom > /dev/null 看 page cache 是否被计入和回收。

第二,设置一个 cgroup 的 memory.high=32Mmemory.max=64M,运行一个持续分配内存的程序。观察:程序在超过 32M 后是否变慢(被节流)?在什么时候被 OOM kill?memory.eventshighoom 的计数如何变化?

第三,查看一个运行中的 Docker 容器的内存统计:cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.stat。对比 anonfileslab 的比例。对于一个数据库容器,哪个类别占比最大?为什么?

第四,阅读 mm/memcontrol.ctry_charge_memcg() 的实现。追踪从"charge 请求"到"触发回收"到"回收失败后 OOM"的完整路径。标记每个决策点的条件。

第五,在一台运行多个容器的机器上,设置 memory.low 保护一个关键服务容器。然后用另一个容器制造内存压力。观察被保护容器的 memory.events.low 是否增加——如果增加,说明保护被部分突破(全局压力过大)。

系列导航

参考资料