1. 把"卡住"这个词拆开:死锁究竟卡在哪一层
教科书第七章讲死锁,很多人读完的第一反应是"这不就是进程互相等吗",然后翻到银行家算法算完几道题就过去了。真到线上遇到服务整体不响应、线程转储里一堆 BLOCKED,才发现这一章的价值不在算法本身,而在于它提供了一套把"卡死"这个模糊现象拆成可判定结构的语言:资源、持有、请求、等待边、环路。死锁(deadlock)指的是这样一组进程:集合里每一个进程都在等待一个只能由集合内其他进程释放的资源,于是没有外力介入谁也无法推进。这个定义看起来朴素,但它把问题锁进了一个非常窄的框里——必须是"互相等",单向等下去不叫死锁,那叫等待链,前端进程总会结束。框定得越窄,处理手段才越有针对性。这一章真正想教的是:如何判断一个系统当前状态是安全的、不安全的、还是已经死锁;以及在这三种状态之间,操作系统该用哪种代价模型去干预。下面我按自己理解的顺序,把资源分配图、四个必要条件、预防/避免/检测/恢复这四条路线,还有落到实际代码里的坑,一条条拆开讲。
1.1 一次进程冻结的现场还原
先还原一个最朴素的场景,把抽象概念落地。假设有两个进程 P1 和 P2,两台设备性质的资源 R1(比如一台扫描仪接口)和 R2(比如一个独占式串口),每个资源只有一份实例。P1 先申请到 R1,随后去申请 R2;P2 先申请到 R2,随后去申请 R1。如果调度器恰好让两者各自完成第一次申请,那么接下来就是永恒的对峙:P1 拿着 R1 等 R2,P2 拿着 R2 等 R1,二者的等待边首尾相接构成一个环,环上没有外部进程持有它们需要的东西,谁都动不了。
这个场景里有个关键细节容易被忽略:死锁不是"资源不够"导致的,而是"资源分配顺序"导致的。系统总共就两份资源,此刻两份都被占着,但即使再多加一份 R1 或者 R2,只要申请顺序不变,两个进程依然可能互相卡住——新增的实例只会让卡住的概率下降,不会让卡住的可能消失。这是跟"饥饿"最本质的区别,饥饿是资源一直被更强势的竞争者抢走,只要竞争者减少,饥饿者就能前进;死锁则是参与者自己构成了闭环,外部资源再多也没用。
从内核视角看,这件事通常表现为几个进程的状态机同时停在"等待资源"这一格,并且它们等待的对象在同一条链表或同一张表里形成了环。教科书画成图,内核实现里就是资源表 + 进程表之间互相指向的关系。理解这层映射很重要,因为它决定了后面检测算法的形态:判断有没有死锁,本质上就是判断一张有向图里有没有环。
1.2 资源分配图:把"谁在等谁"画成一张有向图
资源分配图是这一章的第一个工具,节点分两类:圆形是进程,方形是资源类型(方框里的小圆点表示实例数量)。边也分两类:申请边从进程指向资源,表示"我要但还没拿到";分配边从资源实例指向进程,表示"这份已经给你了"。申请边在语义上要求该进程在对应资源上没有其他持有的实例,否则它就是在"占有并等待",这种情形在严格的图模型里需要额外处理。
有了这张图,判断死锁就变成纯图论问题:
- 如果图中没有环,一定没有死锁,这个结论无条件是成立的。
- 如果图中有环且每类资源只有一个实例,那么环即死锁,充要条件成立。
- 如果图中有环但资源有多实例,环只是死锁的必要条件,不是充分条件。因为环上某个进程可能在等的那一份,恰好能被环外某个进程释放出来。
第三条是很多人做题时栽跟头的地方。举个例子:P1 持有 R1 的一份实例、请求 R3;P2 持有 R3、请求 R1;表面上 P1 和 P2 之间有个环,但 R1 有两份实例,其中一份被 P3 持有。如果 P3 不参与这个环并且很快会释放,那么 P1 完全可能从 P3 那里拿到 R1,环自行解开。所以多实例场景下判定死锁,必须用后面要讲的"化简"或者"检测算法",靠肉眼看环是不牢靠的。
| 图的形态 | 资源实例数 | 结论 |
|---|---|---|
| 无环 | 任意 | 一定无死锁 |
| 有环 | 单实例 | 一定死锁 |
| 有环 | 多实例 | 可能死锁,需化简判定 |
| 有环且环上有非阻塞进程 | 多实例 | 通常可化解,需看后续释放顺序 |
1.3 四个必要条件常被误读成"操作清单"
互斥、占有并等待、非抢占、循环等待,这四条几乎所有资料都会列,但列完之后常常被当成四把可以随便挑一把砍的刀。真实情况是:这四个条件是同时成立才有死锁,缺一不可,但它们之间不是平权的,破坏难度差异极大。
- 互斥是很多资源的物理属性。打印机、写文件、设备寄存器,天然不能共享,硬要破坏就得引入假脱机(spooling)这类中间层,把独占资源虚拟成共享队列。这招在打印机上成立,在"进程私有内存页"上就不成立。
- 占有并等待要破坏就得让进程一次性申请全部所需资源,或者申请前先释放已有资源。前者降低资源利用率,后者可能导致活锁(反复申请释放却始终凑不齐)。
- 非抢占要破坏就得允许系统强行收回资源,代价是进程状态要能保存和回滚,对于写了一半的数据库事务来说,这个代价可以直接劝退。
- 循环等待是四条里最"软"的一条,只要给所有资源类型编个全局序号,规定只能按递增顺序申请,环就构造不出来。这条在工程里被用得最多,因为它不改语义、不改资源属性,只在编码规范层面动手。
注意:破坏循环等待是"最便宜"的方案,但它的前提是资源集合在编译期或部署期就基本确定。资源是动态产生、编号无法预先约定的场景(比如对象锁的地址、按需创建的连接),这一招就退化成了"约定俗成的加锁顺序",需要靠代码评审和工具保障,而不是靠系统机制。
2. 死锁预防:四条路各自要付的账单
预防(prevention)这个词在教材里的含义很窄:通过约束请求方式来确保四个必要条件至少有一个永远不成立。它不管当前系统状态如何,属于静态策略,一旦生效就永久生效。这跟后面要讲的"避免"是两回事,避免是动态的,每次分配前都要算一遍。搞混这两个概念,读后续章节会一路拧巴。
预防的四种手段里,前三种基本属于"用吞吐量换确定性",第四种属于"用编码约束换确定性"。从工程落地角度看,只有第四种被大面积采用,但前三种在特定场景(比如实时系统、嵌入式资源管理、批处理调度)里仍然有价值。下面逐个过一遍,重点讲代价,因为不写代价的方案在真实系统里是没法选型的。
2.1 破坏互斥:把独占资源改造成可共享
破坏互斥的思路是让资源本身变成可同时访问的,最典型的是打印机的假脱机:进程不直接操作打印机,而是把打印任务写进磁盘上的队列,由一个守护进程串行输出。这样进程之间争夺的是"往队列里写"这件事,而队列是可以并发写的,互斥被转移到了粒度更小、时间更短的地方。
这招的适用边界很窄:只有当资源的使用过程可以被延迟、被重排时才成立。打印任务早几秒晚几秒无所谓,但一个事务里的写锁如果被假脱机式地推迟,读到的数据就可能是旧值,语义直接崩了。所以判断能不能破坏互斥,只看一个问题:这个资源的使用能不能被解耦成"提交请求"和"实际执行"两段?能,就有戏;不能,就别想了。
还有一类资源天生就是要互斥的,比如进程的写指针、共享内存里的计数器。这类东西破坏互斥 = 引入竞争 = 数据错乱,属于绝对不能碰的红线。所以教材把"破坏互斥"排在第一位讲,但实际工程里它能覆盖的面很窄,更多是作为一种思路存在。
2.2 破坏占有并等待:全有或全无的两种实现
这条有两个实现路径,代价各自不同。
第一条是静态全量申请:进程启动时一次性声明并申请它整个生命周期需要的所有资源。做不到就等待,一个都不给。这个方案的缺点是资源利用率极低——一个进程可能要到最后一秒才用得上某台设备,但设备从第一秒就被它占着,别的进程干瞪眼。更麻烦的是它要求进程在运行前就知道自己要什么,这对交互式程序和动态加载的模块几乎不可能。
第二条是申请前先释放:进程在申请新资源之前,必须先释放手上所有已持有的资源,然后再重新一次性申请。听起来优雅,但它会引入活锁:两个进程各自释放、各自重申请、再次冲突、再次释放,CPU 空转在"申请—释放"的循环里,谁也没前进。活锁不是死锁,因为进程状态在变,但对外表现同样是"卡住",而且更难排查,因为转储里看到的线程是 RUNNABLE 而不是 BLOCKED。
写到这里补一句经验:如果你的系统里出现了"CPU 占用不低但业务毫无进展"的现象,除了查死循环,也要怀疑这类反复申请释放的活锁。排查方法是采样几次线程转储,看同一批线程是不是一直停在申请资源的同一行代码上,栈帧在不断重复——这就是活锁的典型指纹。
2.3 破坏非抢占与循环等待:代价最小也最常用的那一条
破坏非抢占意味着允许系统强行剥夺。具体做法是:当进程 P 申请的资源被 Q 持有时,检查 Q 是否正在等待 P 持有的资源;如果是,就强制 Q 释放,把资源给 P,Q 回滚到某个可恢复点等待。这个方案要求进程状态可保存可重入,对纯计算型任务勉强能做,对已经产生外部副作用的操作(发了消息、写了文件)基本不可行。它在教材里是标准答案之一,在工程里几乎见不到,原因就是回滚成本。
破坏循环等待的做法是把所有资源类型全局编号,规定进程只能按严格递增的顺序申请。这样任何一个进程的申请方向都是"向编号更大的资源",而等待边永远从小编号指向大编号,图就不可能成环。证明很简洁,不需要更多解释。
代价在于两点:一是编号必须在系统范围内稳定,动态资源(比如运行时创建的对象锁)无法预先编号,只能退化成代码约定;二是如果进程实际需要的资源顺序与编号顺序相反,它就得先申请小号的再申请大号的,中间可能要空持一段时间,资源利用率略降。但比起前三条,这点代价几乎可以忽略,所以它成了工业界唯一被普遍采用的预防手段。
2.4 预防路线的共同天花板
四条路走完会发现一个共性:它们都在"限制请求方式",而不是"判断当前状态"。这意味着无论系统当前多空闲,这些约束都照常生效。系统空闲时也不让你自由申请,这就叫保守。
保守带来的直接后果是并发度下降。全量申请让进程无法逐步推进,递增编号让某些合法的并发序列被人为禁止——两个进程本来可以错开使用资源,因为编号顺序限制,被迫串行。对于吞吐量敏感的服务器程序,这种损失是不能接受的。
所以工程实践里,预防更多是作为"编码规范"存在:团队约定加锁顺序、约定长事务拆分、约定不在持锁期间调用外部服务。这些约定本质就是在破坏循环等待和占有并等待,只不过执行者是人而不是内核。内核层面真正用预防的场景,是那些"一旦死锁就无从恢复"的系统,比如航天器控制、工业产线的实时调度,那里宁可牺牲吞吐量也要保证确定性。
3. 死锁避免:安全序列到底在保证什么
避免(avoidance)是这一章的技术核心,也是最容易被当成"算数题"绕过去的部分。它的思路跟预防完全不同:不限制申请方式,而是在每次分配前动态检查,如果这次分配会让系统进入一个"可能死锁"的状态,就拒绝。注意这里的措辞是"可能死锁"而不是"已经死锁",这就引出了安全状态的概念。
安全状态的定义是:存在至少一个进程的完成顺序,使得按这个顺序依次满足每个进程的最大需求,所有进程都能跑完。这个顺序叫安全序列。系统处于安全状态意味着未来一定不会死锁;处于不安全状态意味着可能死锁——注意是可能,不是必然,不安全状态仍有概率顺利走完。这个区别是理解整章的关键分水岭。
3.1 安全状态、不安全状态、死锁三者的关系
三者是包含关系:死锁状态 ⊂ 不安全状态 ⊂ 所有状态。安全状态里绝不会有死锁;不安全状态里可能有死锁,也可能没有;死锁一定是不安全状态,但并非所有不安全状态都已经死锁。这个嵌套关系决定了一个基本事实——避免策略是保守的,它会拒绝一些其实不会死锁的分配,因为它只认"安全序列是否存在"这个充分条件,不去赌运气。
| 状态 | 是否可能死锁 | 避免策略的处理 |
|---|---|---|
| 安全 | 不会 | 允许分配 |
| 不安全但未死锁 | 可能 | 拒绝分配 |
| 已死锁 | 已经发生 | 无法用避免处理,只能检测恢复 |
用一个类比:安全状态像是一条有明确撤退路线的阵地,无论敌人怎么打,你都能按预定路线撤出去;不安全状态是阵地上还有路,但哪条路都不保证通。避免策略相当于"只要没有百分之百的撤退路线,这仗就不打"。它保守,但它给出的是硬保证。
提示:很多资料说"避免策略保证不死锁",严谨的说法是"避免策略保证系统永不进入不安全状态,而安全状态必然不死锁"。这两句不等价,后者更强也更准确。
3.2 银行家算法的手工推演
银行家算法的本质就是"每次都判断分配后是否还存在安全序列"。用一个完整例子走一遍,比看十遍定义强。
系统有 A、B、C 三类资源,总量分别 10、5、7。五个进程的已分配矩阵 Allocation 和最大需求矩阵 Max 如下,Need = Max − Allocation。
| 进程 | Allocation (A B C) | Max (A B C) | Need (A B C) |
|---|---|---|---|
| P0 | 0 1 0 | 7 5 3 | 7 4 3 |
| P1 | 2 0 0 | 3 2 2 | 1 2 2 |
| P2 | 3 0 2 | 9 0 2 | 6 0 0 |
| P3 | 2 1 1 | 2 2 2 | 0 1 1 |
| P4 | 0 0 2 | 4 3 3 | 4 3 1 |
已分配总量是 (7, 2, 5),所以 Available = (10,5,7) − (7,2,5) = (3, 3, 2)。
第一步,找安全序列。从 Available = (3,3,2) 出发,逐个比对 Need:
- P0 需要 (7,4,3),A 类差太多,跳过。
- P1 需要 (1,2,2),三类的需求都 ≤ 可用量,可以满足。假设 P1 跑完并归还全部资源,Available 变成 (3+2, 3+0, 2+0) = (5,3,2)。
- P3 需要 (0,1,1),满足。归还后 Available = (5+2, 3+1, 2+1) = (7,4,3)。
- P4 需要 (4,3,1),满足。归还后 Available = (7+0, 4+0, 3+2) = (7,4,5)。
- P0 需要 (7,4,3),满足。归还后 Available = (7,7,5)。
- P2 需要 (6,0,0),满足。全部完成。
得到安全序列 <P1, P3, P4, P0, P2>,说明当前系统是安全的。注意安全序列不唯一,能找到一条就够了,不必找全部。
第二步,处理请求。现在 P1 提出请求 Request = (1,0,2)。先做两道检查:Request ≤ Need((1,0,2) ≤ (1,2,2),通过)和 Request ≤ Available((1,0,2) ≤ (3,3,2),通过)。然后试探性分配:Available 变成 (2,3,0),P1 的 Allocation 变成 (3,0,2),Need 变成 (0,2,0)。
再做安全性检查:从 (2,3,0) 出发,P1 的 Need (0,2,0) 满足,归还后 Available = (5,3,2);接着 P3 → (7,4,3);P4 → (7,4,5);P0 → (7,5,5);P2 → (10,5,7)。安全序列存在,所以这次分配被批准。
第三步,一个会被拒绝的请求。P4 请求 Request = (3,3,0)。检查:≤ Need (4,3,1) 通过,≤ Available (3,3,2) 通过。试探分配后 Available = (0,0,2),P4 的 Allocation 变成 (3,3,2),Need 变成 (1,0,1)。
安全性检查:从 (0,0,2) 出发,没有任何一个进程的 Need 能被满足——P0 差 A 类 7 个,P1 差 A 类 1 个,P2 差 A 类 6 个,P3 差 B 类 1 个,P4 自己还差 A 类 1 个。找不到任何可推进的进程,判定为不安全,请求被拒绝,系统回到试探前的状态,P4 进入等待。
这个"试探—检查—回滚"的三段式是银行家算法的骨架,任何一个请求的处理都可以套用。
3.3 算法落地时的数据结构与伪代码
手算之外,实现层面的关键点是数据结构的选择。三张二维表(Allocation、Max、Need)+ 一个一维向量(Available)。Need 不必单独存,实时用 Max − Allocation 算也行,但会引入一点计算开销,规模大时建议单独存一份。
安全性检查的核心逻辑可以直接写成:
def is_safe(available, allocation, need): n = len(allocation) m = len(available) work = available[:] finish = [False] * n seq = [] while True: progressed = 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) progressed = True if not progressed: break return all(finish), seq时间复杂度是 O(n²m):外层循环最多跑 n 轮(每轮至少标记一个进程完成),内层扫 n 个进程、比 m 类资源。资源类数 m 通常很小(几类到十几类),所以实际开销主要随进程数 n 平方增长。
is_safe返回的布尔值表示系统是否安全,第二个返回值是找到的那条安全序列,调试时打出来非常有用——线上出问题时能直接看到"当时系统认为可以这样跑完",很容易定位是哪个进程的需求算错了。
3.4 银行家算法为什么在工程里用得少
算法本身是对的,但它在通用操作系统里几乎见不到,原因有四个,每一个都是硬伤。
第一,它要求进程预先声明最大资源需求。通用系统跑的是未知程序,一个进程明天要开多少线程、申请多少内存,代码自己都说不准,别说提前告诉内核。批处理系统勉强能接受,交互式系统无从谈起。
第二,它假设资源数量固定且进程之间不会互相影响。真实系统里资源是可伸缩的(内存池、连接池),进程之间还会通过共享内存、信号量耦合,这些关系没法塞进一个静态矩阵。
第三,它的开销随进程数平方增长。服务器上几千个线程,每次申请都跑一遍 O(n²m) 的检查,这个成本足以让性能崩掉。有人会想"那我只在少数关键资源上用",这确实可行,但已经偏离了通用算法的定位。
第四,也是最根本的,它对死锁的判定粒度是"资源类型 + 数量",而现实中的死锁往往发生在更细的层面。比如两个线程争两把对象锁,锁的数量是 1 和 1,算法能处理;但如果锁的存在与否由业务逻辑动态决定,算法输入都凑不齐。
所以银行家算法真正的价值不在实现,而在于它建立了一个可判定的安全模型。它的思想被大量借用:连接池的准入控制、Kubernetes 式的资源配额、数据库事务的隔离级别选择,本质上都是在做"这次分配会不会让我失去退路"的判断。理解了它,再去读那些工程的准入策略,会发现思路是同一个。
4. 死锁检测与恢复:不预防就得算清代价
第四条路线索性放弃干预:允许系统进入不安全状态,甚至允许真的死锁,但定期检测,发现了再处理。这条路线的合理性在于——死锁发生的概率往往远低于预防带来的性能损失。如果一天死锁一次,恢复成本是重启一个进程;而为了防它,全系统性能下降 30%,这笔账显然不划算。
检测路线的两个核心问题是:怎么检测(算法),多久检测一次(频率)。前者决定正确性,后者决定成本。恢复则是第三件事:检测出来之后,杀谁、抢什么。
4.1 资源分配图化简与等待图找环
检测算法本质上就是判断资源分配图里有没有环,但因为有多个实例,得用化简的办法。
化简步骤:拿一个当前可用向量 Work,反复找满足"Need ≤ Work"的进程,找到就把它的 Allocation 加回 Work 并标记完成,直到没有进程可以推进。如果最后所有进程都被标记完成,说明无死锁;如果剩下一些进程标记不了,这些进程就处在死锁中。
这个过程和银行家算法的安全性检查几乎一模一样,区别只在语义:银行家是"分配前试探,判断未来会不会死锁",检测是"当前状态下判断现在已经死锁没有"。同样的代码换个调用时机,用途就变了,这是这一章里很漂亮的一个设计。
对于每类资源只有一个实例的情况,可以直接简化成等待图(wait-for graph):节点只有进程,边 P→Q 表示"P 在等 Q 持有的东西"。这时候判断死锁就是纯粹的找环问题,用深度优先搜索维护一个递归栈即可:
def has_cycle(graph): WHITE, GRAY, BLACK = 0, 1, 2 color = {u: WHITE for u in graph} def dfs(u): color[u] = GRAY for v in graph.get(u, []): if color[v] == GRAY: return True if color[v] == WHITE and dfs(v): return True color[u] = BLACK return False return any(color[u] == WHITE and dfs(u) for u in graph)三色标记法的好处是能把环路径完整还原出来,而不仅仅回答"有没有"。实际排查时,"环是哪几个线程构成的"比"有没有环"有用得多。
4.2 检测时机:什么时候跑一次才划算
检测频率是个纯粹的工程权衡,教材给的一般是两个极端加一个折中:
- 每次申请都检测:一旦出现环立刻发现,恢复代价最小,但检测开销被摊到每一次申请上,性能最差。
- 定时检测:比如每 30 秒一次。开销可控,但发现时可能已经积累了多个环,恢复起来要处理一团乱麻。
- 按资源利用率触发:当 CPU 利用率下降到某个阈值时启动检测。思路是"系统明明有活可干却不干活,可能是死锁了"。这个策略很实用,因为它不增加正常运行时的开销,只在性能异常时才付出检测成本。
我个人的偏好是第三种,配合一个兜底的低频定时检测。理由很实际:死锁的特征就是资源闲置,而资源利用率下降是最容易观察到的信号,拿它当触发器,误报的代价只是多跑一次检测,漏报的风险由定时兜底来补。
4.3 恢复手段:杀进程与抢资源分别伤到什么
检测出死锁之后必须打破它,手段就两类:终止进程、抢占资源。两类手段的破坏力差别很大。
终止进程分三种粒度:终止所有死锁进程(最粗暴,也最快)、每次终止一个直到环解开(需要反复跑检测,开销大但损失可控)、按优先级/已运行时间/剩余工作量选一个牺牲者。选择牺牲者的策略值得展开说:应该优先杀代价最小的,比如已消耗 CPU 时间少的、还没产生外部副作用的、重启后能自动恢复的。数据库系统通常会选事务回滚代价最小的那个,用的就是这套逻辑。一个常见做法是:先杀持有资源最少的,因为释放量小反而更可能快速解开环——听起来反直觉,但实测有效,因为持有最少的进程往往是那根"多余的边",砍掉它环就断了。
抢占资源不杀进程,但要做两件事:回滚被抢占者到某个安全点,以及防止饥饿——不能总抢同一个进程的资源,否则它就变成那个永远拿不到资源的倒霉蛋。解决饥饿的常见写法是把被抢占次数计进代价函数,被抢次数越多,下一次被选中的概率越低。
代价评估可以形式化一点:设进程 i 的代价为 C_i,通常考虑三块——已经消耗的 CPU 时间、已打开的文件或网络连接数、已完成的外部副作用(不可回滚的操作数)。C_i 越小越该被杀。如果 C_i 里有任何一项是不可回滚的,直接把这个进程排除在候选之外,因为杀它可能留下脏数据,比死锁本身更麻烦。
5. 工程现场的死锁:从锁顺序到线上排查
教材讲完四条路线,实务上最常用的其实只有"破坏循环等待"和"超时放弃"两种,而真正解决问题靠的是排查能力。这一节讲三个东西:怎么区分死锁和其他几种"卡住"、写代码时怎么把环掐在源头、线上怎么快速定位。
5.1 死锁、活锁、饥饿、优先级反转的分界
这四种现象在外行看来都是"服务不动了",但成因和处理方式完全不同,先分清再动手。
| 现象 | 成因 | 典型特征 | 处理方向 |
|---|---|---|---|
| 死锁 | 循环等待 | 线程停在 BLOCKED/WAITING,CPU 低 | 打破环,改加锁顺序 |
| 活锁 | 反复重试冲突 | 线程 RUNNABLE,CPU 高,无进展 | 引入随机退避 |
| 饥饿 | 长期抢不到资源 | 特定线程长期不被调度 | 提高优先级或公平调度 |
| 优先级反转 | 高优先级等低优先级持有者 | 高优先级线程等待中 | 优先级继承 |
活锁最容易被误判成死锁,因为两者都表现为"没进展"。区分方法很简单:看 CPU。死锁时 CPU 是闲的,活锁时 CPU 是忙的。这个判据我在多次排查中都用它做第一步分诊。
优先级反转值得单独说一句,它不是死锁,但结果一样致命:高优先级任务 H 等在低优先级任务 L 持有的锁上,而中优先级任务 M 不断抢占 L 的 CPU,导致 L 迟迟跑不完、H 一直等。解决办法是优先级继承——L 一旦被 H 等待,就临时提升到 H 的优先级,这样 M 就抢不进去了。这个机制在实时系统里是标配,在通用系统里也有实现,只是效果没有那么强。
5.2 全局锁顺序约定与超时退避
先说约定加锁顺序,这是最划算的一招。原理前面讲过,实践上要做三件事:
第一,把锁排序规则写进代码结构里。不要靠注释约定,而是让顺序在类型系统里体现。比如给锁对象一个rank字段,加锁时校验 rank 必须递增,违反就报错。下面是一个极简的实现:
final class OrderedLock { private final int rank; OrderedLock(int rank) { this.rank = rank; } int rank() { return rank; } void lockAfter(OrderedLock held) { if (held != null && held.rank() >= this.rank) { throw new IllegalStateException( "lock order violated: " + held.rank() + " -> " + rank); } // 实际加锁逻辑 } }这套东西在开发环境开启、生产环境关闭,成本几乎为零,收益是能在测试阶段把绝大部分顺序问题揪出来。
第二,跨对象操作时先排序再逐个加锁。转账是最经典的例子:从 A 转到 B,必须先比较两者的 ID,按小 ID 到大 ID 的顺序加锁,而不是"先锁转出方再锁转入方"。后者的写法在 A→B 和 B→A 并发时必然成环。这个改动只需要两行代码,却能消灭一整类线上事故。
第三,无条件加 tryLock 加超时退避。对于无法确定顺序的场景(比如资源是动态发现的),用带超时的加锁,超时后释放已持有的锁、随机睡一小段、再重试。随机退避是重点,固定时间的重试会让两个线程永远同步撞击,那不是死锁,而是活锁,比死锁还难查。
注意:
tryLock超时后必须把已经拿到的锁全部释放再重试,否则只是把死锁的时间窗口拉长,问题依然在。
5.3 线上定位:线程转储里找"循环等待"
真出事了,第一步永远是抓线程转储。以 JVM 为例,jstack <pid>抓一次,然后搜Found one Java-level deadlock这个关键字——JVM 自己就会做检测,而且检测逻辑就是等待图找环,跟教材里讲的是同一套。
抓不到自动检测也没关系,手工找法如下:搜BLOCKED,看每个线程的waiting to lock和locked两组地址,画成有向图,找环。下面是一段典型的转储片段:
"worker-1" #12 prio=5 os_prio=0 tid=0x00007f8c1c0a2000 nid=0x1a2b waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at demo.Account.transfer(Account.java:28) - waiting to lock <0x000000076ab0c1a0> (a demo.Account) - locked <0x000000076ab0c110> (a demo.Account) "worker-2" #13 prio=5 os_prio=0 tid=0x00007f8c1c0a3800 nid=0x1a2c waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at demo.Account.transfer(Account.java:28) - waiting to lock <0x000000076ab0c110> (a demo.Account) - locked <0x000000076ab0c1a0> (a demo.Account)worker-1 持有 c110、等 c1a0;worker-2 持有 c1a0、等 c110。两条边首尾相接,环成立,第一时间就能确认是死锁,而且是顺序问题导致的。修复方法就是上一节说的按 ID 排序加锁。
如果不是 JVM 环境,思路完全一样:拿到所有线程的等待关系和持有关系,构图找环。C/C++ 程序可以看 core dump 里的 mutex 结构,或者用 gdb 挂上去看各线程的栈和锁状态;Go 程序更省事,SIGQUIT打出来的栈里如果出现semacquire互相等待,基本可以定性。不管哪种语言,判据始终是同一个:等待关系成环。
还有一类死锁不在线程层面,而在数据库里。两个事务交叉更新两行数据,数据库自己会检测并杀掉其中一个(通常是回滚代价小的那个),报错信息里会说死锁。这时候别急着加重试了事,先看两个事务的 SQL 是不是更新顺序相反——如果是,改成按主键排序批量更新,问题就根治了。如果实在改不了顺序,把重试逻辑加上随机退避,也比裸重试强。
写到这里,我个人在实际操作中的体会是:教科书第七章真正教给我们的不是银行家算法那几行伪代码,而是一种判断方式——遇到"卡住"的现象,先把它翻译成"资源—持有—等待"的三元关系,然后问一句"这里面有没有环"。有环就是死锁,没环就往活锁和饥饿的方向查。这个判断映射建起来之后,剩下的都是顺着图找边的事,语言、框架、平台都不影响。至于四条处理路线怎么选,我的经验是:能用约定消灭的环就用约定消灭,消灭不了的用超时兜底,兜底还压不住的才上检测恢复——因为检测恢复永远是最后一道防线,它保证的是"不至于彻底锁死",而不是"系统跑得顺畅"。真要追求顺畅,功夫还得下在写代码那一步。