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