先聊一个挺典型的场景:你正写一个并发程序,两个线程各持有一把锁,然后互相等对方释放另一把锁。程序既不报错也不退出,就是卡在那里不动。这种局面在操作系统课程里叫死锁,在真实的线上环境里同样让人头疼。无论你是在准备面试、复习操作系统,还是排查线上服务卡顿,死锁这块知识点都绕不开。我尽量不摆教科书脸,把概念、条件、预防、避免、检测、解除这几个层面一次讲透,最后再分享一些我在实际项目中踩过的坑和排查方法。
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,初始状态如下。
| 进程 | Allocation | Max | Need |
|---|---|---|---|
| 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、远程调用这类高延迟操作,能放到锁外面就放外面。很多人以为死锁只跟锁顺序有关,其实持锁时间越长,和别人重叠等待的概率也越大。
第五条,数据库事务合理拆分,保持一致的访问顺序。几个表一起更新,顺序定好后就不要轻易改。事务隔离级别也别盲目提高,隔离级别越高,持锁范围和时间通常越大,死锁概率也会跟着上去。
我自己的体会是,操作系统教材讲死锁讲得最多的是一堆定义和算法,真正到工程里,最值钱的反而是两条朴素的原则:让所有线程按同一个顺序拿锁,以及拿不到锁时不要死等。银行家算法是很好的思维训练,能让你明白资源分配前先评估后果有多重要,但现实系统很少能提前知道进程的全部资源需求,所以大部分时候我们靠的是规则约束和超时兜底。你要是再遇到服务卡住,别急着重启,先拉一把线程栈看看有没有锁的环,说不定几分钟就定位了。