try ai
科普
编辑
分享
反馈
  • 危险等待的艺术:理解死锁的“持有并等待”条件

危险等待的艺术:理解死锁的“持有并等待”条件

SciencePedia玻尔百科
核心要点
  • “持有并等待”是死锁的四个基本条件之一,指一个进程在持有一个或多个资源的同时,等待获取其他资源。
  • 预防此条件涉及多种策略,例如要求进程一次性请求所有资源,或强制其在等待新资源前释放已持有的资源。
  • “持有并等待”模式是导致系统冻结的根本原因,广泛存在于从多线程应用、操作系统到全球网络协议等不同领域。
  • 即使未导致完全死锁,“持有并等待”行为也是一种低效设计的标志,可能导致资源利用率不足或活锁。

引言

在计算这个错综复杂的世界里,无数进程为有限的资源而竞争,一种特殊而灾难性的瘫痪状态可能发生:死锁。这不是随机的崩溃,而是一种完美的、结构性的冻结,整个系统因组件陷入循环依赖而停止运转,每个组件都在等待另一个组件采取行动。但造成这种灾难的确切因素是什么?更重要的是,我们如何设计出能够免疫这种问题的系统?本文将深入探讨死锁最根本的原因之一:“持有并等待”条件——一种看似简单的行为,即持有一个资源的同时等待另一个资源。

我们将在“原理与机制”一章中首先剖析核心理论,探讨死锁的四个必要条件以及“持有并等待”在其中扮演的具体角色。我们将审视旨在打破这种问题模式的优雅策略,从“全有或全无”的资源分配到异步的“先释放后请求”协议。随后,“应用与跨学科联系”一章将带您领略真实世界的案例,揭示这一单一原理如何导致从多线程应用和操作系统到复杂的分布式网络等一切事物的失败。读完本文,您将深刻体会到管理并发的精妙艺术,以及通过“放手”来构建健壮、无死锁系统的重要准则。

原理与机制

你是否曾遇到过一场如此完美、如此精致的交通堵塞,以至于感觉像是一件艺术品?想象一个简单的十字路口,一个被划分为四个格子的完美正方形。四辆车从四个方向驶来。每辆车进入路口,占据一个格子,现在打算向左前方进入下一个格子。但那个格子被前面的车占着。车1需要车2占有的空间。车2需要车3占有的空间。车3需要车4的空间,而车4,以一种最终的、诗意的闭环方式,需要车1占有的空间。谁也动不了,一寸都动不了。交通完全锁死。

这种完美的、对称的瘫痪状态,就是计算机科学家所说的​​死锁​​。这不仅仅是运气不好;它是一种特定的、结构性的故障,当多个进程或线程竞争有限的一组资源时可能出现。对科学家来说,这不仅仅是一个令人沮mer的错误;它是一个有其自身规律的迷人现象。通过理解这些规律,我们可以学会如何设计出能免疫死锁的系统。

僵局剖析:四个必要条件

通过仔细观察,计算机科学家已经确定了四个基本条件,它们必须全部同时为真,死锁才会发生。可以把它们看作并发末日的四骑士。如果你能驱逐其中任何一个,死锁的预言就无法实现。让我们用我们的交通堵塞来理解它们。

  1. ​​互斥​​:所涉及的资源必须是不可共享的。在我们的十字路口,每个格子一次只能被一辆车占据。这些车相互排斥对方进入自己的空间。在计算中,这适用于像一次只能打印一个作业的打印机 或一次只能由一个程序写入的文件之类的资源。

  2. ​​持有并等待​​:一个进程必须在持有一个或多个资源的同时,等待获取额外的资源。每辆车都在持有其在十字路口中的当前格子,并且在等待下一个格子变为空闲。它不会后退并释放当前的位置。这种贪婪的行为就是“持有并等待”条件。

  3. ​​不可抢占​​:资源不能被强制夺走。没有一个拿着空中吊车的交警来把其中一辆车吊走。一辆车只有在成功前进后才会自愿放弃它的格子。在操作系统中,这意味着像磁盘驱动器这样的资源不能从正在使用它的进程中被抢走。

  4. ​​循环等待​​:必须存在一个等待进程的闭环链。车1等待车2,车2等待车3,车3等待车4,而车4——这是关键的转折点——等待车1。依赖关系链形成了一个完美的循环。

当这四个条件同时满足时,系统就会冻结。这个框架的美妙之处在于它为我们提供了一个清晰的攻击计划:为了防止死锁,我们必须设计系统以确保这四个条件中至少有一个永远不会发生。

贪婪的囤积者:“持有并等待”的深入观察

让我们聚焦于“持有并等待”条件。这是四个条件中最直观的,而且事实证明,也是最容易攻破的。它归结为一个简单、自私的策略:“我拥有的东西我留着,直到我得到我需要的东西,否则我什么也不做。”

考虑一个不同但稍微复杂一些的比喻:一家杂货店。你购物完毕,购物车已满。你现在持有一个宝贵的资源:购物车。接下来,你需要一个不同的资源:一个空闲的收银员。你加入一个队列,等待收银员可用。一直以来,你仍然持有你的购物车。这是一个完美的现实世界中持有并等待的例子。

那么,杂货店是否死锁了?可能没有。死锁不太可能发生,因为获取资源有一个自然的顺序:你总是在得到收银员之前得到购物车。你永远不会看到一个收银员在等待顾客给他们带来购物车。这种严格的顺序防止了循环等待条件的形成。然而,持有并等待的行为仍然在 gây ra 问题。如果许多人在排长队结账,商店里所有的购物车可能都被这些队伍占用,新顾客无法开始购物。系统效率低下,吞吐量降低。

这揭示了一个深刻的观点:即使不导致灾难性的死锁,“持有并等待”模式通常也是笨拙或低效设计的症状。它是一种“代码异味”,暗示着潜在的问题。在软件中,这种模式以危险的频率出现。一个程序可能获取了一个关键数据结构的锁,然后调用一个从慢速磁盘驱动器读取数据的操作。在等待磁盘的同时——这可能需要几毫秒,在CPU时间里是永恒——它持有锁,阻止程序的任何其他部分取得进展。一个简单的编程错误,比如在等待条件变量前忘记释放锁,也可能造成一个完美的持有并等待场景,导致必然的死锁。

构建礼貌社会的策略

如果“持有并等待”是一种资源贪婪的形式,那么解决方案就是设计鼓励更“礼貌”行为的系统。计算机科学家已经开发了几种优雅的策略来实现这一点,每种策略都有其自身的特点和权衡。

全有或全无策略

战胜持有并等待最直接的方法是禁止在等待时持有资源。最简单的方法是要求一个进程在单个原子操作中请求它需要的所有资源。只有当系统能够一次性提供所有资源时,才会批准请求。如果不能,进程什么也得不到,只是等待,不持有任何资源。

想象一个特殊的打印作业,它需要同时使用两台打印机来生成一份双面文档。使用此策略,该作业会向操作系统请求:“请问我可以拥有两台打印机吗?”如果有两台空闲,操作系统会批准请求。如果没有,操作系统会说:“抱歉,现在不行”,然后该作业在不持有任何打印机的情况下等待。它永远不会处于持有一台打印机同时等待第二台的状态。持有并等待条件被打破,死锁不可能发生。

但是,正如任何物理学家或经济学家会告诉你的,天下没有免费的午餐。这种策略有一个显著的缺点:​​资源利用率不足​​。假设我们的作业需要打印机A和打印机B,但只有A可用。系统拒绝了请求,作业开始等待。与此同时,打印机A处于空闲状态,即使有一个进程需要它。增量策略或许会让作业抓住打印ter A,但“全有或全无”规则将死锁安全性置于效率之上。

先释放后请求协议

一种更灵活且通常更高效的策略是允许进程增量地获取资源,但有一个简单的规则:如果你需要等待一个新资源,你必须首先释放你已经持有的资源。

让我们回到我们的杂货店。“先释放后请求”的解决方案非常巧妙。当你准备结账时,你去到一个中转区,把你的商品卸到传送带上,然后把你的购物车还回购物车池。然后你才排队等待收银员。你在开始等待“收银员”资源之前,自愿释放了“购物车”资源。持有并等待被消除了。

这种模式是高性能软件的基石。还记得那个在等待磁盘时持有锁的程序吗?现代的解决方案是使用​​异步I/O​​。线程获取锁,快速从共享数据结构中复制所需信息,然后释放锁。锁被释放后,它向磁盘发出一个非阻塞请求:“为我获取这些数据,完成后通知我。”在磁盘忙碌时,锁对其他线程是可用的,系统的吞吐量大增。当磁盘发送完成信号时,线程可以重新获取锁来处理数据。这打破了持有并等待的链条,将瓶颈转变为流畅的并发工作流。正确设计的同步原语,如现代编程语言中的条件变量,遵循同样的原则,在线程等待前自动释放锁,并在其被唤醒后重新获取。

乐观的赌徒

还有第三种更动态的策略,非常适合无法预先知道所有所需资源的情况。其思想是保持乐观:尝试获取你需要的资源,但如果你未能获得所有资源,你必须立即释放你已设法抓住的任何资源。然后,你等待一个短暂的、随机的时间,再重新尝试整个过程。

这就是非阻塞​​尝试锁​​背后的哲学。线程永远不会在持有资源的情况下进入被动等待状态。如果它获取了资源 AAA 但在尝试获取资源 BBB 时失败,它不会阻塞。它会立即释放 AAA 并后退。这也果断地打破了持有并等待条件,使死锁不可能发生。

然而,这种乐观主义可能导致另一种近乎滑稽的病态。想象一下两个线程 T1T_1T1​ 和 T2T_2T2​,都需要资源 AAA 和 BBB。在一个不幸的时机,T1T_1T1​ 抓住了 AAA 而 T2T_2T2​ 抓住了 BBB。现在,T1T_1T1​ 尝试获取 BBB,失败,并释放 AAA。与此同时,T2T_2T2​ 尝试获取 AAA,失败,并释放 BBB。它们都后退,然后……再次尝试,结果完全相同。它们在疯狂地忙碌,获取和释放资源,但整个系统没有任何进展。

这种徒劳的、重复性的行为不是死锁——系统没有冻结——而是一种相关的状态,称为​​活锁​​。这就像两个人试图在狭窄的走廊里擦肩而过,却总是向同一个方向避让。虽然活锁通常是暂时的,但在高竞争的系统中,不断的重试会浪费大量CPU周期并严重降低吞吐量。

预防的交响曲

我们已经深入探讨了“持有并等待”,但必须记住它只是交响乐团中的一件乐器。要预防死锁,我们只需要让四骑士中的一个沉默,而选择哪个目标是一个深刻的设计决策。

  • 我们可以通过使所有资源可共享来打破​​互斥​​。在可能的情况下这是理想的(例如,对于只读数据),但对于必须被独占修改的资源来说是不可能的。

  • 我们可以通过允许系统强制收回资源来打破​​不可抢占​​。然而,这通常难以正确实现。此外,抢占只有在能应用于实际参与死锁循环的资源时才有效。从一个进程抢占CPU时间片对于解决关于不可抢占的磁盘驱动器的死锁毫无作用。

  • 我们可以打破​​循环等待​​,这是一种非常强大和常见的策略。其技术是为系统中的每个资源分配一个唯一的、有序的等级(例如,锁1,锁2,锁3…)。然后,我们强制执行一个简单的规则:一个进程只有在请求的资源等级高于其当前持有的任何资源时才能请求该资源。像 R1→R2→⋯→Rn→R1R_1 \to R_2 \to \dots \to R_n \to R_1R1​→R2​→⋯→Rn​→R1​ 这样的依赖循环变得不可能,因为它意味着 R1R_1R1​ 的等级小于其自身——一个逻辑矛盾。这种方法非常有效,以至于在某些“持有并等待”仍然存在的情况下也能防止死锁。例如,一项要求在获取表锁之前获取所有缓冲区的策略,仍然涉及在等待锁的同时持有缓冲区。然而,它没有死锁,因为这种严格的排序(先缓冲区,后锁)防止了循环等待。

设计并发系统是一门艺术。它是在安全性和正确性的铁律与性能和效率的流畅需求之间进行的美妙平衡。通过理解这些基本原则——四个条件以及战胜它们的策略——我们获得了一个丰富的工具包来编排这场错综复杂的舞蹈,构建出在和谐、无死锁的交响乐中工作的健壮而强大的系统。

应用与跨学科联系

我们已经看到,一个系统要陷入死锁的停顿状态,必须同时满足四个条件。其中,“持有并等待”条件或许是四个中最具人性化的一个。它描述了一种我们都非常熟悉的情景:顽固地抓住你拥有的东西,同时等待别人拥有的东西。这就像两个人在狭窄的走廊里,各自抱着一个大包裹,谁也不愿意放下自己的包裹让对方通过。这个简单、近乎幼稚的僵局是一个强大的模式。它的逻辑如此基础,以至于它会以各种伪装反复出现,在我们有史以来构建的一些最复杂的系统中引发灾难性的故障。让我们踏上一段旅程,看看这种危险的等待艺术将我们引向何方。

程序员的博弈:你代码中的死锁

最常见到死锁的地方是在现代软件的核心:并发程序中,多个执行线程竞相完成工作。想象一个媒体流应用,一个线程解码视频(TdT_dTd​),另一个处理网络数据包(TnT_nTn​)。为了保持秩序,解码器有一个用于其内部数据的锁(LdL_dLd​),网络缓冲区有一个用于其数据的锁(LbL_bLb​)。问题始于解码器线程在持有自己的锁 LdL_dLd​ 的情况下,需要访问网络缓冲区,因此等待锁 LbL_bLb​。而恰在此时,网络线程持有它的锁 LbL_bLb​,需要将信息传递给解码器,于是等待 LdL_dLd​。每个线程都持有一个资源并等待另一个。这是一个完美的、两步的循环等待,一个“致命拥抱”,应用程序就此冻结。两个线程都将永远等待下去。

这里的问题不仅仅是等待,而是*持有并等待*。一个简单而强大的防止这种情况的准则是建立一个获取锁的规范顺序。如果所有人都同意总是先锁定 LbL_bLb​ 再锁定 LdL_dLd​,循环等待就变得不可能。一个线程会得到两个锁,另一个只需等待轮到自己。

这种“持有并等待”的模式可能更加微妙。它不只发生在两个线程和两个锁之间。考虑一个正在启动的操作系统。一个初始化线程(III)抓取系统服务注册表上的一个锁(LgL_gLg​)来添加一些初始服务。然后它启动一个新的服务线程(SSS)并等待它的“就绪”信号。但这里有个陷阱:新的服务 SSS 要变得就绪,它首先需要注册自己,这个行为需要获取同一个注册表锁 LgL_gLg​!所以,初始化线程 III 持有锁并等待来自 SSS 的事件,而 SSS 在能够产生那个事件之前,被卡住等待 III 持有的锁。系统再次在启动期间冻结。解决方案和诊断一样简单:等待时不要持有锁。初始化线程应该在等待服务发出就绪信号之前释放锁。这种放手的准则是健壮并发编程的基石。

当我们调用我们没有编写的代码,一个“黑盒”库时,危险变得更大。想象一个Web服务中的工作线程获取一个全局注册表的锁以读取一些配置。然后它进行一个看似简单的阻塞调用来查找域名(DNS查询)。该线程在等待网络响应时持有锁。但是,隐藏在DNS库内部,响应由一个不同的库线程处理,该线程随后触发一个回调函数来更新注册表——这个函数需要我们第一个线程仍然持有的那个锁!第一个线程持有锁等待DNS结果,而结果无法传递,因为传递它的回调函数正在等待那个锁。程序死锁了。这是一个特别阴险的错误,因为依赖关系隐藏在API之后。解决方案是打破“持有并等待”条件:要么在进行阻塞调用前释放锁,要么更好的是,使用现代异步API,它们根本不会阻塞线程。

机器中的幽灵:当软硬件碰撞时

死锁的原理并不局限于软件世界。它是资源竞争的一条基本法则,同样适用于计算机的物理组件。“线程”不必是软件线程,“资源”也不必是锁。

考虑一个管理高速直接内存访问(DMA)传输的设备驱动程序。这里我们有两个代理:软件驱动程序线程(TTT)和硬件DMA引擎(DDD)。我们有两个资源:内存中的一个缓冲区(RBUFR_{BUF}RBUF​)和DMA硬件通道(RDMAR_{DMA}RDMA​)。如果驱动程序线程 TTT 预留了内存缓冲区(持有 RBUFR_{BUF}RBUF​),然后试图启动传输,等待DMA通道变为空闲,就可能发生死锁。与此同时,DMA引擎 DDD 可能已经预留了通道(持有 RDMAR_{DMA}RDMA​),现在正等待驱动程序为其提供一个要使用的内存缓冲区。软件持有缓冲区等待通道;硬件持有通道等待缓冲区。结果是系统范围的冻结,一场硅片与软件之间的无声对峙。

“资源”的概念甚至可以更抽象。在单核处理器上,每个进程和每个中断最终需要的那个资源是什么?是CPU本身!假设一个驱动程序线程(PTP_TPT​)获取一个自旋锁(LLL)来保护某些数据。当它处于这个临界区时,一个硬件中断发生。CPU立即停止执行该线程,跳转到一个中断服务程序(ISR),我们称之为 PIP_IPI​。现在,假设这个ISR也需要访问相同的数据并试图获取同一个自旋锁 LLL。系统现在死锁了。为什么?因为线程 PTP_TPT​ 持有锁 LLL 并等待CPU再次可用以完成其工作并释放锁。但是ISR,PIP_IPI​,现在持有CPU并且在它能够获取锁 LLL 之前不会放弃它。一个持有锁等待CPU;另一个持有CPU等待锁。一个完美的、致命的循环。标准的内核解决方案是直接针对这种情况:驱动程序线程必须在获取锁之前禁用中断,确保没有ISR可以运行并试图获取同一个锁,从而打破循环。

抽象层层,死锁层层

随着我们的计算系统变得越来越复杂,具有层层叠叠的抽象,我们发现这些基本问题并没有消失。它们只是以新的、有趣的方式重新出现。

在经典的类Unix操作系统中,文件系统是一个抽象的奇迹。但即使是像重命名文件这样看似简单的操作也可能隐藏着死锁。当跨目录重命名文件时,比如从 /dirA/file1 到 /dirB/file2,操作系统必须锁定源目录(DAD_ADA​)和目标目录(DBD_BDB​)。现在,如果一个进程试图从 DAD_ADA​ 重命名到 DBD_BDB​(锁定 DAD_ADA​,然后等待 DBD_BDB​),同时另一个进程从 DBD_BDB​ 重命名到 DAD_ADA​(锁定 DBD_BDB​,然后等待 DAD_ADA​),会发生什么?我们遇到了我们熟悉的致命拥抱。每个进程都持有一个目录锁,同时等待另一个。解决方案,在真实的文件系统中实现,是强制执行一个规范的锁顺序,例如,总是先锁定inode编号较小的目录。

现在让我们跳到现代云端。我们的计算机不再是物理机器,而是运行在虚拟机监控程序上的虚拟机(VM)。这个新的虚拟化层肯定解决这些老问题了吧?根本没有。客户VM的内核可能获取一个锁(LGL_GLG​)来准备网络传输的数据。然后它向宿主虚拟机监控程序发出一个“超级调用”来处理物理I/O。这个超级调用,现在在宿主级别运行,需要获取一个宿主端的锁(LHL_HLH​)。但是如果一个宿主工作线程已经持有 LHL_HLH​ 来处理一个之前的I/O完成呢?又如果,为了完成其任务,那个宿主线程需要向客户机注入一条消息,这个任务需要等待客户机锁 LGL_GLG​ 被释放呢?客户机持有 LGL_GLG​ 等待 LHL_HLH​;宿主机持有 LHL_HLH​ 等待 LGL_GLG​。这是相同的死锁模式,但这次它跨越了虚拟机和其宿主之间的边界——一个跨越特权级别的死锁。

跨越网络:分布式世界中的死锁

也许最令人震惊的认识是,“持有并等待”不仅可以在一台计算机内部引发死锁,还可以在地球两端的计算机之间引发死锁。这些不是锁的死锁,而是协议的死锁。

考虑为现代网络大部分提供动力的HTTP/2协议。它使用流控制机制来防止快速发送方压垮慢速接收方。接收方授予发送方一定数量的“窗口信用”,这是发送方必须“获取”才能发送数据的资源。当接收方的应用程序读取数据时,它会释放其缓冲区,并可以发送一个WINDOW_UPDATE来授予发送方更多信用。

现在,想象两台服务器 E1E_1E1​ 和 E2E_2E2​ 处于一个紧密的通信循环中。E1E_1E1​ 正在向 E2E_2E2​ 发送一个大文件,而 E2E_2E2​ 正在向 E1E_1E1​ 发送一个文件。假设两台服务器上的应用程序逻辑都有缺陷:“直到我发送完我自己的所有数据,我才处理我正在接收的数据。”分布式死锁的舞台已经搭好。E1E_1E1​ 发送数据,直到用完来自 E2E_2E2​ 的所有窗口信用。它现在等待来自 E2E_2E2​ 的WINDOW_UPDATE。对称地,E2E_2E2​ 发送数据,直到用完来自 E1E_1E1​ 的所有信用并等待。但是 E1E_1E1​ 不会发送WINDOW_UPDATE,因为它的应用程序没有在读取传入的数据——它在等待完成发送。而 E2E_2E2​ 出于同样的原因也不会发送WINDOW_UPDATE。每个服务器都将对方的进展作为人质,同时等待对方迈出第一步。它们通过不消耗数据来持有自己的接收缓冲区,同时等待发送许可。这是一个遍布全球网络的死锁,由同样基本的“持有并等待”逻辑引起。

放手的准则

从一个简单的编程错误到全球网络协议的冻结,故事都是一样的。“持有并等待”条件是系统故障的有力根源。然而,解决方案也共享一个共同的、优雅的主题:放手的准则。

为了避免这些死锁,一个人要么在持有独占资源时不要等待,要么一次性获取所有需要的资源,要么在获取资源的顺序上极其自律。现代工程中这一原则的一个 güzel 例子是日志子系统的重新设计。旧设计中,线程会锁定一个缓冲区然后等待缓慢的磁盘I/O,容易发生死 deadlock。现代解决方案使用一个复杂的“无锁”环形缓冲区。生产者线程使用原子硬件指令添加它们的日志消息,而从不获取锁,因此从不在等待I/O时持有资源。它们已经与慢操作解耦,从根本上打破了持有并等待条件。

这个简单的原则——持有一个东西同时等待另一个是危险的游戏——是计算机科学中一个统一的概念。它的回响可以在CPU、操作系统、数据库和全球网络的设计中找到。理解它不仅仅是一项学术练习;它是构建健壮、可靠且最重要的是,能够正常工作的系统的艺术中必不可少的一部分。