memory controller 到底是什么

cgroup v2 的内存 controller 给每个 cgroup 强制执行一份按 cgroup 分配的内存预算,由页分配器在每次分配时检查。cgroup 超额度时,内核会回收它的页、在内存压力下拖慢它的任务,要是 cgroup 撞上硬上限又回收不出——就杀掉其中一个任务把页腾出来。
它不只是记账。页分配器能把页charge到一个 cgroup 的计数器上,如果这次 charge 会让 cgroup 撞 memory.max,分配就回退到 slowpath → 从该 cgroup 自己的 LRU 回收 → 没有可回收的就返回 -ENOMEM,把请求任务送进 OOM-kill 路径。
实现在 mm/memcontrol.c,每个内存 cgroup 一个 struct mem_cgroup(挂在 CSS = cgroup_subsys_state 上)。计数器叫 memory_usage_in_bytes(底层名;memory.current 是用户看到的文件),底层是按 NUMA 节点的 memcg_vmstats 数组。
三个限额,不是一个
cgroup v2 内存记账最容易误解的一点——一共三个可调的限额,不是一个:
| 文件 | 含义 | 触发时机 |
|---|---|---|
memory.low |
软保护——除非父 cgroup 没进展,否则不回收这一档 | 全局回收有压力、但这个 cgroup 在 low 之下,跳过它 |
memory.high |
软上限——超了就把这一档往下回收到这里 | memory.current > high 任何时候;回收卡了就让 allocator 慢路径睡一会 |
memory.max |
硬上限——到了就触发 OOM-kill | 一次分配会让 current >= max 就把路径前置到 OOM killer |
自顶向下读:low 是「保护」的别被回收、high 是「目标」往那里回收、max 是「强制」撞了就杀。常见配置:high 设 80% max,让内核在跨进 OOM 区之前先节流工作负载。
最常见的配置错:把 memory.max 设成你真正想要的值,却不另外设 memory.high——结果工作负载在最糟的时刻被 OOM-kill,而不是平滑节流。
LRU 那侧——每个 cgroup 都有自己的 LRU

一个 cgroup 拥有一页时,这页被记到该 cgroup 的 LRU 列表(mem_cgroup_per_node 里的 memcg_lruvec)。内核绝不会回收兄弟 cgroup 的页去满足另一个 cgroup 的内存缺口。这就是「按 cgroup 隔离」真的能 work 的原因。
LRU 记账从页 charge 时开始(mem_cgroup_charge())。cgroup 的页被访问时进入它自己的 active/inactive LRU,落在对应 node 上。回收只走那个 cgroup 的 LRU。两个后果:
- 一个行为不端的 cgroup 不能耗尽兄弟 cgroup 的回收带宽。A cgroup 撞
memory.max只会折腾自己的 LRU,不会波及 B cgroup。这是「隔离」属性——legacy cgroup v1 历史上搞砸 OOM-kill 的部分。 - 回收代价正比于这个 cgroup 的工作集。如果 A cgroup 的页全冷、inactive,一次 direct-reclaim 一遍就走完;如果全是 active 的,回收就慢,任务会在 slowpath 阻塞。
LRU 列表结构
mm/memcontrol.c 里:
struct mem_cgroup_per_node {
struct mem_cgroup *memcg;
struct lruvec lruvec; // LRU 状态嵌这儿
unsigned long lru_size[NR_LRU_LISTS];
struct deferred_split shrink_deferred;
// ...
};内核回收代码(mm/vmscan.c 的 shrink_node()、shrink_lruvec())的入参是 struct lruvec * 指针,从 cgroup 的 per-node 结构体里取。不走 per-cgroup 的 memcg_lruvec,全局回收路径就碰不到你的 cgroup。
热路径——每次 fault 触发什么
每次内核服务一次页 fault(匿名或文件)都会沿 cgroup 链去 charge 这个页:
// mm/memory.c handle_pte_fault -> do_anonymous_page -> ...
// 后台有这一行:
struct mem_cgroup *memcg = get_mem_cgroup_from_mm(current->mm);
int err = mem_cgroup_charge(memcg, page, mm, GFP_KERNEL);
// ^-- cgroup 撞 memory.max 时这里就 failmem_cgroup_charge() 只在还有预算的时候返成功。失败时调用方进 try_charge_memcg():
- 触发当前 cgroup 的直接回收:
try_to_free_pages()走该 cgroup 的 LRU。 - 出页就 retry charge。
- 回收没出够,路径返回
ENOMEM或者调用该 cgroup 的 OOM killer。
OOM-kill 路径:
// mm/oom_kill.c
mem_cgroup_out_of_memory(memcg, ...);
// -> select_bad_process() 挑受害者
// -> oom_kill_process(cgrp, victim, ...)
// -> 发 SIGKILLOOM-kill 历史从 memory.events 读:
cat /sys/fs/cgroup/canary/memory.events
# low 3
# high 12
# max 89
# oom 4
# oom_kill 1oom 非零就意味着至少有一个任务被杀。oom_kill + oom 合在一起表达了「OOM-kill 触发过」「这个 cgroup 至少丢过一个进程」。
memory.pressure 和 PSI 后端

Linux PSI 内存压力:到底在测什么那篇讲的 PSI 会计,正好架在 cgroup 的回收压力上。该 cgroup 里任何一个任务在 page fault 路径上等内存 stall,就调 psi_memstall_enter,该 cgroup 的 some/memory 行就打卡。
实操:memory.pressure 显示 some avg10 > 5.0,说明你的 cgroup 撞或超过 memory.high 预算、回收遇到麻烦。可能原因:
- 工作负载的工作集超过 cgroup 预算(扩容)
- per-node 压力不均衡(需要 NUMA 调优)
- 内存泄漏(看
memory.current历史)
memory.pressure、memory.current、memory.high、memory.events 一起读——四文件覆盖「当下发生什么、最近又发生过什么」。
读写 cgroup 内存状态
CG=/sys/fs/cgroup/lab
# 当前实际用量
echo "current: $(cat $CG/memory.current)"
# 设软上限(回收目标)
echo 536870912 > $CG/memory.high # 512 MiB
# 设硬上限(杀进程目标)
echo 1073741824 > $CG/memory.max # 1 GiB
# cache / buffer 配池(默认 0)
echo 0 > $CG/memory.swap.max # 我们没开 swap
# 读 OOM 历史
cat $CG/memory.events把工作负载挪到 $CG:
ls /proc/$$/cgroup # 找当前 controller 路径
echo $$ > $CG/cgroup.procs # 把本进程移进去
# cgroup.procs 一次写一个 PID;多个用循环
while read p; do echo $p > $CG/cgroup.procs; done < pids-to-move.txt如果想整个 Kubernetes pod 进去,选 kubelet 给的 cgroup(一般是 kubepods/.../podid/)。Pod 层设 memory.max 对里面所有容器都生效;单个容器层设就是让这个容器跟 pod 的兄弟隔开。
memory.events 字段解读
| 计数器 | 意思 |
|---|---|
low |
因为 cgroup 在 memory.low 之下而被跳过回收的次数(别的 cgroup 先回收了) |
high |
cgroup 撞 memory.high 被强制进回收的次数 |
max |
cgroup 撞 memory.max 的次数 |
oom |
cgroup OOM-killer 调用过的次数 |
oom_kill |
实际杀掉任务的次数 |
oom_group_kill |
如果 memory.oom.group=1,一次杀掉整个 cgroup |
常见生产模式:
# 每分钟 high > 60 报警(平均 > 1Hz 回收)
watch -n 60 'awk "{print \$1\" \"\$2}" /sys/fs/cgroup/canary/memory.events | grep high'high 在涨而 current 平着——你的 cgroup 在颠簸,回收不停但没出活。要扩容或者调工作负载,不是正常运行。
cgroup v2 下的 swap 和 zswap
memory.swap.max 控制 cgroup 级的换出。设 0 不代表全局「没 swap」,是「这个 cgroup 不能换出」,内核会在 swap-out 路径上失败,memory.swap.events 上 fail 计数器累加。
跟 v1 比的大变化:v2 把 swap 记账改成强制+分层。父级 memory.swap.max=0 的话,子级再设正值也不允许换出。没有”子级覆盖父级 0 设置”的口子。
zswap 单独讲一下:CONFIG_ZSWAP=y 启用,系统级。cgroup v2 不按 cgroup 控 zswap,但全局池子大小 /sys/module/zswap/parameters/max_pool_percent 是全局限制。如果你在激进收紧 cgroup 内存限额,一定要同时考虑 zswap 的池子大小,否则一个吃内存的工作负载就能把池子灌爆、退回到磁盘。
祖先继承模型
cgroup v2 比 v1 好用的地方:controller 是分层的、单模式的。没有 v1 那种「每个 controller 各管一摊」的层级来跟你 debug。子树上设的限额向下流、累加。
干净的后端 web service 布局:
system.slice/
├── api-server.slice/ # memory.max=2G, memory.high=1.5G
│ ├── service-A.slice/ # memory.max=1G, memory.high=800M
│ └── service-B.slice/ # memory.max=1G, memory.high=800M
└── worker-batch.slice/ # memory.max=8G, memory.high=6G
└── worker-N/ # memory.max=1G 单 worker,但被父亲封顶叶子设 memory.max、设成 80% 的 memory.high、父亲设 memory.max 做总预算封顶。记账按 cgroup 走、分层,父亲的 max 是针对所有孩子的总和独立执行,不是平均。
和 PSI 配合——一张 headroom 仪表盘
实战可观测性配方:memory.pressure + memory.current / memory.high / memory.max 四件套:
| 症状 | 可能原因 |
|---|---|
memory.pressure some avg10 > 5 且 current > high 且 memory.events.high 在涨 |
工作集 > high 预算。调高 high,或调工作负载。 |
同上但 current < high |
兄弟 cgroup 在抢回收,或 NUMA 不均衡。看兄弟的 memory.current。 |
pressure 平、但 memory.events.low 在涨 |
别的 cgroup 正在被回收因为这个 cgroup 被 memory.low 保护住了。你保护过头了。 |
memory.events.oom 非零 |
high 没撞但到 max 了;下游在 burst。调大 high 或消 burst。 |
memory.events.max 非零但 oom 是 0 |
charge 撞 max 但前面进回收已经成功了,内核重试成功。冷缓存期间常见。 |
这四个文件的读数给到所有 memory 类 incident 的全部信息,不用再翻 /proc/meminfo。
page cache 记账——非显然的细节
cgroup v2 确实把 page cache 算进 cgroup 的预算,按最先生成 cache 的 cgroup 归属。如果你服务在 cgroup A 打开一个 4GB CSV,这 4GB 进 A 的 memory.current。A 之后 fork 出去子进程到 cgroup B 读这页(已经是 page cache),B 不重复算——但 B 脏化(写)这页,dirty 归 B。
这是「页级的内存所有权」,直接决定缓存满、回收时由谁掏腰包。验证:
# 加载大文件后,读 cgroup 的 current 应该跟文件大小对得上
cat /sys/fs/cgroup/api-server.slice/memory.current # before
# ...
cat /sys/fs/cgroup/api-server.slice/memory.current # after如果你读的 cgroup 拿到这页时,这页已经在另一个 cgroup 里热着——内核按「最近读得最多的 cgroup」启发式归。不要把它当账单系统,hot cache 是启发式而已。
大页
hugetlb. controllers 是独立的 cgroup controller。cgroup v2 下玩大页需要 CONFIG_MEMCG_HUGETLB=y,并且父亲必须 cgroup.subtree_control +hugetlb。你会看到:
hugetlb.<pagesize>.currenthugetlb.<pagesize>.maxhugetlb.<pagesize>.events
同样的记账模型,独立的计数器。常踩的坑:以为 memory.current 含大页——不含。JVM / ClickHouse 这种钉大 arena 的工作负载因为这个被记错了账。
看内核代码
必备文件:
mm/memcontrol.c—— 主体实现mm/vmscan.c—— 回收走 memcg 的 LRUmm/page_alloc.c—— 带__GFP_ACCOUNTflag 的慢路径mm/oom_kill.c—— OOM-kill 选择逻辑kernel/cgroup/cgroup.c—— controller 注册机制Documentation/admin-guide/cgroup-v2.rst—— 规范的用户文档
debug 时第一件事 grep 这些代码:搜 do_try_to_free_pages(回收入口)、mem_cgroup_charge(charge 路径)、mem_cgroup_out_of_memory(OOM 路径)。
生产踩坑清单
- 只设
memory.max不设memory.high。工作负载在最糟时刻直接 OOM-kill,没机会节流。一律同时设 high。 - 忘记父亲的封顶。一个子 cgroup 设
memory.max = 4G在memory.max = 2G的父亲下,会在总和到 2G 时被 OOM-kill。别想着子级能超。 - 错层启用 memory.swap。cgroup 上 swap 没开(
subtree_control +swap),memory.swap.*文件就不存在。 - 仪表盘把 page cache 和 RSS 混一起。都进
memory.current,但只有 RSS 对+50M容量规划有意义。看memory.stat。 - 只信
memory.events.oom。也要看oom_kill——oom=4 oom_kill=1意思是 4 次触发只成功了 1 次杀(其余被回收或者信号机制救下来了)。 - 忘了 OOM killer 认
oom_score_adj。低分 = 活得久。如果 sidecar 给了很低分,主服务会被反复杀,主动设 OOM 分数。 - 没开
memory.peak做历史记录。Linux 5.9+ 支持每 cgroupmemory.peak,开机以来的最高水位。容量规划就用它。 - 不小心用了 cgroup v1。systemd 管理的机器带
systemd.unified_cgroup_hierarchy=0就启动 v1。cat /proc/cmdline | grep cgroup_no_v1=验一下 v2 启用了。简单办法就是看cgroup.subtree_control文件存不存在(只在 v2 有)。
跟 PSI 怎么联动
把闭环画出来:PSI 告诉你「cgroup 在 stall」,memory.events + memory.current 告诉你「预算在被执行」,memory.high 设的值告诉内核什么时候开始软节流。三者合起来就是个闭环控制:
┌──────────────────┐
│ 工作负载任务 │
└────────┬──────────┘
│ page fault
▼
┌──────────────────┐ ┌────────────────┐
│ mem_cgroup_charge │────────▶│ PSI memstall_enter│──▶ memory.pressure
└────────┬──────────┘ └────────────────┘
│ over limit
▼
┌──────────────────┐
│ reclaim walk LRU │──▶ memory.events.high++
└────────┬──────────┘
│ 没进展
▼
┌──────────────────┐
│ OOM-kill task │──▶ memory.events.oom++
└──────────────────┘完整画面用 PSI(实时信号)+ memory.events(次数统计)+ memory.current(瞬时用量)——同一份预算的三个角度。
什么时候用 memory.high 什么时候 memory.max
工作负载 bursty(分配尖峰),用 memory.high——内核可以平滑地跨尖峰节流回收,不会撞 OOM。仅在你想要工作负载的实例数硬切(多租户集群防 noisy neighbour)的时候才上 memory.max。两层防护:max 当兜底、high 当告警线。
## 相关文章
这是 tux.fan os-kernel 标签下三篇 Linux 内核内存管理文章之一。
– [Linux PSI 内存压力:到底在测什么](https://tux.fan/zh/2026/07/21/psi-memory-pressure-zh/) —— 配套篇。`memory.pressure` 上的 PSI 就是 cgroup 回收压力的运行时信号;本文是那些信号在报告的那个预算模型。
– [Linux 多代 LRU:mglru 在 2026 年的真实状态](https://tux.fan/zh/2026/07/21/mglru-page-reclaim-zh/) —— 走每个 memcg LRU 的那个回收算法。这篇聊代际算法本身、跟 active/inactive 的对比,以及 2026 LSFMM+BPF 上正在发生的「这个算法该不该留」之争。
评论