try ai
科普
编辑
分享
反馈
  • 内存超售

内存超售

SciencePedia玻尔百科
核心要点
  • 内存超售是一种操作系统策略,它向应用程序分配的虚拟内存总量超过了可用的物理 RAM,其依据是程序很少会使用它们所请求的全部内存。
  • 该策略的主要风险是“颠簸”(thrashing)——系统因过度的磁盘交换而急剧变慢,以及“内存不足(OOM)杀手”——为防止系统崩溃而突然终止进程。
  • 操作系统通过可配置的策略、对内存压力的动态监控以及诸如 mlock 等保护措施来管理内存超售,以防止敏感数据被交换到磁盘。
  • 这项技术是现代计算效率的基石,它支撑了高密度的云环境、容器化技术以及诸如用于 GPU 的 CUDA 统一内存等高级功能。

引言

现代计算系统被期望能完成一项不可思议的壮举:在有限的物理内存上同时运行众多复杂的应用程序。这给操作系统带来了根本性的挑战:如何在不浪费宝贵资源或损害稳定性的前提下,应对这些程序巨大的内存需求?答案在于一种被称为内存超售的、巧妙而审慎的博弈——这一策略对从笔记本电脑到大型数据中心的一切都至关重要。通过承诺比物理上存在的内存更多的内存,操作系统可以释放显著的性能增益,但这种能力也伴随着固有的风险。

本文旨在揭开内存超售这门艺术与科学的神秘面纱。我们将探索操作系统为每个应用程序创造的无限内存幻象,以及使其成为可能的机制。您将清晰地理解当这场博弈成功时会发生什么,更重要的是,当它失败时又会发生什么。

第一章“原理与机制”将深入探讨核心概念,解释虚拟内存与物理内存之间的关系、请求分页的过程,以及导致灾难性故障(如颠簸和内存不足杀手的介入)的条件。随后的“应用与跨学科联系”一章将揭示这些原理在现实世界中的应用,从编排云虚拟机和容器,到支持高级 GPU 计算,再到应对安全漏洞。

原理与机制

要真正掌握内存超售,我们必须首先探索计算机科学中最优美、最成功的幻象之一:虚拟内存。这是操作系统(OS)上演的一出巧夺天工的戏法,以至于每个运行中的程序都相信自己独占了计算机的全部内存。

宏大的幻象:虚拟内存 vs. 物理内存

想象一个拥有数百万册图书的巨大图书馆,但只有少数几把椅子。操作系统就是首席图书管理员。当你启动一个程序时,就像你被发了一张借书证,可以进入一个私人的、巨大的阅览室,其中每本书中的每个字符都有一个唯一的地址——你自己的私有​​虚拟地址空间​​。对于一个现代 64 位程序来说,这个虚拟图书馆大得惊人,足以存储比人类有史以来记录的所有信息还要多的信息。

然而,你的程序并不需要一次性使用所有这些信息。它只需要当前正在阅读的那一页书。当你的程序第一次尝试访问一个内存地址时——相当于翻开书的某一页——它会发现还没有物理内存,即没有“椅子”分配给它。这不是一个错误。它会触发一个​​缺页中断​​(page fault),这只是向图书管理员(操作系统)发出的一个礼貌请求。中央处理器(CPU)会暂停该程序,并对操作系统说:“不好意思,这个程序需要从地址 xxx 读取数据,但它还没有分配到物理页帧。”

此时,我们的图书管理员——操作系统便开始行动。它会核实该地址是否是程序分配的阅览室(其​​虚拟内存区域​​或 VMA)的有效部分。如果访问是合法的,操作系统会找到一个空闲的物理页帧——一把椅子——并将其分配给程序想要的虚拟页。对于一个全新的页面,操作系统会尽职地将其擦拭干净,用零填充(​​按需零填充​​,zero-fill-on-demand),以确保不会意外泄露前一个用户的数据。然后,它更新其记录(​​页表项​​,PTEs),并告诉 CPU:“一切就绪,椅子准备好了。”程序恢复运行,完全不知道刚才发生了一场复杂的协商。这整个过程被称为​​请求分页​​(demand paging):物理内存仅在需要时才分配,一刻也不提前。

这种硬件与软件之间优雅的配合,使得数百个程序能够同时运行,每个程序都生活在自己广阔、私密的宇宙中,同时共享一个有限的物理内存“椅子”池。

博弈的艺术:超售

一个聪明的图书管理员很快会注意到一个模式:大多数持有借书证的人从未出现,而那些出现的人一次也只读几页。为每位持证人都预留一把椅子将是巨大的浪费。于是,图书管理员做出了一个乐观的赌注。他们发出的借书证远多于椅子的数量,相信实际前来阅读的人数是可控的。

这就是​​内存超售​​的精髓。操作系统允许程序请求(或“分配”)远超系统物理 RAM 总量的虚拟内存。为什么?因为数十年的观察表明,大多数应用程序都是内存“囤积者”。它们请求数 GB 的内存,但在任何给定时刻只活跃地使用其中一小部分。这部分被活跃使用的内存被称为程序的​​工作集​​(working set)。

通过超售,操作系统可以同时运行更多的应用程序,从而大大提高系统利用率和效率。这是一场经过计算的博弈。操作系统并非鲁莽行事,而是根据典型的程序行为来权衡概率。它赌的是,总的已提交内存(committed memory)——即所有已被实际接触(touched)并因此需要物理页帧的页的总和——将保持在可用​​后备存储​​(backing store)的总量之下。这个后备存储是物理 RAM(MMM)和磁盘上指定的溢出区域——​​交换空间​​(SSS)的总和。操作系统试图维持的基本规则是:

Total Touched Memory≤RAM+Swap\text{Total Touched Memory} \le \text{RAM} + \text{Swap}Total Touched Memory≤RAM+Swap

当一个程序请求一个 6 GiB6 \text{ GiB}6 GiB 的内存块时,即使只有 5 GiB5 \text{ GiB}5 GiB 的物理 RAM 可用,操作系统也可能立即批准。操作系统赌的是该程序的​​接触率​​(touch ratio)——即它实际会使用的已分配内存的比例——会很低。如果一个虚拟内存分配为 V=160 GiBV = 160 \text{ GiB}V=160 GiB 的程序,其接触率 α\alphaα 预计在 0.200.200.20 到 0.500.500.50 之间波动,那么操作系统可以计算出预期的物理内存压力 E[αV]\mathbb{E}[\alpha V]E[αV] 仅为 56 GiB56 \text{ GiB}56 GiB。如果物理内存 MMM 是 64 GiB64 \text{ GiB}64 GiB,这似乎是一个合理的赌注。然而,这个模型也让我们能够计算风险:存在一个可量化的概率,即活动突然激增可能将接触率 α\alphaα 推高到足以使内存需求 αV\alpha VαV 超过 MMM,从而触发内存不足事件。

当博弈失败时:颠簸与 OOM 杀手

当乐观的赌注出错,太多程序同时前来阅读时,会发生什么?系统面临两条通往灾难的潜在路径。

路径一:病态颠簸

想象一下图书馆已经坐满了,又来了一位新的读者。为了腾出空间,图书管理员找到一个正在打瞌睡的人(一个​​最近最少使用​​,或 LRU 的页),请他到一个缓慢、不舒适的附楼(磁盘上的交换文件)去等。这样就空出了一把椅子。但如果附楼很远,而新来者的队伍很长,图书管理员将把所有时间都花在来回安排人员上。几乎没有人能真正进行阅读。图书馆的效率急剧下降。

这就是​​颠簸​​(thrashing)。当所有活动进程的工作集总和超过了可用的物理 RAM 时,就会发生颠簸。假设有三个进程,每个进程的工作集为 Wi=800W_i = 800Wi​=800 页,但在压力之下,操作系统只能给每个进程分配 fi=400f_i = 400fi​=400 个物理页帧。尽管它们最初的内存分配因超售而成功,但它们的现实处境却很严峻。每当一个进程试图访问其工作集中一个不在其微小的 400 页帧配额内的页面时,就会触发一次缺页中断。操作系统必须将一个页面换出到磁盘,以便换入另一个页面。因为该进程需要所有 800 个页面才能高效工作,所以它几乎会持续不断地产生缺页中断。磁盘疯狂读写,CPU 大部分时间都在等待磁盘,系统变得极其缓慢,尽管没有一个程序技术上崩溃了。

路径二:内存不足(OOM)杀手

颠簸是一种性能故障。但如果系统面临的是容量故障呢?假设图书馆和它的附楼都已完全占满。一个进程发出了一个新的、合法的椅子请求——也许它是一个通过 fork 操作创建的子进程。当它第一次写入一个共享页面时,​​写时复制(COW)​​机制规定,为了与父进程保持隔离,它必须获得自己的私有副本。这需要一个新的物理页面。

操作系统试图寻找一个空闲页面。没有。它试图换出一个页面。交换空间已满。系统现在处于无法兑现一个合法请求的状态。它违背了虚拟内存的承诺。为了防止系统完全冻结或损坏,操作系统必须采取极端措施。它会调用​​内存不足(OOM)杀手​​。

OOM 杀手是图书管理员最后的冷酷手段。它扫描图书馆中的所有进程,并使用一种“坏度”(badness)启发式算法来选择一个牺牲品。然后,它终止这个牺牲品进程,强制收回其所有内存,以满足待处理的请求并维持系统的运行。从用户的角度来看,他们的应用程序就这样消失了。

这不是一个 bug;它是超售策略的一个直接且预期的后果。考虑一个拥有 8 GiB8 \text{ GiB}8 GiB RAM 且没有交换空间的系统。一系列看似无害的事件——一个进程使用 3 GiB3 \text{ GiB}3 GiB,另一个分配了 6 GiB6 \text{ GiB}6 GiB 但只接触了 1.8 GiB1.8 \text{ GiB}1.8 GiB,以及一个 fork 触发了 0.9 GiB0.9 \text{ GiB}0.9 GiB 的写时复制——可以悄悄地消耗掉几乎所有可用内存。当最后一个进程试图只接触 2 GiB2 \text{ GiB}2 GiB 时,它越过了阈值。内存不存在了,OOM 杀手被调用。无限内存的幻象破碎了。

明智的图书管理员:策略与保障

一个现代操作系统并非一个盲目的赌徒。它是一个复杂的风险管理者,拥有各种策略和保障措施,以使超售既强大又安全。

准入控制策略

操作系统可以扮演不同的角色,这些角色可由系统管理员配置。在 Linux 中,这由 vm.overcommit_memory 参数控制:

  • ​​模式 1 (总是超售):​​ 永远的乐观主义者。图书管理员对每个内存请求都说“是”,无论多么离谱。这最大化了内存利用率,但也带来了最高的 OOM 事件风险。
  • ​​模式 2 (严格禁止超售):​​ 悲观主义者。图书管理员拒绝任何会使总承诺内存超过严格限制的请求,该限制通常为 (RAM×ratio)+Swap(\text{RAM} \times \text{ratio}) + \text{Swap}(RAM×ratio)+Swap。对于一个拥有 8 GiB8 \text{ GiB}8 GiB RAM、 4 GiB4 \text{ GiB}4 GiB 交换空间和 50%50\%50% 比率的系统,这个限制将是 (8×0.5)+4=8 GiB(8 \times 0.5) + 4 = 8 \text{ GiB}(8×0.5)+4=8 GiB。这种模式最安全,因为它几乎保证了已承诺的页面可以被兑现,但效率可能不高,常常导致 RAM 未被充分利用。
  • ​​模式 0 (启发式):​​ 实用主义者。这是默认模式,一种复杂的启发式算法,试图兼顾两者的优点。它对空闲内存做出有根据的猜测,但像任何启发式算法一样,它可能被对抗性的工作负载所欺骗。

动态监控与颠簸预防

一个明智的操作系统不只是在分配时做决策;它会持续监控系统的健康状况。它会观察缺页中断率和交换活动。如果它检测到系统花费了大量时间——比如 60%60\%60% ——仅仅用于处理缺页中断,它就知道系统正处于病态颠簸状态。一个智能的策略此时会停止批准新的大内存请求,即使技术上还有容量,以防止颠簸恶化。它将系统响应性置于原始容量之上。

保护敏感数据

最后,操作系统必须认识到并非所有数据都是平等的。想象一个程序在内存中处理一个已解密的加密密钥。如果包含该密钥的页面被换出到一个​​未加密的交换设备​​上,那么这个秘密密钥现在就以明文形式被写在了持久化磁盘上,这是一个灾难性的安全漏洞。

为了防止这种情况,操作系统提供了一项特殊服务。程序可以告知内核某些页面是“敏感的”。然后,操作系统会将这些页面​​锁定​​在内存中(在 POSIX 中为 mlock),将它们钉在物理 RAM 上,并标记为不可交换。这些页面被排除在所有页面替换考虑之外。这提供了一个确定性的保证,即你的秘密永远不会离开 RAM 的安全范围。当然,这种能力是有限的;一个进程不能锁定不合理的内存量而导致系统资源枯竭。但它是在超售世界中编写安全软件的关键工具,提醒我们内存管理不仅仅关乎性能,更是系统安全的基础。

应用与跨学科联系

在探索了内存超售的基本原理之后,我们可能会感到一丝不安。这感觉有点像金融魔法,像是开出你无法完全兑现的支票。但故事才刚刚开始。超售不仅仅是一个巧妙的技巧;它是现代计算效率的基石,是预测与现实之间的一支优雅舞蹈。它代表了一种深刻的转变:从一个我们为绝对最坏情况做计划的世界,转变为一个我们为大概率事件进行工程设计的世界。现在,让我们来探索这个强大的思想在何处焕发生机,从广阔的云服务器集群到单个处理器内部线程的复杂交织,甚至延伸到系统安全的阴暗角落。

现代数据中心:编排云端

内存超售的重要性在云端体现得最为淋漓尽致。虚拟化的梦想是将庞大而强大的服务器分割成更小的、独立的虚拟机(VM),从而创造一个灵活且成本效益高的数字世界。但是,当所有这些虚拟机被承诺的内存总和超过了主机物理内存时,会发生什么?这不是一个 bug,而是核心商业模式。云服务提供商正在进行一场统计学上的赌博:并非所有虚拟机都会同时需要其全部分配的内存。

因此,挑战在于,当主机内存不足时,如何优雅地从虚拟机回收内存。想象两种方法。第一种是非合作式的:虚拟机监控程序(hypervisor)对客户机内部发生的事情一无所知,只是简单地抓取客户机的一些内存页,并将它们转移到慢速的磁盘存储(交换)中。第二种是合作式的:hypervisor 通过一个“气球驱动程序”(balloon driver)礼貌地通知客户机操作系统,它需要回收内存。客户机了解自己的业务,因此可以智能地决定放弃哪些内存——也许会先丢弃干净的、易于重建的文件缓存,然后再触及关键的应用程序数据。

这两种方法的差异并非微不足道。非合作式方法充满了危险。Hypervisor 在无知的情况下,可能会换出一个客户机本可以零 I/O 成本直接丢弃的“干净”页缓存。当 hypervisor 将这个页面写入其交换文件,稍后又读回时,它执行了两次 I/O 操作,而合作式的客户机最多执行一次(从原始文件重新读取),甚至常常是零次。这种“I/O 放大”效应可能非常严重,将一个高效的优化转变为性能瓶颈。真正的效率需要沟通和智能。

这个原则可以从单个虚拟机扩展到整个集群。一个成熟的云服务提供商会围绕这种智能合作建立一整套策略。他们不只是等待内存危机的发生。他们采用主动气球技术,在空闲的虚拟机中轻轻“充气”气球,以建立一个空闲内存缓冲区。他们使用准入控制,如果一个新虚拟机的预计峰值需求会将系统推过安全阈值,就拒绝将其放置在该主机上。最重要的是,他们为每个虚拟机建立一个“内存底线”,通常基于其观察到的活跃工作集,承诺不回收低于此水平的内存,从而保护客户机免于其自身应用程序的颠簸。作为最后的应急出口,如果一台主机长期过载,编排器可以触发实时迁移,将一个运行中的虚拟机移动到另一台压力较小的服务器上,整个过程几乎没有中断。这是作为一门高雅艺术的内存超售:一个多层次、动态的控制和安全阀系统,旨在最大化密度的同时保证性能。

超越虚拟机:容器与共享现实

对密度的追求将我们推向了超越虚拟机的容器世界。在这里,数百个隔离的应用程序可以在单个操作系统内核上运行,共享公共的库和二进制文件。这种共享对于效率来说非常棒,但它也带来了一个有趣的记账问题。如果两个容器 A 和 B 都使用了同一个 100 MiB 的共享库,那么每个容器应该被“收取”多少内存费用?

主要有两种哲学。一种策略,我们称之为 full(完全)计费,让 A 和 B 都为这 100 MiB 的全部费用买单。从系统的角度来看,这是安全的;总记账内存是物理内存的高估值,降低了系统承诺超出其所拥有物理 RAM 的风险。然而,这对应用程序不公平。一个使用许多共享库的容器可能会达到其内存限制而被限流或杀死,即使它对内存压力的独特贡献非常小。

另一种选择是 split(分摊)计费,它将成本分开。在我们的例子中,A 和 B 将各被收取 50 MiB。这非常公平。所有容器的费用总和恰好等于所使用的物理内存。这里的危险在于,系统很容易在没有意识到的情况下超售物理内存。系统的资源管理器看到两个温和的 50 MiB 费用,可能会接纳越来越多的容器,却没有意识到底层的共享物理页面正在支持一个大得多的虚拟内存限制总量。这揭示了用户公平性与系统安全性之间的一种美妙张力,这是像 Kubernetes 这样的每个容器编排平台都必须驾驭的权衡。

特殊工作负载:驯服猛兽

并非所有应用程序都是生而平等的。对超售采取“一刀切”的方法对于专门的、性能敏感的工作负载可能是灾难性的。考虑一个拥有大内存堆的 Java 应用程序。它的垃圾回收器(GC)可能是一种“stop-the-world”类型,意味着它会周期性地暂停应用程序,以扫描整个堆来寻找活动对象。这个回收器的编写基于一个关键假设:堆内存位于 RAM 中,并且访问速度极快。

现在,想象一下这个应用程序运行在一个已经超售了内存,并将大块“非活跃”Java 堆交换到磁盘的系统上。当 GC 暂停开始时,回收器开始扫描。当它接触到堆的每一页时,会引发一连串的缺页中断。“暂停”不再是一个短暂的停顿;它变成了一场受 I/O 限制的马拉松,其持续时间不是由计算决定,而是由从存储中检索数 GB 数据的痛苦缓慢过程所主导。总暂停时间 TTT 可以建模为换出页面的数量乘以处理每个缺页中断的时间:T=(H−LP)⋅(s+PB)T = (\frac{H - L}{P}) \cdot (s + \frac{P}{B})T=(PH−L​)⋅(s+BP​),其中 HHH 是总堆大小, LLL 是已经在 RAM 中的部分, PPP 是页面大小, sss 是固定的缺页中断开销, BBB 是存储带宽。对于大型应用程序,这很容易延长到数秒甚至数分钟,使应用程序毫无用处。

为了容纳这样的“猛兽”,现代系统已经发展到提供不同等级的内存服务。对于像数据库或科学模拟这样需要可预测、低延迟内存访问的应用程序,系统可以提供“巨页”(huge pages)。这些是大的、数兆字节的页面,它们被预先保留,固定在物理 RAM 中,并且不受超售影响。系统其余的内存仍然是一个灵活的、可超售的池。准入控制变成了一个更复杂的计算:只有当新容器的巨页硬性预留加上其可超售内存的记账部分适合机器的物理容量时,才会被接纳。这种混合方法允许系统为通用任务获得超售的效率,同时为真正需要它们的应用程序提供铁定的保证。

统一世界:从 CPU 到 GPU

超售的原则是如此基础,以至于它们出现在完全不同的领域。考虑一个现代图形处理单元(GPU)。多年来,一个主要的限制是 GPU 只能处理完全容纳在其专用高速内存中的数据。

CUDA 的统一内存(Unified Memory)利用我们一直在讨论的同样思想打破了这一限制。它为整个系统创建了一个单一的虚拟地址空间,允许 GPU 运行一个总内存占用 FFF 远超 GPU 设备内存 DDD 的程序。当 GPU 内核需要一块数据时,它只需访问其地址。如果相应的页面不在 GPU 上,就会发生缺页中断,系统会自动将该页面从 CPU 的主内存迁移到 GPU。如果 GPU 的内存已满,一个最近最少使用的页面将被驱逐回主机。

就像在操作系统中一样,如果计算的活跃工作集超过了设备的内存,这也会导致颠簸。解决方案也是类似的。程序员可以对问题进行分块(tile),确保每个计算扫描都在一个能舒适地容纳在 GPU 内存中的数据块 WtileW_{\text{tile}}Wtile​ 上工作。此外,他们可以向系统提供明确的提示,使用 cudaMemPrefetchAsync 在处理当前数据块的同时预加载下一个数据块的数据,并使用 cudaMemAdvise 告诉驱动程序哪些数据是“只读为主”或有“首选位置”。这些提示让系统能够做出智能决策,例如在不将页面从 GPU 迁移走的情况下为 CPU 创建只读副本,从而防止 CPU-GPU 之间的内存争夺。这是一个绝佳的例子,说明了虚拟内存和智能分页的普适原则如何能弥合完全不同的处理架构之间的鸿沟。

阴暗面:病态与危险

强大的力量伴随着巨大的责任——以及新的作恶途径。一个超售系统的慷慨可以被用来对付它自己。一个非特权攻击者可以通过分配大量它从未打算认真使用,而只是为了强制其进入物理 RAM 而触摸的内存来利用这一点。通过内存映射大文件来填充页缓存,并写入内存中的临时文件系统(tmpfs),攻击者可以迅速制造远超系统物理容量的内存压力,从而触发内存不足(OOM)杀手。在默认设置下,OOM 杀手可能会选择终止一个关键的系统守护进程而不是攻击者的进程,导致一次成功的拒绝服务攻击。

对此的防御需要将系统自身的工具用于新的目的。内存控制组([cgroups](/sciencepedia/feynman/keyword/cgroups))可用于对不受信任的应用程序可以消耗的总内存设置硬性上限。文件系统级别的配额可以限制 tmpfs 滥用所造成的损害。OOM 杀手本身也可以调整:通过为一个关键守护进程设置一个特殊参数 oom_score_adj 到其最低值,我们可以使它们在实际上免于被杀死,确保系统的核心服务在这种攻击下得以幸存。这重新将内存管理定义为系统安全的关键组成部分。

最后,我们必须面对超售的最终极限。整个策略依赖于系统在需要时回收资源的能力。但如果一个资源一旦给出就无法收回呢?这引出了一个经典的死锁故事,通过“哲学家就餐问题”得到了优雅的阐释。想象进程是哲学家,而页锁是叉子。一个进程需要“锁定”内存中的两个页面来完成其工作。一个被锁定的页面是固定的(pinned)——它不能被内核换出或回收。现在,如果有五个进程启动,并且每个都成功锁定了它的“左”页面,我们假设系统中的所有五个物理页帧都变得固定了。然后每个进程都等待它的“右”页面,而该页面正被其邻居持有。我们得到了一个对非抢占资源的循环等待:一个经典的死锁。内核的回收机制无能为力;它搜索一个未固定的页面来换出,但一个也找不到。系统完全冻结,成为自己承诺的受害者。这是一个强有力的提醒,超售只有在资源最终是可互换和可抢占的时候才有效。

智能的妥协

我们的旅程揭示了内存超售远非一个简单的技巧。它是一种复杂的、智能的妥协。它是对我们程序可预测性的一种赌注,赌的是我们可以通过合作管理资源来达到更高的效率。它的成功实现是硬件和软件的交响乐。它利用了以最小开销跟踪内存访问模式的硬件特性。它依赖于巧妙的概率模型来指导回收,确保被放弃的页面确实是价值最低的那些。

内存超售不是“免费内存”。它是创造一个强大而有用的幻象的艺术,背后是一个由智能、合作和控制组成的深刻、多层次的系统。它证明了这样一个理念:通过深入理解我们的系统,我们可以让它们做到的远比我们想象的要多。