try ai
科普
编辑
分享
反馈
  • 死锁预防:原理、策略与应用

死锁预防:原理、策略与应用

SciencePedia玻尔百科
核心要点
  • 只有当四个条件同时满足时,死锁才会发生:互斥、持有并等待、不可抢占和循环等待。
  • 最常见的预防策略是通过强制执行严格的全局资源获取顺序来打破循环等待条件。
  • 读-复制-更新(RCU)是一种先进技术,它允许读取者在不使用锁的情况下访问数据,从而预防死锁。
  • 死锁预防原则在各种应用中都至关重要,包括操作系统内核、数据库、机器人系统和金融交易。

引言

在并发计算的世界里,很少有问题像死锁一样微妙且能造成瘫痪。它不是传统意义上的崩溃或错误,而是一种完全无生产力的瘫痪状态,其中多个进程被冻结,每个进程都在等待另一个进程持有的资源。这种无声的对峙可能使整个系统陷入停顿,因此预防死锁成为可靠软件设计的基石。核心挑战不仅在于发生死锁时如何修复,更在于如何构建从一开始就在结构上不可能发生死锁的系统。

本文对死锁预防——一种确保系统稳定性的主动方法——进行了全面探讨。首先,在“原理与机制”一章中,我们将剖析死锁的构成,审视由 E.G. Coffman, Jr. 等计算机科学家提出的四个著名条件。然后,我们将探索用于打破这些条件的精妙策略,重点关注资源排序这一强大技术,以及非阻塞尝试和读-复制-更新(RCU)的无锁哲学等替代方案。在这一理论基础之上,“应用与跨学科联系”一章将展示这些原理在实践中的应用。我们将从操作系统和数据库的核心,走向分布式系统、机器人学乃至高风险金融网络的复杂交互,揭示一个单一而强大的理念如何为混乱的世界带来秩序。

原理与机制

要理解我们如何防止系统陷入停顿,我们必须首先领会其困境的精妙脆弱性。死锁不是一次简单的崩溃,而是一种完全无生产力的瘫痪状态。想象两位建筑大师,各自正在建造一座宏伟的建筑。第一位大师手握最后一块红砖,但需要最后一块蓝砖才能继续。而命运使然,第二位大师恰好手握最后一块蓝砖,却需要那块红砖。他们都礼貌而固执地等待着一块永远不会被递出的砖。工作停止了,不是伴随着一声巨响,而是在无声、永恒的对峙中。这就是死锁。

这个看似简单的问题背后,有着一个精确而优美的结构。以 E.G. Coffman, Jr. 为首的计算机科学家们发现,死锁的发生必须同时满足四个特定条件。可以把它们想象成系统末日的四骑士:只要其中一个缺席,灾难就能避免。因此,我们整个死锁预防策略,就是通过设计系统来确保这四骑士中至少有一个永远无法登场。

僵局的剖析:四个条件

让我们来认识这四个条件。为了让它们更具体,可以把计算机“资源”想象成任何一次只能被一个进程使用的东西,比如一个特定的文件、一台打印机,或是一份数据上的锁。

  1. ​​互斥(Mutual Exclusion)​​:一个资源一次只能被一个进程持有。我们的建筑大师不能共享他的红砖;它一次只能在一个地方。这是我们关心的大多数资源的基本属性。

  2. ​​持有并等待(Hold and Wait)​​:一个进程在等待新资源的同时,会继续持有它已经拥有的资源。我们的建筑大师紧握着红砖等待蓝砖就是完美的例子。他不会为了等待而放下已有的东西。

  3. ​​不可抢占(No Preemption)​​:资源不能被从一个进程中强行夺走。第二位建筑大师不能直接抢走红砖;它必须被自愿释放。这条规则确保了行为的有序性,但也可能导致顽固的对峙。

  4. ​​循环等待(Circular Wait)​​:必须存在一个恶性的等待循环。建筑大师1等待建筑大师2,而建筑大师2又在等待建筑大师1。这个链条可能更长——建筑大师1等2,2等3,3又等1——但封闭的循环是其决定性特征。

奇妙之处在于,只要你能打破其中任何一个条件,整个死锁结构就会崩溃。死锁预防就是一门艺术,它通过主动设计系统,用规则在死锁形成之前就打破其中一个链条。

打破循环:排序的精妙

预防死锁最精妙、最常用的方法是攻击第四个骑士:循环等待。其逻辑出奇地简单。如果每个人都必须朝同一个方向前进,又怎么可能形成一个圆圈呢?

想象一下,我们为系统中的每一个资源——每一把锁、每一个文件、每一个设备——都分配一个唯一的编号或等级。然后,我们建立一条全局性的、铁板钉钉的规则:​​任何进程必须严格按照其等级的递增顺序来请求资源。​​ 你可以请求3号资源,然后是5号,再然后是8号。但是,如果你已经持有了8号资源,就禁止你再去请求5号资源。

为什么这能行得通?让我们在​​资源分配图​​(Resource Allocation Graph)中追踪一个潜在的循环。这是一个我们将等待资源的进程指向它想要的资源的示意图。死锁就是这个图上的一个环路。假设进程A持有资源 RiR_iRi​ 并请求资源 RjR_jRj​。我们的规则规定,必须满足 i<ji \lt ji<j。现在,假设进程B持有 RjR_jRj​ 并请求 RkR_kRk​。我们的规则要求 j<kj \lt kj<k。如果我们继续这个链条,PA→Rj→PB→Rk→…P_A \to R_j \to P_B \to R_k \to \dotsPA​→Rj​→PB​→Rk​→…,资源等级必须形成一个严格递增的序列:i<j<k<…i \lt j \lt k \lt \dotsi<j<k<…。要形成一个环路,这个链条最终必须回到进程A,这意味着进程A必须在等待链条中最后一个进程所持有的某个资源。但要让链条循环回来并使进程A请求最初的资源 RiR_iRi​,就意味着序列中某个资源的等级会大于后面资源的等级,这与我们等级永远递增的序列相矛盾。因此,从数学上讲,环路是不可能形成的。

这条简单的规则异常强大。例如,当工程师为了更好的性能,从使用单一“大锁”保护整个系统转向使用多个细粒度的锁——这个过程称为​​锁拆分​​(lock splitting)——他们获得了并发性,但也引入了死锁的风险。如果一个线程需要访问两个子系统A和B,它们现在分别由锁 LaL_aLa​ 和 LbL_bLb​ 保护,那么当一个线程锁住 LaL_aLa​ 后又想获取 LbL_bLb​,而另一个线程锁住 LbL_bLb​ 后又想获取 LaL_aLa​ 时,就可能发生死锁。解决方案?强制规定一个顺序:始终先锁 LaL_aLa​ 再锁 LbL_bLb​。这个简单的纪律就恢复了安全性。

当然,这种精妙的方法在实践中也有限制。有时,程序的内在逻辑会与任何可能的全局排序方案发生冲突。想象两笔金融交易:一笔为了正确性必须先处理资源 L1L_1L1​ 再处理 L2L_2L2​,而另一笔则必须先处理 L2L_2L2​ 再处理 L1L_1L1​。没有任何单一的全局排序能同时满足两者。在这种情况下,死锁预防不是一个简单的补丁,它需要重新设计应用程序本身,以符合一个统一的顺序。排序的纪律必须是普适的,才能有效。

其中的权衡也十分明显。我们可以通过为所有操作使用一个​​全局锁​​(global lock)来轻易地强制一个顺序。任何线程在接触任何东西之前都必须获取这个主锁。这确实能起作用——它隐式地创造了一个全局锁为1号资源的顺序——但这会迫使所有操作排成单行,从而摧毁并发性和性能。这个代价通常太高了。其艺术在于找到一种既能预防死锁,又能最大程度保留并行性的排序方法。

替代策略:攻击其他条件

消除持有并等待

“持有并等待”条件可以通过两种主要方式打破。第一种是暴力方法:一个进程必须在开始时一次性请求它将需要的所有资源。如果不能全部获取,它就一个也不获取,然后等待。这种方法有效但不切实际;通常不可能预知未来的所有需求,而且这会导致资源被占用的时间远超必要,从而损害效率。

一种更复杂的方法是不要那么“固执”。线程可以不采用在持有一个资源的同时阻塞等待第二个资源的方式,而是使用非阻塞的 trylock。它尝试获取第二个锁。如果失败,它不会等待,而是立即​​释放已持有的锁​​,稍后再重试整个过程。这打破了“持有并等待”中的“等待”部分。线程永远不会在持有资源的同时被阻塞。这可以防止死锁,但也引入了一个新的潜在问题:​​活锁​​(livelock)。两个线程可能反复尝试获取锁、失败、退让,然后以一种完全同步但毫无进展的方式重试,就像两个人在走廊里试图擦肩而过,却总是同时向同一个方向避让。它们都在活动,但没有任何进展。

挑战不可抢占

“不可抢占”条件似乎是神圣不可侵犯的。你不能直接从一个正在运行的进程中拿走资源!但如果资源是更抽象的呢?在云服务中,“CPU配额”和“网络配额”都是资源。如果所有CPU配额都被等待网络的实例持有,而所有网络配额又被等待CPU的实例持有,系统就可能发生死锁。一个聪明的解决方案是授权系统从一个等待中的实例那里​​撤销配额​​。通过抢占一个实例的CPU配额,可以将其分配给一个只在等待CPU的实例,该实例随后可以完成工作并释放其所有配额,从而打破死锁僵局。对于可以保存和恢复状态的逻辑或虚拟资源,这一策略非常强大。

为自由而设计:读-复制-更新(RCU)的禅意

科学领域最深刻的解决方案,往往不是那些仅仅解决了问题,而是那些使问题本身消解的方案。读-复制-更新(RCU)就是针对一种特定但非常常见的数据访问模式——多读少写——的这样一种解决方案。

RCU的核心思想是激进的:​​读取者不使用锁​​。当一个读取者想要访问一个数据结构时,它只需进入一个“读端临界区”,这本质上是告诉系统:“我正在看!”然后它就可以遍历数据结构而无需任何等待。如果一个写入者出现,它不会阻塞读取者。相反,它会对自己想要更改的部分制作一个副本,修改这个副本,然后原子性地移动一个指针来发布这个新版本。旧版本不会立即被销毁;写入者会等待一个“宽限期”(grace period),以确保所有正在查看旧版本的读取者都已完成操作,然后才回收其内存。

这如何预防死锁呢?其简单性堪称优美。在我们的等待图中,一个RCU读取者从不等待写入者或其他读取者。它只管读取。这意味着图中的读取者顶点没有任何出向箭头。而一个没有出向箭头的顶点永远不可能成为环路的一部分。通过这种设计,读取者被完全从死锁的等式中移除了。

这并不意味着死锁不可能发生。写入者之间仍然使用锁来协调,如果不遵守排序纪律,它们之间仍然可能发生死锁。事实上,如果一个写入者粗心大意,在仍持有锁的情况下等待宽限期结束,一种新型的死锁就可能出现。但RCU优雅地将问题进行了划分,使其管理起来变得极为简单。

多角度看预防

死锁预防是一种强大的主动策略。通过施加像资源排序这样的规则,我们可以构建可被证明无死锁的系统。这为我们提供了强大的系统安全保障。然而,这种安全性通常是有代价的,要么是串行化导致的性能下降,要么是设计复杂性的增加。

预防并非唯一途径。另一种选择是​​死锁避免​​(deadlock avoidance),即系统在运行时检查每一个资源请求,判断批准该请求是否可能导致未来发生死锁。著名的银行家算法(Banker's Algorithm)就是这样做的,但其运行时开销可能很大。第三条路径是​​死锁检测与恢复​​(deadlock detection and recovery),这是最乐观的方法:让死锁发生,周期性地检查它们,然后通过强制手段(例如,终止一个进程)来打破循环。当死锁很少发生时,这种方法可以提供最佳性能,但恢复过程可能很混乱并具有破坏性。

在这些策略之间做出选择是一项深刻的工程决策,是在前期设计纪律和运行时复杂性之间的权衡。死锁预防以其精妙且通常简单的规则,为我们描绘了构建这样一种系统的前景:它不仅健壮,而且生来就摆脱了计算领域最令人烦恼的悖论之一。

应用与跨学科联系

既然我们已经探索了死锁预防那近乎数学确定性的精妙之处,现在让我们看看这个理念在现实世界中是如何存在和应用的。你可能会感到惊讶。打破循环的原则不仅仅是计算机科学家的技巧,它还是构建可靠系统的基本设计模式,从你的计算机核心到全球金融网络,无处不在。这是一个绝佳的例子,展示了一个单一、简单的理念如何为极其复杂的环境带来秩序。

机器的心脏:操作系统

我们的旅程始于你的计算机深处,在操作系统(OS)——所有软件和硬件的总指挥——内部。在这里,死锁不仅仅是小麻烦,它们可能导致整个系统冻结,即屏幕卡住、鼠标光标一动不动的可怕状态。预防它们是生死攸关的问题。

思考一下操作系统必须执行的最精巧的平衡操作之一:处理中断。中断是一个紧急信号,可能来自你的键盘或网卡,要求立即关注,并暂停处理器正在做的事情。响应中断而运行的代码,即中断服务程序(ISR),在比普通程序更高的权限级别上运行。如果一个ISR和一个常规进程都需要访问相同的两个共享数据结构,比如说,通过获取锁 LIL_ILI​(中断级锁)和 LPL_PLP​(进程级锁),会发生什么?致命的拥抱是可能发生的:一个进程可能获取 LPL_PLP​ 后被一个获取了 LIL_ILI​ 的ISR中断。如果这个ISR随后需要 LPL_PLP​,而进程在恢复后又需要 LIL_ILI​,系统就会停止。进程无法释放它的锁,因为它在等待ISR;而ISR也无法完成工作以释放它的锁,因为它在等待进程。

解决方案是一条极其简单的规则:建立一个层次结构。内核设计者强制执行一项严格的策略,声明中断级资源的“等级”低于进程级资源。规则是绝对的:你必须始终按照等级递增的顺序获取锁。因此,任何代码路径,无论是在ISR中还是在进程中,都必须在获取 LPL_PLP​ 之前 获取 LIL_ILI​。这个简单的纪律使得危险的循环在结构上不可能发生,确保了内核的稳定性。

这种排序思想贯穿整个操作系统。想一想查找一个文件,比如/home/user/document.txt。这个看似简单的操作涉及在虚拟文件系统(VFS)中对不同数据结构的一系列查找,每个结构都由自己的锁保护:目录项(dentry)的锁、文件元数据(inode)的锁,以及文件系统本身(superblock)的锁。一个复杂的操作,比如跨目录重命名文件,可能需要获取其中几个锁。如果两个这样的操作同时发生,它们很容易发生死锁。

为了防止这种情况,设计者强制实施了类级排序:例如,始终在获取inode锁之前获取dentry锁,在获取superblock锁之前获取inode锁。但正如我们的分析所揭示的,这还不够!如果一个操作需要锁定两个dentry对象 d1d_1d1​ 和 d2d_2d2​ 呢?进程A可能锁定 d1d_1d1​ 并等待 d2d_2d2​,而进程B锁定 d2d_2d2​ 并等待 d1d_1d1​。死锁。类级排序是一个偏序,但安全需要一个全序。为了解决这个问题,增加了一条更细粒度的规则:在任何锁类内部,锁必须按照一个一致的键(例如它们的内存地址)来获取。这种两级排序——首先按类,然后在类内部按地址——最终施加了防止循环所需的完全纪律。

这个原则甚至可以扩展到现代的虚拟化架构,其中整个“客户机”操作系统在“主机”操作系统内部运行。想象一下主机需要从客户机回收内存,这个过程称为“内存气球”(ballooning)。这可能涉及主机锁定其内存管理器(LhostmemL_{\text{hostmem}}Lhostmem​)同时告知客户机收缩内存,这又需要客户机锁定其自己的内存管理器(LvmemL_{\text{vmem}}Lvmem​)。与此同时,客户机可能正在向主机请求更多内存,这个过程可能涉及先锁定 LvmemL_{\text{vmem}}Lvmem​,然后触发一个需要 LhostmemL_{\text{hostmem}}Lhostmem​ 的操作。我们又一次创造了一个潜在的循环,这次跨越了两个完整操作系统之间的边界!解决方案是主机和客户机之间的一个“条约”:一个全球商定的顺序,例如“始终在获取 LvmemL_{\text{vmem}}Lvmem​ 之前获取 LhostmemL_{\text{hostmem}}Lhostmem​”。任何违反此条约的代码路径都必须重写以遵守它,例如在继续之前释放“乱序”的锁。这确保了两个世界之间的和谐。

构建数字世界:数据库与分布式系统

走出单台机器,我们在支撑互联网的庞大服务网络中也发现了同样的挑战。当多个独立系统必须协调时,它们也面临陷入同样陷阱的风险。

考虑一个既使用操作系统互斥锁(mutex)来保护内存缓存,又使用数据库(DBMS)来存储持久数据的服务。一个线程可能启动一个数据库事务,锁定一个数据库行,然后需要锁定操作系统的互斥锁来更新缓存。与此同时,另一个线程可能持有操作系统的互斥锁,并需要锁定同一个数据库行。这是一个典型的死锁,但这次它跨越了两个完全不同的资源管理器。DBMS对操作系统互斥锁一无所知,操作系统也不知道数据库锁。两者都无法看到完整的循环。解决方案不是让一个系统服从另一个,而是建立一个共享协议。我们可以在资源类别之间定义一个全局顺序:例如,所有数据库锁必须在任何操作系统互斥锁之前获取。这个简单的、分层的规则在应用程序代码中强制执行,可以防止任何一个子系统都无法独立预防的跨系统循环。

这种模式在现代微服务架构中无处不在。想象一个数字城市,其中专门的服务处理不同的任务,相互调用以完成用户的请求。服务A可能处理用户配置文件,而服务B处理支付。一个请求可能从A流向B。但如果另一种类型的请求从B流向A呢?如果两个服务都有速率限制(模型化为有限数量的“并发令牌”),我们可能会遇到僵局。服务A的所有令牌可能都被等待服务B的请求占用,而服务B的所有令牌则被等待服务A的请求占用。解决方案仍然是排序。通过对服务本身施加全局顺序(例如,始终先获取A的令牌再获取B的令牌),我们确保请求流量永远不会陷入循环拥堵。

有趣的是,排序并非预防死锁的唯一方法。有时,我们可以通过巧妙的架构规模设计来预防它们。考虑一个拥有固定大小工作线程池(mmm 个线程)和固定大小数据库连接池(nnn 个连接)的服务器。一种常见的模式是,一个任务获取一个线程,然后在持有该线程的同时请求一个数据库连接。当数据库查询完成后,一个回调函数必须在工作线程上运行以处理结果并释放连接。这里隐藏着一个微妙的陷阱。如果所有 nnn 个数据库连接都在使用中,每个连接都被一个持有我们宝贵工作线程的任务占用怎么办?如果剩下的 m−nm-nm−n 个工作线程也被任务占用,而这些任务现在都在等待数据库连接变为空闲怎么办?在这种状态下,所有 mmm 个线程都被占用了。数据库为前 nnn 个任务完成了工作,但没有空闲线程来运行回调。没有回调,连接就无法释放。没有释放的连接,等待中的任务就无法继续进行以释放它们的线程。整个系统都被冻结了。

这里的解决方案不是排序,而是确保你总有一个“备用”资源来打破循环。如果你能保证线程池总是大于连接池——具体来说,m≥n+1m \ge n + 1m≥n+1——死锁就被预防了。在最坏的情况下,即所有 nnn 个连接都繁忙时,保证至少有一个空闲线程可用来运行第一个完成回调,该回调会释放一个连接,从而解除了一个任务的阻塞,该任务又会释放一个线程,系统就这样优雅地自我解开了。

建模真实世界:从机器人到金融

死锁预防原则的美妙之处在于它不仅限于数字领域。它同样适用于物理对象和金融资产的系统。

想象一条机器人装配线,工作站沿着传送带排列。机械臂必须拾取零件并使用工作站来完成工作。每个零件和每个工作站都是一个独占资源。一个机械臂可能需要先使用工作站 S2S_2S2​,然后再使用工作站 S3S_3S3​。另一个可能需要将一个零件从 S4S_4S4​ 移动到 S1S_1S1​。很容易看出“机器人交通堵塞”是如何发生的。解决方案是对所有资源施加一个全序。我们可以为每个零件和每个工作站分配一个编号,也许是根据它们在传送带上的物理位置,然后规定所有机械臂必须严格按照数字递增的顺序获取资源。这个简单的规则确保了工作流程始终是前进的,机械臂之间的循环等待是不可能的。一个更简单的例子是带有传感器和执行器的移动机器人。一个自然的控制循环包括读取传感器、处理数据,然后命令执行器。如果我们将传感器数据和执行器命令建模为由锁 LSL_SLS​ 和 LAL_ALA​ 保护的资源,一个“三思而后行”的简单策略——始终在获取 LAL_ALA​ 之前获取 LSL_SLS​——就能防止机器人在自己的大脑中发生自我造成的死锁。

最后,让我们考虑一些风险极高的系统:金融服务和在线经济。当你把钱从你的账户转给朋友时,银行系统必须锁定两个账户以确保交易是原子性的。如果你在给朋友转钱的同时,他们也在给你转钱,我们就有问题了。你的转账可能会锁定你的账户然后等待你朋友的账户,而他们的转账可能会锁定他们的账户然后等待你的账户。死锁意味着真金白银被冻结了。解决方案既优雅又有效:对所有账户施加一个全局顺序,例如,按照它们唯一的账号。任何转账都必须按照账户ID的递增顺序锁定账户。这个实施起来微不足道的规则,当普遍应用时,能完全防止在一个处理数百万并发交易的系统中发生死锁。

完全相同的逻辑也适用于大型多人在线游戏的虚拟经济。当玩家交易物品时,游戏服务器必须锁定所有相关物品的记录。如果两个玩家试图同时相互交易,就可能发生死锁。解决方案是相同的:按物品的唯一ID对物品锁进行排序,虚拟经济就能保持流畅和功能正常。这也凸显了一个局限性:虽然排序可以防止死锁,但它不能解决饥饿问题。一个不幸玩家的交易可能会因为冲突而反复回滚,即使整个系统在取得进展。

从操作系统内核的最底层到全球金融的最高层,循环等待的幽灵无处不在。然而,在每个领域,我们都看到同一个强大的理念在起作用:在循环开始之前就打破它。无论是通过严格的数字排序、层次级别,还是仔细的架构规模设计,这个主动设计的原则都证明了将秩序带入混乱的强大力量。