try ai
科普
编辑
分享
反馈
  • 占有并等待:死锁剖析

占有并等待:死锁剖析

SciencePedia玻尔百科
核心要点
  • 占有并等待是死锁的一个基本条件,即一个进程在等待获取另一个资源的同时,至少持有一个资源。
  • 它是死锁发生的四个 Coffman 条件之一,必须与互斥、不可抢占和循环等待同时存在。
  • 预防策略侧重于打破这一条件,例如要求进程一次性请求所有资源,或在新的请求失败时释放已持有的资源。
  • 占有并等待问题不仅限于操作系统,还出现在机器人学、分布式微服务,甚至像 HTTP/2 这样的网络协议中。

引言

在现代计算的复杂编排中,无数进程必须并行工作,共享内存、处理器和网络连接等资源。这种并发性带来了惊人的性能,但同时也引入了潜在的危险。其中最严重的一种是死锁,这是一种完全的系统瘫痪状态,进程被冻结,每个进程都在等待另一个进程,形成一个无法打破的依赖循环。虽然死锁看起来像一个复杂的系统故障,但其根源往往在于简单、直观的行为。其中一种被称为“占有并等待”的行为,就是一个特别隐蔽的罪魁祸首。这是一种简单的行为:在等待所需之物时,紧紧抓住已有之物——在特定条件下,这种习惯可能导致整个系统陷入停顿。

本文深入探讨“占有并等待”条件,剖析其作为死锁基石的角色。我们将探究其基本原理以及它如何导致系统瘫痪。随后,我们将审视其深远影响,并将其与从操作系统内核到分布式网络服务的各种应用联系起来。通过理解这一个条件,您将对并发编程的挑战以及为防止这种优美而寂静的僵局而开发的精妙策略有更深刻的认识。

原理与机制

要理解并发进程的微妙之舞,我们必须首先认识到它们可能如何失足。其中一个最基本的失误,一个看似无害的习惯,被称为​​占有并等待​​。这种行为是如此直观,以至于我们每天都在实践它,但在计算世界中,它却是导致一种被称为死锁的、完全无效率瘫痪状态的四个关键要素之一。

贪婪之手与僵局

想象一下你在一家杂货店。为了购物,你需要按顺序获得两样东西:一辆购物车,然后是收银员的服务。你尽职地拿起一辆购物车——一个资源——并装满商品。现在你走向收银台。你排队等候,手持购物车,等待收银员有空。这正是占有并等待的精髓:你占有一个资源(购物车),同时等待获取另一个资源(收银员)。

这看起来完全合乎逻辑。你为什么会把所有商品都卸到地上,归还购物车,然后再去等待呢?那将是低效且混乱的。这种“贪婪之手”的冲动——在等待所需之物时紧握已有之物——是人之常情。

用操作系统的语言来说,这个场景是著名的​​Coffman 条件​​之一,这是一组必须全部存在才会发生死锁的情况。让我们从杂货店的角度来看这些条件:

  1. ​​互斥​​:某些资源是不可共享的。一次只有一个顾客能使用一辆特定的购物车,一个收银员一次也只能为一个顾客服务。这是避免混乱的基础。

  2. ​​占有并等待​​:一个进程(你,即顾客)至少持有一个资源(购物车)并等待另一个资源(收银员)。我们已经确定了这一点。

  3. ​​不可抢占​​:商店经理不能在你排队等候时强行拿走你的购物车。你必须自愿释放它。这确保了你的工作不会被任意中断。

  4. ​​循环等待​​:一个封闭的依赖循环。例如,你正在等待一个收银员,而他/她又在等待你正持有的购物车。

在我们简单的杂货店里,完全的死锁并不会发生。为什么?因为第四个条件,即循环等待,并不存在。这里有一个严格且普遍遵守的获取顺序:你总是在需要收银员之前先拿到购物车。你绝不会发现自己占有一个收银员却在等待一辆购物车。这种对资源的严格排序是一种强大的技术,可以防止致命循环的形成,我们稍后会再回到这一点。但占有并等待条件仍然存在,像一个潜伏的威胁,等待合适的时机被唤醒。

死锁之舞

那么,当占有并等待遇到其毁灭性的伙伴——循环等待时,会发生什么呢?想象一个设计得非常糟糕的系统:一个简单的十字路口,模型化为一个 2×22 \times 22×2 的网格。四辆车同时到达,每辆车占据一个单元格,并且每辆车都想顺时针方向前进到前面那辆车所占据的单元格中。

让我们再次检查这些条件:

  • ​​互斥​​:网格的每个单元格只能容纳一辆车。满足。
  • ​​不可抢占​​:拖车不能随便把一辆车拖走。满足。
  • ​​占有并等待​​:这是致命的缺陷。车1占有着它当前的单元格,同时等待着车2所占据的单元格。车2占有它的单元格,同时等待车3的。以此类推。每个参与者都在实践占有并等待。满足。
  • ​​循环等待​​:这是致命一击。车1等待车2,车2等待车3,车3等待车4,车4等待……车1。循环完成了。满足。

所有四个条件都满足了。结果呢?一个完美、优雅且彻底的僵局。没有车能移动。系统被冻结,不是因为故障或崩溃,而是因为其操作的逻辑规则将它绑成了一个无法解开的结。这就是​​死锁​​。这是一种悲剧性的瘫痪状态,由一群进程引起,每个进程都礼貌地等待着其他进程,但其配置却确保了没有一个进程能够继续前进。

打破占有:预防策略

如果占有并等待是如此危险的同谋,一个自然的问题就出现了:我们能简单地禁止它吗?答案是肯定的,并且有两种主要策略可以做到这一点,每种策略都有其有趣的权衡。指导原则很简单:​​一个进程在等待另一个资源时,不得持有任何资源。​​

策略1:要么全有,要么全无

强制执行此原则的一种方法是,要求进程在一开始就一次性请求其任务所需的所有资源。操作系统充当一个严格的守门人。它检查请求,并核实每一个资源是否都可用。如果都可用,它就批准所有资源。如果哪怕只有一个不可用,它就会拒绝整个请求,并告诉进程:“你什么也得不到。去角落里等着,直到你需要的一切都空闲出来”。

这个策略优雅地打破了占有并等待条件。一个进程要么在拥有所需全部资源的情况下运行,要么在等待,不持有任何资源。由于等待中的进程不持有资源,它们就不可能成为依赖链的一部分。死锁是不可能的。

但这个优美而简单的解决方案是有代价的:低效。想象一个进程需要十个单位的内存和一个打印机。如果所有十个内存单位都空闲,但打印机正忙,那么“要么全有,要么全无”的策略会使进程等待。那十个单位的内存就闲置了,任何其他进程都无法使用,即使等待的进程暂时也用不上它们。这导致了​​资源利用率不足​​,并可能显著降低系统的整体吞吐量。这种方法安全,但可能非常慢。

策略2:礼貌性撤退

一个更动态的策略是允许进程逐个获取资源,但有一个关键规则:你决不能阻塞。这就是“尝试,如果不成功,就放手”的方法。

在代码中,这通常通过一个非阻塞的 try_lock() 函数来实现。一个线程成功获取了它的第一个资源,锁 AAA。然后它尝试获取第二个资源,锁 BBB。但另一个线程已经持有了 BBB。try_lock(B) 调用不会阻塞等待 BBB,而是立即失败。在这次失败后,线程的编程逻辑指示它进行礼貌性撤退:它立即释放锁 AAA 并退后,也许会等待一个短暂的随机时间,然后再重新尝试整个序列。

这也打破了占有并等待条件。一个线程永远不会在持有一个资源的同时阻塞等待另一个资源。一旦需要等待,线程就会放弃它所拥有的,从而打破任何潜在的依赖链。死锁再次被避免了。

这里的权衡不是闲置资源,而是白费功夫。想象这样一个场景:两个线程 T1T_1T1​ 和 T2T_2T2​ 都需要锁 AAA 和 BBB。T1T_1T1​ 拿到了 AAA。与此同时,T2T_2T2​ 拿到了 BBB。T1T_1T1​ 尝试获取 BBB 但失败了。T2T_2T2​ 尝试获取 AAA 也失败了。两者都撤退,释放它们的锁,然后退后。然后,它们可能会再次做完全相同的事情。一次又一次。它们都在活动,消耗 CPU 周期,但毫无进展。这种状态被称为​​活锁​​(livelock)。与死锁的冻结瘫痪不同,活锁是一种狂热而无用的舞蹈。虽然随机化的退避时间使得这种病态的对称性不太可能发生,但重复重试的开销仍然可能导致 CPU 使用率增加和吞吐量降低,相比于一个简单(但易于死锁)的阻塞策略。

现代编程中的微妙陷阱

有人可能认为这些问题只涉及低级锁管理。但占有并等待的幽灵甚至萦绕在最复杂的编程结构中,为粗心的人设下微妙的陷阱。

考虑​​管程​​(monitor),这是一种常见的同步工具,它将共享数据与其保护锁捆绑在一起。它就像一个一次只允许一个线程进入的房间。房间里有一个等待区,由​​条件变量(CV)​​管理。如果一个线程进入房间后发现它需要的条件不满足(例如,“数据尚未准备好”),它可以调用 wait(CV)。一个设计良好的 wait 操作是工程上的一个奇迹:它原子性地将线程置于休眠状态并解锁管程的门,允许另一个线程进入,准备数据,并发出信号唤醒等待的线程。这种设计是针对占有并等待的内置解决方案:它强制你在等待时释放管程锁。

但陷阱就在这里。如果一个线程在进入管程房间之前,获取了另一个资源,比如一个硬件设备 DDD 的锁,会怎么样?在房间内,它调用 wait(CV)。管程锁被正确释放了,但该线程在休眠时继续持有设备锁 DDD。

现在,假设那个需要生产数据并给你发信号的线程,必须首先获取同一个设备锁 DDD。我们就遇到了死锁。等待的线程持有 DDD 并等待一个信号。生产者线程被阻塞,等待 DDD,无法产生第一个线程所需要的信号。占有并等待已经悄悄地潜了回来,隐藏在层层抽象之下。这是一个经典的并发错误,被称为​​嵌套管程问题​​。

这个教训是深刻的。避免占有并等待的原则不仅仅是设计低级分配器的规则;它是在软件设计的所有层面上都必须遵守的纪律。它要求对一个线程在进入等待状态时可能持有的每一个资源都有深刻的认识。从一个简单的购物车到一个复杂的网络服务,规则都是一样的:在一个协作的、并发的世界里,你不能贪婪。尽管在等待时占有已有之物的冲动是自然的,但在错误的情况下,这条路会通向一个完美、寂静而优美的僵局。

应用与跨学科联系

在解开了死锁配方的四个秘密成分之后,我们可能会倾向于认为它是一道罕见而奇特的菜肴,只在操作系统内核的深奥世界里才会烹制。但事实恰恰相反。这种冻结的动画状态,这种无声的对峙,是任何由必须共享有限资源的交互主体组成的系统中最基本、最反复出现的模式之一。我们讨论的原则不仅仅是计算机科学的琐事;它们像运动定律一样普遍。通过观察一些例子,从我们计算机的核心到广阔的互联网,我们可以开始领会这个概念的美妙统一性。

机器人与显微镜的故事

让我们从一个简单、直观的故事开始。想象一群小机器人,一整队,沿着一个由踏脚石构成的圆形轨道移动。每块石头一次只能容纳一个机器人。游行的规则很简单:要前进,每个机器人必须预订路径上的下一块石头。现在,假设每个机器人都在同一时刻试图前进。在石头 v1v_1v1​ 上的机器人 R1R_1R1​ 试图预订石头 v2v_2v2​,而 v2v_2v2​ 正被机器人 R2R_2R2​ 占据。机器人 R2R_2R2​ 试图预订 v3v_3v3​,它被 R3R_3R3​ 占据,以此类推,环绕一圈,直到最后一个机器人 RmR_mRm​ 试图预订石头 v1v_1v1​——当然,它正被我们的第一个机器人 R1R_1R1​ 占据。

每个机器人都在占有一个资源(它当前的石头)并等待另一个。等待是循环的。谁也动不了。游行队伍被冻结了——一个完美、无声的死锁。

这不仅仅是机器人的困境。想象一下一所大学实验室里的两个雄心勃勃的学生,他们都需要一台高倍显微镜(MMM)和一台特殊的科学相机(CCC)来完成他们的实验。预订系统允许他们分开预订这些设备。一个决定性的下午,学生 S1S_1S1​ 拿到了显微镜,等待相机,而学生 S2S_2S2​ 拿到了相机,等待显微镜。他们陷入了僵局,每人手持拼图的一部分,等待对方让步。这是“占有并等待”条件最具体的形式。最直接的解决方案是什么?一个“要么全有,要么全无”的策略。你必须一次性申请两台设备,只有当两者都空闲时,系统才会把它们都给你。这个简单的规则改变使得在等待一个资源的同时持有另一个资源变得不可能,通过打破占有并等待条件,巧妙地消除了死锁的可能性。

内核的内部纠葛

同样的对峙也困扰着我们计算机内部的无形世界。在操作系统的核心,无数执行线程竞相管理内存、文件和网络连接,死锁的潜力是巨大的。

一个经典的例子发生在像跨两个不同目录重命名文件这样简单的事情上。为了安全地做到这一点,系统必须锁定源目录和目标目录。如果一个线程 T1T_1T1​ 试图将文件从目录 AAA 移动到 BBB,于是它锁定了 AAA 然后等待 BBB 呢?与此同时,线程 T2T_2T2​ 试图将文件从 BBB 移动到 AAA,锁定了 BBB 并等待 AAA 呢? 我们就有了对峙。这里的解决方案纯粹而优雅:强制实施一个通用顺序。例如,总是先锁定 inode 号较小的目录。通过强制执行这个任意规则,我们打破了对称性。两个线程现在都会争夺同一个第一把锁,其中一个会获胜,从而阻止了循环等待的形成。

但是我们不能总是创建这样一个简单的全局排序,特别是当一个大系统的不同部分以意想不到的方式交互时。想象一个线程,在处理内存访问错误(页错误)时,需要从磁盘获取数据。为此,它必须获取磁盘通道资源 CdiskC_{\mathrm{disk}}Cdisk​。但首先,为了管理内存表,它持有了全局虚拟内存锁 LVML_{\mathrm{VM}}LVM​。现在,如果另一个线程,也许是一个设备驱动程序,已经持有了磁盘通道 CdiskC_{\mathrm{disk}}Cdisk​,并且在其职责的一部分中,需要访问一个需要同一个 LVML_{\mathrm{VM}}LVM​ 的内存服务呢? 第一个线程持有内存锁并等待磁盘;第二个线程持有磁盘并等待内存锁。僵局。

当我们意识到这场戏剧中的“主体”甚至不必是软件线程时,情节就变得更加复杂了。一块硅片,一个直接内存访问(DMA)引擎,也可以是一个同样固执的演员。一个驱动程序线程可能持有一个内存缓冲区,等待 DMA 硬件可用。但 DMA 硬件,在保留了自己的通道后,可能反过来在等待驱动程序线程的缓冲区对其操作变得可访问。这揭示了死锁原则的美妙普适性:它无缝地跨越了软硬件的鸿沟。

有时被持有的资源不是一个设备或一个锁,而是更抽象的东西,比如一种权限。考虑一个复杂的“读写”锁,它允许多个“读者”线程同时访问数据,但要求“写者”线程具有独占访问权。一些系统允许读者将其共享锁“升级”为独占锁。如果一个读者进程 PRP_RPR​ 持有共享锁并请求升级,而一个写者进程 PWP_WPW​ 已经在等待独占锁呢?如果系统优先考虑等待的写者,它可能会阻塞 PRP_RPR​ 的升级请求。现在,PRP_RPR​ 被卡住,等待 PWP_WPW​ 完成,但 PWP_WPW​ 甚至无法开始,因为 PRP_RPR​ 仍然持有它的共享锁! 解决方案通常涉及一个小小的谦卑行为:为了打破占有并等待的循环,升级的读者必须完全释放其共享锁,并从头开始重新请求一个独占锁,与其他人一起排队。

超越内核:跨越边界

这些纠缠的依赖关系并不仅限于操作系统的单体内核。它们在我们构建抽象边界然后尝试跨越这些边界进行通信时随时都会出现。

一个绝佳的例子是“用户空间文件系统”(FUSE),其中文件系统的逻辑作为一个普通程序运行,而不是在内核中。一个用户空间线程可能会获取一个用户空间锁 UUU,然后进行一个系统调用,该调用要求内核获取一个内部内核锁 KKK。与此同时,内核可能正在处理一个请求,该请求迫使它向上调用到用户空间守护进程,这个过程需要获取同一个锁 UUU。如果一个持有 KKK 的内核线程进行向上调用并等待 UUU,而一个持有 UUU 的用户线程进行系统调用并等待 KKK,我们又有了死锁。解决方案揭示了一个深刻的架构原则:在持有自己的内部锁时,永远不要调用“外部”或更低级的代码。在进行向上调用之前释放内核锁,可以打破占有并等待条件,保持系统流畅。

同样的模式在现代云的架构中再次出现,在客户虚拟机和其主机 hypervisor 的边界处。一个客户操作系统可能在进行“hypercall”到主机时持有锁 LGL_GLG​,而主机又需要一个主机锁 LHL_HLH​。与此同时,一个主机进程可能持有 LHL_HLH​,同时需要与客户机以一种需要 LGL_GLG​ 的方式进行交互。这是同样的死锁模式,只是演员不同。在这里,一个常见的解决方案是使用分阶段操作来打破占有并等待。客户机不是持有锁并进行阻塞调用,而是释放其锁并发起一个异步请求。主机完成工作后,只需通知客户机即可。没有占有,没有等待,没有死锁。

全球等待之网

到目前为止,我们死锁的主体都生活在单台计算机上。但这些原则是普适的。当主体分散在全球网络中,仅通过发送消息进行通信时,它们同样适用。

想象一下三个微服务,它们是许多现代 Web 应用的构建块。服务 SAS_ASA​ 通过获取与其数据库 DAD_ADA​ 的连接来处理请求,然后调用服务 SBS_BSB​ 以获取更多信息。SBS_BSB​ 在处理该调用时,获取其自己的数据库连接 DBD_BDB​ 并调用 SCS_CSC​。当 SCS_CSC​ 持有其数据库连接 DCD_CDC​ 并回调 SAS_ASA​ 时,循环就完成了。由于每个服务都在忙于等待响应,它们都无法应答传入的调用。这是一个分布式死锁。一个常见但粗糙的解决方案是超时,它作为一种抢占形式:一段时间后,等待的服务放弃,释放其数据库连接,并返回一个错误。但一个更优雅的解决方案直接攻击占有并等待条件:设计服务,使其在进行缓慢的同步网络调用之前释放其宝贵的数据库连接。

也许最令人惊讶的发现死锁的地方,是它被编织在网络协议的结构中。在为现代网络大部分提供动力的 HTTP/2 中,数据以多路复用流的形式发送,每个流都有流量控制,以防止发送方压垮接收方。发送方消耗“窗口信用”来发送数据,而接收方只有在处理完数据后才会发放更多信用。现在,考虑一个设计不佳的应用程序,其中两个端点 E1E_1E1​ 和 E2E_2E2​ 正在相互通信。E1E_1E1​ 处的应用程序逻辑规定,在完成发送自己的数据之前,它不会读取来自 E2E_2E2​ 的传入数据。E2E_2E2​ 也是如此。可能会出现这样一种情况:双方都发送了刚好足够耗尽对等方窗口信用的数据。E1E_1E1​ 被卡住,无法发送更多数据,直到 E2E_2E2​ 读取数据并发送信用更新。但 E2E_2E2​ 不会读取,因为它正在等待完成发送自己的数据,而这需要来自 E1E_1E1​ 的信用更新。每个都在等待一个资源——发送的许可——而这个资源被对方持有。这是一个完美的、协议级别的死锁,源于流量控制机制和应用程序逻辑的相互作用。打破它需要一个像 RST_STREAM 帧这样的激烈措施,这是一种协议级别的抢占,它中止其中一个流,让另一个继续进行。

从轨道上的机器人到全球的服务,死锁的条件是一个普遍的常数。特别是,占有并等待条件是一个常见的罪魁祸首。然而,正如我们所见,击败它的策略往往会带来更健壮、更优雅、更周到的设计。“要么全有,要么全无”的资源获取原则,在进入陌生领域之前释放锁的原则,或者异步构建工作的原则,不仅仅是避免死锁的技巧。它们是一个架构良好的系统的标志。这是通过率先退后一步来避免对峙的艺术。