
在现代计算机体系结构中,虚拟内存的概念是一个基石,它在物理内存(RAM)有限的约束下,提供了广阔内存空间的假象。这种假象由操作系统管理,它在高速的 RAM 和较慢的硬盘之间来回传送数据。然而,这个系统带来了一个关键挑战:当物理内存已满,而需要一个新的数据块——一个“页面”时,应该牺牲哪个现有页面?这个决策属于页面置换算法的范畴,这是一套直接决定系统性能和响应能力的策略。一个低效的选择可能导致“颠簸”(thrashing)状态,此时系统会陷入停滞,不断地交换页面而不是执行有效的工作。
本文深入探讨了这个内存管理难题的核心。首先,在“原理与机制”一章中,我们将剖析主导这一选择的基本算法,从先进先出(FIFO)的简单公平性,到最近最少使用(LRU)的预测智能,再到现实世界系统所做的实际妥协。随后,“应用与跨学科联系”一章将探讨这些理论概念如何在现代计算的复杂环境中体现,影响着从用户界面的响应速度到数据安全,再到云基础设施效率的方方面面。
想象一下,你的书桌是你计算机的物理内存(RAM),而一座巨大的大学图书馆是它的硬盘。你可以非常迅速地处理书桌上的书,但从图书馆取一本新书则是一趟缓慢而乏味的过程。你的虚拟内存则是一个神奇的承诺,让你能够使用任何一本书,就好像它就在你的书桌上一样。操作系统就是来回奔走的图书管理员,负责交换书籍。但问题是,你的书桌太小了。当你需要一本新书而书桌已满时,图书管理员必须做出选择:哪本书要被送回图书馆?这就是页面置换的根本困境。这个决策策略,即页面置换算法,是虚拟内存性能表现的核心。一个好的策略能让你流畅地工作;一个坏的策略则会让图书管理员疯狂地奔波,而你只能等待,这是一种我们称之为颠簸(thrashing)的非生产性恐慌状态。
最简单、最公平的决定方式是什么?“先到先走”。在书桌上停留时间最长的那本书将被送回。这就是先进先出(FIFO)算法。它像管理队列一样管理内存中的页面。当需要加载新页面而内存已满时,最旧的页面——即队列头部的页面——将被淘汰。
让我们看看这个过程如何上演。假设你的书桌只有 本书(页面)的空间,而你需要的书的顺序如下:。最初,你的书桌是空的。
2:缺页。获取它。书桌: [2]3:缺页。获取它。书桌: [2, 3]2:命中!它已经在这里了。书桌: [2, 3] (FIFO 不关心你刚刚使用了 2;2 仍然是“最旧的”,因为它最先到达。)1:缺页。获取它。书桌: [2, 3, 1]。书桌现在满了。5:缺页。书桌已满。谁该离开?页面 2,第一个进入的。淘汰 2,获取 5。书桌: [3, 1, 5]。2:缺页!我们刚把它换出去!淘汰 3。书桌: [1, 5, 2]。正如你所见,FIFO 简单化的公平性可能成为它的致命弱点。在第 3 步,我们使用了页面 2,这是一个明确的信号,表明它很重要。然而,在第 5 步,FIFO 仅仅因为它停留时间最长就将其淘汰。这种淘汰一个可能有用页面的行为,是 FIFO 忽略页面实际使用方式的直接后果。
这种盲目性导致了一种真正奇异的现象,称为 Belady 异常。常识告诉我们,给一个进程更多的内存——一张更大的书桌——应该能提高其性能,或者至少不会使其变得更糟。但对于 FIFO 来说,情况并非总是如此!对于某些引用模式,增加页面帧的数量实际上可能增加页面错误的数量。
考虑引用字符串 。使用 3 个帧时,它会产生 9 次页面错误。但是,如果我们慷慨地提供 4 个帧,它会产生 10 次页面错误!。这怎么可能?这种异常的发生是因为被淘汰页面的序列发生了不利的变化。有了更多的帧,一个不同的“旧”页面可能会多停留一会儿,结果恰好在它被需要之前被淘汰。这个悖论之所以可能,是因为 FIFO 不是一个栈算法。栈算法具有天然的“子集”属性:在有 个帧时内存中的页面集合总是那些在有 个帧时内存中页面集合的子集。这保证了性能永远不会随着内存的增加而变差。FIFO 缺乏这个属性,导致了不可预测且有时荒谬的行为。
如果 FIFO 太过天真,也许我们可以更聪明一些。大多数程序都表现出引用局部性:它们最近访问过的页面很可能在不久的将来再次被访问。这就是把当前项目的书籍放在书桌上的原则。因此,一个更好的想法出现了:当我们需要淘汰一个页面时,让我们选择那个最长时间未被使用的页面。这就是最近最少使用(LRU)算法。
LRU 具备 FIFO 所不具备的一切。它是一个栈算法,因此永远不会遭受 Belady 异常的影响。它很智能,利用过去的行为来预测未来的行为。对于许多常见的工作负载,比如程序中的紧密循环,LRU 的性能非常接近最优。但 LRU 并非万无一失;它的智慧基于一个假设,而当这个假设被打破时,它可能会惨败。
考虑一个大规模的顺序扫描,比如从头到尾读取一个数 GB 大小的文件。每个页面只被读取一次,之后再也不会被用到。当这些扫描页面流式进入内存时,它们都是“最近使用过的”。LRU 策略将这些新的一次性使用页面视为比你核心工作集(例如,你的文本编辑器的代码)中那些在扫描前刚刚还在使用的“热”页面更重要。如果扫描的长度足以填满所有可用的内存帧,LRU 会很乐意地淘汰你的编辑器代码,为那些你再也不会接触的扫描页面腾出空间。这被称为内存污染,是纯 LRU 的一个典型失败案例。
LRU 也可能被更结构化、非循环的访问模式所欺骗。想象一个程序正在对一棵大树进行深度优先搜索。它深入一个分支,接触到根()、一个子节点()、一个孙节点(),最后是一系列叶子节点()。当内存已满时,LRU 会淘汰什么?它会淘汰根页面 ,因为它是“最近最少使用的”。但那些叶子节点将永远不会再被访问,而页面 对于回溯到树的上方至关重要!一个最优算法会知道应该淘汰那些无用的叶子页面。在这里,LRU 的启发式规则——即最近使用意味着重要——恰恰是错误的。
一个完美的算法会怎么做?如果我们的图书管理员是一个能预见未来的神谕,选择就会很简单:淘汰那个在未来最远时间点才会被再次需要的页面。这就是 Belady 最佳算法(OPT 或 MIN)。在真实系统中,这是不可能实现的,因为它需要预知所有未来的内存引用。然而,它作为一个终极基准。通过将其他算法与 OPT 进行比较,我们可以理解它们的优缺点。
OPT 的行为揭示了一个深刻的真理。对于具有良好局部性的工作负载(如程序循环),OPT 的决策与 LRU 的决策几乎完全相同。这告诉我们,LRU 的启发式规则之所以强大,是因为在大多数情况下,最近最少使用的页面确实是未来最远才会使用的那个。但对于单次使用的页面的顺序扫描,OPT 做了一些令人惊讶的事情:它的行为类似于最近最常使用(MRU)算法。它会淘汰刚刚调入的页面,因为它知道这个页面不会再被需要。这种根据未来访问模式调整策略的能力使 OPT 变得完美,并向我们展示了实用算法努力模仿的理想行为。
实现完美的 LRU 在计算上是昂贵的,需要特殊的硬件来跟踪每一次内存访问的确切时间。鉴于它无论如何都不是完美的,现实世界的操作系统使用了巧妙的近似方法。其中最著名的是时钟算法,也称为第二次机会算法。
想象一下,所有的页面帧都排成一个圆圈,就像一个钟面,有一个“指针”指向其中一个帧。每个帧都有一个简单的“引用位”( 位)。当一个页面被访问时,硬件会将其 位置为 。当发生页面错误需要一个牺牲品时,时钟指针开始扫描:
时钟算法是 FIFO 的循环缓冲区和 LRU 的粗粒度近期性信息的巧妙结合。它高效且出奇地有效。引用位的重要性至关重要。在一个思想实验中,如果硬件停止设置 位,算法就会失去其“记忆”。时钟指针会扫描,发现每一位都是 ,然后简单地按照它访问的严格循环顺序淘汰页面。该算法退化为它试图改进的简单 FIFO 策略。
更高级的版本,如工作集时钟(WSClock),通过使用时间戳来更好地分辨活动“工作集”中的页面和旧的、未使用的页面,从而增强了这一点,使其对扫描污染更具弹性。它们甚至可以变得更智能,倾向于淘汰“干净”的页面(未被修改的页面),而不是“脏”的页面,从而避免了将页面写回磁盘的昂贵步骤。
所有这些算法都是在糟糕的情况下尽力而为。但是,当情况变得不可能时会发生什么?如果一个程序的活动使用页面集——即其工作集——根本就比分配给它的物理内存还要大呢?
结果是一种灾难性的性能崩溃,称为颠簸(thrashing)。系统陷入一个恶性循环:需要一个页面,导致缺页。为了加载它,另一个同样属于工作集的页面被淘汰。几乎是瞬间,被淘汰的页面又被需要,导致另一次缺页,这又淘汰了另一个必需的页面。CPU 几乎不花时间执行指令;相反,它在不断地等待磁盘。硬盘不停地运转,系统没有任何进展。这就像一个厨师在一个对于他的食谱来说太小的厨房里,把所有时间都花在了在台面和储藏室之间交换食材,而不是真正地烹饪。
颠簸的开始就像从悬崖上掉下来。性能在某个点之前可能是可以接受的,但只要再减少几个内存帧,页面错误率就可能飙升至接近 100%。在一个几乎没有局部性的场景中,如果一个程序在只有 个可用帧()的情况下循环访问 个不同的页面,命中的概率最多为 。如果你的工作集有 1000 个页面而你只有 50 个帧,你的命中率将是糟糕的 0.05,这意味着 95% 的内存访问将是页面错误。在这种情况下,FIFO、LRU 或时钟算法之间的选择几乎无关紧要;它们都会失败,因为将工作集装入内存这个基本要求没有得到满足。
当发生颠簸时,唯一的解决方案是系统级别的干预。操作系统必须检测到失控的页面错误率并减轻内存压力。一个常见的策略是降低多道程序设计级别——也就是说,暂停一个或多个进程,收回它们的内存帧,并将其重新分配给其余的进程。这给了每个幸存者一个更大的书桌,希望足够大以容纳其工作集并打破颠簸的循环,从而将系统恢复到高效工作的状态。
既然我们已经探讨了页面置换的基本机制,你可能会认为这是一个已经解决的问题,是操作系统中一个尘封的角落。事实远非如此!这个简单的想法——决定保留哪些内存,放弃哪些内存——是一个战场,性能、公平性甚至安全性每时每刻都在这里被决定胜负。我们讨论过的那些优雅、抽象的算法与现代计算中混乱、复杂的现实相遇,其结果往往出人意料且总是引人入胜。
让我们踏上一段进入“野外”的旅程,看看这个原理在实践中如何发挥作用,以你可能意想不到的方式塑造着我们的数字世界。
我们在野外发现的第一件事是,“一刀切”的算法只是一个神话。不同的应用程序有不同的需求,一个好的操作系统必须能够适应。想想你使用电脑的体验:你要求用户界面——窗口、菜单、光标——能够即时响应。然而,在后台,其他任务正在运行,也许是扫描你的文件以查找病毒,或是为搜索建立索引。
当一个后台任务顺序读取大量文件时会发生什么?一个简单的最近最少使用(LRU)策略看到这股新页面的洪流,会相当合乎逻辑地得出结论:属于你用户界面(UI)的页面已经有一段时间没有被使用了。于是它着手淘汰它们。当你移动鼠标或点击按钮的那一刻,系统就会冻结,疯狂地发生缺页中断,以将 UI 页面重新调入内存。我们都感受过这种令人沮丧的“延迟”。
为了解决这个问题,设计者们想出了更聪明的算法。想象一种策略,只有当一个页面不受欢迎了一段时间后,才考虑将其淘汰。它需要被忽略不止一次,而是多次。像 LRU-2 这样的算法,它跟踪页面倒数第二次访问的近期性,正是这样做的。它能区分一个属于稳定工作集(如我们的 UI)的“热”页面和一个仅作为大规模扫描一部分路过的“冷”页面。通过优先淘汰那些只有一个近期引用的页面,它保护了交互式工作集免受瞬时后台噪音的污染,保持系统感觉灵敏和响应迅速。
这种区分页面的想法不仅仅局限于它们的访问历史。有时,淘汰的成本并非均等。考虑一个现代的图形处理单元(GPU)。当它需要一个不在其专用内存中的页面时,它必须通过像 PCIe 这样的连接从主系统内存中获取。如果 GPU 需要淘汰一个页面来腾出空间,它面临一个选择。如果该页面只被读取过(它是“干净”的),GPU 可以简单地丢弃它,因为一个有效的副本已经存在于主内存中。但如果该页面被写入过(它是“脏”的),GPU 必须将修改过的内容写回主内存,这是一个消耗宝贵 PCIe 带宽的缓慢过程。
一个智能的置换策略,比如增强型第二次机会算法,会考虑到这一点。它为每个页面使用两个标志:一个引用位()和一个修改位()。最适合淘汰的页面是既非最近使用()又是干净的(),因为丢弃它没有任何成本。最差的是既是最近使用又是脏的页面()。通过扫描寻找成本最低的牺牲品,系统可以显著降低内存管理的开销,这是一个经典的操作系统算法在专用硬件中找到新家的绝佳例子。
当我们有多个进程在运行时,内存就成了一种共享资源。我们如何划分它?最简单的方法是全局置换策略,即所有进程的所有页面都存在一个大池子里。淘汰算法,如 LRU,只是简单地选择整个系统中最近最少使用的页面。这似乎非常高效;我们总是在淘汰“最冷”的页面,从而最大化我们对内存的使用。
但这可能导致“公地悲剧”。想象两个进程:一个是一个行为良好的分析作业,内存占用小而稳定;另一个是一个“贪婪的”文件服务器,迅速地循环使用大量数据。在全局 LRU 策略下,文件服务器持续的新页面访问流使其页面总是看起来很“热”。它开始从行为良好的分析作业那里窃取帧,而后者访问其页面的频率并不高。很快,分析作业的帧太少,无法容纳其工作集,于是开始颠簸——把所有时间都花在处理刚刚还拥有的页面的缺页中断上。全局策略本应最大化的系统整体效率因此急剧下降。而局部策略,它为每个进程分配固定的帧配额,并只允许它淘汰自己的页面,本可以保护行为良好的进程免受贪婪进程的影响,确保了公平性,但可能以牺牲一些效率为代价。
这种冲突不仅仅存在于用户应用程序之间。有时,操作系统自身的子系统之间也会相互争斗。在具有统一缓冲区缓存的现代操作系统中,用于缓存文件数据的内存和用于应用程序进程的内存(匿名内存)来自同一个池。为了加速文件访问,操作系统可能会执行激进的“预读”,预先获取它认为你很快会需要的文件数据。但是这些预读数据的帧从哪里来呢?它们来自同一个全局池。如果预读逻辑过于激进,它可能会用文件数据填满内存,挤出一个正在运行的应用程序的重要工作集。结果是“跨子系统颠簸”,即操作系统的一个部分在试图提供帮助时,却导致另一部分灾难性地失败。解决方案需要仔细调优,确保像预读这样的后台活动消耗的内存永远不会超过可用内存的“安全”预算,从而保护活动应用程序的工作集。
这些挑战在虚拟化世界中表现得最为明显。一台物理服务器可能托管数十个虚拟机(VM),每个虚拟机都运行自己的操作系统和应用程序,所有这些都在争夺相同的物理 RAM。虚拟机监控程序(hypervisor)——管理这些 VM 的主程序——现在扮演着内存中央银行的角色。
它应该如何在其竞争的 VM 之间分配有限的物理帧?它应该给每个 VM 平等的一份吗?如果一个 VM 正在运行一个耗费内存的数据库,而另一个大部分时间处于空闲状态呢?给它们相等的份额将是不公平且低效的。虚拟机监控程序面临一个复杂的优化问题:它必须以一种方式分配帧,既能最小化所有 VM 的总页面错误数(最大化吞吐量),又能为每个租户确保最低水平的性能或公平性。这就是云计算中资源管理的精髓。
为了节省内存,虚拟机监控程序采用了一种称为内存去重(或内核同页合并,KSM)的巧妙技巧。虚拟机监控程序会定期扫描其所有 VM 的内存,如果发现两个或多个具有相同内容的页面(比如,几个 VM 中加载的同一个公共库文件),它会将它们全部映射到一个共享的物理帧上,从而释放出重复的副本。这是提高服务器容量的一种绝佳方式。
但它引入了一种奇妙而微妙的“超距鬼魅作用”。想象一下,VM-A 和 VM-B 都有一个数据相同的页面,虚拟机监控程序将它们合并到一个物理帧 上。现在,假设 VM-A 频繁使用这个页面。它的访问将 标记为最近使用过。稍后,当虚拟机监控程序需要淘汰一个页面时,它看到 是“热”的,就会放过它。即使 VM-B 已经数小时没有碰过它的页面副本,这种情况仍然会发生!一个 VM 的行为现在正在影响另一个 VM 的页面置换命运,打破了我们以为拥有的清晰隔离。一个简单的优化在原本独立的世界之间建立了一个鬼魅般的联系。此外,最初实现这种共享的常用技术——写时复制(COW),实际上通过减少总内存占用,并允许全局置换策略正确识别哪些共享页面对整个系统真正最有价值,从而有助于内存管理。
到目前为止,我们关心的一直是性能。但页面置换有其阴暗面:安全。如果我们选择淘汰的页面包含敏感数据——密码、解密的私钥、你的银行账户详情——会发生什么?该页面会被写入硬盘上的交换文件。如果该交换分区未加密,我们就刚刚将我们最深的秘密以明文形式写入了持久存储。拥有机器物理访问权限,或者仅仅是足够权限的攻击者,可以稍后读取交换设备并恢复这些数据。这将一个简单的性能机制变成了一个明显的安全漏洞。
为了防止这种情况,操作系统提供了一种将页面“锁定”在内存中的机制。一个进程可以请求将某些敏感页面标记为不可交换。这是来自内核的绝对保证:“我永远不会将此页面写入交换设备。”页面置换算法现在被禁止将这些锁定的页面视为淘汰的候选者。当然,这种权力必须受到控制;不能允许一个流氓进程锁定所有内存。因此,操作系统对一个进程可以锁定的内存量强制执行严格的限制,从而创建一个安全而公平的系统。
保护某些页面的需求不仅仅是为了安全。考虑一个现代的区块链节点。它维护着一组经过验证的区块元数据,这些元数据构成了链的历史证明。这些数据必须以绝对的完整性保存在内存中;将其淘汰并从可能不受信任的磁盘中重新调入是不可接受的选项。同时,该节点管理着一个庞大的、动态的待处理交易“内存池”(mempool),这些交易可以安全地进行分页。系统管理员必须做出战略选择:锁定足够多的内存来保护经过验证的区块,并将余下的内存留给页面置换算法来管理内存池,从而在保证完整性的同时最大化性能。
最后,有时我们必须锁定页面不是为了安全,而是为了简单的物理正确性。当像网卡或存储控制器这样的硬件设备需要直接向内存或从内存传输数据而不打扰 CPU——这个过程称为直接内存访问(DMA)——它需要一个稳定的物理地址。它不能容忍操作系统在传输过程中突然将页面移动到不同的帧或将其淘汰到交换区。为了实现这一点,操作系统必须“钉住”(pin)参与 DMA 传输的内存页面,使它们暂时不可淘汰。这就像在某些帧上挂上“请勿打扰”的标志。页面置换算法必须尊重这些标志,并在可用的内存池减少的情况下工作。如果一次钉住的页面太多,可淘汰帧的池子可能会缩小到使系统突然陷入颠簸的程度,而这一切仅仅是因为一个 I/O 操作。
从维护应用程序的响应性到平衡云中的公平性,从保护加密密钥到确保硬件传输的物理完整性,选择替换哪个页面的简单决定,其后果会波及计算机系统的每一层。这是现代计算核心中一个根本性的、动态的、不断演变的挑战。