缺页:内存不够的时候
走到 PTE 发现 P 位是 0——这一页不在内存里。接下来发生的事有两个版本,一个花 1.5 微秒,另一个花 3 毫秒。它们差了两千倍,而你的代码看起来一模一样。
「分配内存」其实什么都没分配
先看一件反直觉的事:
char *p = malloc(1024L * 1024 * 1024); // 申请 1 GB
printf("拿到了 %p\n", p); // 成功了
// 此刻你的进程实际占用的物理内存:几乎没变
你申请了 1 GB,malloc 返回了一个有效指针。但你的 RSS(真实物理内存占用)几乎没动。
因为内核做的只是在 vma 链表里记了一笔账:「从这个地址开始的 1 GB,这个进程有权使用。」它没有分配任何物理页,没有建任何页表项。
真正的分配发生在你第一次碰那块内存的时候:
p[0] = 'x'; // ← 就是这一行,触发了一次缺页
这一行的完整经过是:
- MMU 翻译
p,走到 PTE,发现 P 位是 0 → 触发#PF。 - 内核查 vma:这地址合法吗?合法(你确实 malloc 过)。
- 内核分配一个物理页(4 KB),清零(不能让你看到别人用过的数据)。
- 填好页表项,P 位置 1。
iretq返回。因为#PF是 fault 类,重新执行p[0] = 'x'(第 4 章)。- 这次翻译成功,写入完成。
你的程序对这一整趟毫不知情。它只是执行了一条赋值语句,唯一的痕迹是这次花了 约 1.5 μs 而不是 约 100 ns。
malloc 给你的是一张支票,不是现金。内核承诺「这块地址你可以用」,但只在你真的去用的时候才兑现。
这为什么划算?因为程序普遍申请得比用得多:你开一个 1 MB 的缓冲区可能只用了前 4 KB;你 fork 出来的子进程可能立刻 exec 掉;某个库申请了一大块表结构但只填了几行。
把分配推迟到真正使用的那一刻,内存利用率能高出好几倍。代价就是第一次访问要额外付 1.5 微秒。
你可以在第 11 章那台引擎里点「碰没触过的堆页」,亲眼看这个过程——而且再点一次,第二次就不缺页了。
两种缺页,差两千倍
上面那种叫 minor fault(次缺页):内核只需要分配一页内存,不碰磁盘。
另一种叫 major fault(主缺页):这一页的内容在磁盘上——可能被换到 swap 去了,也可能是文件映射还没读进来。内核必须真的去读盘。
| 次缺页 minor | 主缺页 major | |
|---|---|---|
| 要碰磁盘吗 | 不用 | 要 |
| 耗时 | 约 1.5 μs | 约 3 ms(NVMe) |
| 进程状态 | 短暂停顿,不换出 CPU | ★ 进入 D 状态睡眠,让出 CPU |
| 典型来源 | 第一次碰新内存、写时复制、映射已在 page cache 的文件 | swap 换回、文件第一次读、被回收的页又要用 |
| 可怕吗 | 不可怕,一次性开销 | ★ 非常可怕,尤其是持续发生 |
两千倍的差距。而在你的代码里,它们是同一行。
怎么区分?看统计:
$ ps -o min_flt,maj_flt,cmd -p 1234 MINFL MAJFL CMD 48291 3 ./myserver # 健康:几万次次缺页,几乎没有主缺页 $ vmstat 1 si so # swap in / swap out。★ 这两列持续非零 = 正在换页 = 麻烦
次缺页几万次是完全正常的——那就是进程正常启动和运行时逐步兑现支票。主缺页持续增长才是警报。
工作集,和那道悬崖
现在到了这一章最重要的概念。
工作集(working set):一个程序在某段时间内真正频繁访问的那部分内存。注意它不是「程序申请了多少」,也不是「程序总共用了多少」,而是「此刻它在反复摸哪几页」。
关键在于工作集和物理内存的关系:
- 物理内存 ≥ 工作集:常用的页都能留在内存里,缺页只发生在第一次碰的时候。一切正常。
- 物理内存 < 工作集:★ 刚被换出去的页,马上又要用。换进来,又挤掉了另一个马上要用的页。
第二种情况叫颠簸(thrashing)。它的可怕之处不在于「慢一点」,而在于它是一个正反馈:换页越多,可用内存越紧,换页就更多。
下面这台机器是一台真的 LRU 模拟器:4000 次带局部性的页访问,逐次查表、逐次淘汰最久未用的页。
请这样操作:先看默认状态(工作集 64、物理内存 64),注意「容量缺页」是 0。然后把物理内存滑块慢慢往下拖,盯住那个数字。
那道悬崖有多陡
把引擎跑出来的几个点摆在一起,你会看到一件很触目惊心的事:
| 物理内存 | 够不够 | 容量缺页率 | 平均一次访存 | 比全命中慢 |
|---|---|---|---|---|
| 64 页 | 刚好够 | 0.00% | 80 ns | 1.0 倍 |
| 48 页(75%) | 差一点 | 15.90% | 477 μs | ★ 5963.3 倍 |
| 32 页(50%) | 差不少 | 32.40% | 972 μs | 12150.7 倍 |
| 16 页(25%) | 差很多 | 47.38% | 1.4 ms | 17766.2 倍 |
看第一行到第二行:内存只少了 25%,性能掉到了六千分之一。
内存不足不是线性的性能下降,是断崖式的塌方。
原因是那两千倍的差距:只要有百分之几的访存变成主缺页,平均值就被完全主导了。15.9% 的缺页率听起来「只有一成多」,但 0.159 × 3 ms 已经把 0.841 × 80 ns 淹没得无影无踪。
实践含义:「内存刚好够」和「内存差一点」是两个世界。前者性能是稳的,后者直接躺平。所以给服务留内存余量不是保守,是必需——因为你不会看到一个平缓的下坡警告你,你会直接掉下去。
还要注意引擎里那个「冷启动缺页」和「容量缺页」的区分。这个区分很重要:
- 冷启动缺页(compulsory):每一页第一次被碰时必然发生,躲不掉,但只发生一次。跑久了就摊薄没了。
- 容量缺页(capacity):★ 因为内存装不下而被挤出去、之后又要用。这才是颠簸,它会持续发生,永远不会摊薄。
看监控时要盯的是后者。一个刚启动的服务缺页很多是完全正常的。
为什么是 LRU(以及为什么不完全是)
内存不够时该换出哪一页?理论最优是换出「未来最久不会用的那一页」——但那需要预知未来。
实际用的近似是 LRU(最近最少使用):假设最近没用过的,将来也不太会用。这个假设在有局部性的程序上相当准。
但真正的 LRU 实现不了——它要求每次访存都更新一个时间戳或链表,而访存是每纳秒都在发生的事,这个开销无法接受。
所以内核用的是近似方案。核心工具是第 11 章那个 A 位(Accessed):硬件在访问页面时自动置位,内核定期扫描并清零。谁的 A 位在一轮扫描后还是 0,谁就是「最近没被访问」。
Linux 具体用的是双链表 LRU:active 和 inactive 两条链。新页进 inactive,被访问两次才升到 active;回收时从 inactive 的尾部拿。这个「两次机会」的设计防止了一次性的顺序扫描(比如 cat 一个大文件)把真正的热数据全冲掉。
很多人的做法是直接关掉 swap,理由是「swap 慢,不如让它 OOM 快点死」。
这个想法有一半对。但关掉 swap 不能阻止换页——因为文件映射的页永远可以被丢弃(它们在磁盘上有副本,不需要 swap 空间)。
而你的程序代码本身就是文件映射。所以内存紧张时,内核会开始丢弃代码页,然后你的程序执行到那里又要从磁盘读回来——照样颠簸,只是换的东西不同。
症状很典型:CPU 使用率不高,磁盘读很忙,程序慢得像在水里走。vmstat 的 si/so 是 0(确实没 swap),但 bi(块设备读入)很高。
真正的解法只有两个:加内存,或者减小工作集。关 swap 只是换了个死法。
- 次缺页(分配一页):约 1.5 μs
- 主缺页(要读盘):约 3 ms —— 是次缺页的 2000 倍,是内存访问的 3.75 万倍
这两行之间的鸿沟,是本章所有现象的根源。
「为什么服务重启后前几分钟特别慢?」
因为三层缓存全是冷的,而且它们各自要付不同的钱:
- page cache 冷:代码和数据文件都要从磁盘读进来,一路主缺页。这是最大的一笔。
- 应用层缓存冷:Redis 空了、本地缓存空了,每个请求都打到后端。
- JIT 没热:JVM/V8 还在解释执行,还没编译成机器码。
第 1 层可以主动解决——这就是预热的意义。vmtouch 之类的工具可以把文件强行读进 page cache;很多数据库有「启动时预加载」选项,做的就是这件事。
也解释了为什么滚动重启要控制节奏:如果一次性重启一半实例,剩下一半要扛全部流量,而新起来的那批还在冷启动阶段,很容易触发雪崩。
Android 上对应的现象是冷启动 vs 温启动:应用被杀掉后再打开,APK 的代码页要重新映射进来、Zygote fork 出的共享页要重建,那几百毫秒里有相当一部分就花在缺页上。
这一章的一句话
malloc 给的是支票不是现金,兑现发生在你第一次碰那一页的时候——这次访存要多花 1.5 微秒。而如果内存装不下工作集,缺页就会变成要读盘的主缺页,两千倍的差距让性能不是下降而是塌方:内存少 25%,慢六千倍。
下一章:既然「第一次写才真的分配」,那把这个思路用到 fork 上会怎样?答案是写时复制——fork 一个 1 GB 的进程只要几百微秒的秘密。