Update 计算机操作系统 - 死锁.md

重新绘制银行家算法与死锁检测配图;调整死锁检测、安全状态与预防策略的描述,补充自死锁说明及测试附录,细化 Java 与 MySQL 死锁示例的执行步骤。
This commit is contained in:
CyC2018
2026-10-30 20:19:29 +08:00
parent cd0d36c2db
commit 41b3cf9175
6 changed files with 55 additions and 32 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 132 KiB

+55 -32
View File
@@ -25,6 +25,7 @@
* [A2 MySQL 死锁](#a2-mysql-死锁)
* [A3 先查后插的间隙锁死锁](#a3-先查后插的间隙锁死锁)
* [A4 死锁线程的状态与 CPU 时间](#a4-死锁线程的状态与-cpu-时间)
* [A5 单个线程重复加锁](#a5-单个线程重复加锁)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -42,6 +43,8 @@
- 不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放;
- 环路等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源。
占有和等待说的是单个进程的行为,环路等待说的是进程之间的等待关系。例如 T1 持有 alice 后请求 T2 持有的 bob,如果 T2 不再请求别的锁,用完就会释放 bob,T1 只是多等一会儿;只有等待关系连成环(例如 T2 反过来等 alice),才是死锁。
## 处理方法
主要有以下四种方法:
@@ -51,13 +54,11 @@
- 死锁预防
- 死锁避免
死锁预防在设计时破坏某个必要条件,死锁避免在运行时每次分配前检查是否安全。平时说的“避免死锁”多指预防的做法,例如固定加锁顺序、加锁时设置超时,见[程序中的死锁](#程序中的死锁)。
## 鸵鸟策略
把头埋在沙子里,假装根本没发生问题。
因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任何措施的方案会获得更高的性能。
当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。
鸵鸟策略指的是假装死锁不会发生,不采取任何措施。死锁发生的概率很低、发生后影响不大时,处理死锁的代价比死锁本身更高,不处理反而性能更好。
大多数操作系统,包括 Unix、Linux 和 Windows,处理死锁问题的办法仅仅是忽略它。
@@ -73,7 +74,7 @@
图中 P1、P2、P3 和 R1、R2、R3 构成一个环,满足环路等待条件,因此发生了死锁。
每种类型一个资源的死锁检测算法是通过检测有向图是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有向图存在环,也就是检测到死锁的发生。
每种类型一个资源的死锁检测算法是通过检测有向图是否存在环来实现:从每个节点出发进行深度优先搜索,记录当前搜索路径上的节点,如果沿着边又走回了路径上的节点,就找到了环,也就是检测到死锁。需要注意的是,走到之前搜索过、但不在当前路径上的节点并不表示有环,例如 P1、P2 都在请求 R1 时,从两者出发都会走到 R1。
### 2. 每种类型多个资源的死锁检测
@@ -98,9 +99,9 @@
### 3. 死锁恢复
- 利用抢占恢复
- 利用回滚恢复
- 通过杀死进程恢复
- 利用抢占恢复:把资源从持有它的进程手中临时拿走交给另一个进程,能否做到取决于资源的性质;
- 利用回滚恢复:进程定期保存检查点,检测到死锁时回滚到获得该资源之前的检查点;
- 通过杀死进程恢复:杀死环中的一个或多个进程以释放资源,InnoDB 回滚其中一个事务也属于这一类,见 [MySQL 死锁](#2-mysql-死锁)。
## 死锁预防
@@ -108,7 +109,7 @@
### 1. 破坏互斥条件
例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机守护进程。
例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机守护进程。但锁这类资源存在的意义就是互斥,大多数情况下这个条件无法破坏。
### 2. 破坏占有和等待条件
@@ -130,17 +131,17 @@
### 1. 安全状态
如果没有死锁发生,并且即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得每一个进程运行完毕,则称该状态是安全的。
<div align="center"> <img src="pics/deadlock-safe-state.png" width="760px"/> </div><br>
图中条形的长度表示进程总共需要的资源数(Max),浅蓝部分表示已拥有的资源数(Has),Free 表示还可以使用的资源数。从图 a 开始出发,先让 B 拥有所需的所有资源,运行结束后释放 B,此时 Free 变为 6(图 b);接着以同样的方式运行 A 和 C,使得所有进程都能成功运行,因此可以称图 a 所示的状态是安全的。
定义:如果没有死锁发生,并且即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得每一个进程运行完毕,则称该状态是安全的。
安全状态的检测与死锁的检测类似,因为安全状态必须要求不能发生死锁。下面的银行家算法与死锁检测算法非常类似,可以结合着做参考对比。
需要注意的是,不安全状态不等于死锁:进程不一定真的会请求到最大需求,只是系统已经无法保证所有进程都能完成。判断方法与[每种类型多个资源的死锁检测](#2-每种类型多个资源的死锁检测)相同,只是把当前请求换成最多还需要的数量。
### 2. 单个资源的银行家算法
一个小城镇的银行家,他向一群客户分别承诺了一定的贷款额度,算法要做的是判断对请求的满足是否会进入不安全状态,如果是,就拒绝请求;否则予以分配。
银行家算法把系统比作银行:每个客户(进程)事先声明最多借多少(Max),银行家每次放款前先假设批准,检查之后是否仍安全,安全才放款,否则让客户等待。
<div align="center"> <img src="pics/deadlock-banker-single.png" width="760px"/> </div><br>
@@ -154,11 +155,11 @@
检查一个状态是否安全的算法如下:
- 查找 Need 矩阵中是否存在一行小于等于向量 Available。如果不存在这样的行,那么系统将会发生死锁,状态是不安全的;
- 查找 Need 矩阵中是否存在一行小于等于向量 Available。如果不存在这样的行,那么系统可能发生死锁,状态是不安全的;
- 假若找到这样一行,将该进程标记为终止,并将其已分配资源加到 Available 中;
- 重复以上两步,直到所有进程都标记为终止,则状态是安全的。
如果一个状态不是安全的,需要拒绝进入这个状态。
多个资源时,银行家算法同样先假设分配,再用这个方法检查分配后的状态,不安全就拒绝。
## 程序中的死锁
@@ -204,41 +205,45 @@ synchronized (first) {
}
```
两个线程各反向转账 10 万次,原来的写法每次运行都会卡住,按 id 顺序加锁后都能完成,两个账户的余额仍为 1000(JDK 21 实测,见[附录 A1](#a1-java-线程死锁))。使用 ReentrantLock 时还可以用 tryLock 设置超时,拿不到锁就释放已持有的锁再重试,破坏的是占有和等待条件。例如获取第二把锁等待 10 ms 仍失败时释放第一把锁,随机等待 1~4 ms 后重试,同样的两个线程在 30~60 ms 内完成(见[附录 A4](#a4-死锁线程的状态与-cpu-时间))。等待时间要随机,否则两个线程可能总是同时释放、同时重试,谁都拿不到两把锁,这称为活锁。
两个线程各反向转账 10 万次,原来的写法每次运行都会卡住,按 id 顺序加锁后都能完成,两个账户的余额仍为 1000(JDK 21 实测,见[附录 A1](#a1-java-线程死锁))。使用 ReentrantLock 时还可以用 tryLock 设置超时,拿不到锁就释放已持有的锁再重试,破坏的是[不可抢占条件](#3-破坏不可抢占条件)。例如获取第二把锁等待 10 ms 仍失败时释放第一把锁,随机等待 1~4 ms 后重试,同样的两个线程在 30~60 ms 内完成(见[附录 A4](#a4-死锁线程的状态与-cpu-时间))。等待时间要随机,否则两个线程可能总是同时释放、同时重试,谁都拿不到两把锁,这称为活锁。
需要注意的是,死锁的线程处于阻塞状态,并不消耗 CPU。上例卡住后,两个线程的状态都是 BLOCKED,之后 3 秒内各自消耗的 CPU 时间都不到 0.1 ms(JDK 21 实测,见[附录 A4](#a4-死锁线程的状态与-cpu-时间))。因此 CPU 占用很高时应先按 [CPU 占用过高](Java%20虚拟机.md#cpu-占用过高)排查死循环、频繁 GC 等问题;CPU 很低、请求却全部卡住,线程都停在加锁的地方,才更像死锁,用 jstack 或 jcmd 的 Thread.print 命令打印线程栈确认。程序中也可以定时调用 ThreadMXBean 的 findDeadlockedThreads() 检测死锁并告警。
需要注意的是,用阻塞式的锁时,死锁的线程处于阻塞状态,并不消耗 CPU;用自旋锁形成的死锁则会一直占用 CPU。上例卡住后,两个线程的状态都是 BLOCKED,之后 3 秒内各自消耗的 CPU 时间都不到 0.1 ms(JDK 21 实测,见[附录 A4](#a4-死锁线程的状态与-cpu-时间))。因此 CPU 占用很高时应先按 [CPU 占用过高](Java%20虚拟机.md#cpu-占用过高)排查死循环、频繁 GC 等问题;CPU 很低、请求却全部卡住,线程都停在加锁的地方,才更像死锁,用 jstack 或 jcmd 的 Thread.print 命令打印线程栈确认。JVM 不会像 InnoDB 那样选一个线程回滚,卡在 synchronized 上的线程也无法被中断,通常只能重启进程,因此要靠固定加锁顺序或 tryLock 提前预防。程序中也可以定时调用 ThreadMXBean 的 findDeadlockedThreads() 检测死锁并告警,但它检测不到包含虚拟线程的死锁环。
一个线程持有锁后再次申请同一把锁,等的是只有自己才会释放的锁,也会卡死,称为自死锁(self-deadlock)。synchronized 和 ReentrantLock 是可重入锁,同一线程再次加锁只把计数加 1;不可重入的锁会卡住,例如许可数为 1 的 Semaphore,同一线程连续 acquire 两次后一直处于 WAITING,findDeadlockedThreads() 也检不出来(JDK 21 实测,见[附录 A5](#a5-单个线程重复加锁))。
### 2. MySQL 死锁
事务按不同的顺序更新同一组行时也会死锁。例如两个事务同时反向转账,事务 1 先更新 id = 1,事务 2 先更新 id = 2,之后各自更新对方已经锁住的行:
事务按不同的顺序更新同一组行时也会死锁。例如两个事务同时反向转账,事务 1 先更新 id = 1,事务 2 先更新 id = 2,之后各自更新对方已经锁住的行(按编号顺序执行):
```sql
-- 事务 1
-- ① 事务 1
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance - 100 WHERE id = 1; -- 锁住 id = 1
-- ② 事务 2
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2; -- 锁住 id = 2
-- ③ 事务 1
UPDATE account SET balance = balance + 100 WHERE id = 2; -- 等待事务 2 释放 id = 2 的行锁
-- 事务 2
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2;
UPDATE account SET balance = balance + 50 WHERE id = 1;
-- ④ 事务 2
UPDATE account SET balance = balance + 50 WHERE id = 1; -- 等待事务 1,形成环
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
```
InnoDB 默认开启死锁检测,发现环后立即回滚其中一个事务(倾向于回滚修改行数少的事务),另一个事务继续执行。上例中事务 2 被回滚,事务 1 提交后余额为 900 和 1100(MySQL 8.0 实测,见[附录 A2](#a2-mysql-死锁))。执行 SHOW ENGINE INNODB STATUS,在 LATEST DETECTED DEADLOCK 一节中可以看到两个事务各自持有和等待的锁。被回滚的事务需要由应用重试。
按相同顺序加锁并不能避免所有死锁。可重复读隔离级别下,常见的“先查有没有,没有再插入”也会死锁,例如两个事务分别为订单 1024 和 1025 写支付记录,表中已有 1000 和 2000 两行,两个事务先后执行 SELECT,再先后执行 INSERT:
按相同顺序加锁并不能避免所有死锁。可重复读隔离级别下,常见的“先查有没有,没有再插入”也会死锁,例如两个事务分别为订单 1024 和 1025 写支付记录,表中已有 1000 和 2000 两行,两个事务先后执行 SELECT,再先后执行 INSERT(按编号顺序):
```sql
-- 事务 1
-- ① 事务 1
BEGIN;
SELECT * FROM payment WHERE order_id = 1024 FOR UPDATE; -- 没有这一行,锁住间隙 (1000, 2000)
INSERT INTO payment VALUES (1024, 100); -- 等待事务 2 的间隙锁
-- 事务 2
-- ② 事务 2
BEGIN;
SELECT * FROM payment WHERE order_id = 1025 FOR UPDATE; -- 同一个间隙,间隙锁之间互不冲突
INSERT INTO payment VALUES (1025, 50);
-- ③ 事务 1
INSERT INTO payment VALUES (1024, 100); -- 插入意向锁与事务 2 的间隙锁冲突,等待
-- ④ 事务 2
INSERT INTO payment VALUES (1025, 50); -- 同样等待事务 1,形成环
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
```
@@ -379,6 +384,24 @@ tryLock 版本:完成,余额 1000 1000,用时 29 ms
tryLock 版本:完成,余额 1000 1000,用时 43 ms
```
### A5 单个线程重复加锁
JDK 21.0.13,同一线程对同一把锁加锁两次,Semaphore 的线程设为守护线程,等待 2 秒后查看状态:
```java
synchronized (o) { synchronized (o) { System.out.println("synchronized 重入:OK"); } }
l.lock(); l.lock(); // ReentrantLock
Semaphore s = new Semaphore(1);
s.acquire(); s.acquire(); // 在新线程中执行
```
```text
synchronized 重入:OK
ReentrantLock 重入:OK,holdCount=2
Semaphore(1) 同一线程 acquire 两次,2 秒后状态:WAITING
此时 findDeadlockedThreads() 返回 null
```
## 参考资料
- Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014.