
1. 死锁到底是什么从一次线上卡死说起死锁这个概念大多数人的学习路径都差不多考试前把四个必要条件背下来把银行家算法那张表格推一遍卷子交完就合上书。真正让人回头翻这一章的场景往往发生在工作几年之后——某个服务突然大面积超时日志里所有线程都停在等锁的语句上重启之后恢复正常过几天原样再来一次。我第一次遇到这种情况时盯着线程转储看了两个小时最后发现是两个后台任务以相反的加锁顺序去抢同一对资源。那一刻我才明白教科书上那四条必要条件不是用来应付考试的它是一份排查清单。这一章的核心内容就是解决多个执行单元因为互相等待对方手里的资源导致谁也无法继续推进这个问题。它要回答的事情很具体死锁是怎么形成的、怎么提前画图判断、怎么从操作系统层面预防和避免、真发生了怎么检测和恢复以及在应用代码里怎么把它摁死。适合谁看如果你正在学操作系统的进程管理部分这里能帮你把抽象定义落到具象场景如果你已经有几年开发经验被线上卡死折磨过这里提供的锁序规范、等待图检测、数据库死锁日志读法基本可以直接搬进项目里用。1.1 一次典型的卡死现场还原把死锁说得再形象一点两个人去吃面桌上只有一双筷子和一把勺子。甲拿到了筷子等着乙手里的勺子才能吃乙拿到了勺子等着甲手里的筷子。两个人都在等谁也不松手谁也吃不上。这不是慢是永远不动了——这个永远不动就是死锁和普通等待最关键的区别。对应到计算机里资源可以是打印机、文件、数据库里的行锁也可以是内存里的一个互斥量。进程或者线程在申请资源时如果所需的资源已经被别人占着它就得等而占着资源的那个又反过来等它手里的东西环就闭合了。教科书把这种情况归纳成一句话一组进程中的每一个都在等待一个事件而这个事件只能由这组进程中的另一个成员触发。注意这组进程中的另一个成员这几个字它说明问题出在组内部外部力量不介入这个等待链就永远不会解开。我在实际排查中总结出一个很实用的判断标准如果一个卡顿把所有相关线程等的东西列出来最后能连成一个闭环那基本可以断定是死锁如果这条链最后指向的是某个外部系统、磁盘 IO 或者一个根本没启动的任务那就是普通阻塞或者上游超时别往死锁上凑。这个区分非常重要因为两者的处理方式完全相反——前者要打破环路后者要排查上游。1.2 死锁、活锁、饥饿三个容易混淆的概念这三个词在面试和文档里经常被混着用但它们的症状和处理手段差别很大。死锁是完全静止所有人都在等CPU 占用可能很低系统看起来像睡着了。活锁是一直在动但没进展——两个人在走廊里迎面碰上都想给对方让路结果同时往左、同时往右反复折腾动作很热闹就是过不去。饥饿则是有人一直被插队比如低优先级的任务永远排不上 CPU或者某个读请求总被源源不断的写请求挤到后面。用一个表格把这三种情况摊开对比排查的时候对号入座会快很多特征死锁活锁饥饿执行状态完全停止无人推进持续消耗 CPU 但状态反复回退偶尔推进长期拿不到资源CPU 占用通常很低通常很高中等随调度波动是否形成等待环是否否典型场景交叉加锁、循环转账冲突重试策略过于对称低优先级任务被高频任务压制处理手段打破环路引入随机退避或优先级老化、公平调度活锁的处理经验特别值得说一句。我见过一个重试逻辑两个节点检测到冲突后都用固定的固定间隔重试结果每次都撞在一起日志里全是冲突重试业务一条都没成功。后来把重试间隔改成随机指数退避问题当天就消失了。这个改动背后就是活锁的原理——对称的策略必然导致同步的碰撞。1.3 四个必要条件缺一不可的四个支点死锁的产生需要四个条件同时成立任意一个不成立死锁就构不成。把这一条记牢后面所有预防手段都有了出发点。互斥资源同一时刻只能被一个执行单元占用。筷子只有一双打印机只有一台这是资源本身的属性。占有且等待也叫请求与保持一个执行单元已经占着至少一个资源同时又在申请新的资源而新资源被别人占着它就卡在中间不撒手。不可抢占不剥夺资源只能由占有者主动释放别人不能强行拿走。操作系统一般不会强行把一个进程正在用的文件句柄抢走否则程序状态就崩了。循环等待存在一条首尾相接的等待链P0 等 P1 的资源P1 等 P2 的资源……最后一个又回头等 P0。注意这四条是同时成立才构成死锁不是有其中一条就危险。很多新手会误以为有互斥就可能死锁其实互斥是绝大多数资源的固有属性根本没法消除真正的突破口在后三条。我在理解这四条的时候用了一个类比四个人围成一圈每个人手里握着一把钥匙但每个人要打开自己面前的锁需要的是右边那个人手里的钥匙。四个条件在现实里对应的是——钥匙只能一个人拿互斥、手里已经有一把但还在要占有且等待、别人不能抢你的钥匙不可抢占、而且恰好围成了一个圈循环等待。要拆掉这个局面最省事的方法永远是打散那个圈也就是破坏循环等待这也是后面工程实践里用得最多的一条。2. 资源分配图把谁在等谁画出来光靠脑子想复杂的等待关系很快就会乱尤其是线程数量上去以后。资源分配图的作用就是把这个混乱的关系摊到纸面上变成一张有向图让判断从感觉好像有问题变成图上有没有环这个可以机械执行的检查。这一节讲清楚怎么画、怎么看、怎么判断。2.1 图的画法与边的含义资源分配图有两类节点圆形代表进程或线程方形代表资源类型方形里的圆点数量表示这类资源有多少个实例。边只有两种方向含义完全相反这是最容易记混的地方。申请边从进程指向资源表示我想要这个资源。分配边从资源指向进程表示这个资源已经给你了。规范的画法是分配边从方形里的某个圆点出发指向进程申请边从进程出发指向整个方形框意思是它还不知道自己会拿到哪一个实例。很多考卷上省掉了圆点只画框这时候单实例资源就直接理解成框里只有一个点的情形。举个具体例子。系统里有资源 R1 两个实例、R2 一个实例。进程 P1 已经拿到一个 R1正在申请 R2进程 P2 已经拿到 R2正在申请一个 R1。画出来就是R1 的一个点指向 P1P2 指向 R1 的框R2 的框指向 P2P1 指向 R2 的框。四条边首尾相连一个标准的环这个状态就是死锁。注意画图时不要只画谁给了谁一定要把谁在等谁也画上去。我见过不少人只画分配关系那永远画不出环因为等待关系才是闭环的关键部分。2.2 有环是否一定死锁单实例与多实例的差别这里有一处极容易踩的坑图上出现环不代表一定死锁。结论要分情况讨论。如果系统里每一类资源都只有一个实例那么有环和死锁是等价关系判断非常干脆。只要出现环环上的所有进程都卡死了环外的进程如果依赖环上的资源也可能被拖累。如果资源有多个实例情况就变了。环可能只是局部拥挤环上某个进程其实还能从别处拿到同类型资源的另一个空闲实例拿完之后执行结束、释放资源环就自己解开了。这种环叫有环但未死锁。教材里更严密的做法是通过图化简来判断只要某个进程的申请能被当前空闲资源满足就假设它执行完毕、释放全部资源把这个节点和相关边删掉反复执行最后如果所有进程都能化简掉说明状态是安全的剩下的环才是真死锁。举个数R1 有两个实例P1 占一个P2 占一个同时 P2 申请第二个 R1。这时候 P2 指向 R1 的申请边和 R1 指向 P2 的分配边形成一个环。但 R1 没有空闲实例了P1 也不申请任何东西如果 P1 能自然结束它释放出来的实例正好给 P2 用。这就要看 P1 后续会不会再申请新资源。所以实际判定一定要走完化简流程不能看一眼环就下结论。2.3 图化简的完整推演流程化简的操作可以归纳成三步循环我在教学和复盘时都用这个顺序讲在当前图里找申请边可以被满足的进程也就是它申请的每个资源实例都存在未分配的空闲实例。假设这个进程正常执行完毕删掉它的所有申请边和分配边把占用的实例还给资源节点。重复第一步直到没有进程可以被化简。如果最后所有进程都被删光了残留图为空说明不存在死锁如果还剩下一些进程和边剩下的部分就是死锁涉及哪些资源、哪些执行单元一目了然。这个方法的价值在于它同时给出了死锁的范围而不只是有没有死锁排查的时候能直接锁定目标。实际工程里我们很难真的去画这张图因为线程和资源动辄成百上千。但思想完全可以搬过去用把线程转储里所有等待锁的记录整理出来每条记录的格式是线程 A 在等被线程 B 持有的锁 X这就是一条申请边加一条分配边把这些关系理成一张等待表剩下的就是查环。我在 6.3 节会讲具体怎么从转储里提取这些关系这里先记住一句话——图的本质就是一张谁在等谁的清单。3. 死锁预防动手拆掉四个条件中的一个预防的思路非常直接既然死锁需要四个条件同时成立那就在分配资源的时候用规则强行废掉其中一个让这四条永远凑不齐。代价是这些规则会限制资源的使用方式导致利用率下降或者编程模型变复杂。这一节把四种破坏手段逐一拆开讲说清楚各自实现方式、代价以及在真实项目里怎么落地。3.1 破坏互斥条件把独占资源改成可共享互斥是资源本身的属性能改动的空间很有限。真正的思路是把一次只能一个人用改造成多人可以同时用而不冲突。最典型的例子是打印机——物理打印机只有一台两个进程同时往里写数据打出来的东西就串了。常见的做法是引入一个后台的打印队列进程把打印任务交给队列就返回由专门的守护进程按顺序输出。这样进程之间抢的不再是打印机而是一个入队操作而队列的入队是可以做到原子且极短的环路根本形不成。同样的思路在软件里到处都是读多写少的场景用共享读锁代替独占锁、把共享状态改成消息传递、用不可变数据代替可变数据。这个方向的好处是不会让程序卡住坏处是适用范围窄。只要资源本质上是独占的比如对同一个文件同一段字节的写、对同一个账户余额的扣减互斥就消不掉。所以这条路的定位是能改就改改不了换别的招。3.2 破坏占有且等待一次性申请与预分配这条的思路是要么一个资源都不拿要么一次把所有要用的资源全部拿到手中间不允许拿一个等下一个的状态出现。经典实现是在进程开始执行前让它一次性申请声明所需的全部资源如果申请不到那就一个都不给进程继续等重新排队。这种策略的代价很实在。第一资源利用率会大幅下降——进程可能要到执行后期的某个分支才会用到某个资源但为了符合规则必须提前申请这段时间它就被白白占着。第二会造成饿死的风险一个需要大量稀缺资源的进程可能永远凑不齐一直排队一直失败。第三必须预先知道进程需要哪些资源这在通用程序里很难做到很多资源是按需动态申请的。所以这条在实际系统里很少全盘使用但在特定场合很好用。比如批处理作业的调度提交时就把资源需求写进作业描述里调度器统一分配再比如某些实时系统里任务的关键资源在初始化阶段就全部创建好运行期间只使用不再申请。我做过的一个数据导入任务就用过这个思路把内存缓冲、临时文件句柄、数据库连接全部在启动阶段申请好跑的时候不再申请任何东西代价是并发度必须按最坏情况配置好处是永远不会有死锁和资源竞争。3.3 破坏不可抢占可抢占资源的代价不可抢占的意思是别人不能抢你的资源。要破坏它就是允许抢占一个进程申请新资源失败时必须释放自己已经持有的所有资源退回去重新来或者由系统强行把资源从持有者手里夺走给等待者用。前一种做法叫主动释放重来后一种叫强制剥夺。强制剥夺的适用面很窄只对状态容易保存和恢复的资源有效比如 CPU 和内存——把进程换出到磁盘再换回来状态是完整的。对打印机、文件写操作这类资源就没法抢抢到一半的打印作业怎么恢复所以这条策略在 CPU 调度里常见时间片轮转本质就是抢占在资源分配层面很少用。主动释放重来则会导致大量重复劳动一个进程可能跑了百分之九十被迫回滚这个开销在很多业务场景下无法接受。注意如果你在设计一个任务调度器想用申请失败就释放重试的策略一定要配套随机退避和最大重试次数否则很容易从死锁变成活锁——所有任务同时释放、同时重试、再次同时失败。3.4 破坏循环等待全局锁序法可直接抄的规范这是四种手段里工程价值最高、落地成本最低的一种。核心思路给系统里所有需要加锁的资源编一个全局唯一的序号规定任何执行单元必须按序号从小到大申请资源不允许逆序。这样一来等待关系只可能沿着序号递增的方向延伸永远回不到起点环就不可能形成。为什么这样就一定没有环可以用反证法想一下假设存在环 P0→P1→P2→…→P0每条边意味着 Pi 持有序号为 i 的资源、在等序号更大的资源那么序号一路上升最后一条边要求 P0 持有的资源序号比前面所有都大同时又要小于 P0 最初持有的那个矛盾。所以只要规则被严格遵守环就构造不出来。落地的时候重点在三件事上缺一件规则就形同虚设第一建立一张全局的资源序号表锁必须在表里注册没注册的不许用。我倾向于把这个表做成一个常量类或者配置文件加锁时从表里取值避免有人随手写硬编码。第二所有加锁点统一按序。多把锁一起拿的时候先用一个列表收集要拿的锁对象按序号排序再依次加。这段代码可以封装成一个工具方法业务代码里不用每次都写排序逻辑。第三代码评审里把加锁顺序当成必查项。规则再清楚也架不住有人写新代码时忘了。我在几个项目里都推动过把这条写进评审清单比事后排查省太多时间。序号表的设计也有讲究。我一般按层级编号比如 10 段是数据库连接20 段是缓存客户端30 段是业务对象段与段之间留出空隙方便后续插入新锁。层级的划分原则是被依赖的放前面这样加锁顺序天然符合调用顺序符合直觉不容易出错误。3.5 四种预防策略的代价对照把四种手段放在一起比一比选型的时候会清楚很多破坏的条件具体做法主要收益主要代价适用场景互斥队列化、共享读、消息化从根本上消除竞争只对可抽象的资源有效打印池、日志写入、读多写少占有且等待一次性预分配全部资源实现简单、判定清晰利用率低、可能饿死批处理、实时任务、初始化固定不可抢占申请失败释放重来或强制剥夺避免长期僵持回滚开销大、适用范围窄CPU 调度、可保存恢复的状态循环等待全局编号、按序加锁开销几乎为零、效果确定需要全团队遵守规范几乎所有多线程业务代码从这张表能看出来前三种的代价都是系统层面的需要调整资源使用方式只有最后一种是纯约定改的是编码习惯。这也是为什么我在实际项目里只推锁序规范其他三种只在特定模块里用。顺带说一句预防策略的共性是提前限制它不问这次会不会真的死锁一律按最严格的方式办所以在资源紧张、并发度高的系统里会明显拖低吞吐。这也是后面两节要讲的避免和检测存在的理由。4. 死锁避免银行家算法的手工推演与代码落地预防是一刀切宁可降低效率也不让死锁有机会出现。避免则聪明得多每次分配资源之前先算一算这次分配之后系统还安不安全安全就给不安全就先不给让进程等着。这个算一算的经典方法就是银行家算法。它来自一个很形象的类比——银行家手里有一笔资金每次放贷前都要评估如果放出去之后还能满足所有客户的潜在最大需求就放否则就拖着等有回款了再说。4.1 安全状态、安全序列的真正含义先把几个数据结构立起来这是理解算法的地基Available每类资源当前还有多少空闲实例。Max每个进程声明的、对每类资源的最大需求必须提前告知。Allocation每个进程当前已经占了多少。Need每个进程还差多少等于 Max 减 Allocation。所谓安全状态指的是存在至少一个执行顺序让所有进程都能顺利跑完。这个顺序叫安全序列。判断方法是从当前空闲资源出发找一个 Need 能被满足的进程假设它跑完并把资源全还回来空闲量增加再找下一个如此往复。能把所有进程都安排上就是安全卡在中间谁都满足不了就是不安全。这里必须强调一个容易被误解的点不安全状态不等于死锁。不安全只是说存在死锁的可能系统可能一直到结束都没出事而安全状态则保证一定不会死锁。这就像天气预报说降水概率高不代表一定下雨但出门带伞是稳妥的。银行家算法宁可误判把本可以安全执行的请求拒掉也不愿冒险这是它保守的地方也是它在真实系统里用得少的原因之一。4.2 一道完整算例的手工推演光看定义容易飘拿一组具体数字推一遍最实在。系统有三类资源 A、B、C总量分别是 10、5、7五个进程的分配情况如下进程Allocation (A B C)Max (A B C)Need (A B C)P00 1 07 5 37 4 3P12 0 03 2 21 2 2P23 0 29 0 26 0 0P32 1 12 2 20 1 1P40 0 24 3 34 3 1先算当前空闲量。A 类已分配 023207空闲 3B 类已分配 100102空闲 3C 类已分配 002125空闲 2。所以 Available (3, 3, 2)。开始推安全序列Work 初始为 (3, 3, 2)看 P0Need(7,4,3)A 差 4 个不够。看 P1Need(1,2,2)三项都满足选它。P1 跑完归还 (2,0,0)Work 变成 (5,3,2)记录 P1。看 P2Need(6,0,0)A 要 6 个但只有 5不够。看 P3Need(0,1,1)满足。归还 (2,1,1)Work 变成 (7,4,3)记录 P3。看 P4Need(4,3,1)满足。归还 (0,0,2)Work 变成 (7,4,5)记录 P4。回头看 P0Need(7,4,3)Work 是 (7,4,5)满足。归还 (0,1,0)Work 变成 (7,5,5)记录 P0。最后 P2Need(6,0,0)满足。归还 (3,0,2)Work 变成 (10,5,7)记录 P2。安全序列是 P1 → P3 → P4 → P0 → P2五个进程全排上了所以当前状态安全。这个推演过程建议手写两三遍比看十遍文字管用尤其是归还资源后 Work 怎么变这一步很容易漏加。4.3 请求能否批准的两次判定进程运行中会提出新请求记为 Request。批准与否要过两道关顺序不能反第一道合法性检查。Request 的每一项都不能超过该进程声明的 Need也不能超过当前 Available。超过 Need 说明这个进程申请的资源量超出了它自己承诺的上限属于程序错误直接拒绝超过 Available 说明系统眼下没货让它等这个等待本身不是死锁。第二道安全性试探。假设把资源真给它修改三个数据结构Available 减掉 RequestAllocation 加上 RequestNeed 减掉 Request。然后跑一遍安全性算法。安全就真分配不安全就撤销这次试探恢复原状让进程等待。拿上面的状态试一个请求P1 申请 (1, 0, 2)。检查 Need(1,2,2)各项不超检查 Available(3,3,2)各项不超通过。试探分配后 Available 变成 (2,3,0)P1 的 Allocation 变成 (3,0,2)Need 变成 (0,2,0)。跑安全性检查Work(2,3,0) 能满足 P1 的 (0,2,0)P1 跑完归还 (3,0,2)Work 变 (5,3,2)后续 P3、P4、P0、P2 依次都能满足得到安全序列 P1 → P3 → P4 → P0 → P2。所以这个请求可以立即批准。再试一个会失败的P4 申请 (3, 3, 0)。合法性检查通过Need 是 (4,3,1)Available 是 (3,3,2)。试探分配后 Available 变成 (0,0,2)P4 的 Allocation 变成 (3,3,2)Need 变成 (1,0,1)。此时 Work 只有 (0,0,2)看 P0 要 (7,4,3) 不够P1 要 (1,2,2) 的 B 类不够P2 要 (6,0,0) 不够P3 要 (0,1,1) 的 B 类不够P4 自己要 (1,0,1) 的 A 类也不够。一个都排不上试探失败必须回滚——把 Available 还给 (3,3,2)P4 的 Allocation 和 Need 恢复原样让 P4 继续等。注意试探失败后的回滚一定要完整三个数据结构都要还原。我见过一份实现只还原了 Available忘了还原 Allocation 和 Need跑几十次请求之后账目全乱了症状是明明有资源却一直判不安全排查了很久才发现是记账错误。4.4 用 Python 把银行家算法写出来把上面的推演翻成代码逻辑其实很短。先写安全性检查核心是一个反复扫描的循环def is_safe(available, allocation, need): 返回 (是否安全, 安全序列) n len(allocation) m len(available) work available[:] finish [False] * n seq [] while len(seq) n: found False for i in range(n): if finish[i]: continue if all(need[i][j] work[j] for j in range(m)): for j in range(m): work[j] allocation[i][j] finish[i] True seq.append(i) found True break if not found: return False, [] return True, seq再写请求处理注意回滚要彻底def request_resources(pid, req, available, allocation, need): m len(available) if any(req[j] need[pid][j] for j in range(m)): return False, 请求超过声明的最大需求属于非法请求 if any(req[j] available[j] for j in range(m)): return False, 当前空闲资源不足进程需等待 # 试探性分配 for j in range(m): available[j] - req[j] allocation[pid][j] req[j] need[pid][j] - req[j] ok, seq is_safe(available, allocation, need) if ok: return True, f批准安全序列 {seq} # 不安全完整回滚 for j in range(m): available[j] req[j] allocation[pid][j] - req[j] need[pid][j] req[j] return False, 试探后进入不安全状态请求被拒绝跑一遍 4.2 节的数据就能复现出安全序列。这段代码我建议你真的贴上跑一次改几个数字看看结论怎么变比看别人推十遍更有效。有个小细节值得留意安全性检查里我用了反复扫描直到排满的写法而不是维护一个候选队列好处是不会漏掉因为 Work 增长才变得可满足的进程坏处是复杂度到 O(n²m)进程数上千时会慢。真实系统里进程数远小于这个量级所以这个写法够用。4.5 为什么工程里几乎不用银行家算法理论很漂亮但我在实际项目里一次都没见过有人在生产系统上跑银行家算法。原因有三条都很硬。第一必须预先知道最大需求。Max 矩阵要求每个进程在启动时声明它这辈子最多会用多少资源这在业务代码里根本不可能做到。一个请求处理线程要拿几个数据库连接、用多少内存完全取决于请求内容事先说不清。第二资源必须是可数的、固定的。算法假设资源总量恒定、每类资源可以按整数计数、进程之间互不干扰。现实中资源会动态加入或下线还有各种不可数的资源比如带宽、锁的排队位次模型套不上。第三每次请求都要做全局安全性检查进程数和资源类数一大开销就下不来而且这个检查必须持有全局状态天然是个串行瓶颈。所以这条路的真实定位是理解安全状态和安全序列这两个思想在资源可以事先规划的场景里局部借鉴。比如容器编排里给任务预留资源配额、提前判断能不能调度下去本质上就是同一个思路的简化版。至于通用业务代码还是老老实实用预防里的锁序规范加上下一节的检测兜底。5. 死锁检测与恢复等它发生再收拾它预防和避免都是不让它发生代价是限制多、开销大。第三条路是完全放弃事前约束让死锁有发生的可能但建立一套机制定期检查一旦发现就动手解开。这条路的优点是资源利用率最高缺点是检测和恢复本身有成本而且恢复往往意味着有工作要白做。多数通用操作系统走的其实是第四条路——干脆不管假装看不见因为死锁在通用系统里发生频率低处理成本高。但应用层不能这么潇洒业务系统的死锁必须有人管。5.1 检测的时机与开销权衡检测什么时候做是个纯工程权衡。主要有三种时机选择各自的适用场景差别很大。每次请求都检测最及时但开销最大相当于把检测逻辑塞进了每次加锁的路径上延迟会明显上升。只适合那种死锁代价极高、请求频率不高的系统。定时轮询检测每隔固定时间跑一次比如 30 秒或者 1 分钟。这是最常用的做法开销可控发现延迟也能接受。轮询间隔的选择有个经验值不要小于一次典型业务处理的耗时否则会频繁扫到看起来像环其实马上会解开的瞬时状态误报一堆。按需触发检测平时不查等到某个监控指标异常时再查比如线程池里等待超过阈值的任务数量突然飙升或者接口的平均响应时间翻了几倍。这种做法把成本压到最低但需要一套还不错的监控体系配合否则该查的时候想不起来。还有一个绕不开的问题检测算法本身要读全局状态而全局状态在被别的线程改着。所以检测通常需要拿一个元锁这个锁会短暂地卡住所有正常加锁操作。我见过一个实现把检测放在高频路径上结果检测本身成了性能瓶颈监控面板上看到的是加了死锁检测之后吞吐掉了三成。这个教训值得记住检测的开销不仅要看算法复杂度还要看它和业务路径的耦合程度。5.2 等待图上的环检测实现思路检测的核心数据结构就是等待图节点是执行单元边 A→B 表示 A 在等 B 持有的锁。有了这张图问题就退化成有向图找环经典的深度优先搜索就能干用三种颜色标记节点的状态最直观——白色是没访问过灰色是正在当前搜索路径上黑色是已经搜完的。搜索过程中一旦碰到灰色节点说明绕回来了环就找到了。def find_cycle(graph): graph: dict, 节点 - 它等待的节点列表 WHITE, GRAY, BLACK 0, 1, 2 color {node: WHITE for node in graph} path [] def dfs(node): color[node] GRAY path.append(node) for nxt in graph.get(node, []): if color.get(nxt, WHITE) GRAY: idx path.index(nxt) return path[idx:] [nxt] if color.get(nxt, WHITE) WHITE: res dfs(nxt) if res: return res path.pop() color[node] BLACK return None for node in graph: if color[node] WHITE: res dfs(node) if res: return res return None这段代码的返回值是环上的完整路径比如[T1, T3, T2, T1]读起来就是T1 等 T3T3 等 T2T2 等 T1直接就能拿去定位代码。比只返回发现死锁有用得多因为排查的人需要知道具体是哪几个执行单元、以什么顺序绕成圈的。实际落地时还有个细节真实系统的等待关系是动态变化的你读到一半可能就变了。所以通常会先做一次快照把当前的等待关系拷出来再分析分析结果可能略微过期但对于发现一个持续存在的死锁这个目的完全够用——真正的死锁会一直存在等你下次扫描还在。5.3 恢复手段终止进程还是抢占资源检测到环之后必须打破它。两种主要手段选哪种取决于业务能承受什么损失。终止执行单元是最简单粗暴的。可以终止环上的全部成员彻底但损失最大也可以一次只终止一个终止完再检测直到环消失精细但要多扫几轮。选哪个终止通常按代价排序优先级最低的先杀、已经执行时间最短的先杀、占用资源最少的先杀、剩余工作量最大的先杀。这几个标准有时候互相冲突实际系统里一般会给每个任务算一个牺牲分数分数最高的先被杀。资源抢占是另一个方向把资源从某个持有者手里收回来给等待者。难点在于被抢的那个执行单元怎么办如果它的状态可以回滚比如事务可以回滚、计算可以重来那就回滚到某个检查点如果不能回滚抢占就等于变相终止。所以抢占一般只用在有事务语义的场景里。数据库系统走的基本是终止 回滚这条路线检测到死锁后挑一个代价最小的事务回滚掉释放它持有的锁让其他事务继续。被回滚的事务通常会收到一个明确的错误码应用层捕获后重试。这也是为什么用数据库的时候应用程序必须写好重试逻辑——死锁被系统解开了但被牺牲的那一方需要自己重新来一次。注意恢复策略一定要配合重试预算。如果业务代码在收到死锁错误后无脑重试而重试又触发了新的死锁就可能陷入无限循环。我一般会设一个最大重试次数三次是个常见值并加随机退避超过就报错上抛让人工介入。6. 工程实战代码里和数据库里的死锁怎么排前面五节讲的是模型和算法这一节讲现场。真实世界的死锁排查八成以上靠的是锁顺序不一致这一个原因剩下的两成里一部分是对同一个资源重复加锁一部分是数据库的锁范围比预期大。把这些典型场景和排查动作整理清楚遇到问题时能少走很多弯路。6.1 一段必然死锁的多线程代码及其三种修法先看最经典的一段两个线程以相反顺序拿两把锁import threading import time lock_a threading.Lock() lock_b threading.Lock() def worker_one(): with lock_a: time.sleep(0.05) with lock_b: print(worker one done) def worker_two(): with lock_b: time.sleep(0.05) with lock_a: print(worker two done) t1 threading.Thread(targetworker_one) t2 threading.Thread(targetworker_two) t1.start(); t2.start() t1.join(); t2.join()这段代码的两个 sleep 是关键它们保证了两个线程都能先拿到自己第一把锁再去抢第二把从而稳定复现死锁。没有这两个 sleep 的话大概率其中一个线程一口气跑完看不出问题——这也是这类 bug 在测试环境难复现、上线之后偶尔出现的原因。注意靠加 sleep 来保证顺序是最脆弱的手段。业务代码里的时序取决于网络、磁盘、调度测试环境调好的时序在生产环境完全不成立。所以验证并发代码时不要依赖 sleep 去构造交错要反复高频压测或者用专门的并发测试工具强行打乱执行顺序。三种修法按推荐度排序第一种固定加锁顺序。这就是 3.4 节讲的锁序法。给 lock_a 和 lock_b 定一个全局顺序两个线程都按同样的顺序加。改完之后环路在构造上就不存在了成本几乎为零是首选。第二种一次拿全或者不拿。用一个外层互斥量把拿 a 再拿 b这个组合动作保护起来或者把两把锁合并成一把。合并的代价是并发度下降两把锁本来可以让两组不相干的业务并行合并之后就得排队。第三种带超时的加锁。用lock.acquire(timeout1)拿不到就释放自己手里的等一会儿按同样的顺序再试。这种方式能让系统从死锁里自己爬出来但它只是兜底不是根治——它把死锁变成了偶发的超时重试问题还在只是不致命了。适合用在不好统一锁序的遗留代码里。实践中的组合是主体代码严格按锁序第一种关键的对外接口加超时兜底第三种让系统即使被别人的错误代码带进去也能自愈。6.2 数据库死锁日志的读法数据库的死锁和线程死锁在原理上是一回事只是资源从锁对象变成了行锁、索引记录。以行锁为例两个事务分别更新同一张表的两条记录顺序相反就构成了环事务甲持有第 1 行的锁在等第 2 行事务乙持有第 2 行的锁在等第 1 行。数据库通常会定期扫描锁等待关系发现环之后主动回滚其中一个事务并在日志里记一笔。日志内容一般包含几个部分哪个事务被选为牺牲者、涉及哪两个事务、每个事务当前在等什么锁、以及它已经持有了什么锁。读这类日志的关键是找到两个事务各自在等对方持有的锁这个对称结构一旦看到基本就定性了。排查的时候有几个高频诱因值得优先怀疑诱因表现处理方式更新顺序不一致两个事务更新同一批记录但顺序相反业务代码统一按主键排序后批量更新事务过长事务里夹杂了远程调用、文件操作把远程调用挪到事务外缩短持锁时间缺少合适索引更新时扫描了大量行锁范围远超预期补上查询条件对应的索引间隙锁范围过大范围条件更新时锁住了不存在的间隙调整隔离级别或改用等值条件并发批量任务多个定时任务同时对同一批数据做更新错开执行时间或加任务级分布式锁其中缺少索引这一条最隐蔽。逻辑上只更新一行实际上因为条件字段没索引扫描了整张表并锁住了沿途的行跟另一个同样没走索引的事务正好交叉环就成了。我遇到过一例现象是两个完全不相干的功能互相死锁看代码怎么也看不出交集最后查执行计划发现两边都在做全表扫描锁在了同一段数据上。补了两个索引之后彻底消失。6.3 线上排查的动作清单真发生卡死时的处置顺序我整理成了一套固定的动作按这个顺序走基本不会乱先确认是卡死还是慢。看接口的响应时间分布如果是从某个时刻开始全都超时而不是变慢卡死的可能性大如果只是变慢先查下游依赖和慢查询。抓现场别急着重启。重启会丢掉唯一的证据。连续抓两到三份线程转储间隔十秒左右对比哪些线程一直停在同一个位置——两三次都在同一个加锁语句上基本就是卡住了。提取等待关系。从转储里找出所有等待获取锁的记录每条都记录了谁在等、被谁持有把这些关系整理成一张表或者直接喂给 5.2 节那个找环的函数。定位到代码。拿到环上的线程名之后去看对应的代码找加锁顺序不一致的地方。这一步通常很快因为环上的线程数量不会多。确认影响面再动手。杀掉一个线程还是重启整个实例取决于这个任务能不能安全中断。如果是扣款、下单这类任务中断前得先确认事务状态别留下半截数据。改完要验证。改成统一锁序之后用高频并发压测跑一遍确认那个环不再出现。只看代码觉得对是不够的。注意第 2 步的连续抓多份非常重要。单份转储只能看到某一瞬间的状态很多正常业务在某一瞬间也在等锁容易误判。连续几份中持续不变的等待关系才是死锁的可信证据。7. 常见问题速查与踩坑记录这一节把前面散落的概念和实操问题集中一下方便回头查。里面的坑基本都是我自己踩过或者见过别人踩的按出现频率排。7.1 概念辨析速查表问题结论说明有环就一定死锁吗不一定单实例资源时等价多实例要看能不能化简不安全状态等于死锁吗不等于不安全只是有可能死锁系统可能一直没事互斥条件能消除吗大多数不能能改造成队列化、共享读的只是少数场景死锁和饥饿的区别死锁是闭环饥饿是排队不公饥饿状态下的执行单元最终能推进只是慢银行家算法能上生产吗一般不能需要预知最大需求现实中做不到加锁超时算解决死锁吗算兜底不算根治问题还在只是不再永久卡住死锁检测放主路径合适吗一般不合适检测需要读全局状态会卡住正常加锁单线程会不会死锁会同一线程重复拿不可重入锁就会自己卡死最后一行值得单独说一句。很多人以为死锁一定要两个以上线程才会发生其实同一个线程对不可重入的锁连续加两次第二次就会永远等自己这也是死锁——只不过环的长度是一。写递归函数或者有回调的代码时特别容易撞上这种一旦进入不可重入锁保护的代码里又间接调用了自己立刻卡死。排查的时候如果看到只有一个线程卡住、没有其他线程在等它就往这个方向想。7.2 五个高频误区的实战纠正误区一以为加了锁就安全不考虑锁的粒度。锁太大并发上不去锁太小组合操作之间就有竞争窗口。我的经验是先按业务不变量来划边界——哪些数据必须一起变、不能看到中间状态这些就放在同一把锁里其余尽量拆开。误区二在持有锁的时候做远程调用。网络一抖锁就被长时间占着本来不会死锁的场景也被拖成死锁。这条在数据库事务里同样成立事务里夹远程调用是长事务的头号来源。误区三嵌套加锁不定顺序等出问题再补。锁序规范必须在写第一行代码时立起来事后补的成本是数倍。尤其是多人协作的项目等出过事故再补往往已经有一堆历史代码需要挨个改。误区四把重试当万能药。重试只有在失败是偶发、且重试本身不会再引发同样冲突的时候才有效。不做退避的立即重试很容易把一次冲突放大成雪崩。误区五只在测试环境验证并发。并发问题的复现高度依赖时序测试环境的机器数、数据量、延迟都和线上不同。我见过测试环境跑一个月没事、上线第一天就死锁的代码。验证并发只能靠高频压测、代码审查和静态检查三件套不能靠跑过一遍没出问题。最后分享一个我在实际项目里养成的习惯凡是需要同时拿两把以上锁的地方我都会在那一行上面写个注释标明锁的编号和顺序比如按序加锁30 业务对象 → 40 缓存。这个注释看着啰嗦但它让后面接手的人一眼就能判断出顺序对不对也让代码评审有个明确的锚点。规范真正的生命力不在纸面上在每一次加锁的具体位置。