程序为什么会卡在死锁里?从四个必要条件到预防与解除

两个线程,一个先锁 l1 再锁 l2,另一个反过来先锁 l2 再锁 l1:

java 复制代码
// 线程 A
synchronized (l1) { synchronized (l2) { ... } }

// 线程 B
synchronized (l2) { synchronized (l1) { ... } }

跑到某一刻,A 攥着 l1 在等 l2,B 攥着 l2 在等 l1。两个都不肯放手,就永远停在那儿了------这就是死锁。它和竞态条件是一对孪生问题:竞态是"顺序没定对",死锁是"谁都不肯松手"。


四个必要条件

死锁要发生,下面四条必须同时成立,少一条都不行,我们对着上面的例子逐条看:

条件 意思 在上面例子里
互斥(Mutual Exclusion) 资源一次只能被一个进程占用 l1、l2 同一时刻只能被一个线程持有
持有并等待(Hold and Wait) 拿着手里的资源,同时去等新的资源 A 拿着 l1 的同时去等 l2
不可抢占(No Preemption) 资源不能被强行夺走,只能持有者主动放 A 手里的 l1 不能被别人抢走
循环等待(Circular Wait) 存在一条首尾相接的等待链 A 等 B 的 l2,B 等 A 的 l1,绕成环

这四条不是"原因",是"特征"。反过来说,只要想办法破掉其中任意一条,死锁就不可能发生------这也是各种处理办法的出发点。


怎么处理

围绕这四个条件,处理思路分四类:

策略 做法 代价
预防(Prevention) 静态地破坏四个条件之一,让死锁永远不可能发生 限制资源使用方式,利用率下降
避免(Avoidance) 运行时每次分配前判断"分了之后会不会不安全" 要提前知道最大需求,开销大
检测 + 恢复(Detection & Recovery) 允许发生,检测出来再善后 检测要开销,恢复要牺牲进程
忽略(鸵鸟) 当它不存在 真发生时系统卡死

现实中大多数操作系统选的是最后一种。死锁不算高频,为它付出的复杂度和性能代价不划算,所以 Linux、Windows 对普通进程是"假装看不见"的。真正认真处理死锁的是数据库这类系统。


预防

预防是把死锁的四个条件一个一个堵死。

破坏"持有并等待":要么进程一开始就一次性申请全部需要的资源,要么规定"申请新资源前必须先释放手里已有的"。前者资源利用率低(明明后面才用的资源也得先占着),后者可能导致饥饿。

破坏"不可抢占" :申请不到就主动释放自己已经拿到的资源,等一会儿再整体重来。对锁可行(tryLock 失败就回退释放),但像打印机这种打到一半没法交还的资源就不行。

破坏"互斥":一般做不到,锁和打印机天生就是互斥的。

破坏"循环等待" (最常用):给所有资源定一个全局的申请顺序,规定所有进程必须按同一顺序申请 。回到开头的例子,只要定下"永远先申请编号小的锁",A 和 B 就都变成"先 l1 后 l2",不可能再互相等着对方手里的那把,环就形成不了。这条几乎不损失性能,是工程里最实用的做法。


避免:银行家算法

预防是"一刀切地禁止",避免是"运行时看情况"。代表是银行家算法,核心问一句:这次分配之后,系统还安全吗?

"安全"的定义:存在一个进程执行顺序(安全序列),能按这个顺序把所有进程都跑完。判断方法是------从当前可用资源出发,找一个"需要的量 ≤ 当前可用"的进程,假装它跑完并把资源还回来,重复下去;如果所有进程都能被这样安排进来,系统就安全。

举个例子,系统有 A/B/C 三类资源,总量 (10, 5, 7):

text 复制代码
          Allocation   Max       Need(=Max-Allocation)
          A  B  C      A  B  C   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)。

从 (3,3,2) 出发找安全序列:

  • P1 需要 (1,2,2) ≤ (3,3,2),能满足。P1 跑完归还 (2,0,0),Available 变成 (5,3,2)。
  • P3 需要 (0,1,1) ≤ (5,3,2),满足。归还 (2,1,1),Available 变成 (7,4,3)。
  • P4 需要 (4,3,1) ≤ (7,4,3),满足。归还 (0,0,2),Available 变成 (7,4,5)。
  • P0 需要 (7,4,3) ≤ (7,4,5),满足。归还 (0,1,0),Available 变成 (7,5,5)。
  • P2 需要 (6,0,0) ≤ (7,5,5),满足,完成。

一路下来所有进程都能跑完,说明 (3,3,2) 是个安全状态,安全序列是 <P1, P3, P4, P0, P2>。如果某次分配后找不到任何安全序列,这次分配就会被推迟,让申请者等着。

银行家算法的名字来自它的场景:银行(内核)手里有现金(资源),每个客户(进程)提前声明了最多要借多少。只要每一笔放款之后,银行还能按某种顺序满足所有人的最大需求,就不怕出坏账。现实里它很少被真正用上,因为它要求"提前知道每个进程的最大需求",程序运行时这个数往往说不出来。


检测与恢复

"避免"需要提前知道最大需求,"检测"不要,它允许死锁发生,发生之后再抓出来。

工具是资源分配图 :方框是资源(里面画几个点代表实例数),圆圈是进程,边表示"进程持有资源"或"进程等待资源"。如果图里出现了环,就可能死锁------单实例情况下,有环就等于死锁;多实例情况下,有环是死锁的必要条件,还得再细判。

检测到之后要恢复,常见手段:

  • 杀进程:挑一个或几个(greedy 地挑代价最小的)干掉,释放它的资源。
  • 回滚:定期给进程打检查点(checkpoint),回滚到死锁发生之前,重新跑。
  • 抢占资源:把资源从一个进程手里夺回来给另一个,但要处理被夺者做到一半的状态。

数据库就是这么干的。MySQL InnoDB 有死锁检测,一发现环,主动把其中一个事务判为"牺牲者(sacrifice)"回滚掉,让另一个继续------Deadlock found when trying to get lock; try restarting transaction 这条报错就是它干的。


现实里怎么看

普通程序里死锁发生了怎么查?以 Java 为例,jstack <pid> 会直接把它检测到的死锁打出来:

text 复制代码
Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00007f... (object 0x...),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x00007f... (object 0x...),
  which is held by "Thread-1"

Found 1 deadlock.

看到这种互相 held by 的环形输出,就说明锁的申请顺序出了问题,回到"破坏循环等待"那节------统一加锁顺序,通常就能解决。

相关推荐
实名上网宋凯宣2 小时前
Django
后端·python·django
利威尔xu4 小时前
Go IM 源码实战(14):本地缓存快 3000 倍,失效却难 10 倍
后端·缓存·微服务·面试·golang
逃逸线LOF4 小时前
Ribbon负载均衡原理(Irule组件、源码分析)
后端·spring cloud·ribbon
慢云智慧空间5 小时前
从设备智能到空间理解,空间智能终端解决方案商如何重新定义行业标准
后端
利威尔xu5 小时前
Go IM 源码实战(13):30 个会话只有 2 个红点,未读数错在三条路径
redis·后端·微服务·面试·golang
薛定谔的猫-菜鸟程序员5 小时前
软件架构设计师开发资料大全(从入门到精通)
后端·python·系统架构·flask·架构设计师
IT_陈寒7 小时前
SpringBoot多环境配置把我坑惨了,这个踩坑经验你最好看看
前端·人工智能·后端
码事漫谈7 小时前
DeepSeek 搞了个 "Super-agent",但不是超级智能
后端
吴凡wudianmei8 小时前
从 0 搭一个 AI Agent(四):SSE 跑通之后,三处“契约错配”与它们的修复边界
前端·后端·aigc
小兔子8 小时前
defer 的参数在写下那一刻就求值了,循环里这样写会踩坑
后端