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

两个线程,一个先锁 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 的环形输出,就说明锁的申请顺序出了问题,回到"破坏循环等待"那节------统一加锁顺序,通常就能解决。

相关推荐
Bazingga1 小时前
知识注入的两条路:RAG知识库与Skills蒸馏
后端
QQ14220784491 小时前
基于 Flask 的「自动任务控制台」设计与踩坑实录
后端·python·flask
Bazingga1 小时前
中文提问,能检到英文文档吗?——一次跨语言RAG检索五语种实测
后端
对象存储与RustFS1 小时前
用 warp 压测 S3 兼容存储,十分钟跑出一份可复现的基准报告
后端·云原生·性能优化
用户298698530141 小时前
Python 实现 Word 转 XPS、PostScript 与 OFD 格式
后端·python·api
武子康1 小时前
PUBACK 已到,灯还没亮:Agent 设备指令何时算完成?
人工智能·后端·agent
ClouGence1 小时前
SAP HANA 数据同步遇到长事务,如何兼顾实时性与完整性?
数据库·后端
蜗牛互联网1 小时前
Python Agent 工具调用:审批令牌、参数白名单和累计预算
java·开发语言·人工智能·windows·后端·python
环境栈笔记1 小时前
指纹浏览器检查代理失败怎么排查?先分清代理、网络和检测服务
前端·人工智能·后端·自动化