try ai
科普
编辑
分享
反馈
  • OOM Killer

OOM Killer

SciencePedia玻尔百科
核心要点
  • OOM 杀手是一种最后的补救机制,由内存超量分配(memory overcommit)触发。内存超量分配是操作系统承诺分配比物理可用内存更多的内存的一种策略。
  • 它通过一种启发式的“不良分数”(oom_score)来选择一个牺牲进程,该分数旨在最大化释放的内存,同时最小化对系统和用户的影响。
  • 在现代系统中,诸如控制组(cgroups)之类的工具允许将 OOM 杀手的影响限制在特定的应用程序或容器内,从而防止系统范围内的不稳定。
  • OOM 杀手的行为受硬件架构(如 NUMA)的影响。在 NUMA 架构中,内存被分割成多个本地节点,可能导致节点本地的 OOM 事件。

引言

任何现代计算机系统的稳定性都取决于一场精巧且通常不可见的资源管理之舞,其中内存是最关键的资源。在系统的众多守护者中,有一个因其残酷的效率而脱颖而出:内存不足(OOM)杀手。OOM 杀手通常被视为灾难性故障的标志,但实际上,它是一种必要且复杂的最后补救机制。本文旨在揭开 OOM 杀手的神秘面纱,纠正将其简单视为错误的普遍误解,并揭示其是强大而高效的内存超量分配策略的直接后果。为了提供全面的理解,我们将首先探讨其基础的“原理与机制”,深入研究虚拟内存的幻象、超量分配的原因,以及选择牺牲进程时的冷酷计算。随后,“应用与跨领域关联”一章将审视 OOM 杀手在现实世界中的作用,从使用 cgroups 在云中控制资源,到应对现代硬件架构的复杂性,及其作为系统安全最后一道防线的重要性。

原理与机制

要真正理解内存不足(OOM)杀手,我们必须首先领会现代操作系统每天都在向我们诉说的一个美丽的谎言:无限内存的谎言。当您运行一个程序时,它表现得好像完全独占一片广阔的私有内存空间,远大于您计算机中安装的物理 RAM 芯片的容量。这种障眼法被称为​​虚拟内存​​,是计算领域最绝妙的技巧之一。但就像任何宏大的幻象一样,它依赖于一套精心管理的假设。当这些假设崩溃时,OOM 杀手就会登场。

宏大的幻象:一种乐观主义策略

想象一家新航空公司决定为一架有 100 个座位的飞机售票。它没有只卖 100 张票,而是卖了 300 张。这听起来很疯狂,但该航空公司有一个聪明的理由:“大多数人预订了航班,但实际登机的只是一小部分。通过超售,我们能确保飞机总是满员且高效运行。”这种经过计算的风险策略,正是操作系统处理内存的方式。这种策略被称为​​内存超量分配​​。

当您的程序请求一大块内存——比如 6 GiB——操作系统会说:“当然可以!”然后递给它一个相应大小的虚拟地址范围。但它实际上并没有预留 6 GiB 的物理 RAM。它只是做出了一个承诺。物理 RAM 是在程序实际尝试触碰(读取或写入)特定地址时,才按需一页一页地分配。这被称为​​按需分页​​。操作系统在赌您不会用完所有已预留的内存。

这种乐观主义通常是合理的。一个经典的例子是 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 系统调用,它会创建一个与父进程几乎相同的新进程。操作系统并不会浪费地复制父进程的所有内存,而是使用一种名为​​写时复制(Copy-on-Write, COW)​​的技巧。最初,子进程只是共享父进程所有的物理内存页,并将其标记为只读。只有当任一进程尝试写入共享页面时,操作系统才会介入,制作一个私有副本,然后让写操作继续。如果父进程正在使用 7 GiB 内存,一个严格的系统在允许 fork 之前必须确保有另外 7 GiB 可用,以防子进程修改所有内容。然而,一个超量分配的系统只会让 fork 发生,赌子进程只会写入这些页面的一小部分,从而推迟了真正的成本。。

这种承诺游戏在大多数时候都运作得非常完美。它允许系统运行比物理上可能的更多的应用程序,如果每次内存预留都从一开始就由真实 RAM 支持的话。但它也引入了一种新的危险。系统不再受限于已分配的内存,而是受限于已提交的内存——即那些已经被触碰并现在需要一个物理家园的页面。OOM 条件不是在总分配量超过 RAM 时触发的;它发生在所有进程的总提交内存超过系统的总后备存储(物理 RAM 加上交换空间)时。一个看似稳定的系统可能因为内存泄漏或程序行为的改变(导致其突然触碰大片之前未触碰的承诺内存)而在一瞬间被推向崩溃的边缘。当超过 100 名持票乘客出现在登机口时,航空公司的赌局就失败了。

无法回头的时刻:通往 OOM 的升级路径

那么,当赌局失败时会发生什么?一个进程触碰了它被承诺的内存页面,触发了​​页错误​​,操作系统的内存管理器醒来后发现“可用内存”的柜子已经空了。这是危机的时刻。操作系统不能简单地告诉进程“不行”——那很可能会使程序崩溃。它必须找到一个物理内存帧,而且必须立即找到。

在诉诸“谋杀”之前,操作系统会变成一个疯狂的拾荒者。它有一套明确的层级结构来释放空间:

  1. ​​回收简单部分:​​ 它首先查看的是​​页面缓存​​。如果存在“干净”的页面——即由磁盘上的文件支持且未被修改的页面——操作系统可以直接丢弃它们。如果之后再次需要它们,可以轻松地从文件中重新读取。这是为什么由文件支持的内存(mmap)在压力下通常比凭空创建的内存更安全的原因之一。

  2. ​​做些内务整理:​​ 如果存在“脏”的文件支持页面(从磁盘读取后被修改过),操作系统可以将它们写回对应的文件。一旦写操作完成,它们就变成干净的,可以被丢弃。

  3. ​​使用后备存储:​​ 下一个目标是​​匿名内存​​——由 malloc 或匿名 mmap 分配的、与任何文件无关的内存。为了回收一页匿名内存,操作系统必须将其写入磁盘上一个称为​​交换空间​​的专用区域。

当这些选项都用尽时,危机加深了。如果没有空闲帧,没有干净的页面可供丢弃,并且交换空间也完全满了,该怎么办?内存管理器现在被逼到了墙角。它有一个必须服务的合法请求,却没有资源去满足。

更糟糕的是,一些内存页面是​​固定​​的(pinned)。进程可能会请求操作系统将某个页面“固定”在物理 RAM 中,使其不可移动且不可回收,这通常是为了让硬件设备能直接访问它(这个过程称为直接内存访问,或 DMA)。一个被固定的页面是神圣不可侵犯的;移动或回收它会导致灾难性的数据损坏。操作系统会不惜一切代价尊重这个固定请求,这进一步减少了它的可用选项。

此时,系统处于极端困境中。它找不到可回收的页面,也无法满足页错误请求。如果它什么都不做,触发错误的进程将永远卡住,如果该进程持有其他进程需要的锁,整个系统可能会陷入死锁而停止运行。操作系统只剩下最后一张牌可打。它必须通过强制手段抢先释放内存。它必须召唤 OOM 杀手。

行刑者的算法:如何选择牺牲品

OOM 杀手不是一个狂战士。它是一个绝望而冷血的计算者。它的目标不仅仅是杀死进程,而是要有效地杀死。它必须终止一个或多个进程,以释放恰好足够的内存来解决眼前的危机,同时对系统和用户造成最小的附带损害。这是一个复杂的优化问题,类似于战场军医进行伤员分类。

什么使一个进程成为“好”的牺牲品?一个天真的方法可能是杀死使用最多内存的进程。但如果那个进程是您关键的数据库服务器呢?一个更好的方法是使用​​启发式算法​​(一种经验法则)来为每个符合条件的进程计算一个“不良分数”。

内核像经济学家一样思考,权衡成本和收益。

  • ​​收益​​:将释放多少内存?更具体地说,是哪种类型的内存?杀死一个主要使用共享库的进程可能只会释放很少的独占内存。一个复杂的策略可能会优先杀死一个囤积了大量私有匿名内存的进程。
  • ​​成本​​:用户将遭受多大的损失?一个交互式 shell 比一个后台数据处理脚本更有价值。一个以特权用户身份运行的进程可能比普通用户的进程更重要。

理想的牺牲品是那种能以最小代价换取最大回报的进程:以较低的“用户影响”分数释放大量的内存。这是一个经典的背包问题:你有一个特定大小的背包(内存缺口)和一堆物品(进程),每个物品都有重量(释放的内存)和价值(影响分数)。你想要在填满背包的同时,最小化你丢弃物品的总价值。一种常见且有效的启发式方法是,迭代地选择那个能提供最佳内存释放与影响成本比率的牺牲品,直到内存缺口被填补。

像 Linux 这样的现代操作系统正是实现了这种逻辑。Linux 内核根据进程的内存大小、CPU 时间、优先级(“niceness”)以及运行时间等因素为每个进程计算一个 oom_score。系统关键的内核线程被豁免。oom_score 最高的进程即为被选中的牺牲品。

计算过程甚至可以更加复杂。有时系统不仅仅是 RAM 不足;它可能同时缺少 RAM 和交换空间。在这种情况下,最好的牺牲品是那个能同时有效释放两种资源的进程。OOM 杀手可能会选择一个能够最均衡地解决所有未决赤字的进程,即使它不是任何单一资源的最大消耗者。

最终,OOM 杀手尽管残酷,但它是一种旨在维护整个系统完整性的最后补救机制。它是那个美丽而高效的无限内存幻象所带来的严峻但必然的后果。它是我们为了一个尽力满足我们所有请求的系统所付出的代价,也是当那个乐观的承诺无法再被兑现时接住系统的安全网。

应用与跨领域关联

想象一家银行,它知道大多数客户不会同时取走他们的钱,于是决定贷出比它金库里实际持有的更多的钱。这种被称为部分准备金银行制度的策略效率极高——它让本会闲置的资本投入了运作。大多数时候,这套方法运作得非常完美。但如果恐慌开始,所有人都冲向银行,系统就会崩溃。银行无法兑现它的承诺。在操作系统的世界里,这种策略被称为​​内存超量分配​​,而内存不足(OOM)杀手就是当银行挤兑发生时到来的那个面色冷峻的审计员。

人们很容易将 OOM 杀手视为一种粗糙的工具,一个灾难性故障的标志。然而,它的存在并非偶然,而是一种对效率刻意且大体上成功的押注所带来的后果。理解 OOM 杀手在何处以及如何行动,就如同进行一次穿越现代计算领域最前沿和最紧迫挑战的旅行——从云的架构到网络安全的前线。这不仅是一个关于失败的故事,更是一个关于遏制、控制和资源管理复杂之舞的故事。

超量分配的困境:一种哲学选择

为什么操作系统会做出它无法兑现的承诺?原因很简单:程序通常既贪婪又懒惰。它们会“以防万一”地请求大片内存,但可能只使用其中的一小部分。如果操作系统为每一个请求的字节都预留物理内存,那么大部分宝贵的 RAM 都会闲置浪费。因此,操作系统选择赌一把。它对大多数请求都说“是”,分配虚拟地址空间,但只有当程序实际尝试触碰该内存时,才提供一个物理内存页。

然而,这种乐观的策略带来了一个两难选择。什么样的乐观程度是合适的?Linux 内核实际上允许系统管理员选择一种哲学。通过 vm.overcommit_memory 设置,你可以告诉内核如何行事。将其设置为 1 是终极乐观主义者:“永远说‘是’!” 这最大限度地提高了内存利用率,但风险很高;攻击者可以轻易地预留巨量的虚拟内存,并通过一次性触碰所有内存,几乎确定无疑地触发 OOM 事件。

在光谱的另一端,设置为 2 代表了坚定的悲观主义者:“绝不承诺超过你所拥有的。”它会根据可用的 RAM 和交换空间计算一个严格的提交限制,并拒绝任何超过该限制的请求。这很安全,但可能效率低下,因为它可能会拒绝那些行为良好且无意使用其所请求全部内存的程序的请求。然后是默认的模式 0,它使用一种“启发式”——一个复杂的最佳猜测——来判断一个请求是否合理。这是一种务实的平衡,但像任何猜测一样,它也可能被愚弄。

关键点在于,OOM 杀手的存在是这种哲学选择的直接后果。在任何允许超量分配的系统中,都可能出现这样一个时刻:一个程序在一个合法承诺给它的页面上发生页错误,但此时已没有任何物理内存可用。让程序失败就等于违背了合同。操作系统要兑现其承诺的唯一方法就是从别人那里拿走内存。因此,OOM 杀手被召唤出来,不仅仅是为了惩罚,更是为了维护一个承诺。

驯服猛兽:云环境中的资源控制

在单用户计算机上,OOM 事件是一种烦恼。而在托管着数千个容器的大型多租户云服务器上,一个不受控制的 OOM 事件将是一场经济灾难。在这种规模下运行的关键不是完全消除 OOM 事件,而是遏制它们。

想象一个容器就像高楼里的一间出租公寓。大楼的管理层需要确保一个租户的疯狂派对不会导致整栋楼停电。在 Linux 中,这种遏制是通过控制组(cgroups)实现的。通过为容器设置硬性内存限制(memory.max),管理员划定了一条严格的界限。如果容器内的进程试图使用超过其配额的内存,它们会触发一个cgroup 范围的 OOM。OOM 杀手被调用,但它的视野被限制在那个单一容器内的进程中。它关闭了一间公寓里的“派对”,而其他住户甚至都不知道发生了什么。这与全局 OOM 是根本不同的事件,后者是整栋楼的资源都已耗尽,OOM 杀手可能会从任何一间公寓中选择一个牺牲品。

现代系统管理提供了更精细的控制。有时一个“服务”不是单个进程,而是一组协作的进程。如果其中一个必须被终止,最好将它们全部终止,以便服务能够干净地重启。通过为一个 cgroup 设置 memory.oom.group 属性,管理员可以告诉内核:“这些进程是一个团队。如果你必须选择其中一个作为牺牲品,请把整个团队一起干掉。”这将 OOM 杀手从一个盲目的行刑者转变为一个理解应用层语义的智能工具,例如,能够干净地终止一个有故障的批处理分析作业,同时让关键服务安然无恙。

OOM 杀手与计算物理学

OOM 杀手的行为不仅受软件策略的影响,也受计算机物理架构的影响。为了追求性能,现代高端服务器已经变得不像一台单一、统一的机器,而更像一个由互联节点组成的联邦,这种设计被称为非统一内存访问(NUMA)。

把一个 NUMA 系统想象成一个拥有多个独立阅览室的大型大学图书馆。每个阅览室都有一组 CPU(读者)和自己的本地内存书架(RAM)。读者从自己房间的书架上取书(本地内存访问)非常快。但如果他们需要另一间阅览室的书,就必须穿过大楼(跨 NUMA 互连的远程内存访问),这要慢得多。

现在,想象一个有内存泄漏的恶意程序在其中一个阅览室(一个 NUMA 节点)里运行。如果它的内存策略是严格的(MPOL_BIND),它就被禁止使用其他阅览室的书。随着内存泄漏,它最终会填满本地的书架,触发一个节点本地 OOM。OOM 杀手被调用,但其影响仅限于该单个节点。然而,如果策略更宽松(MPOL_PREFERRED),程序会首先填满本地书架,然后开始“溢出”,请求远程阅览室的书。这不仅会减慢恶意程序的速度,还会在互连上造成流量,并在其他节点的内存控制器上产生争用,从而可能降低在别处运行的完全健康程序的性能。OOM 杀手的战场不再是一个单一的全局池,而是一个有边界、桥梁和局部冲突的景观。

在虚拟化世界中,这种与硬件的相互作用变得更加明显。当虚拟机需要与高速网卡等硬件设备直接通信时(这个过程称为设备直通或 VFIO),它必须给设备一个稳定的物理内存地址来写入数据。为保证这一点,宿主机操作系统会“固定”(pin)虚拟机的内存页,实际上是将它们焊在地板上。这些被固定的页面不能被移动或交换到磁盘。从宿主机的角度看,它们在其管理的内存池中成了一个黑洞。一个恶意的或配置不当的客户虚拟机可能会固定宿主机 RAM 的很大一部分,从而急剧减少可回收内存的数量,并将整个宿主系统推向 OOM 状态。解决方案再次是遏制:使用 cgroups 或进程资源限制(RLIMIT_MEMLOCK)来限制一个虚拟机允许固定的内存量,防止它挟持宿主机的稳定性。

实战中的 OOM 杀手:安全视角

哪里有有限的资源,哪里就总会有人试图滥用它们。资源耗尽是最古老的拒绝服务攻击形式之一,而 OOM 杀手则站在最后一道防线上。一个安全系统的目标不仅是在攻击中幸存下来,还要在保护真正重要的东西的同时,优雅地遏制攻击。

系统管理员必须能够指定他们的“核心资产”——那些必须不惜一切代价存活下来的关键守护进程和服务。这通过将进程的 oom_score_adj 设置为 -1000 来实现。这个值就像一种外交豁免权,告诉 OOM 杀手:“无论发生什么,你都不能动这个进程。”当攻击者试图耗尽系统内存时,例如通过填满一个临时文件系统(tmpfs)或强行将文件加载到页面缓存中,这项策略确保 OOM 杀手会牺牲攻击者的进程或其他非必要任务,而受保护的守护进程则继续运行。

当然,最好的防御是主动遏制。对于像​​fork 炸弹​​这样的经典攻击——一个只做一件事就是不断创建自身副本以耗尽系统进程表的小程序——目标是在它能触发全局 OOM 事件之前就阻止攻击。通过将不受信任的用户放入一个严格约束的 cgroup 中,并对其进程数(pids.max)、内存使用量(memory.max)和 CPU 时间(cpu.max)设置硬性限制,管理员可以拆除这颗炸弹。fork 炸弹会达到其 cgroup 的进程限制而失败,整个过程都不会威胁到更广泛系统的稳定性。在这种情况下,OOM 杀手甚至不必被唤醒;威胁已被外围的栅栏中和了。

最后,一些系统提供了一种真正极端的替代方案:panic_on_oom。这个设置不会杀死单个进程,而是导致整个内核恐慌并重启机器。虽然这看起来很极端,但对于某些高可靠性系统来说,这可能是一个理性的选择,因为在这些系统中,不可预测的状态被认为比一次干净但有破坏性的重启更危险。然而,对于大多数多用户系统来说,让 OOM 杀手完成它的工作是远为优越的选择,它将一个潜在的系统范围的灾难转变为一个可控、可存活的事件。

从一个处理乐观主义者承诺破灭的简单机制,我们看到 OOM 杀手演变成复杂生态系统中的一个微妙角色。它在容器的边界间穿梭,尊重机器的物理布局,并执行安全策略。它的每一次调用都是一个信号,一段丰富的数据,讲述着一个关于维持我们数字世界运行的复杂而美丽的资源管理之舞的故事。