
在任何复杂的系统中,当多个实体竞争单一资源时——从厨房里共享一个烤箱的厨师,到计算机中争夺CPU时间的线程——都存在巨大的混乱可能。这个基本挑战被称为临界区问题,需要一套规则来确保有序和独占的访问。没有这些规则,一些进程可能会被永久拒绝访问,这种情况被称为饥饿。于是,核心问题就变成了:我们如何设计一个不仅正确而且明显公平的系统?
本文通过探讨有界等待原则来解决这个问题,这是一种对抗饥饿的强大保证。它超越了进程最终会得到服务的模糊承诺,提供了一个可量化的保证:进程的执行机会将在一个有限且可预测的界限内到来。
为了理解这个关键概念,我们将首先在“原理与机制”一章中探讨其核心宗旨,审视从简单队列到优先级继承等赋予其生命力的算法和架构。然后,我们将踏上“应用与跨学科联系”的旅程,发现在从硬盘调度器和操作系统到复杂的分布式网络和虚拟化云环境等一切事物中,这一单一原则是如何确保公平与稳定的。
想象一个繁忙的餐厅厨房,有许多才华横溢的厨师,但只有一个神奇的烤箱。每位厨师在自己的工作台准备杰作,但要达到完美,每道菜都必须在这个烤箱中烘焙。烤箱一次只能烤一道菜。这个简单的场景抓住了计算领域一个基本挑战的精髓:临界区问题。许多独立的进程或线程常常需要访问同一个共享资源——可能是一份数据、一个硬件设备或一段代码——并且必须以独占的方式进行。厨房就是计算机系统,厨师是线程,而烤箱就是临界区。
那么,我们如何决定下一个谁来使用烤箱呢?我们可以采用“自由竞争”的方式。当烤箱门打开时,每个准备好菜肴的厨师都挤到前面去。这看似充满活力,实则一片混乱。一个特别温顺或不幸的厨师可能永远无法将自己的菜放进烤箱。他们不断地被更具攻击性或更幸运的同事推开。这种进程被无限期地拒绝访问其所需资源的噩梦般场景,被称为饥饿。
作为文明系统的设计者,我们的工作就是用秩序取代这种混乱。我们需要一套规则,一种算法,它不仅要正确,还要公平。
要建立一个公平的系统,我们必须首先就参与规则达成一致。在并发编程的世界里,这些规则由三个基本属性来体现。
首先是互斥。这是最显而易见的规则:一次只能有一个厨师使用烤箱。如果两个厨师同时把他们的菜放进去,我们可能会得到一个可怕的阿拉斯加火焰冰淇淋-千层面混合体。在计算中,这意味着要确保如果一个线程正在其临界区中执行,那么没有其他线程可以在同一临界区中执行。
其次是前进。如果烤箱是空闲的,并且至少有一位厨师带着菜在等待,那么必须选择某人来使用烤箱。这个决定不能被无限期推迟。此外,那些仍在自己工作台切菜的厨师——即不试图进入临界区的线程——不应在决定下一个谁用烤箱的问题上有发言权。他们的不参与不能阻碍那些有兴趣的线程的前进。
第三条也是最深刻的规则是有界等待。这是我们对抗饥饿的正式保证。仅仅说“别担心,你最终会轮到的”是不够的。那是一个软弱的承诺。有界等待作出了一个更强大、可量化的承诺:一旦你,作为一名厨师,表明你需要烤箱,那么在你获得使用权之前,其他厨师使用烤箱的次数存在一个有限的界限。这个属性确保你的等待不仅是有限的,而且是可度量地有限。它将获得服务的模糊希望转变为一个具体的保证。
我们如何构建一个遵守这些戒律的系统?最直观的机制是我们每天都在使用的:排队。
先进先出(FIFO)队列是实现公平最简单、最直接的途径。当一位作者准备好让编辑审阅其作品时,他会加入队列的末尾。编辑总是从队首挑选作者。这个简单的策略内在地满足了有界等待。如果你到达时前面有 位作者,你将恰好等待这 位作者被审阅完毕,而任何在你之后到达的人都不能插队。你的等待时间由排在你前面的人数函数所限定。票据锁(ticket lock)为每个到达的线程分配一个编号的票据并按顺序服务它们,是这一原则的优美数字实现。
要欣赏 FIFO 的优雅,看看其他策略如何会大错特错是很有帮助的。考虑一个后进先出(LIFO)策略,就像一叠盘子。最后一个请求资源的线程最先得到它。在一个负载很重的系统中,新请求的到达速度比旧请求被处理的速度快,一个早到的请求可能会被埋在栈底,可能永远无法得到服务。这是导致饥饿的温床。
另一条诱人但危险的道路是追求“效率”。考虑一个用于CPU的最短作业优先(SJF)调度器。它总是下一个运行任务最短的进程。这最大化了吞吐量,听起来很棒!但那个漫长而重要的任务怎么办?如果源源不断的短小、琐碎的任务持续到达,这个长任务将被永久推迟,因得不到CPU时间而饥饿。类似的问题也发生在磁盘调度中。最短寻道时间优先(SSTF)算法服务于离磁盘磁头当前位置最近的请求。这最小化了磁头移动,但它可能将磁头困在磁盘的一个繁忙区域,使远离该区域的磁道请求陷入饥饿,无论它们已经等待了多久。这些“贪心”算法教给我们一个至关重要的教训:为局部效率进行优化可能导致全局不公和饥饿。
最后,如果我们根本没有任何明确的排序呢?一个简单的测试并设置(test-and-set)自旋锁就是这样。所有等待的线程都疯狂地、反复地检查锁是否空闲。当锁空闲时,它们都竞相抢夺。没有队列,没有顺序。一个不幸的线程可能持续输掉这场竞争,在其他线程一次又一次进入临界区时无限期地饥饿。公平并非偶然;它必须被设计。
虽然 FIFO 是一个强大的工具,但它并非实现有界等待的唯一途径。自然界和计算机科学已经找到了其他优美的解决方案。
令牌环方法是中心队列的一种去中心化替代方案。厨师们传递一只烤箱手套。只有持有手套的厨师才能使用烤箱。使用完毕后(或者如果他们不需要),他们立即将手套传给固定圈子里的下一个厨师。这个简单的协议保证了每个厨师都将在手套完整轮转一圈内有机会使用烤箱。等待的界限最多是 次其他进入,其中 是厨师的数量。
对于磁盘调度问题,SCAN(或电梯)算法提供了一个极好的系统性解决方案。磁盘磁头在柱面之间来回扫描,就像服务于各个楼层的电梯一样。它服务于其路径上的所有请求。如果一个请求在磁头刚刚经过其磁道后到达,它可能需要等待磁头移动到磁盘末端再一直返回。这就定义了最坏情况下的等待时间。对于一个柱面跨度为 、磁头速度为 的磁盘,最大等待时间被优雅地限定为一次完整往返的时间:。与 FCFS(其最坏情况等待时间可能随待处理请求数量增长)不同,SCAN 的界限仅取决于设备的物理特性。它提供了一个确定性的保证,这对于性能敏感的应用至关重要。
但是,如果我们既想要像 SJF 这样的优先级系统所带来的效率,又不想发生饥饿,该怎么办?我们可以引入老化(aging)机制。这个想法异常简单:一个已经等待了很长时间的进程变得更加“重要”。它的优先级随时间推移而增加。一个长作业可能以低优先级开始,但随着等待,其优先级稳步攀升,直到最终超过所有短作业并保证得到运行。对于一个基准优先级为 的长进程 与优先级为 的短进程竞争,我们可以将其等待时间 后的有效优先级定义为 。当其优先级超过 时,它将被选中,从而保证其等待时间是有界的。老化是伟大的均衡器,是一种将优先级与公平性优雅地融合在一起的机制。
到目前为止,我们设计了非常公平的锁和调度器。但是,计算机系统是一台由许多相互作用部分组成的复杂机器。一个完美公平的锁可以被置于一个串谋使其变得不公平的系统中。这就引出了一个微妙但关键的问题:优先级反转。
想象一个拥有抢占式、固定优先级调度器的系统和三个线程:(高优先级)、(中优先级)和 (低优先级)。场景如同一场悲剧般展开:
看看这个局面! 正在等待 释放锁。但 无法运行以释放锁,因为它正被 抢占。一个中等优先级的线程正在无限期地阻塞一个高优先级的线程。锁的有界等待保证现在变得毫无意义。在 之前能够进入临界区的其他线程数量为零,满足了锁的公平性定义,但 的等待时间却是无界的,因为锁的持有者被剥夺了CPU时间。
这是一个深刻的见解:系统在一个层面上的公平性并不能保证整个系统的公平性。解决方案与问题本身一样令人烦恼但优雅:优先级继承。当 因 持有的锁而阻塞时,系统会临时提升 的优先级,使其与 相匹配。现在, 可以运行,抢占 。它迅速完成工作,释放锁,其优先级恢复正常,等待中的 终于可以继续执行。有界等待得以恢复。
在现代多核系统的复杂世界中,确保有界等待需要综合运用这些思想。我们可能需要一个能够感知优先级并同时实现优先级继承的队列锁。对于非常长的临界区,我们甚至可能需要添加协作式抢占点,即当有更高优先级的线程在等待时,持有锁的线程会自愿让出CPU。通过结合这些机制,我们可以构建出健壮的系统,即使在重负载和复杂交互下也能提供强大、可证明的公平性保证。有界等待原则,我们对抗饥饿的简单承诺,是这项复杂而美好事业中的指路明灯。
我们花了一些时间来理解有界等待的机制——即一个资源请求不会被永远忽略的优雅承诺。但要真正领会其重要性,我们必须看它在实践中的应用。就像一条基本的物理定律,有界等待原则不仅仅存在于教科书中;它悄无声息地、深刻地塑造着我们周围的世界。它是为我们数字生活的混乱带来秩序的无形之手,从在硅芯片上跳跃的电子,到将电影传送到我们屏幕上的全球网络。让我们踏上一段旅程,穿越这些不同的世界,看看这个单一而优美的思想如何发挥作用。
想象一辆勤奋的邮政卡车在一条笔直的长路上行驶,这个场景类似于硬盘驱动器的读/写磁头在其柱面上寻找数据。卡车从中间出发。沿途散布着许多投递点,有些很近,有些在遥远的两端。最“高效”的策略似乎是贪心策略:总是选择到下一个地址的最短行程。这就是最短寻道时间优先(SSTF)算法的精髓。它在每次投递的基础上最小化了行程时间,在一段时间内,一切似乎都很完美。
但现在,想象一个转折:卡车当前位置附近突然出现了一个新的住宅区,针对这个小区域的新投递请求源源不断地涌来。贪心算法,这个永远的投机者,陷入了困境。它服务一个本地投递,然后又出现一个更近的。行程很短,吞吐量很高,但是在路两端等待的投递却永远得不到服务。它们被饿死了。它们的等待时间,实际上是无限的。
在这里,我们看到了局部优化的暴政。解决方案既简单又深刻:卡车必须采取公平策略。它必须进行扫描。在SCAN算法中,我们的卡车表现得像一部电梯,有条不紊地从路的一端移动到另一端,服务沿途所有待处理的请求,然后再掉头。在相关的C-SCAN算法中,它只朝一个方向扫描,然后迅速复位到起点开始下一次扫描。无论哪种情况,一个寄往最远地址的包裹现在都有了保证。它可能需要等待卡车完成一次完整的巡回,但它知道轮到它的时刻终将到来。这个等待现在是有界的。为了实现全局公平并防止系统崩溃(饥饿),我们有时必须放弃最直接满足的选项。这是有界等待的第一个,或许也是最直观的教训。
让我们从物理道路转向操作系统内部的路径。在这里,我们没有卡车,而是有进程和线程在争夺宝贵的CPU时间或I/O设备的访问权。一种常见的方法是分配优先级:根据常识,高优先级的前台应用程序应该总是在低优先级的后台维护任务之前得到服务。但如果前台工作永无止境呢?后台任务,就像遥远的邮政投递一样,可能会永远被饿死。
操作系统可以采用一个非常优雅的技巧:老化(aging)。一个等待中的后台任务不是静态的;它的优先级会随着等待时间的延长而缓慢增加。它以一个低的基准优先级 开始,但在时间 之后,其有效优先级可能变为 。无论前台任务的固定优先级有多高,老化的后台任务的优先级最终都会超过它。这是一个数学上的确定性。老化确保了即使是系统中最低等级的公民最终也能有出头之日。
这种比例公正的思想可以变得更加明确。考虑一个有“付费”和“免费”用户的流媒体服务。严格的优先级意味着在高峰时段,免费用户可能永远无法开始播放流。服务可以不使用简单的队列,而是使用加权公平队列(WFQ)调度器。在这里,每个类别被分配一个权重,比如付费用户为 ,免费用户为 。系统不承诺在服务任何免费用户之前服务所有付费用户。它承诺,在重负载下,免费用户能保证获得与他们权重成比例的总容量的一部分——具体来说,服务速率至少为 ,其中 是总容量。只要免费用户的到达率低于这个保证的最低速率,他们的队列就是稳定的,等待也是有界的。他们不会被饿死。这不仅仅是对免费用户“友好”;这是关于构建一个在可预测负载下不会为整个用户群体崩溃的健壮服务。
对抗饥饿的战斗在并发编程领域最为激烈,在这里,许多线程可能试图获取一个单一的锁来访问共享资源。一个天真的实现很容易导致饥饿。但只要一点点巧思,我们就能强制实现有界等待。
调度器可以充当仲裁者。即使使用一个简单的自旋锁,如果调度器跟踪每个线程已经等待了多长时间,它就可以进行干预。当锁被释放时,调度器可以不让混乱的竞争发生,而是直接选择“最老”的等待线程并授予其立即访问权,确保它下一个获取锁。
更复杂的锁将公平性直接融入其设计中。高性能系统通常使用混合方法,认识到竞争并不总是很激烈。一个混合锁可能有一个“快速路径”,线程在此路径上竞相获取锁——这不公平,但在没有竞争时非常快。然而,如果一个线程在这条路径上失败,它不会只是继续尝试;它会回退到一个“慢速路径”。这个慢速路径是一个明确的、有序的队列。一旦线程进入队列,其等待就是有界的。它将按先进先出(FIFO)的顺序得到服务。这种设计是实用工程的杰作:它在你可以侥幸成功时提供不公平竞争的原始速度,但在系统承受压力时提供有界等待的稳健保证。
这个概念可以被设计为可调的。在一个分布式的读写锁中,大量的读者可能会饿死一个等待的写者。一个简单而有效的策略是引入一个公平性参数 。一旦一个写者表明其意图,系统将允许最多 批读者继续执行,然后强制轮到写者。这为写者的等待时间设置了一个清晰、可量化的上限。
将公平性构建到机制本身这一原则,一直延伸到硬件层面。在设计片上系统(SoC)上的信号量单元时,可以实现一个票据锁(ticket lock)。当一个处理器核心想要锁时,它执行一个原子操作,就像在熟食店取号一样:它获得一个唯一的票号。另一个寄存器保存着“当前服务”的号码。核心只需等待自己的号码被叫到。这是在硅片中对 FIFO 队列的物理实现。它提供了有界等待的铁板钉钉的保证,与像测试并设置(test-and-set)或比较并交换(compare-and-swap)这样更简单的原子操作形成鲜明对比,后者只提供互斥而没有内在的公平性。即使在中断控制器的层面上,也可以使用轮询(round-robin)机制来确保在多个设备以相同优先级发出中断信号时,每个设备都能保证依次得到服务。
在分布式和分层系统中,有界等待的挑战变得更加突出。想象网络上的一组计算机需要共享一个资源。一种方法是让它们竞争中央服务器上的一个锁,但正如我们所见,如果网络延迟不同,这可能是不公平的。一个更优美的解决方案是令牌环。一个特殊的消息,即“令牌”,在一个逻辑环中从一台计算机传递到另一台。计算机只有在持有令牌时才能访问资源。由于令牌总是在循环且永不丢失(在理想系统中),环中的每台计算机都保证最终会收到它。等待时间受令牌的循环时间限制。这是有界等待的一种去中心化、优雅的体现。
现代云计算世界引入了又一个复杂层次。一个操作系统(“客户机”)可能运行在虚拟机内部,其虚拟CPU由虚拟机监视器(Hypervisor)调度到物理CPU上。在客户机操作系统内部做出的公平性保证,可能会在不知不觉中被虚拟机监视器破坏。一个偏向读者的锁本就容易饿死写者,当写者的虚拟CPU随时可能被虚拟机监视器置于休眠状态时,情况就变得更加危险。
这里的解决方案需要合作。通过半虚拟化(paravirtualization),客户机操作系统可以将其意图传达给虚拟机监视器。当一个写者在等待时,客户机操作系统可以做两件事:首先,它可以改变自己的策略,停止接纳新的读者(从而创建一个有界的现有读者集合来等待)。其次,它可以向虚拟机监视器发送一个提示,大意是:“这个虚拟CPU现在极其重要;请调度它。”这种跨层合作重新建立了因虚拟化抽象而被破坏的有界等待保证。
从孤独道路上的邮差到管理数据中心的虚拟机监视器,原理始终如一。有界等待不仅仅是一个技术属性;它是构建健壮、可预测和公平系统的基本设计哲学。它教导我们,要构建持久的系统,我们必须确保没有任何部分,无论多么微小或低优先级,会被永远抛在后面。