try ai
科普
编辑
分享
反馈
  • 进程饥饿

进程饥饿

SciencePedia玻尔百科
核心要点
  • 进程饥饿是一种活性失败,指一个就绪进程被调度器持续忽略;这与死锁不同,死锁中各进程在循环等待中相互阻塞。
  • 它通常源于严格优先级或最短作业优先等调度策略,在这些策略下,持续不断的高优先级或短作业流可能会无限期地推迟一个低优先级或长时间运行的任务。
  • 最有效和常见的解决方案是“老化”,这是一种随着进程等待时间的增长而提高其优先级的技术,从而保证它最终会被调度执行。
  • 饥饿是一个根本性的资源竞争问题,其范围超越了CPU调度,延伸到内存管理、硬件逻辑,并可被武器化用于拒绝服务攻击。

引言

在现代计算世界中,多个应用程序的无缝执行创造了资源无限的假象。然而,在这平滑的表象之下,一场为争夺处理器注意力的持续而激烈的竞争正在上演。在任何资源有限且需求相互竞争的系统中,一种微妙但具毁灭性的病态可能会出现:进程饥饿。当一个进程虽然准备就绪可以运行,却被永久地剥夺了访问CPU的机会时,就会发生这种情况,这导致的是公平性的失败,而非系统范围的崩溃。理解这种“活性失败”对于构建健壮和公平的系统至关重要。

本文深入探讨了进程饥饿现象。第一章“原理与机制”将剖析其核心概念,并将其与更为人熟知的死锁问题进行对比。我们将探讨常见的调度算法如何无意中导致饥饿,并审视那些为防止饥饿而设计的优雅解决方案,如老化和虚拟时间。随后,“应用与跨学科联系”一章将拓宽我们的视野,揭示饥饿在真实世界系统中的表现形式——从网络中断处理和内存管理,到硬件架构,甚至作为网络安全攻击中的一种强力武器。通过探索这些方面,我们将揭示对于一个健康的计算生态系统至关重要的普适公平性原则。

原理与机制

在我们穿越计算世界的旅程中,我们常常理所当然地认为,是那些无声、闪电般的决策让我们的计算机能够同时处理几十个任务。我们看到了同时性的幻象——视频在播放,文件在下载,文档在输入——并惊叹于机器的力量。但在这平静的表面之下,是一个充满激烈竞争的世界,一场为争夺最宝贵资源——处理器注意力——的持续斗争。在这个世界里,如同在任何资源有限、需求相互竞争的系统中一样,存在着一种微妙但具毁灭性的病态,一场被称为​​进程饥饿​​的无声悲剧。

看不见的等待:什么是饥饿?

想象一下,你正在一家非常受欢迎的餐厅排队。然而,这家餐厅有一个奇特的规定:任何持有特殊“VIP”通行证的人都可以立即插到队伍的最前面。起初,这似乎还可以应付。一个VIP来了,入座了,队伍继续前进。但如果开始有源源不断的VIP到来呢?他们一个接一个地从你身边走过,并被立即安排座位。餐厅很忙,食物在上桌,其他顾客也很开心。整个系统在取得进展。但你呢?你被困住了。你准备好吃饭,你有钱,但你却被永久地忽略了。你正在遭受服务的饥饿。

这正是操作系统中进程饥饿的本质。一个进程已准备好执行,它没有持有任何会阻塞其他进程的资源,只是在等待轮到自己使用CPU。然而,由于调度策略的原因,它被反复跳过,让位于其他进程。这是一种​​活性失败​​——系统没有崩溃,但它的某个特定部分无法再取得进展。

这与更为臭名昭著的​​死锁​​问题有着本质上的不同。死锁是一种完全的僵局,一种循环的对峙,其中一组进程都在等待彼此。想象两个人在一条狭窄的走廊里相遇,每个人都拒绝为对方让路。谁也动不了。圈子里的任何人都无法取得进展。相比之下,饥饿是公平性的失败,而不是系统范围的停滞。走廊里人来人往,但有一个人总是被挤到墙边,永远无法通过。

紧急任务的专横:饥饿是如何发生的

饥饿并非传统意义上的程序错误所致,而是调度规则本身的一种涌现属性。当一个通常为优化某些指标(如平均响应时间)而设计的策略存在盲点,允许无限期推迟时,饥饿就产生了。

考虑一个简单的​​严格优先级​​调度器,其中优先级数值较高的进程总是优先于优先级数值较低的进程。想象一个高优先级进程 PHP_{H}PH​ 有一个非常长的任务(比如,编码一部长篇电影),以及一系列低优先级进程 PLP_LPL​ 它们有短而快的任务。如果 PHP_HPH​ 正在运行,只要它处于就绪状态,它就会一直运行。任何变为就绪状态的低优先级进程都会被直接忽略。调度器遵循其规则,只要 PHP_HPH​ 在就绪队列中,就永远不会选择 PLP_LPL​。

你可能会认为解决方案是优先处理较短的作业,这种策略被称为​​最短作业优先(SJF)​​。这似乎很公平,甚至被证明是最小化平均等待时间的最优策略。但它有其阴暗面。假设一个长进程 PLongP_{Long}PLong​ 到达,需要100秒的CPU时间。就在它即将被调度时,一个短进程 PShort1P_{Short1}PShort1​ 到达,仅需2秒。SJF调度器名副其实地选择了 PShort1P_{Short1}PShort1​。当 PShort1P_{Short1}PShort1​ 完成时,另一个2秒的作业 PShort2P_{Short2}PShort2​ 到达。调度器再次选择了较短的作业。如果短作业流持续不断地到达,我们可怜的 PLongP_{Long}PLong​ 可能会被无限期地等待,成为“紧急任务的专横”的受害者。

这并不仅限于一个作业运行至完成的非抢占式系统。即使在像​​最短剩余时间优先(SRTF)​​这样的抢占式调度器中,调度器可以在执行中途切换进程,饥饿也可能发生。一个长作业可能运行了几毫秒,但如果一个新的、更短的作业到达,调度器会立即抢占这个长作业。永无止境的短作业到达序列可以确保长作业几乎没有任何进展,其剩余时间几乎不减少。

这个问题甚至不局限于CPU。它是资源竞争的一个普遍原则。考虑一个​​读写锁​​,这是一种同步工具,允许多个“读者”线程同时访问数据,但要求一个“写者”线程拥有独占访问权。如果锁采用“读者优先”策略,一个写者可能发出了想要写入的信号,但如果新的读者不断到来,它们将被授予访问权,而写者将遭受饥饿。相反,在“写者优先”策略下,持续不断的写者流可能会永久地阻止任何读者。​​哲学家就餐​​问题著名地说明了这一点,其中即使是无死锁的解决方案也可能让一个不幸的哲学家因其邻居总能先拿到相邻的叉子而饿死。

诊断病症:等待图

为了真正掌握死锁和饥饿之间的区别,我们可以使用​​等待图(WFG)​​来可视化进程之间的依赖关系。在这个图中,从进程 PAP_APA​ 指向 PBP_BPB​ 的箭头意味着“PAP_APA​ 正在等待 PBP_BPB​ 持有的资源”。

在这种图形语言中,​​死锁​​是明确无误的:它是一个​​环​​。例如,PA→PB→PAP_A \to P_B \to P_APA​→PB​→PA​。环中的每个进程都在等待下一个进程,形成一个封闭的依赖循环,若无干预则无法逃脱。

​​饥饿​​则没有这样清晰的特征。它可能表现为一条非常长的依赖链:PA→PB→PC→PDP_A \to P_B \to P_C \to P_DPA​→PB​→PC​→PD​。PAP_APA​ 并未死锁,但它必须等待 DDD,然后是 CCC,然后是 BBB。如果这条链不断受到干扰,或者调度器持续优先处理其他任务,就可能产生饥饿。一个有用的诊断方法是比较一个进程的实际等待时间 W(p)W(p)W(p) 与其基于依赖链长度估算的应该等待的时间 L(p)L(p)L(p)。如果你发现 W(p)≫L(p)W(p) \gg L(p)W(p)≫L(p),这是一个强烈的信号,表明该进程不只是在排队,而是被不公平地、反复地延迟了——它正在饥饿。

治愈不公:对抗饥饿

幸运的是,这种疾病有一个极其简单而有效的疗法:​​老化​​。这个概念就如听起来一样直观:一个进程等待的时间越长,它就变得越重要。

我们可以通过修改优先级计算来形式化这一点。我们使用一个随时间变化的有效优先级 e(t)e(t)e(t),而不是一个静态的优先级:

e(t)=pbase+α⋅w(t)e(t) = p_{base} + \alpha \cdot w(t)e(t)=pbase​+α⋅w(t)

这里,pbasep_{base}pbase​ 是进程的基础优先级,w(t)w(t)w(t) 是它已经等待的时间,而 α\alphaα 是“老化速率”。让我们回到我们的长作业 PLongP_{Long}PLong​ 被一连串短作业饿死的情景。短作业可能具有更高的基础优先级(或在SJF中具有更小的基础“大小”)。但随着 PLongP_{Long}PLong​ 的等待,它的 w(t)w(t)w(t) 项不断增长。不可避免地,其有效优先级 e(t)e(t)e(t) 将会攀升,直到超过任何新到达的短作业的优先级,从而保证它最终能轮到使用CPU。

这种优雅的老化思想不仅仅用于CPU调度。它是一种通用的补救措施。考虑一个管理内存的现代操作系统。一个交互式进程,比如你的网络浏览器,有一小组它需要保持响应性的“热”内存页面。它与一个后台进程(如文件备份)竞争物理内存,后台进程会流式处理大量数据,每个页面只接触一次。一个幼稚的页面置换算法可能会在短暂的停顿期间将浏览器的页面视为“最近较少使用”并将其换出,结果却不得不在之后立即将其换回,导致系统迟缓。这是一种内存饥饿。

解决方案?对内存页面进行老化。每个页面被赋予一个“分数”。当一个页面被引用时,其分数增加。所有分数会周期性地略微衰减。一个流式进程接触一个页面一次,给它一个临时分数,这个分数会迅速衰减。然而,你的浏览器的页面被反复引用。它们的分数不断被提升,因此即使有衰减,它也能保持在高位。这保护了交互式进程的“工作集”,防止其饥饿,并使你的系统感觉敏捷。

虽然老化是最常见的解决方案,但也存在其他专门技术。对于抢占式系统中一个饥饿的进程,我们可以保证它一个​​最小服务时间量​​——一旦其等待时间超过阈值,就给予一小片不可抢占的CPU时间。对于一个被读者饿死的写者线程,我们可以引入一个​​意图标志​​或“旋转门”。写者翻转该标志,这会阻止任何新的读者获取锁。现有的读者被允许完成,锁“排空”后,写者就获得了机会。

更深层次的美:作为物理定律的公平性

“老化”这个临时性的想法指向了一个更深刻、更统一的公平性原则。许多现代调度器,如Linux的CFS(完全公平调度器),其构建基础不是优先级,而是一个名为​​虚拟时间​​的优雅概念。

想象每个进程都有自己的时钟,即它的虚拟时间,这个时钟只有在该进程运行时才会走动。调度器的唯一规则极其简单:​​总是运行那个虚拟时间最落后的进程。​​

让我们看看为什么这能防止饥饿。当一个进程在等待时,它的虚拟时钟是冻结的。与此同时,某个其他进程正在运行,其虚拟时钟——以及整个系统的最小虚拟时间——正在前进。我们等待的进程的冻结时间与不断前进的系统时间之间的差距在不断缩小。从数学上可以确定,在有限的时间内,我们等待的进程将成为“最落后”的那个。在那一刻,调度器将选择它。饥饿是不可能的。

在这里,我们看到了 Feynman 经常提及的那种美:一个复杂的问题,伴随着各种临时性的解决方案(老化、时间量、标志),可以被看作是某条单一、简单、底层定律的表现形式。防止饥饿的繁杂工作被转化为保持所有进程虚拟时间公平的优雅而简单的任务。它揭示了在我们的操作系统架构中,就像在物理定律中一样,公平与平衡不仅仅是理想的美德——它们是一个健康、运转正常的宇宙的基本原则。

应用与跨学科联系

我们花时间理解了进程调度的齿轮和杠杆,将饥饿视为一种理论上的可能性——一个就绪的进程被永久地忽略。但要真正领会这一现象,我们必须离开抽象图表的纯净世界,冒险进入真实计算那嘈杂、旋转的机房。在那里,我们会发现饥饿并非某种罕见、奇特的错误。它是机器中一个根本性的幽灵,一个在最意想不到的地方反复出现的模式——从操作系统的最深层核心到蚀刻在硅片上的逻辑,甚至成为网络攻击者武器库中的一件利器。我们的旅程将是一次发现之旅,见证一个单一、简单的理念以各种非凡的形式显现。

调度器的困境:优先级与公平性

让我们从最显而易见的地方开始寻找饥饿:进程调度器,这个负责决定谁在何时运行的组件。显而易见,有些任务比其他任务更重要。当你访问一个文件时,系统必须先读取指向你数据的“索引块”,然后才能读取数据本身。I/O调度器的一个自然冲动是为这些索引请求创建一个高优先级队列,为数据请求创建一个低优先级队列。这能有什么问题呢?

答案在于一个简单的速率问题。想象一个系统处于重负载下,索引块请求的到达速率为 λI\lambda_{I}λI​,而磁盘处理请求的速率为 μ\muμ。如果索引请求的风暴如此猛烈,以至于它们的到达速率达到或超过了磁盘的处理能力(即,如果 λI≥μ\lambda_{I} \ge \muλI​≥μ),高优先级队列将永远不会清空。磁盘将把所有时间都花在处理索引块上,而数据块请求的队列将无限增长。等待其实际数据的进程将遭受饥饿,永远等待一个永不来临的时刻。这在传统意义上不是一个错误;它是一个稳定但灾难性的状态,源于一种“贪婪”的优先级策略。为了保证公平,系统必须采取更复杂的措施,比如为其服务时间的一部分保留给低优先级工作,确保它总有机会取得进展。

同样的戏剧以一种更直观的方式在网络流量中上演。当网卡收到一个数据包时,它会触发一个硬件中断,要求CPU立即关注。CPU必须停止正在做的任何事情来处理这个“紧急”事件。在Linux内核中,工作被分开:一个微小、快速的“顶半部”立即运行,一个更实质性的“底半部”(一个softirq)被调度在稍后运行。但在大规模网络泛洪期间——拒绝服务攻击或流量突发——会发生什么?CPU陷入一个疯狂的循环:处理中断,运行softirq,再次被中断,运行另一个softirq。它可能变得如此专注于“紧急任务的专横”,以至于永远无法回到运行你的网页浏览器或科学模拟等“重要”工作上来。即使有像New API (NAPI)这样批处理中断的巧妙缓解方案,系统仍然可能被拖垮。如果传入数据包所需的总处理时间——到达速率 rrr 乘以每个数据包的处理时间 tpt_ptp​——超过了CPU的容量,用户应用程序的CPU周期实际上就被饿死了。

饥饿也可能是操作系统不同部分之间无意中共谋的结果。考虑一个试图变得聪明的、带有全局内存管理器的系统。它使用“CLOCK”算法来决定在需要空间时驱逐哪个内存页面。该算法如果页面最近被使用过,会给它们“第二次机会”。现在,想象两个进程:一个大型、重要的进程,占用了90%的CPU时间;另一个小型、不太受青睐的进程。因为大进程运行得非常频繁,它的内存页面总是被访问,其“最近使用”位不断被设置。而小进程运行不频繁,它的页面的“使用”位长时间保持未设置状态。当系统处于内存压力下时——通常是由于大进程的需求——CLOCK算法会扫描内存寻找牺牲品。它跳过大进程的页面(“哦,你正在被使用!”),不可避免地落在一个属于小进程的页面上(“啊,一个未使用的页面!你出局了!”)。因此,小进程遭受内存饥饿,其工作集不断被驱逐,不是因为它行为不当,而是因为CPU调度器和内存管理器无意中合谋对付它。这个解决方案揭示了一个深刻的设计原则:为了防止此类干扰,资源有时必须在本地(每个进程)而不是全局进行管理。

架构中的回响:从硬件到应用的饥饿

饥饿问题并不仅限于内核的核心逻辑。它在整个计算技术栈中回响,从我们编写的应用程序一直到它们运行的硬件。

使用现代语言的程序员可能会采用“绿色线程”——由语言运行时而非操作系统管理的轻量级线程——来高效处理数千个并发任务。在简单的实现中,所有这些绿色线程都在单个操作系统线程上运行。问题在于,如果其中一个绿色线程发出了一个同步阻塞I/O调用(比如从慢速磁盘读取),整个操作系统线程就会阻塞。随之而来的是,所有其他绿色线程,无论多么重要,都被冻结了。它们被饿死了,被一个缓慢的操作劫为人质。解决方案是创建一个更复杂的运行时,带有一个专用于处理这些阻塞调用的“工作”操作系统线程池,让主线程可以自由地运行其他绿色线程。这样一个系统的性能成为瓶颈分析的一个优美例证:总吞吐量是CPU容量和I/O工作者集体容量的最小值,速率为 min⁡(1C,NB)\min\left(\frac{1}{C}, \frac{N}{B}\right)min(C1​,BN​),其中 CCC 是每个任务的CPU时间,BBB 是I/O时间,NNN 是工作者数量。

现代硬件架构引入了其自身的微妙之处。在大型、多插槽服务器中,我们发现了非统一内存访问(NUMA)。CPU核心访问其自身插槽上的内存要比访问远程插槽上的内存快得多。调度器被设计为采用“本地优先”策略来利用这一点,倾向于在“靠近”其数据的核心上运行线程。但这种合理的局部优化可能导致全局性的病态。一个其数据恰好位于一个非常繁忙节点上的线程,可能被永久剥夺在该节点核心上运行的机会。它变成了一个远程的弃儿,因其在机器中的物理位置而遭受CPU时间饥饿。为了解决这个问题,操作系统必须引入明确的公平性机制,例如周期性地将等待已久的线程迁移到期望的节点,或者强制实施一个“拒绝上限”,迫使核心在忽略远程请求太多次后最终为其服务。

问题甚至更深,直达硬件缓存层面。转译后备缓冲器(TLB)是一个微小而宝贵的缓存,用于存储最近的地址翻译以加速内存访问。一个系统可能会在多个进程之间划分其TLB条目。但应该如何做呢?如果一个进程被分配了零个条目,其性能将是灾难性的,因为每次内存访问都将触发一次缓慢的查找。它实际上被饿死了这种关键的硬件资源。追求公平可能是为了分配TLB条目,使得每个进程遭受相似的“缺失率”,这是一种精巧的平衡艺术。这展示了最微观层面的饥饿:不仅仅是被剥夺毫秒级的CPU时间,而是被剥夺纳秒级的硬件缓存条目,并重复数十亿次。

也许最深刻的是,饥饿是一个纯粹的逻辑问题。一个异步硬件仲裁器是一个电路,旨在授予两个竞争进程访问共享资源(如内存总线)的权限。其行为由一个状态机定义。通过仔细追踪该状态机的转换,有时可以发现一个恶意的事件序列。例如,进程P1请求资源。在它被授予之前,P2请求并被授予了资源。P2使用资源并释放它。P1仍在等待。然后P2可以再次请求并被授予访问权限,而P1的请求一直处于待处理状态。这个循环可以永远重复。P1被饿死了,不是因为软件调度器,而是因为蚀刻在硅片上状态机基本逻辑中的一个缺陷。幽灵确实在机器之中。

武器化饥饿:拒绝服务的艺术

如果饥饿可以偶然发生,它也可以被蓄意触发。在网络安全的世界里,使系统资源饥饿被称为拒绝服务(DoS)攻击。

考虑一个现代文件系统特性,如FUSE(用户空间文件系统),它允许一个程序实现一个文件系统。内核将文件操作(如read或open)转发给这个用户空间守护进程,并等待回复。这种通信依赖于一个有限大小的队列,比如 QQQ 个请求。一个恶意的FUSE守护进程可以干脆停止应答。所有试图访问此文件系统的进程发出的请求都会堆积在内核的队列中。来自 kkk 个进程的总请求到达速率 k/τk/\tauk/τ 会迅速填满队列。在短至 t=Qτ/kt = Q\tau/kt=Qτ/k 的时间内,队列就满了。从那一刻起,任何其他接触该恶意文件系统的进程,其线程都会立即被阻塞。攻击者已经将队列武器化,饿死了合法进程使其无法取得进展的能力。

最复杂的攻击将此与算法复杂性结合起来。Linux内核的eBPF子系统允许特权用户在内核内部运行沙盒程序,例如,高速处理网络数据包。一个强大的“验证器”会对eBPF字节码进行静态分析,以确保其安全——即它不会访问无效内存或陷入无限循环。攻击者可以编写一个很容易通过此验证的程序。验证器自身的分析运行得很快,看到的是一个小型、无害的程序。但隐藏在内部的是一个对数据结构(如哈希表)进行操作的函数调用。然后,攻击者精心构造网络流量,不仅触发此函数调用,还操纵哈希表的状态,使其进入最坏情况性能,导致单次查找耗费大量时间。现在,每个传入的数据包都会触发这个“复杂度炸弹”,导致内核花费所有时间来处理它。系统的其余部分,包括关键的内核任务和所有用户应用程序,都被饿死了CPU时间。这展示了安全领域一个深刻的挑战:一个程序在静态上可以是“安全的”,但在动态上却是致命的。

结论:对活性的追求

我们的巡览揭示了饥饿的多种面貌:一个调度缺陷、一场中断风暴、子系统的合谋、程序员的错误、硬件的怪癖,以及一种恶意武器。在任何并发活动竞争有限资源的系统中,这都是一个根本性的挑战。

经典的*优先级反转*问题作为一个最终的、统一的寓言。想象一个仓库里有一个高优先级机器人(HHH)、两个中优先级机器人(M1,M2M_1, M_2M1​,M2​)和一个低优先级机器人(LLL)。只有一个充电坞。LLL 当前正在使用充电坞。HHH 完成了它的任务需要充电,但它必须等待 LLL。现在,中优先级机器人 M1M_1M1​ 和 M2M_2M2​ 准备好运行。因为它们的优先级高于 LLL,它们抢占了 LLL 并开始在仓库里飞驰。结果是一场灾难:高优先级机器人 HHH 被卡住,等待低优先级机器人 LLL,而 LLL 又因为被中优先级机器人永久抢占而无法完成其工作。

一个幼稚的资源策略允许这种情况发生,并且 HHH 可能被无限期地饿死。一个更智能的系统使用像*优先级继承*这样的协议:当 HHH 开始等待由 LLL 持有的锁时,系统暂时将 HHH 的高优先级“借给”LLL。现在,LLL 不能被中优先级机器人抢占。它迅速完成其在临界区的工作,释放锁,然后 HHH 就可以继续。HHH 的等待时间现在只受限于 LLL 在其临界区中剩余的时间。

这个优雅的解决方案抓住了对抗饥饿斗争的精髓。这不仅仅是防止缺陷;它是关于构建具备活性属性的系统——一个正式的保证,即任何准备好取得进展的任务最终都将被允许这样做。理解饥饿的 myriad 面貌是欣赏这个深刻而美丽的、融入计算结构之中的公平原则的第一步。