1. 引言:哲学家就餐问题与科夫曼(Coffman)死锁四条件
1965年,荷兰计算机科学家艾兹赫尔·迪杰斯特拉(Edsger W. Dijkstra)提出了著名的“哲学家就餐问题(Dining Philosophers Problem)”:五位哲学家 $P_0 \dots P_4$ 围坐圆桌,每人左右两侧各有一把叉子 $F_i$ 与 $F_{(i+1)\bmod 5}$(共5把叉子),必须同时拿起左右两把叉子才能进餐。如果五位哲学家在同一瞬间感到饥饿,并各自拿起左手的叉子 $F_i$,那么每个人都会握着左叉永远等待邻居释放右叉——没有任何硬件故障,整个系统却陷入了永久停滞,这便是死锁(Deadlock)。
形式化地说,进程集合 $D \subseteq \mathcal{P}$($D \neq \emptyset$)处于死锁状态,当且仅当 $D$ 中的每一个进程 $P_i \in D$ 都在等待某个只能由同一集合 $D$ 中的其他进程 $P_j \in D$ 触发的事件(如释放锁资源)。
1971年,Edward G. Coffman Jr. 等人证明了资源死锁发生必须同时满足四个必要条件(科夫曼条件):
- 互斥(Mutual Exclusion):至少有一种资源以排他(非共享)模式被单个进程独占。
- 占有并等待(Hold and Wait):进程在已经持有至少一个资源的同时,阻塞等待获取其他进程持有的额外资源。
- 不可抢占(No Preemption):已分配给进程的资源不能被操作系统或他者强行剥夺,只能由持有者主动释放。
- 循环等待(Circular Wait):存在闭合的等待链 $\{P_0, P_1, \dots, P_{k-1}\}$,使得 $P_i$ 等待 $P_{(i+1)\bmod k}$ 持有的资源。
由于这四个条件均为必要条件,只要在系统设计中逻辑否定其中任意一个条件,死锁在数学上就 $100\%$ 不可能发生。
2. 资源分配图(RAG)与环路定理(Holt, 1972)
1972年,Richard C. Holt 引入了有向二分图——资源分配图(Resource Allocation Graph, RAG) $G = (V, E)$,其中顶点集 $V = \mathcal{P} \cup \mathcal{R}$(进程顶点 $P_i$ 与资源类型顶点 $R_j$,每个 $R_j$ 含 $W_j \ge 1$ 个实例),有向边分为请求边 $P_i \to R_j$(等待分配)与分配边 $R_j \to P_i$(已持有实例)。
关于 RAG 中的有向环(Directed Cycle)与死锁的关系,存在三大核心定理:
- 定理 1(无环必无死锁):若 RAG 中不存在有向环(即为 DAG),则系统中绝无死锁。
- 定理 2(单实例充要定理):若所有资源类型均只有一个实例($\forall j, W_j = 1$),则存在有向环 $\iff$ 发生死锁(充分必要条件)。
- 定理 3(多实例非充分定理):若某资源类型具有多个实例($\exists j, W_j > 1$),则有向环只是死锁的必要条件而非充分条件!
flowchart TD
subgraph RAG_Single["情况 A:单实例资源系 (有向环 <==> 必然死锁)"]
PA1(("P1 (阻塞)")) -->|"请求"| RA1["R1 (1 个实例: ●)"]
RA1 -->|"已分配"| PA2(("P2 (阻塞)"))
PA2 -->|"请求"| RA2["R2 (1 个实例: ●)"]
RA2 -->|"已分配"| PA1
end
subgraph RAG_Multi["情况 B:多实例资源系 (存在环 P1->R1->P3->R2->P1,但并未死锁!)"]
PB1(("P1 (阻塞)")) -->|"请求"| RB1["R1 (2 个实例: ●●)"]
RB1 -->|"分配实例 #1"| PB3(("P3 (阻塞)"))
PB3 -->|"请求"| RB2["R2 (2 个实例: ●●)"]
RB2 -->|"分配实例 #1"| PB1
RB1 -->|"分配实例 #2"| PB2(("P2 (运行中: 无请求边!)"))
RB2 -->|"分配实例 #2"| PB4(("P4 (运行中: 无请求边!)"))
end
在情况 B 中,虽然存在环 $P_1 \to R_1 \to P_3 \to R_2 \to P_1$,但持有第二个实例的 $P_2$ 与 $P_4$ 出度为 $0$(汇点,正在运行),它们执行完毕释放 $R_1, R_2$ 后即可立即解开环路!
3. 策略1(死锁预防):全序加锁规则的反证法证明
在操作系统内核(如 Linux lockdep)与多线程工程中,打破第4条件(循环等待)的黄金法则是全局锁排序(Total Lock Ordering):为所有锁赋予唯一序号 $\phi: \mathcal{L} \to \mathbb{N}$,强制要求任何线程在持有锁集 $\mathcal{H}$ 时,只能请求序号严格更大的新锁:
【反证法证明】:假设系统发生了长度为 $k \ge 2$ 的循环等待环 $\{P_0, P_1, \dots, P_{k-1}\}$,其中 $P_i$ 持有锁 $L_i$ 并等待 $P_{(i+1)\bmod k}$ 持有的锁 $L_{(i+1)\bmod k}$。由于每个线程均遵守全序加锁规则,必有:
$$\phi(L_0) < \phi(L_1) < \phi(L_2) < \cdots < \phi(L_{k-1}) < \phi(L_0) \implies \phi(L_0) < \phi(L_0)$$自然数不可能严格小于自身,矛盾($\bot$)!因此在全序加锁规则下(如银行转账始终先锁 ID 较小的账户,或让哲学家 $P_4$ 先拿右叉 $F_0$ 再拿左叉 $F_4$),循环等待在数学上绝不可能存在。$\blacksquare$
4. 策略2(死锁避免):迪杰斯特拉银行家算法与 5×3 矩阵推演
当无法静态固定加锁顺序时,迪杰斯特拉银行家算法(Banker’s Algorithm, 1965 / Habermann, 1969)要求进程预先申报最大需求矩阵 $\mathbf{Max}$,并在每次分配前通过复杂度为 $O(m \cdot n^2)$ 的安全性算法检验系统是否仍存在至少一条安全序列(Safe Sequence)(注意:$\text{死锁状态} \subsetneq \text{不安全状态}$,不安全状态并非已死锁,而是存在未来可能死锁的风险)。
设系统有 $n=5$ 个进程 $\{P_0 \dots P_4\}$,3 类资源 $\mathbf{Total} = (A=10, B=5, C=7)$,时刻 $t_0$ 状态如下表所示,当前剩余可用资源为 $\mathbf{Available} = (10,5,7) - (7,2,5) = \mathbf{(3, 3, 2)}$:
| 进程 | 已分配 $\mathbf{Allocation}$ $(A,B,C)$ | 最大需求 $\mathbf{Max}$ $(A,B,C)$ | 剩余需求 $\mathbf{Need} = \mathbf{Max} - \mathbf{Allocation}$ | 安全序列推演(初始 $\mathbf{Work} = (3,3,2)$) |
|---|---|---|---|---|
| $P_0$ | $(0, 1, 0)$ | $(7, 5, 3)$ | $(7, 4, 3)$ | 第 4 步满足:$(7,4,3) \le (7,4,5) \to \mathbf{Work}=(7,5,5)$ |
| $P_1$ | $(2, 0, 0)$ | $(3, 2, 2)$ | $(1, 2, 2)$ | 第 1 步满足:$(1,2,2) \le (3,3,2) \to \mathbf{Work}=(5,3,2)$ |
| $P_2$ | $(3, 0, 2)$ | $(9, 0, 2)$ | $(6, 0, 0)$ | 第 5 步满足:$(6,0,0) \le (7,5,5) \to \mathbf{Work}=(10,5,7)$ |
| $P_3$ | $(2, 1, 1)$ | $(2, 2, 2)$ | $(0, 1, 1)$ | 第 2 步满足:$(0,1,1) \le (5,3,2) \to \mathbf{Work}=(7,4,3)$ |
| $P_4$ | $(0, 0, 2)$ | $(4, 3, 3)$ | $(4, 3, 1)$ | 第 3 步满足:$(4,3,1) \le (7,4,3) \to \mathbf{Work}=(7,4,5)$ |
由于存在安全序列 $\langle P_1, P_3, P_4, P_0, P_2 \rangle$,状态 $t_0$ 是安全状态。若此时 $P_1$ 请求 $(1,0,2)$,试分配后 $\mathbf{Available}=(2,3,0)$ 仍满足 $\mathbf{Need}_1=(0,2,0)$,立即批准;但若紧接着 $P_0$ 请求 $(0,2,0)$,试分配后 $\mathbf{Available}=(2,1,0)$ 将导致所有进程的 $\mathbf{Need}$ 均无法满足(进入不安全状态),银行家算法会立即回滚并阻塞 $P_0$!
5. 策略3 & 4(检测恢复与鸵鸟算法)以及死锁 vs. 活锁 vs. 饥饿
在关系型数据库(RDBMS)中,由于 SQL 语句动态到达,系统采用等待图(Wait-For Graph, WFG)通过 $O(|V|+|E|)$ 的 DFS 寻找环路(MySQL InnoDB 阻塞时立即检测并回滚 Undo 量最小的牺牲者;PostgreSQL 则睡眠等待 deadlock_timeout = 1s 后再检测)。而在分布式数据库(如 Google Spanner)中,则采用基于启动时间戳 $\text{TS}(T_i)$(越小越年长)的 Wait-Die(非抢占)与 Wound-Wait(抢占,年长者直接击伤回滚年轻者,重启次数更少)协议,且被中止事务重试时必须保留原时间戳以彻底防止饥饿。至于罕见的操作系统用户态表项耗尽,UNIX/Windows 则采用鸵鸟算法(Ostrich Algorithm)直接忽略,以免日常系统调用背负沉重的检测开销。
flowchart TD
Req["分布式事务 Ti 请求被 Tj 持有的锁"] --> Cmp{"比较启动时间戳: TS(Ti) < TS(Tj) ?<br/>(请求方 Ti 是否比持有方 Tj 更年长?)"}
Cmp -->|"是: Ti 更年长 (Older)"| WD1["【Wait-Die (非抢占)】: 年长的 Ti 等待年轻的 Tj (老 -> 少)<br/>【Wound-Wait (抢占)】: 年长的 Ti 击伤回滚 Tj 并夺锁!"]
Cmp -->|"否: Ti 更年轻 (Younger)"| WD2["【Wait-Die (非抢占)】: 年轻的 Ti 自杀中止 (保留原 TS 重试)<br/>【Wound-Wait (抢占)】: 年轻的 Ti 安静等待年长的 Tj (少 -> 老)"]
最后,在并发系统的活性故障(Liveness Failures)诊断中,必须严格区分死锁、活锁(Livelock)与饥饿(Starvation):
| 诊断维度 | 死锁 (Deadlock) | 活锁 (Livelock) | 饥饿 (Starvation) |
|---|---|---|---|
| 核心机制與定义 | 阻塞线程形成循环等待链,彼此等待对方释放资源 | 线程为避开死锁而对称地不断执行 trylock 失败、释放并立即重试(走廊礼让死循环) | 系统整体正常推进,但特定低优先级线程永远抢不到资源 |
| OS 线程状态与 CPU 占用 | SLEEPING / BLOCKED(CPU 占用 $0\%$) | RUNNING(CPU 飙升至 $100\%$ 空转,有效推进为 0) | 等待队列中长期停滞(受害者 CPU 为 $0\%$) |
| 根本工程解药 | 全序加锁($\phi(L_1)<\phi(L_2)$)、银行家算法、WFG 回滚 | 引入随机指数退避(Randomized Exponential Backoff with Jitter)打破时间对称性 | 优先级老化(Aging)、保留原时间戳、公平 FIFO 队列锁(MCS 锁) |
