卷 III · 幻境CH 12深度 12/24

缺页:内存不够的时候

走到 PTE 发现 P 位是 0——这一页不在内存里。接下来发生的事有两个版本,一个花 1.5 微秒,另一个花 3 毫秒。它们差了两千倍,而你的代码看起来一模一样。

minor / major fault按需分页工作集颠簸

「分配内存」其实什么都没分配

先看一件反直觉的事:

char *p = malloc(1024L * 1024 * 1024);   // 申请 1 GB
printf("拿到了 %p\n", p);                 // 成功了
// 此刻你的进程实际占用的物理内存:几乎没变

你申请了 1 GB,malloc 返回了一个有效指针。但你的 RSS(真实物理内存占用)几乎没动

因为内核做的只是在 vma 链表里记了一笔账:「从这个地址开始的 1 GB,这个进程有权使用。」它没有分配任何物理页,没有建任何页表项。

真正的分配发生在你第一次碰那块内存的时候:

p[0] = 'x';        // ← 就是这一行,触发了一次缺页

这一行的完整经过是:

  1. MMU 翻译 p,走到 PTE,发现 P 位是 0 → 触发 #PF
  2. 内核查 vma:这地址合法吗?合法(你确实 malloc 过)。
  3. 内核分配一个物理页(4 KB),清零(不能让你看到别人用过的数据)。
  4. 填好页表项,P 位置 1。
  5. iretq 返回。因为 #PFfault 类,重新执行 p[0] = 'x'(第 4 章)。
  6. 这次翻译成功,写入完成。

你的程序对这一整趟毫不知情。它只是执行了一条赋值语句,唯一的痕迹是这次花了 约 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 ns1.0 倍
48 页(75%)差一点15.90%477 μs★ 5963.3 倍
32 页(50%)差不少32.40%972 μs12150.7 倍
16 页(25%)差很多47.38%1.4 ms17766.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,理由是「swap 慢,不如让它 OOM 快点死」。

这个想法有一半对。但关掉 swap 不能阻止换页——因为文件映射的页永远可以被丢弃(它们在磁盘上有副本,不需要 swap 空间)。

而你的程序代码本身就是文件映射。所以内存紧张时,内核会开始丢弃代码页,然后你的程序执行到那里又要从磁盘读回来——照样颠簸,只是换的东西不同

症状很典型:CPU 使用率不高,磁盘读很忙,程序慢得像在水里走。vmstatsi/so 是 0(确实没 swap),但 bi(块设备读入)很高。

真正的解法只有两个:加内存,或者减小工作集。关 swap 只是换了个死法。

¤ 价目表 · 本章两行
  • 次缺页(分配一页):约 1.5 μs
  • 主缺页(要读盘):约 3 ms —— 是次缺页的 2000 倍,是内存访问的 3.75 万倍

这两行之间的鸿沟,是本章所有现象的根源。

↑ 回到应用层

「为什么服务重启后前几分钟特别慢?」

因为三层缓存全是冷的,而且它们各自要付不同的钱:

  1. page cache 冷:代码和数据文件都要从磁盘读进来,一路主缺页。这是最大的一笔。
  2. 应用层缓存冷:Redis 空了、本地缓存空了,每个请求都打到后端。
  3. JIT 没热:JVM/V8 还在解释执行,还没编译成机器码。

第 1 层可以主动解决——这就是预热的意义。vmtouch 之类的工具可以把文件强行读进 page cache;很多数据库有「启动时预加载」选项,做的就是这件事。

也解释了为什么滚动重启要控制节奏:如果一次性重启一半实例,剩下一半要扛全部流量,而新起来的那批还在冷启动阶段,很容易触发雪崩。

Android 上对应的现象是冷启动 vs 温启动:应用被杀掉后再打开,APK 的代码页要重新映射进来、Zygote fork 出的共享页要重建,那几百毫秒里有相当一部分就花在缺页上。

这一章的一句话

malloc 给的是支票不是现金,兑现发生在你第一次碰那一页的时候——这次访存要多花 1.5 微秒。而如果内存装不下工作集,缺页就会变成要读盘的主缺页,两千倍的差距让性能不是下降而是塌方:内存少 25%,慢六千倍。

下一章:既然「第一次写才真的分配」,那把这个思路用到 fork 上会怎样?答案是写时复制——fork 一个 1 GB 的进程只要几百微秒的秘密。