Lecture 7: 死锁(Deadlock)

目录 · ← l5 · l7 →

Lecture 7: 死锁(Deadlock)

概述

当系统使用多把锁(细粒度锁、模块化设计)时,线程经常需要同时持有多把锁——这引入了死锁风险。本讲定义死锁、给出其四个必要条件,并用”资源-线程图”的环(circularity)直观解释,最后讨论检测(数据库常用)与预防(消除任一必要条件,实践中常用全局锁序)两种对策。

核心概念与系统机制图解

死锁(Deadlock)

  • 定义:一组线程全部阻塞,每个线程都在等待另一个线程持有的资源;由于所有线程都阻塞,谁也无法释放资源 → “相互阻塞导致无法进展”。
  • 直观解释:两个人都拿着对方要用的钥匙,谁也不肯先放手 → 双双卡死。

经典死锁示例

线程 A:                         线程 B:
m1.lock();                     m2.lock();
m2.lock();   ← 等待B释放m2      m1.lock();   ← 等待A释放m1
...                            ...
m2.unlock();                   m1.unlock();
m1.unlock();                   m2.unlock();
→ A 持有 m1 等 m2, B 持有 m2 等 m1 → 环 → 死锁

死锁的四个必要条件(全部满足才可能死锁)

| # | 条件 | 含义 | 消除途径 | | :— | :— | :— | :— | | 1 | 有限访问(互斥) Mutual Exclusion | 资源不能被共享 | 让线程永不需要等待(资源足够多)——对锁不现实 | | 2 | 不可抢占 No Preemption | 资源一旦给出不能被夺走 | 把资源抢走——对 CPU 可行,对锁不可行 | | 3 | 多重独立请求(持有并等待) Hold & Wait | 线程持有一个资源时再请求另一个 | 一次请求全部资源——难以实现且易过度分配 | | 4 | 循环等待 Circular Wait | 请求-持有图中有环 | 全局锁序:所有线程按同一顺序加锁 ← 最常用 |

资源-线程图(可视化环)

无环(安全)                        有环(死锁!)
R1 ◄── T1                          R1 ◄── T2
      │                                 │
      └──► R2 ◄── T2              R2 ◄── T1
                 │                      │
                 └──► R3 ◄── T3         └──► R3 ◄── T3
  "持有"(owned by) 与 "等待"(waiting for)   └──────► R4 ──► T2 ...(闭环)

死锁检测(Detection)

  • 确定系统是否死锁,然后终止其中一个线程打破死锁。
  • 对操作系统通常不实用,但数据库系统常用:事务可被中止并重试。

死锁预防(Prevention)——重点

  • 消除条件 4(循环等待)是最实用的途径:给每把锁分配一个编号(锁的”等级/rank”),所有线程都按递增(或递减)顺序加锁。运行时可校验加锁顺序是否违反。
  • 案例:两个进程执行 mv 命令
    进程1: mv a/x  b/y    需要锁住目录 a 和 b
    进程2: mv b/z  a/q    需要锁住目录 b 和 a
    按"参数顺序加锁" → 进程1锁a等b, 进程2锁b等a → 死锁
    按"全局固定顺序"(先锁 a 再锁 b) → 进程2也先锁a, 不会与进程1交叉 → 无死锁
    

设计洞见

  • 死锁是全局设计问题:它打破模块化——需要所有模块就加锁顺序达成一致;修改系统时很容易重新引入死锁。

代码示例与系统调用解说

示例:用”全局锁序”避免死锁(锁等级)

#include <mutex>
#include <stdexcept>

std::mutex m1, m2;          // 规定: 永远先锁 m1, 再锁 m2 (lock rank 1 < 2)

void transfer_ab(std::mutex& first, std::mutex& second) {
    // 防止调用方以任意顺序传参: 按地址排序, 保证全局一致的加锁顺序
    if (&first < &second) {         // 先锁地址较小的锁
        first.lock();
        second.lock();
    } else {
        second.lock();
        first.lock();
    }
    // ... 转账等临界区操作 ...
    second.unlock();
    first.unlock();
}

【代码做了什么?】 无论调用者以何种顺序传入两把锁,函数内部都按”地址排序”固定加锁顺序,从结构上消除循环等待。

【系统机制透视】

  • 预防死锁的关键不是”不要同时持有多把锁”,而是”所有线程以相同全局顺序获取它们”——这样请求-持有图中不可能出现环。
  • 如果两个线程以相反顺序加锁,即使只差一把锁,也可能形成等待环;锁序规则把”环”这种全局属性变成”局部可检查”的属性(每个加锁点只需检查等级单调递增)。
  • 现实中(如 Linux 内核)用锁等级 + 静态/动态检查(lockdep)来发现违反锁序的代码路径。

关键要点

  1. 死锁四条件:互斥、不可抢占、持有并等待、循环等待;全部满足才死锁。
  2. 最实用的预防手段:全局锁序(所有线程按同一顺序获取锁)。
  3. 死锁检测(终止线程)在数据库事务中常用,在操作系统中不常用。
  4. 死锁是全局设计问题,破坏模块化;引入新锁时必须与既有锁序保持一致。
  5. 死锁不仅限于锁:任何”等待”(内存耗尽、网络消息、分布式系统)都可能形成死锁。

常见陷阱与注意事项

  • 加锁顺序不一致:两个线程用不同顺序获取同一组锁 → 最经典的死锁来源。
  • 回调/嵌套调用中加锁:A 持锁调用 B,B 又尝试锁 A 持有的锁。
  • 多个互斥锁未分层:新代码随意加锁而不检查全局顺序。
  • 忙等也能死锁:两个线程互相自旋等待对方释放自旋锁(饥饿与死锁的边界)。

思考题

  1. 问题:只有三个必要条件而没有循环等待,会死锁吗?
    • 答案:不会。循环等待是形成”全体阻塞”的最后一环;没有环,等待图有向无环,总有一个线程能获得所需资源并最终释放。
  2. 问题:为什么数据库系统偏好”死锁检测+回滚”而非预防?
    • 答案:数据库事务的访问模式动态多变,难以预先规定全局锁序;而事务具有可回滚性(undo log),检测到死锁后中止并重试一个事务代价可控,比限制所有事务的加锁模式更灵活。
  3. 问题mv a/x b/ymv b/z a/q 若都按”参数顺序”锁目录会死锁,如何修?
    • 答案:改为全局固定的目录锁序(例如按目录 i-number 或路径字典序加锁),两个进程都以相同顺序获取目录锁,消除环。