死锁基础:产生条件、检测与解除
上一篇哲学家进餐留下了一张"环形等待"的图:五个人转着圈互相等对方手里的筷子,谁也吃不上。那不是个孤立的谜题,而是并发世界里一类最特殊故障的缩影——死锁。
死锁的诡异之处在于:系统既没崩溃报错,也没跑出错误结果,它只是彻底停住了。CPU 占用可能是 0,日志不再滚动,所有相关线程都活着、却都在等待一个永远不会发生的事件。对后端来说,这往往表现为接口大面积超时、线程池被占满、数据库连接全部挂起——而进程看起来"好好的"。
这一篇先把死锁的运转机制讲透:它发生需要哪四个条件同时成立、用什么数据结构(资源分配图)描述它、怎么检测出环。讲清机制后,再系统看预防、避免、检测、解除这四类处理策略各自的取舍。
一、先精确定义:死锁不是"卡住"那么简单
工程上"卡住"的原因很多——外部依赖超时、死循环、GC 停顿,都可能让系统看起来不动。但死锁是其中非常特定的一种:
死锁是指一组进程(或线程)中,每一个都在等待只能由该组内另一个进程释放的资源,因而所有进程都无法向前推进的状态。
抓住两个关键词:一组、互相等待。死锁一定涉及多个执行流,且它们的等待关系构成闭环——A 等 B 释放、B 等 C 释放、……、最后一个又回头等 A。这个环里没有任何一个能先动,因为它要的东西攥在环里下一个手里。
要把它和一个近亲概念分清楚——饥饿(starvation):
- 死锁:一组进程互相等待,环已闭合,谁也不可能再推进,是永久的、确定性的卡死;
- 饥饿:某个进程因为优先级低、运气差,长期抢不到资源,但系统整体在推进、别人都在正常跑。它理论上有机会翻身(只要调度照顾到它)。
死锁是"大家一起死",饥饿是"个别人被冷落"。死锁靠结构分析就能确诊,饥饿往往要看长期统计。混淆这两者,排障方向会完全跑偏。
二、死锁的四个必要条件
死锁不是随机降临的厄运,它的发生需要四个条件同时成立,缺一不可。这是死锁理论的基石(由 Coffman 提出,故称 Coffman 条件):
逐条拆开看它们各自意味着什么:
- 互斥(Mutual Exclusion):资源是排他的,同一时刻只能一个进程占用。如果资源能共享(比如只读数据),就谈不上为它死锁。这是资源的固有属性,往往无法改变。
- 占有并等待(Hold and Wait):进程已经攥着一些资源不放,又伸手去要别的资源,要不到就一直等——但手里的也不松开。哲学家拿着左筷子等右筷子,就是这一条。
- 不可剥夺(No Preemption):资源一旦分给某进程,就只能等它自己用完主动交出,系统不能强行收回。锁通常就是这样——你不能从一个线程手里硬抢走它持有的锁。
- 循环等待(Circular Wait):存在一条等待链,首尾相接成环:
P0等P1占的资源,P1等P2占的,……,Pn又回头等P0占的。上一篇哲学家那张图就是这个环。
这四个条件是必要条件的合取:死锁发生时四者必然同时成立;反过来,只要能确保其中任意一个永远不成立,死锁就绝无可能发生。所有"预防死锁"的策略,本质都是盯着这四条挑一个去破坏。
三、资源分配图:把死锁画出来
要在系统里"看见"死锁,需要一个数据结构来描述"谁占着什么、谁在等什么",这就是资源分配图(Resource Allocation Graph)。它是一张有向图:
- 两类节点:进程(画成圆圈)和资源(画成方块);
- 两类边:
- 分配边:资源 → 进程,表示这个资源已分配给该进程(资源指向占有者);
- 请求边:进程 → 资源,表示该进程正在申请这个资源(进程指向想要的东西)。
判定规则非常清晰:
- 如果图里没有环,系统一定没有死锁;
- 如果有环:
- 每类资源都只有一个实例时,有环 = 一定死锁;
- 资源有多个实例时,有环只是死锁的必要不充分条件——可能有环但因为某个实例会被释放而最终解开。
为什么多实例时有环也未必死锁?因为环上某个进程等的那类资源,可能还有别的实例握在环外的进程手里,那个进程用完一释放,环就断了。所以多实例情形下,光看有没有环不够,要进一步跑检测算法(类似银行家算法的安全性检查)去判断是否真的无解。
资源分配图把抽象的"互相等待"变成了可计算的"找环"问题。死锁检测的核心,就是在这张图上找有没有(不可解的)环。
四、四类处理策略:从最严到最松
知道了死锁怎么产生、怎么描述,操作系统该拿它怎么办?历史上形成了四类策略,按"对死锁的容忍度"从低到高排列。
策略一:预防(Prevention)——从设计上让死锁不可能。
破坏四个必要条件中的任意一个:
- 破坏占有并等待:要求进程一次性申请所有需要的资源,要么全拿到要么一个不拿(哲学家"要么拿两根要么不拿")。代价是资源利用率低——你得在最开始就占住后面才用的资源。
- 破坏不可剥夺:申请新资源失败时,主动释放已持有的全部资源,过会儿重来。代价是之前的工作可能白做。
- 破坏循环等待:给所有资源全局编号,规定只能按编号递增顺序申请。这是最常用、最实用的一招——前面哲学家的"资源排序法"、工程上的"统一加锁顺序"都是它。代价小、易落地。
在这四个条件里,循环等待是最容易、代价最低被破坏的那一个。这就是为什么"统一资源申请顺序"成了几乎所有工程团队的死锁防御首选——不需要复杂算法,约定一个顺序大家遵守即可。
策略二:避免(Avoidance)——运行时动态判断,不走进危险。
不像预防那样静态地砍掉条件,而是在每次分配资源前先算一算:如果满足这次请求,系统是否还能保证存在一个让所有进程都顺利完成的执行序列(即仍处于"安全状态")?是就分配,否则让它等。**银行家算法(Banker’s Algorithm)**就是这一策略的代表。代价是需要预先知道每个进程的最大资源需求,且每次分配都要跑一遍计算,开销大,实际系统中很少真用。
策略三:检测与解除(Detection & Recovery)——允许死锁发生,事后处理。
干脆不预防也不避免,让系统正常跑,周期性地构建资源分配图、检测有没有环。一旦发现死锁,再想办法解除。适合死锁很少发生、预防代价又太高的场景(很多数据库走这条路)。
策略四:忽略(Ignore)——鸵鸟算法。
什么都不做,假装死锁不存在。如果死锁极罕见、处理它的成本远高于偶尔重启的成本,那么"出问题就重启"反而最划算。听起来不负责任,但这恰恰是通用操作系统(包括 Linux/Windows)对用户进程死锁的实际态度——内核不会替你的应用层锁去检测死锁。
五、死锁的解除:环已经形成了怎么办
如果走的是"检测与解除"路线,检测到死锁后,要打破那个环,常见两种手段:
手段一:终止进程。
- 全部终止:把死锁环里的所有进程都杀掉。简单粗暴、一定有效,但代价最大——所有进程的工作全丢。
- 逐个终止:一次杀一个,每杀一个就重新检测环是否解开,直到死锁消失。代价小些,但每次都要重跑检测,且要决策"先杀谁"(通常挑优先级低、已完成工作少、回滚代价小的)。
手段二:资源剥夺(抢占)。
强行从某个进程手里抢走资源,给别人用,从而打破环。这要求能回滚被抢进程到一个安全的检查点,否则它后续会因为资源莫名消失而出错。数据库的死锁处理就是典型:检测到事务死锁,挑一个作为"牺牲者(victim)"回滚,释放它持有的锁,让其余事务继续——这正是资源剥夺 + 回滚的工程实现。
解除死锁没有无痛方案,本质都是"牺牲一部分进程的工作来换整体的恢复"。这也反过来解释了为什么"预防"在能做到时总是更优——避免比补救代价小得多。
六、四类策略的取舍:没有银弹
把四类策略放一起对照,你会发现它们是在"安全性"和"代价/利用率"之间滑动:
| 策略 | 何时处理 | 资源利用率 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|
| 预防 | 设计期 | 低(过度保守) | 低~中 | 加锁顺序约定、关键系统 |
| 避免 | 每次分配 | 中 | 高(要跑算法) | 理论价值大于实用 |
| 检测+解除 | 事后 | 高 | 中 | 数据库事务 |
| 忽略 | 不处理 | 最高 | 无 | 通用 OS 对用户进程 |
越靠"预防"端越安全但越保守(牺牲利用率),越靠"忽略"端利用率越高但越冒险。选哪个,取决于死锁发生的概率和它造成的后果。这是典型的工程权衡,没有放之四海皆准的答案。
对绝大多数后端工程师来说,最该掌握的是预防中的"破坏循环等待"——统一加锁顺序、给资源定序申请。它代价最低、最容易在团队里落地,能挡掉现实中绝大部分死锁。
七、和工程实践 / 后端开发的联系
死锁在后端是高频故障,认清机制能直接指导排障和设计:
- 数据库死锁:两个事务以相反顺序锁两行(事务 A 先锁行 1 再锁行 2,事务 B 先锁行 2 再锁行 1),就是占有并等待 + 循环等待。数据库自带死锁检测,会回滚一个牺牲者并报
Deadlock found错误。解法是应用层统一按主键顺序加锁/更新。 - 代码里的多锁死锁:转账同时锁两个账户、对象间互相加锁。
jstack抓线程栈时,JVM 能直接标出Found one Java-level deadlock并打印环上的线程和它们持有/等待的锁——这就是资源分配图找环的工程版。 - 分布式死锁:服务 A 调 B、B 又回调 A,两边都占着连接池的连接等对方响应,连接池被耗尽——这是跨进程的占有并等待。比单机更难检测,常靠超时兜底打破(破坏"不可剥夺",超时即放弃)。
- 连接池与线程池:池资源有限时,"持有一个连接的同时又去申请另一个连接"是经典死锁源。设计原则就是避免持有一种池资源时再去申请同种或另一种池资源。
学习这一部分最容易踩的坑
1. 把死锁和饥饿、普通卡顿混为一谈
死锁是一组进程闭环互等、永久卡死;饥饿是个别进程长期没机会但系统在推进;外部依赖超时则是单纯等一个慢响应。三者排障方向完全不同——确诊死锁要找等待环,确诊饥饿要看长期调度统计。
2. 以为加超时就解决了死锁
超时只是"破坏不可剥夺条件"的一种兜底(等不到就放弃),它能让系统从死锁里恢复,但治标不治本——下次时序踩中照样死锁,而且超时期间已经卡了一段时间。根治要靠破坏循环等待(定序申请)。
3. 只记得四个条件,不会用它们指导设计
背出"互斥、占有并等待、不可剥夺、循环等待"不算懂。真正的用法是:写多锁代码时,主动问自己——我有没有破坏掉其中至少一个条件? 最实际的答案通常是"我保证了所有锁都按同一顺序获取",即破坏循环等待。
4. 多实例资源时,看到环就断定死锁
单实例资源有环必死锁,但多实例资源有环只是必要条件——某个实例可能被环外进程释放从而解开。多实例情形要跑检测算法判断是否真无解,不能只凭"图里有环"下结论。
总结
死锁是一组执行流闭环互等、永久无法推进的特殊故障,理解它的关键是四个必要条件和资源分配图。核心要点:
- 死锁是"一组进程互相等待对方持有的资源",区别于饥饿(个别进程长期得不到、但系统在推进);
- 四个必要条件——互斥、占有并等待、不可剥夺、循环等待——必须同时成立,破坏任一即可预防;
- 资源分配图把死锁变成"找环"问题:单实例有环必死锁,多实例有环是必要不充分条件;
- 四类处理策略按容忍度递增:预防、避免(银行家算法)、检测+解除、忽略(鸵鸟),是安全性与利用率的权衡;
- 解除死锁靠终止进程或资源剥夺+回滚,本质是牺牲局部换整体恢复;
- 工程上性价比最高的是破坏循环等待——统一加锁/资源申请顺序,能挡掉绝大多数现实死锁。
下一篇起我们转向调度——CPU 这个最核心的资源,操作系统是怎么在众多任务之间分配的。先从不同系统类型对调度的不同诉求讲起。
参考资源:
- 《现代操作系统》(Andrew S. Tanenbaum)死锁章节
- 《操作系统导论》(Operating Systems: Three Easy Pieces)
- OSTEP - Deadlock
- 《操作系统概念》(Operating System Concepts,恐龙书)死锁章节