☰
死锁全解析:从四个必要条件到检测解除与实战排障
2026/10/15 12:41:50 网站建设 项目流程

先聊一个挺典型的场景:你正写一个并发程序,两个线程各持有一把锁,然后互相等对方释放另一把锁。程序既不报错也不退出,就是卡在那里不动。这种局面在操作系统课程里叫死锁,在真实的线上环境里同样让人头疼。无论你是在准备面试、复习操作系统,还是排查线上服务卡顿,死锁这块知识点都绕不开。我尽量不摆教科书脸,把概念、条件、预防、避免、检测、解除这几个层面一次讲透,最后再分享一些我在实际项目中踩过的坑和排查方法。

1. 死锁到底是什么:从一个最小复现场景说起

1.1 两把锁的事故现场

死锁定义说起来很绕:多个进程或线程,因为竞争共享资源,彼此都在等待对方占用的资源,导致谁也无法继续推进。抽象,不如直接跑一个例子。下面的 Python 代码就是一个教科书级的最小死锁:

import threading import time lock_a = threading.Lock() lock_b = threading.Lock() def worker_a(): with lock_a: time.sleep(0.1) with lock_b: print("A finished") def worker_b(): with lock_b: time.sleep(0.1) with lock_a: print("B finished") t1 = threading.Thread(target=worker_a) t2 = threading.Thread(target=worker_b) t1.start() t2.start() t1.join() t2.join()

跑一下你会发现,程序卡住,两个线程的 print 都不会出现。原因很清楚:线程 A 拿到 lock_a,线程 B 拿到 lock_b,然后 A 等 B 手里的 lock_b,B 等 A 手里的 lock_a。两边都有东西握在手里,又都不肯松手。这个场景放在代码层面是两把线程锁,放在操作系统层面就是两个进程在竞争资源。你别觉得只有写多线程才遇到死锁,数据库事务、文件锁、分布式系统里的资源协调,本质都是一回事。

1.2 死锁定义里容易被忽略的地方

我见过不少人对死锁的背诵很流利,一追问细节就露馅。比如有人会说“死锁就是两个线程互相等待”,这个说法不够准确。死锁至少要满足三个层面:有多个执行流,每个执行流持有至少一个资源,并且它们在等待的资源恰好被其他执行流持有。更关键的是,如果没有外力介入,这个等待会一直持续下去。

资源这个词也值得展开。资源不光是内存、CPU、打印机这类硬件,也包括文件锁、数据库行锁、信号量、某个数据结构上的互斥锁。操作系统教材通常会按性质把资源分成可重用资源和可消耗资源。可重用资源是指一次只能由一个进程使用,使用完释放后还能继续给别人用,比如打印机、内存块、各种锁;可消耗资源是指生产出来就消耗掉的资源,比如中断信号、缓冲区里的消息,这类资源更容易引发另一种形式的阻塞。死锁经典讨论基本围绕可重用资源展开,因为这类资源有“独占”属性,竞争必然存在。只要理解了这些基础,后面看预防和避免策略都会轻松很多。

2. 四个必要条件与资源分配图:死锁为什么成立

2.1 四个必要条件逐个拆解

死锁之所以发生,需要同时满足四个必要条件,缺一不可。这四个条件是整套死锁理论的基石,我在下面列一下,并且结合实际讲透。

第一个条件是互斥。资源在一个时刻只能被一个进程使用。如果资源可以随便共享,线程 A 用的时候线程 B 也能用,那根本不存在“等对方释放”的问题。但现实里很多资源天生就是互斥的,比如同一行数据库记录,不能让两个事务同时改,打印机不能同时打印两份文件。

第二个条件是持有并等待。进程已经占着一些资源,同时又去申请新的资源,而新资源被其他进程占用。对应到刚才的代码,worker_a 持有 lock_a,还想拿 lock_b,就满足了这个条件。

第三个条件是不可剥夺。进程持有的资源不能被系统强行抢走,只能由进程自己主动释放。锁在正常情况下的确是这样,除了进程结束或者异常退出,你没法从外部强制把一把正在持有的锁拿过来。

第四个条件是循环等待。存在一个进程资源的循环链,链上的每个进程都在等待链中下一个进程占有的资源。A 等 B 的资源,B 又等 A 的资源,这就构成了循环。循环等待是前三个条件叠加后的一种结果。预防死锁的策略,本质上就是从破坏这四个条件入手,只要破坏掉任意一个,死锁就不可能形成。

2.2 资源分配图:有环不一定死锁

资源分配图是判断死锁的一个重要工具。图里有两类节点:进程节点画成圆圈,资源节点画成方框。资源节点内部有几个圆点就代表有几个实例。分配边从资源节点指向进程节点,表示资源被分配给了某个进程;请求边从进程节点指向资源节点,表示进程正在申请某个资源。

很多人会背一句“有环就死锁”,这句话只说对了一半。如果资源是单实例的,也就是每个资源节点内部只有一个实例,那么有环就一定死锁。如果资源是多实例的,比如一个资源池里有三台打印机,那有环不一定死锁,还要看是否真的存在每条边都卡住的情况。

举个例子,两个进程各拿了一台打印机,然后都想申请对方手里的另一台,这个系统只有两台打印机,环存在,死锁成立。但如果资源池里有五台打印机,A 拿了 1 号、2 号,B 拿了 3 号,然后 A 申请 4 号,B 申请 5 号,这时候你画出来的图上可能找不到真正的循环等待,进程可以通过申请空闲资源继续走。所以考试和面试里,遇到“有环就是死锁”这种判断题,一定要看清楚资源是不是单实例。

2.3 操作系统之外的死锁翻版

死锁不只在操作系统教材里存在。说几个我实际见过的场景。数据库里,两个事务各自锁住一行,然后互相 update 对方的行,数据库死锁检测器最后会杀掉其中一个事务,让另一个回滚重试。文件系统里,线程 A 打开文件 1 等着文件 2,线程 B 打开文件 2 等着文件 1,两个线程同时卡住,这在一些老旧的遗留系统里真的会出现。再往后做分布式系统,两个服务之间互相持有对方需要的分布式锁,同样可能形成环路。

理解了这些场景,你对死锁的判断就不会局限于“线程 + mutex”。死锁是资源管理里的一种普遍失败模式,只要符合四个必要条件,在哪一层都可能出事。

3. 死锁的预防:在设计层面就堵住入口

3.1 破坏互斥条件,理论上美好,现实中很难

既然四个条件缺一不可,那最直接的思路就是破坏其中一个。第一个想到的是破坏互斥条件,让资源共享。但实际情况是,很多资源物理上就必须互斥使用,比如打印机没法同时打印两个任务,数据库的行锁必须是排他的。

操作系统里有个折中思路叫假脱机技术,典型的就是打印机假脱机。进程不直接持有打印机,而是把要打印的内容先写到磁盘上的输出队列,再由一个专门的守护进程把队列里的内容逐个送往打印机。这样一来,进程面对的不再是独占的打印机,而是共享的磁盘队列,互斥被绕开了。但这个方案不是所有资源都适用,比如你不能把行锁改成共享队列。所以,破坏互斥很多时候只停留在理论层面,要么引入中间层,要么直接放弃。

3.2 破坏持有并等待:一次性申请全部资源

另一种思路是让进程在运行之前,一次性申请它需要的所有资源,全部拿到才投入运行,运行过程中不再申请新的资源。这样就不会出现“手里攥着资源,还在等新资源”的中间状态。

这个策略实现起来简单,但代价也不小。第一个问题是资源利用率低,进程可能在运行早期只需要部分资源,却把后面一段时间的资源全占着,其他进程只能干等。第二个问题是容易饥饿,如果某个进程需要的资源很多,它可能要等很久才能一次性凑齐所有资源。第三个问题是不可扩展,实际应用里进程很难提前精确预测自己后面需要哪些资源。所以这个策略在操作系统教材里分量很足,但在真实软件里几乎是看不到的。

3.3 破坏不可剥夺条件:资源可以被系统收回

打破不可剥夺条件,核心是允许系统在必要时把进程已经持有的资源抢回来。比如进程申请新资源失败后,不是傻等,而是主动释放自己已经持有的资源,加入等待队列,等以后再重新申请。

这个思路在工程上是有落地的,最典型的就是各种带超时的锁申请。Java 里的 tryLock、Go 里的带超时加锁,本质上都是在打破“死等”。但这里有个容易被忽略的代价:资源被剥夺后,进程之前基于该资源做的操作可能就白做了,甚至需要回滚到某个检查点。比如一个线程已经持有锁更新了半个事务,因为拿不到另一把锁,被迫释放刚才的锁,那这个事务的中间状态怎么处理?如果处理不好,数据一致性就崩了。所以破坏不可剥夺条件适合对状态可以重置的资源,不适合对写操作敏感的数据资源。

3.4 破坏循环等待:给资源一个全局顺序

破坏循环等待是最常用、也最容易被工程化的预防手段。思路是给所有资源规定一个全局编号,进程申请资源时必须严格按照编号递增的顺序进行。比如编号 1 的锁先拿,再拿编号 2 的锁,不能反过来。这样就能从根上杜绝循环链,因为所有进程都会按同一个方向申请资源,不会出现一个进程从低到高,另一个从高到低,然后互相卡死。

我自己的经验是,在代码里维护锁顺序,比想象中更麻烦。团队里每个人写的模块如果都只知道自己需要哪些锁,不知道其他模块的顺序,很容易出问题。所以工程上通常会把锁的申请入口收敛到少数几个函数或工具类里,在入口统一校验顺序,而不是靠程序员自觉。数据库的锁也类似,多表事务里大家约定按同一个表顺序加锁,能大幅降低死锁概率。预防策略的代价是资源编号可能不合理,低编号资源可能被频繁争用,形成瓶颈,但相比死锁本身,这个代价往往可控。

4. 死锁的避免:银行家算法,把风险前置判断

4.1 安全状态与安全序列

预防是在资源分配之前就把死锁条件破坏掉,避免则是在每次分配资源时,先判断这次分配会不会把系统带进“不安全状态”,如果会,就暂缓分配。要理解银行家算法,先得理解什么是安全状态。

一个系统处于安全状态,是指存在一个安全序列。按照这个序列依次让每个进程运行,每个进程都能在资源可满足的情况下完成并释放资源。只要系统处于安全状态,就一定能找到一个办法让所有进程跑完,不会死锁。反过来,不安全状态不一定立刻死锁,但存在进入死锁的可能。银行家算法做的就是每次分配前先做安全性检查,尽量让系统始终留在安全状态里。

这里我想强调安全和不安全不是简单的“是或否”,它是一个动态评估。一个进程的请求如果会让系统从安全状态滑向不安全状态,银行家算法就会拒绝这个请求,即使系统当前还有足够的资源可以先分配给它。因为一旦分配,未来可能谁都完不成。

4.2 银行家算法需要的数据结构和判断流程

银行家算法建立在一组数据结构上。Available 是每类资源的可用实例数量;Max 是每个进程对每类资源的最大需求;Allocation 是每个进程当前已经分到的资源数量;Need 是每个进程还需要的资源数量,计算方式是 Need = Max - Allocation。

进程提出一个资源请求 Request,算法先检查 Request 是否小于等于 Need,如果请求超过了自己声明的最大需求,直接拒绝,这属于进程出错。然后再检查 Request 是否小于等于 Available,如果资源不够,让进程等待。通过这两步后,进入试探分配:把资源先分给进程,更新 Available、Allocation、Need,然后运行安全性检查。

安全性检查的思路很简单,模拟未来运行:找一个 Need 小于等于当前 Available 的进程,假设它完成,回收它占用的所有资源,然后继续找下一个。如果最后所有进程都能被收尾,说明存在安全序列,系统安全,刚才的试探性分配转正;如果中途找不到一个可以继续执行的进程,说明系统会不安全,刚才的试探分配回滚,进程继续等待。

4.3 一个完整的银行家算法算例

只看流程文字容易晕,我拿一个经典例子演示一遍。系统里有三类资源 A、B、C,总量分别是 10、5、7。现有五个进程 P0 到 P4,初始状态如下。

进程AllocationMaxNeed
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)

先算 Available:把已分配资源加起来,A 类 0+2+3+2+0=7,B 类 1+0+0+1+0=2,C 类 0+0+2+1+2=5,所以 Available=(10,5,7)-(7,2,5)=(3,3,2)。

此时检查安全状态。从 P0 到 P4 找一个 Need 小于等于 (3,3,2) 的进程,P0 的 (7,4,3) 不行,P1 的 (1,2,2) 可以。假设 P1 完成,回收它的 (2,0,0),Available 变成 (5,3,2)。接着 P3 的 Need (0,1,1) 满足,回收后 Available 变成 (7,4,3)。再往后 P4、P0、P2 依次都能执行。所以初始状态安全,安全序列可以是 P1、P3、P4、P0、P2。

现在假设 P1 提出请求 Request=(1,0,2)。它不超过自己声明的 Need,也不超过 Available,试探分配后 Available 变成 (2,3,0),P1 的 Allocation 变成 (3,0,2),Need 变成 (0,2,0)。重新检查安全性:当前 Available=(2,3,0),满足条件的进程是 P1(Need=(0,2,0)),回收后 Available=(5,3,2),接着 P3、P4、P0、P2 依次完成。系统仍然安全,所以这次请求可以批准。

再看一个会被拒绝的请求。如果 P4 在初始状态请求 Request=(3,3,0),它满足 Request ≤ Need,也满足 Request ≤ Available。试探分配后 Available=(0,0,2),P4 的 Need 变成 (1,0,1)。此时没有一个进程的 Need 能全部不超过 (0,0,2),P3 需要 B 资源 1 个,但 Available 里 B 是 0,P4 还需要 A 资源 1 个但 Available 里 A 是 0,系统进入不安全状态。银行家算法会撤销这次试探,拒绝 P4 的请求。这个例子能让你直观感受算法为什么叫“避免”:现在给了资源,以后就全堵死了。

4.4 银行家算法的局限

银行家算法逻辑很优雅,但实际操作系统里几乎不直接拿它做通用资源分配。原因很现实:它要求每个进程预先声明最大资源需求,这在很多场景下做不到。比如一个 Web 服务器,它也不知道接下来一个请求会用到多少内存。而且进程数量、资源数量都可能动态变化,算法本身开销也不小,每次分配都要做一轮安全性检查。

那这个算法是不是白学?我并不这么看。银行家算法把“做决策前先预演后果”这个思想表达得淋漓尽致。做系统设计的人如果能保持这个意识,在分配稀缺资源时先画一张风险评估图,往往能避免很多线上事故。考试要考,它也是理解死锁避免最经典的切入点。

5. 死锁的检测与解除:出了问题要想办法捞回来

5.1 检测与解除的整体思路

预防和避免都是尽量不让死锁发生,但真实系统里情况太复杂,尤其是数据库事务、大规模分布式系统,死锁很难完全靠规则挡住。所以还有一个务实的方向:允许死锁发生,但要能快速检测出来,并且用最低成本解除。

检测的时机通常有两个思路。一个是周期性检测,系统每隔一段时间跑一次死锁检测算法,看看有没有进程被卡住。另一个是事件触发,比如某进程等待资源超时,或者系统 CPU 利用率明显下降但一堆任务没进展,这时候触发检测。检测不是越频繁越好,因为检测本身也消耗资源和时间。太频繁,正常分配都被拖慢;太慢,冲突堆积,损失已经发生。

5.2 资源分配图简化:判断死锁的落地方法

检测死锁常见的手段是资源分配图简化。所谓简化,就是模拟资源释放过程。从资源分配图中找一个既不阻塞也没有满足需求的进程,它需要的资源当前都能被满足,那它一定能运行完,我们把它占用的资源全部回收,等价于把这个进程节点以及相关边从图里删掉。然后继续找下一个可完成的进程。

如果最后所有进程都能被删除,说明当前没有死锁。如果剩下一些进程节点无法删除,它们就处于死锁状态。你可以用这个思路去理解多实例资源的检测,但不建议真的去手动画图推导大型系统,工程上都是靠工具直接抓线程栈和锁关系,原理一致,自动化实现。

5.3 死锁解除的三种手段:剥夺、回滚、杀进程

检测到死锁之后,必须强制打破。最常见的解除方式有三种。资源剥夺是从其他进程手里抢占资源,分配给死锁中的进程。这个办法和前面破坏不可剥夺条件类似,需要谨慎处理被剥夺进程的状态。回滚是让一个或多个进程回退到之前的检查点,把它占用的资源释放出来,然后重新执行。前提是系统支持检查点机制,能保存和恢复进程状态,否则回滚只是空谈。

最粗暴也最常用的是杀死进程。从死锁循环里挑一个进程终止,释放它持有的所有资源,让其他进程继续跑。代价是那个被杀掉的进程之前的工作全部白做。数据库的死锁处理就很典型,两个事务死锁后,数据库会检测到环路,挑一个事务作为牺牲者回滚,另一个事务继续,业务层收到死锁错误后通常会自动重试。我见过很多新人第一次看到数据库报 deadlock,以为系统坏了,其实这是数据库解除死锁的正常操作。

5.4 线上排查死锁时常用的几板斧

先看数据库场景。主流关系型数据库都有死锁日志,打开详细日志后能看到哪些事务持有哪些锁、等待哪些锁。日志里会出现类似“transaction A holds lock on row x, waiting for row y”的记录,把几个事务的持锁和等待关系摆出来,环就显形了。

再看 Java 后端服务。线程栈 dump 是排查死锁最直接的证据。执行线程 dump 后,JVM 会打印线程状态,专门有检测器识别死锁,输出类似“Found one Java-level deadlock”的信息,下面会列出两个线程各自持有的锁和等待的锁。你照着这个信息去查代码里锁的获取顺序,基本一击必中。

对于 C/C++ 或 Linux 环境的应用,可以用 gdb attach 到卡住的进程,thread apply all bt打印所有线程的调用栈,或者用 pstack 直接输出线程栈。多线程服务里,看到两个线程在 mutex 的获取函数里来回等,结合各自持有锁的标志,就能判断出来。

6. 常见问题与实战排障实录

6.1 死锁、饥饿、活锁,别再傻傻分不清

很多人把死锁和饥饿、活锁混为一谈。三者的表现都是“进程推进受阻”,但机制差别很大。我整理了一张对比表。

概念定义能否自行恢复典型处理
死锁多个进程互相等待对方资源,形成循环,谁都无法推进不能,需要外力介入检测并解除,如回滚、杀进程
饥饿某个进程长时间得不到资源,但其他进程可能正常运行不一定,等资源空闲后可能恢复采用公平调度,如按等待时间分配、避免优先级翻转
活锁进程没有阻塞,状态不断变化,但始终无法向前推进理论上可以自行恢复,但实际也可能一直耗下去加入随机退避、限制重试次数

活锁是个很有意思的现象。两个线程都在用 tryLock 拿锁,拿不到就释放自己的锁重试,结果两个线程不断地抢、让、再抢,谁也进不去临界区。看起来系统很忙,实际在空转。解决活锁的办法通常是随机等待一会儿再重试,把同步节奏打破。面试时候如果能把这三个概念放一起讲清楚,比单背定义更能打动面试官。

6.2 一个典型的线上死锁排查案例

我之前排查过一个订单系统的接口超时问题。业务逻辑是先锁订单记录,再锁库存记录,然后扣减库存。某天突然出现一批超时,请求积压在接口池里,一开始以为是数据库卡慢,但数据库监控显示负载不高。拉线程 dump 后看到两个工作线程卡在数据库锁等待上,线程 A 持有了订单表的锁,正在等库存表的行锁;线程 B 持有了库存表的锁,正在等订单表的行锁。这个环非常标准。

根因出在两个不同的代码入口。一个入口先操作订单再操作库存,另一个入口为了一些性能优化,先操作库存再操作订单。平时流量低的时候没暴露,流量一上来,两个路径交错执行,死锁概率就出现了。当时我们没有急着加数据库死锁重试,而是统一了所有入口的加锁顺序,然后加了一层超时保护,后续就没再出过问题。这类问题的排查思路,一般是先看线程栈,再反查代码路径,最后统一顺序。

6.3 我在实际编码里常用的一组防死锁习惯

我从这些实战里总结了几条经验,算是一个防死锁清单。

第一条,锁必须全部走统一顺序。多把锁的场景,把锁的获取顺序定义成全局常量或者集中在一个工具类里,而不是散落在各个业务代码里。两条不同代码路径一旦出现相反的锁顺序,死锁只是时间问题。

第二条,优先选择无锁或乐观锁方案。在允许的情况下,用原子操作、读写锁、乐观锁甚至无锁数据结构替代互斥锁,把锁竞争彻底化小。数据库层也类似,能用版本号控制的并发就尽量别用长事务持有的行锁。

第三条,加锁要带超时,失败就释放已持有的锁。线程持有多把锁的时候,尤其要小心。客户端里常见的写法是 tryLock 加超时,拿不到后续锁就把前面拿到的锁全部释放,避免造成半持有状态。这里要记住,超时失败后不是简单重试,最好结合重试上限和随机退避,防止演变成活锁。

第四条,缩小临界区,不在持锁状态下做耗时操作。锁只保护它该保护的共享状态。网络请求、磁盘 IO、远程调用这类高延迟操作,能放到锁外面就放外面。很多人以为死锁只跟锁顺序有关,其实持锁时间越长,和别人重叠等待的概率也越大。

第五条,数据库事务合理拆分,保持一致的访问顺序。几个表一起更新,顺序定好后就不要轻易改。事务隔离级别也别盲目提高,隔离级别越高,持锁范围和时间通常越大,死锁概率也会跟着上去。

我自己的体会是,操作系统教材讲死锁讲得最多的是一堆定义和算法,真正到工程里,最值钱的反而是两条朴素的原则:让所有线程按同一个顺序拿锁,以及拿不到锁时不要死等。银行家算法是很好的思维训练,能让你明白资源分配前先评估后果有多重要,但现实系统很少能提前知道进程的全部资源需求,所以大部分时候我们靠的是规则约束和超时兜底。你要是再遇到服务卡住,别急着重启,先拉一把线程栈看看有没有锁的环,说不定几分钟就定位了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询