操作系统原理系列学习笔记写到第十七篇,主题是死锁检测与死锁解除。前面我们聊过死锁预防和死锁避免,那是“不让死锁发生”的思路;这一次反过来,允许系统先死锁,再靠检测发现、靠解除恢复。2023年11月10日更新,这篇就把这后半段补完。
说实话,很多人在学死锁这一章时最容易卡在两个地方:一是死锁检测到底和银行家算法有什么区别,二是死锁解除时“选哪个进程下手”凭什么选。这两个问题要是没想透,面试题一变形就翻车。这篇笔记就用最直白的方式把这两件事拆开揉碎,再加上一段可以自己跑一遍的模拟代码,争取看完就能自己写一个最小可用的检测与解除程序。
这篇适合刚学完操作系统死锁章节的学生、准备系统复习的考研党,以及工作中要排查并发问题的开发同学。接下来我们先把四种死锁处理策略放到同一张桌上做个对比,再一步步进入检测算法的核心。
1. 死锁处理全家桶:检测与解除到底解决什么问题
1.1 死锁预防与死锁避免的局限
要想理解检测与解除的价值,先得知道前面的预防和避免为什么不总是够用。
死锁预防的核心思路是破坏死锁的四个必要条件。互斥条件天然存在,很多资源就是不能同时共享;持有并等待可以通过“一次申请所有资源”来打破,但这么做资源利用率低得吓人,进程可能为了一个暂不用的资源把整批资源锁死在自己手里;不可剥夺条件在很多场景下根本无法满足,比如一个正在写文件的事务,你强行把它的磁盘块抢走,数据就废了;循环等待可以通过给资源编号、强制按序申请来消除,但编号顺序一旦设计不合理,就会出现低编号资源被高优先级进程占用、高编号资源被低优先级进程占用的尴尬情况。
死锁避免的代表是银行家算法,它确实比预防温和一些,但代价也很具体:系统必须提前知道每个进程的最大资源需求量。问题在于现实中的程序往往在运行前根本不知道自己未来要申请多少资源,尤其交互式应用、批处理任务混杂的通用系统,这个前提太强了。银行家算法还假设进程一旦拿到资源就会一直持有到完成,任何提前退出都会破坏安全性判断。
所以,预防和避免并不是万能的。有些系统业务模式简单、资源也不多,用预防没问题;有些系统能预知最大需求,用避免也还行。但还有大量场景,两者都搞不定。
1.2 允许死锁发生,再用检测与解除兜底
检测与解除的思路正好相反:不阻止死锁出现,而是让系统在死锁发生后自己发现、自己处理。数据库系统里的锁等待超时、事务回滚,走的本质上就是这个路线;一些长时间运行的批处理服务器,也宁可偶尔死锁一次然后自动解除,也不愿意为了预防死锁把资源利用率压到一半以下。
用开车打个比方。死锁预防是提前把所有车辆的行驶路线规划死,谁都不能越线;死锁避免是每个司机出发前申报目的地,交警看情况决定放不放行;而检测与解除,就是交警等到路口已经彻底堵死之后才赶到现场,先确认堵点,然后指挥其中一辆车倒车或者直接拖走。
这个“事后处理”的思路在工程上非常实用,因为它把复杂的约束从“设计阶段”移到了“运行阶段”。但它的前提也很明确:系统必须有能力检测,并且必须承受解除过程带来的代价。这就引出了下一节的问题——什么时候检测、拿什么数据来检测。
2. 死锁检测的触发时机与关键数据结构
2.1 周期检测和事件触发怎么选
检测不是想跑就跑,检测算法本身要消耗CPU和内存,太频繁会拖累正常业务,太疏则会让你在用户已经卡到崩溃的时候才反应过来。
周期检测最常见,系统每隔固定时间或者固定事务数运行一次检测。周期的长短需要权衡:周期太短,死锁虽然能被快速发现,但大量的CPU时间被白白消耗在“一切正常”的扫描上;周期太长,死锁可能已经持续了几十秒甚至几分钟,用户早就感知到了卡顿。这里没有标准答案,实际系统一般基于历史数据调参,比如观察正常业务的资源等待时间波动范围,把周期设置成这个范围的2到3倍。
事件触发则是根据系统的异常信号临时启动检测。常见的信号包括:
- CPU利用率突然持续走低,而系统负载并没有下降,这可能意味着大量进程在等待资源;
- 处于等待状态的进程数量超过阈值;
- 某个锁的等待队列出现明显增长。
比较好的工程实践是两者结合:周期性兜底,再配上事件触发的“急救通道”,双保险。
2.2 资源分配矩阵与wait-for图的维护
无论用哪种算法,检测的前提都是能拿到一个完整且一致的资源状态快照。快照里至少要包含三样信息:
- Allocation矩阵:每个进程当前已经占有的每类资源数量;
- Request矩阵:每个进程当前被阻塞、正在等待的每类资源数量;
- Available向量:系统当前可用的每类资源数量。
这组数据在内核里要随每次资源分配、释放操作动态更新,并不是检测时才现算的。如果更新不及时,检测就是拿过期数据做判断,结果没有意义。
当每类资源都只有单个实例时,可以把资源分配图简化成wait-for图。图中每个节点代表一个进程,如果P1等待的资源正被P2占有,就画一条从P1指向P2的有向边。因为单实例资源只可能被一个进程独占,资源节点可以被省略,边的关系不会丢。判断死锁等价于判断这个图中是否存在有向环。
需要特别提醒的是,多实例资源场景不能这样简化。如果某个资源有3个实例,被两个进程各占一部分,那么“谁等谁”的关系就不是一条简单的有向边能表达清楚的,必须回到矩阵形式来处理。很多初学同学在这里栽跟头,以为wait-for图可以随便用,结果一到多实例就分析错了。
3. 死锁检测算法:从wait-for图到矩阵化简
3.1 单实例资源场景:环路检测可以这么简单
单实例场景最关键的一句话:wait-for图里有环,就说明有死锁。环上的每个进程都在等另一个进程释放它需要的资源,形成一个谁也没法动弹的循环。
实现环路检测有两种常见方式。第一种是拓扑排序法:反复删除当前入度为0的节点,如果一个节点的入度为0,说明没有别的进程在等它持有的资源,它至少可以推进并释放资源。不断删除后如果所有节点都能被删掉,图就无环;如果还剩下一批节点删不掉,这些节点必然组成了一个或多个环,环上的进程就是死锁进程集合。
第二种是DFS找后向边法:从每个节点出发做深度优先遍历,用颜色标记访问状态。如果在遍历一个节点时发现有一条边指向“正在遍历中”的灰色节点,说明找到了环。两种方法都能输出死锁进程集合,复杂度都是O(V+E),工程上可以根据需要选。
用堵车类比的话,wait-for图里的环就像十字路口四辆车互相堵死:每辆车都在等前车移动,但前车又在等另一辆车,最终没有一辆车能先动的了。
3.2 多实例资源场景:银行家式安全检测
资源有多份实例时,wait-for图不够用了,我们要用矩阵化简算法。这个算法和银行家算法的安全性检查长得非常像,但含义完全不同。
算法步骤很简单:
- 设 work = available,也就是当前可用资源;
- 不断扫描进程列表,找到这样一个进程:它尚未被标记完成,且它的当前阻塞请求Request能够被work完全满足;
- 如果找到,就认为这个进程可以运行到完成,让 work = work + Allocation[i],并标记该进程完成;
- 重复第2步,直到某轮扫描没有任何进程能被标记完成;
- 最后仍未被标记完成的进程,就是死锁进程。
这个算法的正确性其实很好理解:一个当前请求能被work满足的进程,至少能继续执行并最终释放它占用的全部资源,它绝对不可能是“卡死”状态;反过来,死锁环上的每个进程,它的Request在当前状态下永远无法被满足,因为环上的资源都被环上其他进程持有,不可能漏出多余的实例给它。
这里必须强调一个高频考点:银行家算法的安全性检查,用的是每个进程的最大剩余需求;而死锁检测算法,用的是进程当前正在阻塞等待的请求。前者在判断“未来有没有可能让所有进程完成任务”,后者在判断“当前剩下的进程是不是已经永远动不了”。两者代码结构几乎一致,但输入语义完全不同,很多面试题就喜欢在这里挖坑。
3.3 检测算法的复杂度与工程优化
矩阵化简算法最坏情况下每轮只能标记一个进程,所以复杂度是O(n²×m),其中n是进程数,m是资源类型数。在动辄上百个进程、几十类资源的系统里,这个复杂度并不低,靠这个算法做全局周期扫描确实有压力。
工程上有几个常见优化方向。一是只检测被阻塞的进程,正在运行或可以被立即满足的进程不可能参与死锁,扫描前先过滤掉;二是增量维护等待图,数据库系统里经常这么做,每次产生一条新的等待关系时,只做一次局部DFS,而不是全量扫描;三是分层检测,先做粗粒度判断,确认有疑似环后再进入精细的矩阵化简。
4. 死锁解除的两种路线与受害者选择
4.1 进程终止:简单粗暴但代价很大
检测出死锁进程集合后,最直接的解除方式就是杀进程。杀有两种杀法。
一种是把所有死锁进程全部终止。这样做一定能解除死锁,但代价可能是灾难性的:一个已经运行了三小时、马上就要出结果的科学计算任务,可能只是因为在最后阶段参与了死锁,就被无差别干掉。所以大多数系统不会选择这种“宁可错杀一千”的方式。
另一种是逐个终止,边杀边检测。每终止一个进程,释放掉它持有的全部资源,再跑一遍检测算法,看看环断了没有。如果还没断,继续挑选下一个受害者。这样做每次牺牲最小,但需要重复执行检测算法,CPU开销增加了。
无论哪种杀法,都绕不开一个问题:先杀谁?受害者选择通常综合以下几个因素:
- 进程优先级:高优先级进程尽量保;
- 已运行时间与剩余时间:完成度越高的进程越不该杀;
- 占用的资源类型和数量:占用稀缺资源越多的进程,杀掉它释放的资源越值钱;
- 交互性:用户正在前台操作的进程优先级要抬高;
- 完成比例:跑了90%的进程被杀了,比杀了跑了10%的进程可惜得多。
把这些因素加权求一个代价函数,每次选代价最低的那个进程下手。注意这是启发式策略,不保证全局最优,但能大幅减少“误杀重要任务”的概率。
4.2 资源抢占:回滚与饥饿问题
除了杀进程,还有一条更“温和”的路线:从死锁环里选择一个进程,强制夺走它的一部分资源,转给等待者。这就好比堵车的时候,不是把某辆车拖走报废,而是让其中一辆车倒回上一个路口,让其他车先过。
但资源抢占要落地,必须解决三个问题。
第一是选择受害者,这和终止进程的评判维度差不多,但可以更精准:可能只抢占该进程的一部分资源,而不是全部进程资源。
第二是回滚。被抢占资源的进程必须能回到一个安全状态。数据库里的做法是事务回滚到savepoint,因为事务本来就有日志可以恢复;普通操作系统进程没有事务机制,强行剥夺它正在使用的资源,可能导致文件数据不一致、内存状态错乱。这也是为什么抢占式解除常见于数据库系统,而普通操作系统内核里很少直接用。
第三是饥饿。如果同一个进程总是被选为受害者,它可能一直被“抢救”中被反复牺牲,永远无法完成。解决方案是记录每个进程被抢占的次数,并把“被抢占次数”作为一个惩罚项放进代价函数,确保已经被欺负过的进程下次不再被优先选中。
4.3 终止还是抢占,怎么选
实际系统选择哪种解除方式,核心看能不能接受“丢失工作”。
如果系统里跑的是大批无关紧要的计算任务,比如离线数据分析任务、日志聚合任务,终止进程一点都不可惜,那就用终止方案,简单直接。如果系统里跑的是关键服务,比如支付链路、订单处理,任何进程终止都意味着业务损失,那就必须走资源抢占加事务回滚的路线。
我个人的体会是,学习时要同时把两条路线都理解透,工程面试也经常把“某数据库死锁后选择回滚哪个事务”包装成场景题,本质上考的就是受害者选择的思想。
5. 手写一套可复现的死锁检测与解除模拟代码
5.1 模拟场景与输入数据
先设计一个最典型的死锁场景。假设系统里有3个进程P0、P1、P2,三类资源R0、R1、R2,每类资源都只有1个实例。它们的状态如下:
| 进程 | 持有资源 | 等待资源 |
|---|---|---|
| P0 | R1 | R0 |
| P1 | R0 | R2 |
| P2 | R2 | R1 |
当前可用资源 Available 是 [0, 0, 0],也就是所有资源都已经被占用完。
围绕这个场景构造矩阵:
- Allocation = [[0, 1, 0], [1, 0, 0], [0, 0, 1]]
- Request = [[1, 0, 0], [0, 0, 1], [0, 1, 0]]
可以看到P0在等R0,R0被P1拿着;P1在等R2,R2被P2拿着;P2在等R1,R1被P0拿着。这是一个非常干净的循环,谁也不让谁。
5.2 Python实现与关键函数拆解
下面这段Python代码实现检测与解除,代码结构刻意保持简单,方便照着理解:
def deadlock_detect(alloc, req, avail): n, m = len(alloc), len(avail) work = avail[:] finish = [False] * n changed = True while changed: changed = False for i in range(n): if not finish[i] and all(req[i][j] <= work[j] for j in range(m)): work = [work[j] + alloc[i][j] for j in range(m)] finish[i] = True changed = True return [i for i in range(n) if not finish[i]] def resolve_by_kill(alloc, req, avail, victim): new_alloc = [alloc[i][:] for i in range(len(alloc)) if i != victim] new_req = [req[i][:] for i in range(len(req)) if i != victim] new_avail = [avail[j] + alloc[victim][j] for j in range(len(avail))] return new_alloc, new_req, new_avail allocation = [[0, 1, 0], [1, 0, 0], [0, 0, 1]] request = [[1, 0, 0], [0, 0, 1], [0, 1, 0]] available = [0, 0, 0] dead = deadlock_detect(allocation, request, available) print("检测到的死锁进程:", dead) victim = 1 new_alloc, new_req, new_avail = resolve_by_kill(allocation, request, available, victim) print(f"终止进程P{victim}后重新检测...") dead_after = deadlock_detect(new_alloc, new_req, new_avail) print("解除后的死锁进程:", dead_after)关键点在于检测函数里的changed变量。每轮扫描至少标记一个进程后,系统状态变了,其他进程的请求可能从“不满足”变成“满足”,所以必须继续下一轮扫描。忘记这个外层循环,算法会提前退出,漏判真正可推进的进程。
resolve_by_kill的思路很直接:终止一个进程后,从Allocation和Request矩阵中移除它,同时把它占用的所有资源归还到Available向量。真实系统里归还资源不是简单的矩阵删行,还要考虑该进程持有资源的内部状态,但作为原理学习,这个简化足够看清流程。
5.3 运行输出与结果解读
运行这段代码,输出是:
检测到的死锁进程: [0, 1, 2] 终止进程P1后重新检测... 解除后的死锁进程: []第一次检测时,work等于[0, 0, 0],三个进程的请求都不能被满足,所以全被标记为死锁。终止P1后,P1持有的R0被释放,available变成[1, 0, 0]。
再跑检测:P0的请求是[1, 0, 0],刚好能被满足,P0完成并释放它持有的R1,work变成[1, 1, 0];P2的请求是[0, 1, 0],也能被满足,P2完成并释放R2,全部进程完成,死锁解除。
这里选择P1作为受害者是有讲究的。P1持有的R0正是P0等待的资源,P1一终止,P0立刻能推进,整个环就从最薄弱的位置断开了。在真实系统里,这个“最薄弱点”通常会结合优先级和资源稀缺性来选。
如果想让代码更贴近真实,可以在dead列表中用代价函数选受害者,比如“优先级最低且占用资源数量最少”的进程优先终止。核心思想就是把第4节的评判维度翻译成代码,这里就不展开写了。
6. 常见错误、排查思路与避坑技巧
6.1 分清“假死锁”与真死锁
检测到wait-for图里有环,并不一定是死锁。比如环上某个进程其实是在等待一个很快就会到来的信号量,或者它等待的I/O事件马上就要完成,那么它并不处于永久不可推进的状态,这个环只是“暂时阻塞”。
我之前调试模拟程序时就吃过这个亏。一个进程因为等待用户输入而阻塞,它虽然没有持有任何资源,却被我误画进了wait-for图里,结果检测算法报出死锁,实际上程序只是挂在输入上。所以判断真死锁要满足两个条件:环上的每个进程都处于资源等待状态,并且每个进程的等待请求在可预见的未来无法被满足。单靠静态快照判断不了“未来”,通常还要结合进程状态和等待原因综合确认。
6.2 检测周期与解除策略的权衡经验
周期设置是很多人容易忽略的工程细节。周期太长,死锁持续的时间会让业务明显受损;周期太短,检测本身的CPU开销又变成新的性能瓶颈。
我自己的经验是,先把周期设到正常业务等待波动周期的两到三倍。比如在某个服务里,正常事务等待锁的时间很少超过5秒,那检测周期可以先设10秒,观察线上指标再回调。光有周期还不够,还要配套事件触发:当等待进程数暴涨或CPU利用率异常跌落到阈值以下时,立即插队跑一轮检测。
解除策略上,如果系统允许丢任务,优先杀“占用资源最多、优先级最低、完成比例最小”的进程,不要按进程号顺序杀。按序杀的结果往往是保下了无关紧要的任务、杀掉了真正要出结果的大任务。
6.3 从学习笔记到工程实践的三个补充
最后补充三点学习之外的经验。
第一,矩阵算法写起来简单,直接套到真实系统里却不一定适用。真实系统里的进程数和资源类型数可能非常庞大,用稠密矩阵存Allocation和Request会浪费大量内存。实际工程中通常用哈希表或者稀疏矩阵记录“每条资源被谁持有、谁在等”,算法思想不变,但存储结构要换。
第二,死锁检测和死锁避免千万别搞混。银行家算法是避免,本质是“预判未来是否安全”;检测算法是“确认现在是否已经死锁”。面试里经常有人把银行家算法说成死锁检测,一开口就暴露概念不牢。
第三,模拟程序最好把每一步work的变化打印出来。光看最终“是否死锁”容易知其然不知其所以然,把每轮进程标记和work更新过程打出来,你能直观看到资源是怎么一步步流动的,对理解算法帮助很大。
我自己在重新写这套模拟程序的时候,最大的感受是:死锁检测算法本身并不难,难的是现实中你往往拿不到干净的状态快照,检测跑得再漂亮,喂进去的数据不对,结论也没救。所以操作系统课程里这几个算法,真正落到工程上,一半的功夫都在数据采集和状态一致性上。这篇笔记把原理和模拟都过了一遍,后续如果再写死锁相关的内容,我打算专门聊聊内核里锁顺序和事务日志那些细节,那是另一个更深的话题了。