一台 4 核 15G 的多用户开发机,某天下午突然卡死了。不是那种「卡一下就好了」的卡,而是整台机器几乎失去响应、持续了将近四个小时的那种卡。SSH 能连上但每个命令都要等很久才有回应,top 刷不动,日志也写不进去。
事后我用 atop(这台机器上每 30 秒打一次点)做了完整的事后分析,从系统级指标一路追到具体进程,最终定位到一条看起来人畜无害的构建命令。然后用 Linux cgroup 给机器加上了系统级的内存防护。
现场还原:30 秒内发生了什么
看 atop 的 CPU 数据,事故的瞬间一目了然。14:56 之前,机器一切正常——四个核心大部分时间空闲。然后 14:57:01 这个采样点,user CPU 从两三百 ticks 跳到将近一万,idle 直接归零。
但 CPU 打满只是表象。真正致命的是内存:
1 | 14:55:01 free= 6622 MB |
从 6.6 GB 到 125 MB,只用了 30 秒。然后在 125 MB 左右一直徘徊了三个半小时,直到 18:37 才恢复。恢复也是瞬间的——从 141 MB 直接跳到 14748 MB,说明占内存的那批进程在这个时刻集体退出了。
谁吃了内存
atop 的进程级数据(-P PRC)可以按 CPU ticks 排序。14:57 到 14:59 这个窗口的 top 消费者是这样的:
1 | 2346 ticks pid=66 (kswapd0) ← 内核换页守护进程 |
kswapd0 排第一,这说明内核在拼命做内存回收。但 kswapd0 是「症状」——它忙是因为有人把内存吃光了。真正的「病因」是下面那一大堆 cc1plus 进程。
查一下这些 cc1plus 各自占多少内存:
1 | 1173 MB pid=2014487 |
再数一下数量:
1 | 14:57:01 cc1plus 进程数: 43 |
43 个 cc1plus 进程,每个吃 1 GB 左右——总共需要大约 43 GB 内存。而这台机器只有 15 GB 物理内存。
那么是谁拉起了 43 个并发编译进程?用 atop 的 PRG(进程通用信息)追溯父进程链:
1 | pid=2014445 (cmake) 命令行: cmake --build project/build -j |
Claude Code agent 在执行一个构建任务时,跑了 cmake --build project/build -j。
那个 -j
cmake --build 的 -j 参数控制并行编译的进程数。-j4 就是最多同时跑 4 个编译进程,-j2 就是 2 个。
但如果只写 -j 后面不跟数字呢?意思是不限制并行数——有多少编译单元就同时拉起多少个编译进程。
这个项目的 C++ 部分有 43 个编译单元。所以 -j 意味着 43 个 cc1plus 同时启动。每个 cc1plus 处理一个包含大量模板和头文件的 C++ 源文件,各自需要大约 1 GB 内存。
算一笔账:
| 项目 | 内存 |
|---|---|
| 43 个 cc1plus 总需求 | ~43 GB |
| 机器物理内存 | 15 GB |
| 事故前可用内存 | ~6.6 GB |
| 同时运行的 6 个 Claude 会话 | ~9.2 GB |
6.6 GB 可用内存瞬间被 43 GB 的需求碾压。
为什么卡了三个半小时
内存耗尽之后,为什么机器没有直接 OOM kill 掉那些进程、然后恢复呢?
这和 Linux 的内存管理策略有关。可用内存不足时,内核不会立刻杀进程,而是先尝试「回收」——把不活跃的内存页面换出,或者丢弃可回收的缓存页。kswapd0 就是干这件事的内核线程。
问题在于,33 个 cc1plus 进程都是活跃的——它们都在编译代码,都需要频繁地读写自己的内存页面。内核把 A 的页面换出去,给 B 用;过一会儿 A 又要用了,再把 B 的换出去、把 A 的换回来。这就是所谓的「换页风暴」(thrashing)。
在这种状态下,CPU 的大部分时间不是在做有用的计算,而是在搬运内存页面。atop 数据显示 kswapd0 一个内核线程就吃掉了 2346 个 CPU ticks——在 4 核 30 秒的窗口内,总共才 12000 ticks,kswapd0 一个就占了 20%。加上各进程自己的缺页中断处理,真正用于编译的 CPU 时间少得可怜。
所以 33 个编译进程本来可能只需要几分钟就能编完,但在换页风暴下,慢到了三个半小时。而在这三个半小时里,整台机器上的所有进程——包括其他用户的——都被拖慢了,因为大家都在争夺那 125 MB 的可用内存。
这就是换页风暴的可怕之处:它不像 OOM kill 那样「一刀切」地解决问题,而是让所有人一起慢性死亡。
治标:限制并发数
最直接的修复当然是给 -j 加上数字。每个 cc1plus 大约吃 1 GB,机器可用内存大约 6 GB,那 -j2 比较安全(给系统和其他进程留够余量)。
但这个方案有个根本性的问题:它依赖于「执行构建命令的人(或 AI)记住要加 -j2」。
在我的场景里,构建命令是 Claude Code agent 自动生成和执行的。我可以在 Claude 的 memory 系统里写一条规则说「构建时必须用 -j2」,Claude 大概率会遵守——但 LLM 的行为终归是概率性的,在长对话、复杂任务链、或者 context window 被压缩之后,这条规则有可能被「漂移」掉。
更何况,构建命令不一定总是直接出现在顶层。如果 Claude 跑的是一个脚本,脚本里面调了 cmake --build -j,那即使 Claude 本身记住了规则,也管不到脚本内部的行为。
所以,靠记忆和约定来防止资源耗尽,是个软约束——有一定效果,但不可靠。我们需要一个无论谁执行什么命令都会生效的硬约束。
治本:cgroup
Linux 的 cgroup(control group)就是这样一个硬约束机制。它是内核级别的资源隔离——你可以把一组进程关进一个「笼子」里,给这个笼子设定 CPU、内存、IO 等资源的上限。笼子里的进程无论怎么折腾,都不可能突破上限。
更关键的是,cgroup 是按进程树继承的。一个进程被放进笼子之后,它 fork 出的所有子进程自动也在笼子里。所以 cmake 和它拉起的 43 个 cc1plus 会在同一个笼子里共享内存上限——不是每个进程 6 GB,而是所有进程加起来 6 GB。
在现代 Linux 上,cgroup 其实已经在默默工作了。systemd 本身就在用 cgroup 管理服务和用户会话。每个登录用户都有一个对应的 slice:
1 | -.slice(根) |
在这个层级上设置内存限制,就可以实现「用户级别」的资源隔离。
MemoryHigh 和 MemoryMax:软硬两层
cgroup v2 提供了两个内存限制参数,它们的作用不一样:
- MemoryHigh(软限制):当一个 cgroup 的内存用量超过这条线时,内核会加大回收力度——主动回收缓存、压缩匿名页面等。进程不会被杀,但会明显变慢。相当于「你该收着点了」。
- MemoryMax(硬限制):这是绝对上限。超过之后,内核直接触发 OOM kill,杀掉笼子里占内存最多的进程。相当于「到此为止」。
两者配合使用,就实现了一种弹性的资源管理:日常情况下 MemoryHigh 提供柔和的压力,避免进程无节制地膨胀;极端情况下 MemoryMax 是最后一道防线,确保系统不会整体崩溃。
多用户场景下的资源分配
我这台机器有 5 个用户。如果简单地给每个人分配 15 / 5 = 3 GB,那每人连一个 Claude 会话都不够用(单个 Claude 进程就要 1 到 2.3 GB)。但如果给每人分配 13 GB(留 2 GB 给系统),两个人同时活跃就可能超过物理内存。
这其实是个经典的超分(overcommit)问题——和云厂商超卖内存是一个道理。解决思路也一样:用两层限制来平衡「独占时不浪费」和「共享时不崩溃」。
最终的配置方案:
1 | user.slice ← MemoryMax=13G(全部用户合计硬上限) |
这个配置在各种并发场景下的表现:
| 同时活跃人数 | 效果 |
|---|---|
| 1 人 | 没有竞争。内核在 MemoryHigh 处做一些回收就能继续涨,最多用到 13G |
| 2 人 | 两人各自超过 MemoryHigh 后都会被减速,内核回收压力均匀分摊 |
| 5 人全上 | 每人在 4G 处被减速,13G 总量硬限保底。极端情况下最大进程被 kill,但其他人不受影响 |
落地也很简单——systemd drop-in 文件:
1 | # 全用户合计硬上限 |
不需要重启,daemon-reload 之后立即生效。验证:
1 | $ systemctl show user.slice -p MemoryMax |
小结
这次事故的因果链其实很简单:一条 cmake --build -j(少写了一个数字),在一台 15 GB 的机器上拉起了 43 GB 的内存需求,触发换页风暴,全机卡死三个半小时。
但它背后有一个更一般的教训:资源限制不应该依赖于「使用者记得住」。不管是人还是 AI,在复杂任务链里都可能忘掉某条规则。构建脚本的嵌套调用、LLM 的上下文漂移、新同事还不知道团队约定——这些都是「软约束」失效的路径。
cgroup 的价值在于它是内核级的硬约束:不管进程是怎么启动的、由谁启动的、是直接执行还是在脚本的第五层嵌套里,只要它属于这个用户,就逃不出笼子。而且 cgroup 的代价很低——在不触发限制的时候,它几乎没有性能开销。
换一个角度看,这也是一个关于故障模式选择的问题。同样是内存不足,有两种故障模式:
- 慢性死亡:换页风暴,所有进程一起变慢,持续数小时,期间整台机器不可用。
- 快速失败:OOM kill 掉占内存最多的进程,几毫秒内释放内存,其他进程继续正常运行。
配好 cgroup 之后,系统选择后者。丢一个编译进程——甚至丢一个 Claude 会话——总比全机卡死四个小时好。