ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

死锁处理全攻略:从四条件到预防/检测/解除

死锁处理全攻略:从四条件到预防/检测/解除

先问一个问题:你在线上有没有遇到过那种"看起来一切正常,但业务就是不动了"的诡异故障?我遇到过。某个模拟订单系统的压测环境里,所有线程的CPU占用都正常,内存也没爆,日志却一行都不再输出。查到最后发现是经典的死锁——两个线程各自持有一把锁,又在等对方手里的那把,互相僵持,谁也没法往前挪一步。从那一刻起我就意识到,"死锁"这四个字在教科书里是一个考点,在线上就是一个让团队深夜加班的定时炸弹。

这篇文章就把死锁这件事讲透。我会从死锁发生的四个必要条件入手,然后分别拆解处理死锁的四种策略:预防死锁、避免死锁、检测死锁、解除死锁。不只是讲理论,我还会带上每一步的推断逻辑、工程实现思路和实际踩坑经验。无论你是刚接触并发的学生,还是已经在写多线程/分布式代码的工程师,这篇内容都应该能帮你建立一套完整的死锁处理框架。

1. 死锁的四个必要条件与一个线上卡死案例

很多人对死锁的印象停留在"两个线程互相等待",但真正遇到问题时你会发现,判断是否死锁、找到死锁链、确认死锁根因,每一步都比想象中麻烦。想系统性地处理死锁,首先要理解死锁到底在什么条件下才会发生。

1.1 四个必要条件,缺一不可

死锁发生需要同时满足四个条件,学术上一般叫"死锁必要条件"。注意"必要"这个词——它是必要条件,不是充分条件。也就是说,这四个条件同时满足,系统只是"可能"发生死锁,不一定会死锁;但想形成死锁,这四个条件一个都不能少。

第一是互斥条件。资源在同一时刻只能被一个进程或线程独占使用,别人想用只能等。典型例子是打印机、文件写锁、数据库行锁。

第二是占有并等待。进程已经占据了至少一个资源,同时又在等待获取其他被别的进程占据的资源。说白了就是"手里抓着不放,眼睛还盯着别人的"。

第三是不可剥夺。进程已经获取的资源,只能由它自己主动释放,系统不能强行抢走。比如你初始化了一把互斥锁,另一个线程不能直接把你手里的锁拿走。

第四是循环等待。存在一条闭合的等待链:进程A等待进程B占有的资源,进程B等待进程C占有的资源,顺着链条走一圈,最终又回到A等待的资源上。

这四个条件组合在一起,就是死锁的完整画像。处理死锁的一切策略,本质上都是在跟这四个条件博弈。

1.2 我遇到的那个卡死案例

说回我在模拟订单系统里遇到的那个问题。系统逻辑很简单:用户下单后,一个线程负责扣减账户余额,另一个线程负责更新库存。当时为了保证数据一致,我在账户服务和库存服务里都加了分布式锁。

故障现象是:压测跑到一半,整个系统的下单接口全部超时,但CPU和内存指标看起来完全正常,线程池队列却越堆越长。当时第一反应是数据库连接池满了,查了一圈发现连接池也正常。最后把两个服务上锁的获取顺序放在一起比对,问题立刻清楚了:

线程A先获取账户锁,再去获取库存锁;线程B刚好反着来,先获取库存锁,再获取账户锁。当线程A拿着账户锁等库存锁时,线程B已经拿着库存锁在等账户锁。两个线程就在这一刻,"完美"地满足了互斥、占有并等待、不可剥夺、循环等待这四个条件。死锁就这么形成了。

这个案例其实非常典型。你不需要多么复杂的分布式系统,两个线程两把锁,只要顺序不对,死锁就来了。理解了这个最简单的模型,后面讲的所有策略就都有了抓手。

2. 预防死锁:四套打破必要条件的工程思路

预防死锁的核心思想是:从根上破坏四个必要条件中的至少一个,让死锁在系统设计层面就不可能发生。它不关心系统当前处于什么状态,靠的是静态规则,写代码时就把死锁的可能性掐死。

2.1 破坏互斥条件:用共享替代独占

互斥条件说的是资源同一时刻只能一个人用。那能不能让资源变成大家都能用?在某些场景下可以。比如读操作使用读写锁中的读锁,多个读者并行访问;再比如无锁数据结构,用CAS(Compare And Swap)这种原子操作代替互斥锁,线程之间不需要阻塞等待。

但必须泼一盆冷水:互斥条件在绝大多数场景里是无法被破坏的。你让打印机同时打印两份文档试试,打出来的东西肯定没法看。数据库的行锁、写锁,更不可能因为你想避免死锁就全部改成共享。所以这条路理论上成立,实际工程里能用到的场景很少,更多是配合其他策略使用。

2.2 破坏占有并等待:一次性拿到全部资源

这条思路比较容易理解:既然"占着不放还去等新的"会出事,那就不允许这种状态出现。具体做法是要求每个进程在开始执行前,一次性申请它需要的全部资源,要么全给,要么全不给,执行过程中不再申请新资源。

我见过一个实际落地案例:某个消息消费组件需要同时操作Redis和MySQL,最初是先用Redis锁,再拿数据库锁,结果在高并发下偶发死锁。后来改成在业务入口处先探测两块资源是否都可用,全部可用才开始处理,不可用就整个任务排队等待。这样就不会出现"占着一个等另一个"的中间状态。

这个方案的代价也很明显:资源利用率低。进程可能只需要资源A的少量空间,但因为"一次性申请"的规则,不得不把整个流程需要的资源全部占住,后面的进程就得干等。而且在资源需求动态变化的场景里,很难做到"事前全申请",所以它更适合需求明确、资源种类固定的批处理任务。

2.3 破坏不可剥夺:允许系统把资源抢走

如果一个进程申请不到新资源,那就不让它继续占着老资源,强制把它手上的资源释放掉。听起来很霸道,但它的成立前提是:资源状态必须能保存和恢复。CPU寄存器可以保存,内存页可以换出,这类资源确实能被剥夺;但正在打印的打印机没法"保存打印到一半的状态",这种资源就不能剥夺。

实际工程里,这条思路最常见的变体是超时机制和租约机制。比如很多锁工具都提供tryLock(timeout),拿不到锁就放弃等待,同时把已持有的锁释放掉,过一会儿再重试。分布式锁更常用租约:锁持有者只拥有一个有限期限的租约,到期后锁自动被系统收回,别人就能抢走了。这种机制天然破坏了"不可剥夺",因为系统层面让锁"可以被抢走"。

2.4 破坏循环等待:给资源编号,强制按序申请

这是工程实践里最常用、最实用的预防手段。做法是给所有资源进行全局编号,规定每个进程只能按编号递增的顺序申请资源。比如只能先申请编号小的资源,再申请编号大的资源,不允许反过来。

回到我那个模拟订单系统的例子。账户锁和库存锁,如果统一约定了"必须先拿账户锁,再拿库存锁",线程B就不会先拿库存锁再拿账户锁,循环等待链直接就断了。数据库场景里也很常见:多张表需要同时更新时,约定按照表名的字典序去加锁,而不是按业务代码里写死的顺序。分布式系统里,则可以给每个共享资源分配一个全局锁ID,谁拿锁都按锁ID从小到大拿。

这套思路的优点是非常简单、完全可控,不需要动态判断。缺点是破坏业务灵活性——有些业务天然就是先拿资源B再拿资源A更合理,强制排序以后可能性能受影响。而且资源编号一旦定下来,后续要新增资源类型时,编号体系就得维护扩展。说白了,这是一套需要自律和规范的预防机制,适合在统一架构规范下执行。

3. 避免死锁:银行家算法的推断逻辑与安全序列

如果说预防死锁靠的是"硬性规则",那避免死锁靠的就是"实时判断"。它的核心思想是:对每一次资源请求都做安全性检查,只有确认分配后系统仍处于安全状态,才允许分配;否则就让进程等待。

3.1 安全状态与不安全状态:动态判断的本质

先理解两个概念。安全状态指的是系统存在一个"安全序列"——按照这个顺序依次执行进程,每个进程需要的最大资源都能被满足,最终所有进程都能完成。不安全状态就是反过来,找不到这么一条执行顺序。

这里有一个很容易搞混的关键点:不安全状态不等于死锁。不安全状态只是"将来有进入死锁的风险",可能最后也没真死锁;而死锁是已经实际发生了。避免死锁的策略做的就是在每一步分配时,把系统往"安全状态"方向引导,只要系统始终处于安全状态,死锁就永远不可能出现。

3.2 银行家算法的数据结构与判断流程

避免死锁最经典的实现就是银行家算法,它的名字来源于银行放贷的场景——银行不知道每个客户到底什么时候还款,但能根据每个客户声明的最大贷款额度,预留足够的资金避免自己被拖垮。

系统需要维护四张表:Available(当前各资源的可用数量)、Max(每个进程对各资源的最大需求矩阵)、Allocation(当前已分配给各进程的资源矩阵)、Need(还需要的资源数量)。其中Need = Max - Allocation。

当一个进程发出资源请求时,算法做如下步骤:

  1. 检查请求是否小于等于Need,超过最大需求就报错。
  2. 检查请求是否小于等于Available,不够就让进程等待。
  3. 试探性地进行资源分配,更新Available、Allocation、Need。
  4. 执行安全性检查算法:以现有的Available为初始Work,遍历所有进程,找到一个Need ≤ Work且未完成的进程,假设它运行完毕,回收它的Allocation并入Work,继续下一轮。
  5. 如果所有进程都能被"运行完毕",说明系统安全,正式分配;否则撤销试探分配,让进程继续等待。

3.3 一个具体的数字推演

光看流程有点抽象,我手算一个例子。假设系统有三类资源,总量分别是A=10、B=5、C=7。有5个进程,当前时刻的分配情况如下:

进程Max (A, B, C)Allocation (A, B, C)Need (A, B, C)
P07, 5, 30, 1, 07, 4, 3
P13, 2, 22, 0, 01, 2, 2
P29, 0, 23, 0, 26, 0, 0
P32, 2, 22, 1, 10, 1, 1
P44, 3, 30, 0, 24, 3, 1

此时剩余资源Available = 总量 - 各进程已分配的总和 = (10, 5, 7) - (7, 2, 5) = (3, 3, 2)。

从P0开始检查:P0的Need是(7,4,3),大于Available(3,3,2),不满足。P1的Need是(1,2,2),每个维度都小于等于(3,3,2),满足。假设P1执行完,回收它的Allocation,Work变成(3+2, 3+0, 2+0) = (5,3,2)。

继续找下一个进程。P3的Need(0,1,1) ≤ (5,3,2),满足;执行后Work变成(7,4,3)。再看P4,Need(4,3,1) ≤ (7,4,3),满足;执行后Work变成(7,4,5)。接着P0,Need(7,4,3) ≤ (7,4,5),满足;执行后Work变成(7,5,5)。最后P2,Need(6,0,0) ≤ (7,5,5),满足。

得到安全序列:P1 → P3 → P4 → P0 → P2。系统当前处于安全状态,这次请求允许分配。

说实话,这个算法在操作系统教材里讲得很细,但工程实践中真正用它的场景非常少。原因也很现实:进程未来的最大资源需求很难提前精确声明,资源数量也在运行中是动态变化的,而且每次分配都要做全量的安全性扫描,开销不小。它更多是作为一种理想的动态判断模型存在,帮助我们理解"安全状态"这个概念。

4. 检测死锁:资源分配图的化简与发现时机

预防和避免都没拦住,死锁已经发生了,那就得靠检测。检测策略的思路是:平时不设任何限制,允许死锁发生,但提供一种手段来判断系统当前是否已经进入死锁状态。如果检测到了,再进入解除环节。

4.1 资源分配图:把等待关系画出来

检测死锁的核心工具是资源分配图(Resource Allocation Graph)。图中的节点分两类:进程节点(用圆圈表示)和资源节点(用方框表示)。从资源节点指向进程节点的边,叫分配边,表示资源已分配给该进程;从进程节点指向资源节点的边,叫请求边,表示进程正在等待该资源。

判断规则是这样的:

  • 如果资源分配图里没有环,一定没有死锁。
  • 如果每个资源类型只有单个实例,有环就说明死锁了。
  • 如果资源类型有多个实例,有环不一定死锁,需要进一步化简判断。

化简的过程是:找出一个既不阻塞又不是孤点的进程,它所有的请求边都能被当前资源满足,那就把它的所有分配边去掉,模拟它运行完毕。不断重复,直到所有进程都能被化简,就说明系统没有死锁;如果剩下一些进程化简不掉,它们就是死锁进程。

4.2 什么时候做检测:检测频率的工程取舍

死锁检测本身有开销,系统不可能每时每刻都在扫描。时机选择一般有三种:

  • 每次资源请求都检测。实时性最高,但频繁扫描资源分配图,性能开销大到不现实。
  • 定时检测。比如每隔几分钟跑一次,适合对实时性要求不高的系统。
  • 资源利用率触发。这是最实用的一种:当CPU利用率下降到阈值(比如40%以下)时,怀疑系统可能因为死锁而空闲,这时候触发检测。因为死锁的直接表现之一就是进程集体阻塞,CPU利用率明显下跌。

我个人的体会是,死锁检测适合用在"资源类型复杂、进程间资源竞争激烈,但死锁发生频率又不算高"的场景。订好合理的触发时机,既能兜住突发问题,又不至于频繁扫描导致正常业务被拖累。

4.3 一个数据库事务死锁的检测实例

以数据库场景为例,两个事务:

事务1:先更新账户A,再更新账户B。 事务2:先更新账户B,再更新账户A。

如果两个事务同时执行,事务1握着账户A的行锁等账户B,事务2握着账户B的行锁等账户A,资源分配图上就出现了一个闭环。

数据库的做法通常是内部维护一张等待图(Wait-for Graph),节点是事务,边表示"事务1在等待事务2释放资源"。死锁检测器会周期性地在等待图里找环,一旦发现环,立刻选择一个事务作为牺牲者,通常是回滚代价最小的那个,比如持有资源少、执行时间短的,然后把它回滚,释放它持有的所有锁,让另一个事务继续执行。

这跟理论上的"资源分配图"思路完全一致,只是落到了数据库的具体场景里。理解原理之后你会发现,数据库报的"Deadlock found when trying to get lock"并不是什么玄学,背后的逻辑就是图上找环。

5. 解除死锁:从资源剥夺到事务回滚的实操做法

检测发现死锁只是第一步,接下来的问题更具体:怎么让系统从死锁状态恢复过来?解除死锁有几种做法,每种都有自己的代价,得根据场景选。

5.1 资源剥夺:抢走一部分资源

从其他进程中强制剥夺资源,分给死锁进程使用,从而打破死锁链。之前讲预防的时候说过,这种操作的前提是资源可以被抢占,状态能保存能恢复。分布式锁里的租约过期机制,其实就是一种自动的资源剥夺。不过在使用时要特别小心:如果被剥夺的是有状态资源,比如数据库写锁,贸然抢走可能导致数据不一致,必须有完善的补偿机制。

5.2 撤销进程:杀鸡儆猴,释放资源

这是最直接的手段。撤销一个或多个死锁进程,把它们的资源全部回收,死锁自然解除。选择撤销谁时,一般按这几个维度评估代价:

  • 进程优先级,优先撤销低优先级的。
  • 进程已执行时间,优先撤销执行时间短的。
  • 进程占用的资源量,优先撤销占用资源少的。
  • 进程是交互式还是批处理式,优先撤销批处理的。

可以只撤销死锁环里的一个进程,也可以撤销环里所有进程,取决于系统对损失的可接受程度。这种方案简单粗暴,但代价是业务可能被中断,在关键业务场景要慎重。

5.3 进程回退:回到检查点重新执行

从进程的角度来看,能不能不死不伤,而是"回到过去"重新走一遍?这就是进程回退。系统周期性地给进程做快照,保存状态和上下文。死锁发生后,将某个进程回退到死锁发生前的一个检查点,释放它从那时起占用的所有资源,让它重新执行。

这个思路在数据库事务里特别常见:事务回滚就是回退到事务开始时的状态,释放所有行锁,让其他事务有机会拿到锁继续推进。分布式事务里的补偿回滚也是同样的道理。回退的频率需要权衡:检查点太密,快照开销大;检查点太稀,回退后重新执行的代价大。

5.4 实际操作中的一个完整恢复流程

还是用那个模拟订单系统的例子来串一遍。检测器在等待图里发现了死锁环,涉及线程A和线程B。做代价评估:线程A持有一把账户锁,线程B持有两把锁(账户锁和库存锁),线程B还处于事务中间过程。如果撤销线程A,业务损失小一点;但线程B持有两把锁,撤销它能同时释放更多资源。

实际操作中,我们选择回滚线程B的事务到检查点,让它释放账户锁和库存锁。线程A拿到库存锁后继续执行,完成后释放所有资源。线程B回滚到检查点后,走另一条业务路径重试,成功处理了订单。整个恢复过程对用户来说只是个短暂的延迟,没有数据损失。这就是"检测+回退"组合拳的典型打法。

6. 四种策略选型对比与经验总结

把这四种策略放在一起对比,你会发现它们的定位完全不同,不是互相替代的关系,而是应对不同场景的组合选择。

策略核心思想典型实现优点缺点适用场景
预防死锁破坏四个必要条件之一资源排序加锁、一次性分配、锁超时实现简单,零运行时开销资源利用率低,规则僵化单机多线程、分布式锁规范
避免死锁动态判断安全状态银行家算法理论上不会死锁,资源利用率较高需要预知最大需求,开销大教学、小型嵌入式系统
检测死锁允许死锁发生,事后发现资源分配图化简、等待图找环不限制并发自由度检测有时延,需要配合解除数据库事务、复杂资源系统
解除死锁死锁已发生,恢复运行资源剥夺、撤销进程、事务回滚能让系统及时恢复有业务损失,需要权衡代价配合检测策略使用

6.1 工程上最常见的组合

我在实际项目里见过最多、也最推荐的是"预防为主 + 检测兜底"的组合。

预防方面,所有加锁操作严格按约定的资源顺序执行,任何新接入的业务代码都要过一遍锁顺序审查。同时所有锁都加上超时机制,就算某个环节出了问题,锁也会因为超时自动释放,不会无限僵持下去。这一步已经把绝大多数死锁挡在门外。

检测方面,线上系统保留死锁日志监控和等待图分析。哪些线程在等哪些锁、等了多少秒,等等。如果发现某个服务的锁等待时间突破阈值,立刻告警并抓取线程栈。数据库层面则依赖数据库自带的事务死锁检测,配合事务超时时间,确保数据库不会因为一个死锁事务把连接池拖垮。

6.2 银行家算法在工程上为什么少见

很多初学者不理解:教材里讲得那么细的银行家算法,怎么工程里几乎见不到?原因我前面提过,最致命的问题就是系统无法提前声明每个进程对各种资源的最大需求。在真实业务里,一个线程要拿多少锁、占多少资源,往往是运行时动态决定的,谁也没法在启动前给出一个准确的"最大需求矩阵"。

还有一点,银行家算法假设资源数量是相对固定的,但真实系统里的资源数量在动态变化,比如线程池大小弹性伸缩、连接池扩容。每变化一次整个矩阵就乱了,维护成本极高。所以它更适合作为理解"安全状态"的理论工具,工程上还是用预防和检测的组合更实在。

6.3 踩过几次坑之后的个人体会

最后多说两句实在话。第一,别迷信任何一种单一策略。死锁处理没有银弹,真实系统里永远是一套组合拳:资源排序来预防、超时机制来兜底、等待图监控来发现、事务回滚来恢复。

第二,死锁的根因往往不在"锁"本身,而在业务的资源获取顺序没定清楚。我后来复盘那个模拟订单系统,发现问题不在分布式锁代码写得不好,而是两个团队各自维护自己的服务,账户锁和库存锁的获取顺序从未有过统一约定。后来把锁ID规范化,所有服务强制按锁ID从小到大获取,问题就再没出现过。

第三,把死锁知识变成排查问题的直觉。看到线程池积压、CPU利用率下跌、日志戛然而止,脑子里要立刻浮现出"有没有可能是死锁"这个选项,然后去抓线程栈、画等待链,而不是漫无目的地重启服务。死锁处理策略这套东西,考试会做选择题只是最低要求,能在线上故障里快速定位并恢复,才算真正吃透了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进