ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

死锁从形成条件到检测恢复:锁序规范与线上排查实战

死锁从形成条件到检测恢复:锁序规范与线上排查实战

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 的一个点指向 P1,P2 指向 R1 的框,R2 的框指向 P2,P1 指向 R2 的框。四条边首尾相连,一个标准的环,这个状态就是死锁。

注意:画图时不要只画"谁给了谁",一定要把"谁在等谁"也画上去。我见过不少人只画分配关系,那永远画不出环,因为等待关系才是闭环的关键部分。

2.2 有环是否一定死锁:单实例与多实例的差别

这里有一处极容易踩的坑:图上出现环,不代表一定死锁。结论要分情况讨论。

如果系统里每一类资源都只有一个实例,那么"有环"和"死锁"是等价关系,判断非常干脆。只要出现环,环上的所有进程都卡死了,环外的进程如果依赖环上的资源,也可能被拖累。

如果资源有多个实例,情况就变了。环可能只是"局部拥挤",环上某个进程其实还能从别处拿到同类型资源的另一个空闲实例,拿完之后执行结束、释放资源,环就自己解开了。这种环叫"有环但未死锁"。教材里更严密的做法是通过图化简来判断:只要某个进程的申请能被当前空闲资源满足,就假设它执行完毕、释放全部资源,把这个节点和相关边删掉;反复执行,最后如果所有进程都能化简掉,说明状态是安全的,剩下的环才是真死锁。

举个数:R1 有两个实例,P1 占一个,P2 占一个,同时 P2 申请第二个 R1。这时候 P2 指向 R1 的申请边和 R1 指向 P2 的分配边形成一个环。但 R1 没有空闲实例了,P1 也不申请任何东西,如果 P1 能自然结束,它释放出来的实例正好给 P2 用。这就要看 P1 后续会不会再申请新资源。所以实际判定一定要走完化简流程,不能看一眼环就下结论。

2.3 图化简的完整推演流程

化简的操作可以归纳成三步循环,我在教学和复盘时都用这个顺序讲:

  1. 在当前图里找"申请边可以被满足"的进程,也就是它申请的每个资源实例,都存在未分配的空闲实例。
  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 3
P12 0 03 2 21 2 2
P23 0 29 0 26 0 0
P32 1 12 2 20 1 1
P40 0 24 3 34 3 1

先算当前空闲量。A 类已分配 0+2+3+2+0=7,空闲 3;B 类已分配 1+0+0+1+0=2,空闲 3;C 类已分配 0+0+2+1+2=5,空闲 2。所以 Available = (3, 3, 2)。

开始推安全序列,Work 初始为 (3, 3, 2):

  • 看 P0,Need(7,4,3),A 差 4 个,不够。
  • 看 P1,Need(1,2,2),三项都满足,选它。P1 跑完归还 (2,0,0),Work 变成 (5,3,2),记录 P1。
  • 看 P2,Need(6,0,0),A 要 6 个但只有 5,不够。
  • 看 P3,Need(0,1,1),满足。归还 (2,1,1),Work 变成 (7,4,3),记录 P3。
  • 看 P4,Need(4,3,1),满足。归还 (0,0,2),Work 变成 (7,4,5),记录 P4。
  • 回头看 P0,Need(7,4,3),Work 是 (7,4,5),满足。归还 (0,1,0),Work 变成 (7,5,5),记录 P0。
  • 最后 P2,Need(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 减掉 Request,Allocation 加上 Request,Need 减掉 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 等 T3,T3 等 T2,T2 等 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(target=worker_one) t2 = threading.Thread(target=worker_two) t1.start(); t2.start() t1.join(); t2.join()

这段代码的两个 sleep 是关键,它们保证了两个线程都能先拿到自己第一把锁再去抢第二把,从而稳定复现死锁。没有这两个 sleep 的话,大概率其中一个线程一口气跑完,看不出问题——这也是这类 bug 在测试环境难复现、上线之后偶尔出现的原因。

注意:靠加 sleep 来"保证顺序"是最脆弱的手段。业务代码里的时序取决于网络、磁盘、调度,测试环境调好的时序在生产环境完全不成立。所以验证并发代码时,不要依赖 sleep 去构造交错,要反复高频压测,或者用专门的并发测试工具强行打乱执行顺序。

三种修法按推荐度排序:

第一种,固定加锁顺序。这就是 3.4 节讲的锁序法。给 lock_a 和 lock_b 定一个全局顺序,两个线程都按同样的顺序加。改完之后环路在构造上就不存在了,成本几乎为零,是首选。

第二种,一次拿全或者不拿。用一个外层互斥量把"拿 a 再拿 b"这个组合动作保护起来,或者把两把锁合并成一把。合并的代价是并发度下降,两把锁本来可以让两组不相干的业务并行,合并之后就得排队。

第三种,带超时的加锁。用lock.acquire(timeout=1),拿不到就释放自己手里的,等一会儿按同样的顺序再试。这种方式能让系统从死锁里自己爬出来,但它只是兜底,不是根治——它把死锁变成了"偶发的超时重试",问题还在,只是不致命了。适合用在不好统一锁序的遗留代码里。

实践中的组合是:主体代码严格按锁序(第一种),关键的对外接口加超时兜底(第三种),让系统即使被别人的错误代码带进去也能自愈。

6.2 数据库死锁日志的读法

数据库的死锁和线程死锁在原理上是一回事,只是"资源"从锁对象变成了行锁、索引记录。以行锁为例,两个事务分别更新同一张表的两条记录,顺序相反,就构成了环:事务甲持有第 1 行的锁在等第 2 行,事务乙持有第 2 行的锁在等第 1 行。

数据库通常会定期扫描锁等待关系,发现环之后主动回滚其中一个事务,并在日志里记一笔。日志内容一般包含几个部分:哪个事务被选为牺牲者、涉及哪两个事务、每个事务当前在等什么锁、以及它已经持有了什么锁。读这类日志的关键是找到"两个事务各自在等对方持有的锁"这个对称结构,一旦看到,基本就定性了。

排查的时候有几个高频诱因值得优先怀疑:

诱因表现处理方式
更新顺序不一致两个事务更新同一批记录但顺序相反业务代码统一按主键排序后批量更新
事务过长事务里夹杂了远程调用、文件操作把远程调用挪到事务外,缩短持锁时间
缺少合适索引更新时扫描了大量行,锁范围远超预期补上查询条件对应的索引
间隙锁范围过大范围条件更新时锁住了不存在的间隙调整隔离级别或改用等值条件
并发批量任务多个定时任务同时对同一批数据做更新错开执行时间或加任务级分布式锁

其中"缺少索引"这一条最隐蔽。逻辑上只更新一行,实际上因为条件字段没索引,扫描了整张表并锁住了沿途的行,跟另一个同样没走索引的事务正好交叉,环就成了。我遇到过一例,现象是两个完全不相干的功能互相死锁,看代码怎么也看不出交集,最后查执行计划发现两边都在做全表扫描,锁在了同一段数据上。补了两个索引之后彻底消失。

6.3 线上排查的动作清单

真发生卡死时的处置顺序,我整理成了一套固定的动作,按这个顺序走基本不会乱:

  1. 先确认是卡死还是慢。看接口的响应时间分布,如果是从某个时刻开始"全都超时"而不是"变慢",卡死的可能性大;如果只是变慢,先查下游依赖和慢查询。
  2. 抓现场,别急着重启。重启会丢掉唯一的证据。连续抓两到三份线程转储,间隔十秒左右,对比哪些线程一直停在同一个位置——两三次都在同一个加锁语句上,基本就是卡住了。
  3. 提取等待关系。从转储里找出所有"等待获取锁"的记录,每条都记录了"谁在等、被谁持有",把这些关系整理成一张表,或者直接喂给 5.2 节那个找环的函数。
  4. 定位到代码。拿到环上的线程名之后,去看对应的代码,找加锁顺序不一致的地方。这一步通常很快,因为环上的线程数量不会多。
  5. 确认影响面再动手。杀掉一个线程还是重启整个实例,取决于这个任务能不能安全中断。如果是扣款、下单这类任务,中断前得先确认事务状态,别留下半截数据。
  6. 改完要验证。改成统一锁序之后,用高频并发压测跑一遍,确认那个环不再出现。只看代码觉得对是不够的。

注意:第 2 步的"连续抓多份"非常重要。单份转储只能看到某一瞬间的状态,很多正常业务在某一瞬间也在等锁,容易误判。连续几份中持续不变的等待关系,才是死锁的可信证据。

7. 常见问题速查与踩坑记录

这一节把前面散落的概念和实操问题集中一下,方便回头查。里面的坑基本都是我自己踩过或者见过别人踩的,按出现频率排。

7.1 概念辨析速查表

问题结论说明
有环就一定死锁吗不一定单实例资源时等价;多实例要看能不能化简
不安全状态等于死锁吗不等于不安全只是有可能死锁,系统可能一直没事
互斥条件能消除吗大多数不能能改造成队列化、共享读的只是少数场景
死锁和饥饿的区别死锁是闭环,饥饿是排队不公饥饿状态下的执行单元最终能推进,只是慢
银行家算法能上生产吗一般不能需要预知最大需求,现实中做不到
加锁超时算解决死锁吗算兜底,不算根治问题还在,只是不再永久卡住
死锁检测放主路径合适吗一般不合适检测需要读全局状态,会卡住正常加锁
单线程会不会死锁会同一线程重复拿不可重入锁就会自己卡死

最后一行值得单独说一句。很多人以为死锁一定要两个以上线程才会发生,其实同一个线程对不可重入的锁连续加两次,第二次就会永远等自己,这也是死锁——只不过环的长度是一。写递归函数或者有回调的代码时特别容易撞上这种,一旦进入不可重入锁保护的代码里又间接调用了自己,立刻卡死。排查的时候如果看到只有一个线程卡住、没有其他线程在等它,就往这个方向想。

7.2 五个高频误区的实战纠正

误区一:以为加了锁就安全,不考虑锁的粒度。锁太大,并发上不去;锁太小,组合操作之间就有竞争窗口。我的经验是先按业务不变量来划边界——哪些数据必须一起变、不能看到中间状态,这些就放在同一把锁里;其余尽量拆开。

误区二:在持有锁的时候做远程调用。网络一抖,锁就被长时间占着,本来不会死锁的场景也被拖成死锁。这条在数据库事务里同样成立,事务里夹远程调用是长事务的头号来源。

误区三:嵌套加锁不定顺序,等出问题再补。锁序规范必须在写第一行代码时立起来,事后补的成本是数倍。尤其是多人协作的项目,等出过事故再补,往往已经有一堆历史代码需要挨个改。

误区四:把重试当万能药。重试只有在失败是偶发、且重试本身不会再引发同样冲突的时候才有效。不做退避的立即重试,很容易把一次冲突放大成雪崩。

误区五:只在测试环境验证并发。并发问题的复现高度依赖时序,测试环境的机器数、数据量、延迟都和线上不同。我见过测试环境跑一个月没事、上线第一天就死锁的代码。验证并发只能靠高频压测、代码审查和静态检查三件套,不能靠"跑过一遍没出问题"。

最后分享一个我在实际项目里养成的习惯:凡是需要同时拿两把以上锁的地方,我都会在那一行上面写个注释,标明锁的编号和顺序,比如"按序加锁:30 业务对象 → 40 缓存"。这个注释看着啰嗦,但它让后面接手的人一眼就能判断出顺序对不对,也让代码评审有个明确的锚点。规范真正的生命力不在纸面上,在每一次加锁的具体位置。

返回列表