这一章要解决什么

如何决定踢出哪个页?操作系统如何决定从内存中踢出哪一页(或哪几页)?这个决定由系统的替换策略(replacement policy)做出,它通常遵循一些通用原则,同时也包含一些调整来避免极端情况下的糟糕表现。

How can the OS decide which page (or pages) to evict from memory?

上一章(第 21 章)搭好了机制:present 位、页错误、交换空间。机制只回答"怎么把页换进换出",却没回答最要命的问题:内存满了,该牺牲谁?选错了,程序会以磁盘速度(比内存慢上万倍)运行。

本章把物理内存看成"虚拟页的缓存",依次研究最优策略 OPT(作为标尺)、FIFO、Random、LRU,再看工作负载如何决定策略优劣、LRU 如何用 use 位和时钟算法近似实现、脏页如何影响决策,最后讨论内存彻底不够时的抖动(thrashing)。下一章(第 23 章)会看到 VAX/VMS 和 Linux 在真实系统里如何落地这些思想。

核心概念速览

内存是页的缓存

目标:让缓存未命中(cache miss)尽量少,也就是少从磁盘取页。

AMAT

平均内存访问时间 = TM + PMiss·TD。磁盘太慢,哪怕 0.1% 的未命中率也会主导总时间。

最优策略 OPT / MIN

踢出"将来最晚才被访问"的页。要预知未来,无法实现,但是绝佳的比较标尺。

FIFO 与 Random

实现简单,但不区分页的重要程度;FIFO 还会出现 Belady 异常。

LRU

踢出最近最少使用的页,依据局部性原理(principle of locality)用历史预测未来。

use 位 + 时钟算法

完美 LRU 太贵;硬件置 use 位、OS 用"转圈的指针"清位找 0,近似 LRU。

脏页(dirty page)

被修改过的页踢出前必须写回磁盘,代价更高;很多系统优先踢干净页。

抖动(thrashing)

进程们的工作集之和超过物理内存,系统一直在换页。对策:准入控制或 OOM killer。

内存是页的缓存:用 AMAT 衡量

主存只能装下系统所有页的一个子集,所以它就是虚拟内存页的缓存。替换策略的目标是最小化缓存未命中次数(等价地,最大化命中次数)。有了命中与未命中,就可以计算平均内存访问时间(Average Memory Access Time,AMAT):

AMAT = TM + (PMiss · TD)

TM:访问内存的代价;TD:访问磁盘的代价;PMiss:未命中概率(0.0~1.0)。注意内存访问的代价总要付,未命中时再额外付一次磁盘代价。

CPU 发出虚拟地址 物理内存 = 虚拟页的"缓存" 0 1 2 4 磁盘(交换空间) 装得下全部页 但慢 10⁴~10⁵ 倍 TM ≈ 100 ns 未命中 TD ≈ 10 ms
内存命中只需 TM;未命中还得付磁盘代价 TD。两者相差五个数量级,这就是替换策略如此重要的原因。

原书的例子:4KB 地址空间、256 字节页(4 位 VPN + 8 位偏移),进程依次访问 0x000, 0x100, …, 0x900(前 10 个页各一次),除了 VPN 3 以外的页都已在内存。于是得到"命中、命中、命中、未命中、命中×6",命中率 90%,PMiss = 0.1。取 TM = 100ns、TD = 10ms:AMAT = 100ns + 0.1·10ms ≈ 1.0001 ms,大约 1 毫秒;若命中率为 99.9%,AMAT 变成 10.1 µs,快了约 100 倍。自己拖一拖滑块感受一下:

模拟器

AMAT 计算器

改命中率、内存和磁盘延迟,看 AMAT 如何被磁盘主导。试试把命中率从 90% 调到 99%、99.9%、99.99%。
90.0%
—
AMAT
—
是纯内存访问的多少倍
—
时间中花在磁盘上的比例

最优替换策略(OPT / MIN)

Belady 很早就证明:踢出将来最晚才会被访问的那个页,得到的未命中次数最少。直觉很简单——既然必须扔掉一个,就扔掉"离下次被需要最远"的那个;其他页都会比它先被用到,所以都比它重要。

原书用的访问序列是 0, 1, 2, 0, 1, 3, 0, 3, 1, 2, 1,缓存能放 3 页。前 3 次访问是冷启动未命中(cold-start / compulsory miss);随后 0、1 命中;访问 3 时缓存已满,必须替换。OPT 往未来看:

访问序号 页号 缓存中的 0、1、2 下次何时被用到? 0 → 第 6 次(马上) 1 → 第 8 次 2 → 第 9 次(最远)⇒ 淘汰 2
在第 5 次访问(页 3,0 基编号)时,OPT 比较缓存中每一页"下一次被访问的位置",踢出最远的页 2。

结果(原书 Figure 22.1):6 次命中、5 次未命中,命中率 6/11 = 54.5%;如果不计冷启动未命中(每个页的第一次访问),命中率是 6/7 = 85.7%。第 9 次访问页 2 时,0 和 3 将来都不再被访问,踢谁都行,原书踢了 3。

强制未命中(compulsory miss,又叫冷启动未命中):缓存一开始是空的,第一次访问某项必然未命中。容量未命中(capacity miss):缓存装满了,只好踢掉某项才能装新项。冲突未命中(conflict miss):硬件缓存因组相联(set-associativity)限制了放置位置而产生;OS 的页缓存是全相联的(页可以放进任意页框),所以不存在这一类。

和最优比较很有用。单说"我的新算法命中率 80%"没有意义;说"最优也只有 82%",就知道已经很接近理想、可以停手了。所以 OPT 虽然无法实现,却是所有模拟研究的标尺。

两个简单策略:FIFO 与 Random

FIFO(先进先出)

页进入内存时排进队列,替换时踢掉最先进入的那个。优点只有一个:实现极其简单。同一序列下 FIFO 只有 4 次命中,命中率 36.4%(不计冷启动 57.1%)。问题出在第 5 次访问:页 0 已经被访问过好几次,FIFO 仍然因为它"来得最早"把它踢掉,紧接着访问 0 就又未命中。FIFO 无法判断页的重要性。

Random(随机)

内存吃紧时随机挑一个踢掉。和 FIFO 一样简单,一样不"聪明",表现完全取决于运气。原书 Figure 22.3 那一次运气不错,拿到 5 次命中。原书把这个实验重复了 10,000 次(不同随机种子):略多于 40% 的情况下 Random 和 OPT 一样好(6 次命中),有时却只有 2 次甚至更少。下面在浏览器里现场重做这个实验:

模拟器

Random 策略 10,000 次试验(复刻 Figure 22.4)

对序列 0,1,2,0,1,3,0,3,1,2,1、缓存 3 页,用种子 1…N 各跑一次 Random,统计命中次数的分布。
点"运行"开始。

Belady 异常(Belady's Anomaly)

直觉上缓存变大,命中率只会变好或不变。可 Belady 和同事找到一个序列:1,2,3,4,1,2,5,1,2,3,4,5,用 FIFO 时,缓存从 3 页增加到 4 页,命中率反而下降了!LRU 则没有这个问题,因为它具有栈特性(stack property):大小为 N+1 的缓存内容总是包含大小为 N 的缓存内容,所以缓存变大时命中只增不减。FIFO 和 Random 没有栈特性。用下面的模拟器亲手验证:

模拟器

Belady 异常:缓存 3 页 vs 4 页并排对比

同一序列同时喂给两个缓存,逐步观察。切换到 LRU,看"小缓存内容 ⊆ 大缓存内容"是否每一步都成立。

利用历史:LRU

FIFO、Random 的通病是可能踢掉一个马上就要用到的重要页。和调度一章一样,我们再次用过去预测未来。两种历史信息:

这类策略都建立在局部性原理(principle of locality)上:程序倾向于反复访问某些代码(如循环)和数据结构(如循环里访问的数组)。

空间局部性(spatial locality):访问了页 P,很可能接着访问 P−1 或 P+1。时间局部性(temporal locality):最近访问过的页很可能不久后再次被访问。局部性只是一种经验规律(启发式),并非所有程序都遵守,有些程序的访问几乎是随机的。

在同一序列上,LRU 第一次替换时踢掉 2(0、1 更近被用过),第二次踢掉 0(1、3 更近)。两次决定都正确,结果 6 次命中,与 OPT 持平(原书脚注坦白:这个例子是"特意挑的")。与之相反的 MFU、MRU 在大多数情况下表现很差,因为它们与局部性背道而驰。

替换策略模拟器(仿 paging-policy.py)

这是本章的核心模拟器。输入页访问序列和缓存大小,选择策略,逐步观察:每次访问是否命中、踢掉了谁、为什么踢它(OPT 显示每页的"下次使用位置",LRU 显示最近使用顺序,Clock 显示指针和 use 位)。在页号后加 w 表示写(例如 3w),被写过的页成为脏页,踢出时要写回磁盘。

模拟器

页替换策略逐步追踪

先在心里预测下一步会不会命中、踢谁,再点"下一步"验证。表格格式与原书 Figure 22.1–22.5 一致。
预设:
同一序列、同一缓存大小下,各策略的最终成绩对比

说明:Random 用本站可复现的伪随机数(mulberry32),种子 12 恰好复现原书 Figure 22.3 那一次的选择(依次踢掉 0、1、3,共 5 次命中);它与 paging-policy.py 用的 Python 随机数不同,所以同一种子下的具体选择不必与官方工具一致。Clock 用的是经典"转圈指针"版本。

工作负载决定一切(复刻 Figure 22.6–22.9)

小序列只能说明机制,要比较策略还得看更大的工作负载。原书用了三种(都是 100 个不同页附近、共 10,000 次访问),横轴是缓存大小(1~100 页),纵轴是命中率:

下面的曲线由你的浏览器用固定随机种子现场计算(每种负载 × 5 种策略 × 22 个缓存大小),数值每次都可复现。

模拟器

三种工作负载下的命中率曲线

切换工作负载、勾选策略,把鼠标移到图上看每个缓存大小下的精确命中率。
表格视图(精确数值)

从这三张图可以读出原书的结论:

  1. 无局部性时,LRU、FIFO、Random 表现几乎一样,命中率就等于"缓存大小 / 100";缓存能装下全部页时,所有策略(包括 Random)都收敛到 100%;OPT 明显更好——偷看未来确实有用。
  2. 80-20 负载下,Random 和 FIFO 还算不错,LRU 更好,因为它更可能留住热页;OPT 仍然更好,说明 LRU 的历史信息并不完美。Clock 介于 LRU 和 FIFO/Random 之间(Figure 22.9)。
  3. 循环顺序是 LRU 和 FIFO 的最坏情况:它们总踢掉最老的页,而循环下最老的页恰恰马上要用。缓存 49 页、循环 50 页,命中率竟是 0%!Random 反而好得多——Random 的一个优点就是没有奇怪的极端行为。这类访问在数据库等重要应用中很常见。

LRU 比 Random/FIFO 高出的那几个百分点重要吗?"看情况":如果每次未命中代价巨大(这很常见),一点点命中率提升也能带来巨大的性能差异。

实现历史算法:LRU 太贵,用 Clock 近似

完美的 LRU 要在每一次内存访问(取指、load、store)时更新数据结构,把该页移到 MRU 端。FIFO 只在装入和淘汰时操作队列,LRU 却要为每次访问记账,没有精心设计就会严重拖慢性能。

一种思路是硬件辅助:每次访问时硬件把当前时间写进该页的"时间字段",替换时 OS 扫描所有时间字段找最小者。可是 4GB 内存、4KB 页就有 100 万个页,每次替换都扫一百万项,太慢了。

如何实现 LRU 替换策略?既然完美实现 LRU 代价高昂,能否以某种方式近似它,并且仍然获得想要的效果?

Given that it will be expensive to implement perfect LRU, can we approximate it in some way, and still obtain the desired behavior?

答案是可以。需要一点硬件支持:use 位(又叫 reference bit,引用位),最早出现在第一个分页系统 Atlas 上。每个页一个 use 位;页被读或写时,硬件把它置 1;硬件从不清零,清零是 OS 的事。

时钟算法(clock algorithm,Corbató 提出):把所有页排成一个环,一根"表针"指向某一页。需要替换时:

  1. 看表针指向的页 P:若 use = 1,说明 P 最近被用过,不是好的牺牲者 → 把 use 清 0,表针前进到 P+1;
  2. 重复,直到遇到 use = 0 的页,把它踢掉(最坏情况下转一整圈,把所有位都清掉后回到起点)。
时钟算法:表针从页 A 开始,A、B 的 use=1 被清零并跳过(给了它们"第二次机会"),C 的 use=0 被选为牺牲者。

这不是唯一的做法:任何"定期清 use 位、然后区分 1 和 0 来决定踢谁"的方案都可以。Clock 的好处是不需要反复扫描全部内存。原书 Figure 22.9 里的 Clock 变体是"随机挑页扫描",遇到 use=1 就清零,遇到 use=0 就选它;它不如完美 LRU,但比完全不看历史的策略好——上面曲线图里的 Clock 线正是如此。

考虑脏页

Corbató 也提出了对时钟算法的一个常见改进:考虑页在内存中是否被修改过。被修改过的页叫脏页(dirty page),踢出前必须写回磁盘,代价高;没修改过的干净页(clean page)可以直接重用页框,不需要额外 I/O。硬件为此提供修改位(modified bit,又叫 dirty bit),页被写时置 1。

于是时钟算法可以改成:先找既没被使用又干净的页踢;找不到,再找没被使用但脏的页;以此类推。在上面的模拟器里勾选"Clock 优先踢干净页"、载入"带写操作"预设,对比写回次数(本站的实现:第一圈只找 (use=0, 干净) 且不清位;找不到就第二圈找 (use=0, 脏),途中把 use=1 清零;再不行重复)。

干净页 (D=0) 直接重用页框 装入新页 脏页 (D=1) 先写回磁盘(一次 I/O) 再装入新页 1 次磁盘读 1 次写 + 1 次读
淘汰脏页比淘汰干净页多一次磁盘写,这就是"优先踢干净页"的理由。

其他虚拟内存策略

替换不是 VM 子系统唯一的策略:

抖动与准入控制

最后一个问题:如果内存被超额订阅(oversubscribed),正在运行的进程们的内存需求之和超过了物理内存,怎么办?系统会不停地换页,这种状况叫抖动(thrashing)。

早期一些系统有相当复杂的机制来检测和应对抖动。例如给定一组进程,系统可以决定暂时不运行其中一部分,希望剩下进程的工作集(working set,正在活跃使用的页)能装进内存,从而取得进展。这叫准入控制(admission control):有时候把少量工作做好,胜过把所有工作一起做砸。

一些现代系统的做法更"粗暴":某些版本的 Linux 在内存超额时运行OOM killer(out-of-memory killer),挑一个吃内存的进程杀掉。它确实能缓解内存压力,但如果杀掉的是 X server,所有需要显示的应用都会无法使用。

模拟器

抖动悬崖与准入控制

n 个进程轮转运行,每个进程在自己的工作集里随机访问,全局 LRU 管理 M 个页框。观察进程数超过"内存 / 工作集"时有效吞吐量怎样断崖式下跌,以及准入控制如何保住吞吐量。
4
不做准入控制 准入控制(最多同时运行 ⌊M/W⌋ 个)

模型说明:每个进程一次运行 50 次访问后轮转;命中耗时 1、未命中耗时 D(真实系统中磁盘比内存慢 10⁴~10⁵ 倍,这里取较小的 D 以便看清曲线);"有效吞吐量" = 访问次数 / 总耗时,1.0 表示全速运行。准入控制下,被挂起的进程在排队,内存里同时只有 ⌊M/W⌋ 个进程的工作集。

先预测再验证

(改编自 Homework 1)在策略模拟器里点"随机生成",用 -s 0 -n 10、-s 1 -n 10、-s 2 -n 10 各生成一条序列,缓存 3 页。先在纸上分别按 FIFO、LRU、OPT 写出每次是命中还是未命中,再逐步验证。
看答案
要点:每条序列的前几个不同页一定是冷启动未命中,三种策略都一样;分歧只发生在缓存满之后需要替换的那一刻。OPT 的命中数一定 ≥ 另外两个(这是它"最优"的含义);LRU 通常 ≥ FIFO,但在个别序列上 FIFO 可能碰巧更好。展开模拟器下方的"各策略最终成绩对比"可以一眼核对。
(改编自 Homework 2)缓存大小为 5 时,给 FIFO 和 LRU 构造"最坏"序列(每次都未命中)。需要把缓存加到多大,命中率才会显著提升并接近 OPT?
看答案
对 FIFO 和 LRU,循环访问 6 个页 0,1,2,3,4,5,0,1,2,3,4,5,… 就能让每次访问都未命中:刚好被踢掉的总是下一个要用的页(载入预设"循环顺序"并把缓存改为 5、序列改成 0~5 循环试试)。缓存一旦增加到 6(能装下整个循环),除了冷启动之外全部命中,瞬间接近 OPT。对 MRU 来说,最坏序列则是在两个页之间来回交替并夹杂新页,让"最近刚用过的"总是下一个要用的。
在 Belady 模拟器里,先预测 FIFO 缓存 3 页和 4 页在序列 1,2,3,4,1,2,5,1,2,3,4,5 上各命中几次,再验证。然后切到 LRU 再预测。
看答案
FIFO:3 页命中 3 次(9 次未命中),4 页只命中 2 次(10 次未命中)——缓存大了反而更差。LRU:3 页命中 2 次,4 页命中 4 次,且每一步 3 页缓存的内容都是 4 页缓存内容的子集(栈特性),所以不可能出现异常。
在工作负载图中选"循环顺序"。预测:缓存 49 页时 LRU 和 FIFO 的命中率各是多少?Random 呢?缓存 50 页时又如何?
看答案
缓存 49 页时 LRU 和 FIFO 都是 0%(每次都恰好踢掉下一个要用的页),Random 却很高(本站种子下约 95%),因为它不会系统性地踢错。缓存到 50 页时,所有策略都只剩 50 次冷启动未命中,命中率 99.5%。
在抖动模拟器中,M=64、W=16。预测吞吐量从哪个 n 开始断崖式下降?勾选准入控制后又会怎样?
看答案
⌊64/16⌋ = 4 个进程的工作集恰好能装下,n ≤ 4 时吞吐量接近 1;n = 5 时工作集总和 80 > 64,全局 LRU 下每个进程轮到自己时,它的页往往已被别人挤出去,吞吐量骤降——这就是抖动。启用准入控制后,同时只运行 4 个,其余排队,吞吐量保持在高位。

动手实验

在 C 里实现一个 paging-policy 模拟器(与本页 JS 模拟器逻辑相同),用它交叉验证原书示例,并跑一遍三种工作负载。

cd labs/22-swap-policies && make
./paging-policy -p ALL -c 3 -a 0,1,2,0,1,3,0,3,1,2,1
./paging-policy -p FIFO -c 3 -a 1,2,3,4,1,2,5,1,2,3,4,5 -v
./workload

示例输出(在本机 WSL2 + gcc 14 上实际运行,确定性模拟,你的结果应一致):

$ ./paging-policy -p ALL -c 3 -a 0,1,2,0,1,3,0,3,1,2,1
policy  hits  misses  hitrate  hitrate(excl. cold)
OPT        6       5    54.5%    85.7%
FIFO       4       7    36.4%    57.1%
LRU        6       5    54.5%    85.7%
CLOCK      4       7    36.4%    57.1%

$ ./paging-policy -p FIFO -c 3 -a 1,2,3,4,1,2,5,1,2,3,4,5 -v
Access: 1  MISS  FirstIn -> [1] <- LastIn  Replaced:-  [Hits:0 Misses:1]
Access: 2  MISS  FirstIn -> [1 2] <- LastIn  Replaced:-  [Hits:0 Misses:2]
Access: 3  MISS  FirstIn -> [1 2 3] <- LastIn  Replaced:-  [Hits:0 Misses:3]
Access: 4  MISS  FirstIn -> [2 3 4] <- LastIn  Replaced:1  [Hits:0 Misses:4]
Access: 1  MISS  FirstIn -> [3 4 1] <- LastIn  Replaced:2  [Hits:0 Misses:5]
Access: 2  MISS  FirstIn -> [4 1 2] <- LastIn  Replaced:3  [Hits:0 Misses:6]
Access: 5  MISS  FirstIn -> [1 2 5] <- LastIn  Replaced:4  [Hits:0 Misses:7]
Access: 1  HIT   FirstIn -> [1 2 5] <- LastIn  Replaced:-  [Hits:1 Misses:7]
Access: 2  HIT   FirstIn -> [1 2 5] <- LastIn  Replaced:-  [Hits:2 Misses:7]
Access: 3  MISS  FirstIn -> [2 5 3] <- LastIn  Replaced:1  [Hits:2 Misses:8]
Access: 4  MISS  FirstIn -> [5 3 4] <- LastIn  Replaced:2  [Hits:2 Misses:9]
Access: 5  HIT   FirstIn -> [5 3 4] <- LastIn  Replaced:-  [Hits:3 Misses:9]
FINALSTATS hits 3   misses 9   hitrate 25.00

$ ./paging-policy -p FIFO -c 4 -a 1,2,3,4,1,2,5,1,2,3,4,5
FINALSTATS hits 2   misses 10   hitrate 16.67

$ ./workload        (节选:80-20 与循环顺序)
== 80-20 workload ==
cache   OPT    LRU   FIFO   RAND  CLOCK
   10  60.0%  30.7%  29.6%  29.6%  30.2%
   20  79.8%  57.6%  51.8%  51.4%  55.1%
   30  87.4%  75.8%  65.8%  66.2%  72.5%
   50  93.4%  87.1%  80.4%  81.4%  86.4%
== looping workload ==
   40  79.2%   0.0%   0.0%  62.3%   0.0%
   49  97.5%   0.0%   0.0%  95.5%   0.0%
   50  99.5%  99.5%  99.5%  99.5%  99.5%

还可以把 valgrind --tool=lackey --trace-mem=yes ls 输出的地址右移 12 位转成页号,喂给 ./paging-policy -f trace.txt,研究真实程序需要多大的缓存(Homework 5)。详见 labs/22-swap-policies/README.md。

自测题

TM=100ns、TD=10ms,命中率 99.9% 时 AMAT 约为?
AMAT = 100ns + 0.001 × 10ms = 100ns + 10µs = 10.1µs。即使只有千分之一的未命中,时间也比纯内存访问慢约 100 倍。
OPT 策略在需要替换时踢出哪个页?
OPT(Belady 的 MIN)看的是未来:踢掉"下次使用最远"的页。C 是 LRU,B 是 FIFO,A 是 LFU。
为什么 OS 的页缓存不存在"冲突未命中(conflict miss)"?
冲突未命中源自硬件缓存的组相联限制(某项只能放在特定几个位置)。OS 管理物理内存时,任何页可以放进任何空闲页框,所以只有强制未命中和容量未命中。
关于 Belady 异常,下列说法正确的是?
具备栈特性的算法(如 LRU、OPT)中,大小为 N+1 的缓存总包含大小为 N 的缓存内容,因此不会异常。FIFO、Random 没有栈特性。
在"循环访问 50 个页"的负载下,缓存有 49 页。LRU 的命中率大约是?
LRU 总是踢掉最久没用过的页,而循环中它恰好是下一个要访问的页,于是每次都未命中。这是 LRU/FIFO 的最坏情况,Random 反而表现好得多。
关于 use 位(引用位),哪项正确?
硬件只负责"置 1",OS(例如时钟算法扫描时)负责清零。脏页由另一个位——修改位(dirty bit)——表示。
时钟算法扫描时遇到 use=1 的页,会怎么做?
use=1 说明它最近被用过,给它"第二次机会":清零并跳过。若在表针转回来之前它又被访问,use 会再次被置 1。
系统发生抖动(thrashing)时,下列哪种做法对应原书说的"准入控制"?
准入控制是"少做一些,但做好":挑出一个子集运行。B 是 Linux OOM killer 的做法,更粗暴。抖动的根源是内存超额订阅,再好的替换算法也救不了(A 错)。

常见误区与小结