2. 死锁的定义与经典条件
死锁(Deadlock)是并发编程中最棘手的故障模式之一。在 Linux 内核中,当两个或多个执行线程(进程、中断处理程序、软中断)互相等待对方持有的资源,且任何一方都无法继续推进时,系统便陷入死锁。死锁一旦发生,受影响的线程将永久阻塞,可能导致系统响应完全停止、关键服务崩溃,甚至触发内核 Panic。
要理解死锁的成因与排查方法,必须首先掌握其产生的四个必要条件。这四个条件由 Coffman 等人于 1971 年系统化总结,至今仍是死锁分析与检测的理论基石。
2.1 死锁的四个必要条件详解
死锁的发生必须同时满足以下四个条件,缺一不可:
| 条件 | 英文名称 | 核心含义 | 内核中的典型表现 |
|---|---|---|---|
| 互斥 | Mutual Exclusion | 资源一次只能被一个线程占用 | spinlock、mutex、rwlock 等锁的排他性 |
| 持有并等待 | Hold and Wait | 线程已持有一个资源,同时等待获取另一个资源 | 在持有 spin_lock(A) 时尝试获取 spin_lock(B) |
| 不可剥夺 | No Preemption | 资源只能由持有者主动释放,不能被强制剥夺 | spinlock 不能被其他线程强行解锁 |
| 循环等待 | Circular Wait | 存在一个线程-资源的闭合等待链 | T1 等 T2 的资源,T2 等 T1 的资源 |
2.1.1 互斥条件
互斥是锁存在的根本原因。如果资源允许多个线程同时访问,就不需要锁,也就不会因此产生死锁。在内核中,绝大多数经典锁(spinlock、mutex、rwlock 的写锁)都强制互斥访问。当一个线程持有锁时,其他试图获取同一锁的线程必须等待。
互斥条件本身是必要的,无法也不应该被消除——否则锁就失去了意义。死锁排查中,我们关注的是互斥锁与其他三个条件的组合。
2.1.2 持有并等待条件
这是驱动开发中最容易引入死锁的编程模式。线程在已经持有一个锁的情况下,又去请求另一个锁。内核中典型的错误模式如下:
/* 危险模式:持有并等待 */
void thread_A(void)
{
spin_lock(&lock_A); /* 持有 lock_A */
/* ... 临界区操作 ... */
spin_lock(&lock_B); /* 等待 lock_B —— 持有并等待 */
/* ... */
spin_unlock(&lock_B);
spin_unlock(&lock_A);
}
如果此时另一个线程以相反的顺序持有 lock_B 并等待 lock_A,死锁便立即形成。lockdep 的核心检测逻辑之一,就是通过记录每个线程的锁获取序列,识别出这种“持有并等待”的交叉依赖。
2.1.3 不可剥夺条件
在内核中,spinlock 和 mutex 默认都是不可剥夺的。持有锁的线程只能通过主动调用解锁原语(spin_unlock / mutex_unlock)来释放资源。其他线程无法强制其交出锁。
这一条件在大多数情况下是合理的——强制剥夺锁会导致临界区数据不一致。但在某些实时场景或死锁恢复机制中,内核提供了可剥夺的变体(如 mutex_trylock、spin_trylock),它们通过“尝试获取,失败则返回”的方式,从编程层面打破了不可剥夺条件。
/* 使用 trylock 打破不可剥夺条件 */
void safe_path(void)
{
spin_lock(&lock_A);
if (!spin_trylock(&lock_B)) {
/* 获取 lock_B 失败,释放 lock_A 后重试或返回 */
spin_unlock(&lock_A);
/* 错误处理或延迟重试 */
return;
}
/* 成功持有 lock_A 和 lock_B */
spin_unlock(&lock_B);
spin_unlock(&lock_A);
}
2.1.4 循环等待条件
循环等待是死锁最直观的表现形式。它描述了一个闭合的等待链:线程 T1 等待 T2 持有的资源,T2 等待 T3 持有的资源,……,Tn 等待 T1 持有的资源。在内核中,最常见的循环等待是两线程两锁的 AB-BA 模式。
下面的 SVG 图展示了经典的 AB-BA 死锁循环:
上图清晰地展示了循环等待的闭合结构:T1 持有 A 并等待 B,T2 持有 B 并等待 A,形成无法打破的等待环。lockdep 在检测到这种循环依赖时,会立即输出死锁警告并打印完整的锁依赖链。
2.2 四个条件的逻辑关系与破坏策略
理解四个条件之间的逻辑关系,有助于在实际开发中快速定位问题并选择正确的修复策略:
- 互斥 是锁存在的根本,不可消除,但可以通过读写锁、RCU 等机制在特定场景下弱化。
- 持有并等待 是编程习惯问题,通过锁顺序规范、一次性获取所有锁来避免。
- 不可剥夺 可通过 trylock 变体或超时机制打破。
- 循环等待 是死锁的直接表现,通过全局锁顺序、锁分级来预防。
2.3 死锁条件在内核中的实际案例
以下是一个真实世界中触发了四个条件的驱动死锁案例:
/* 驱动 A:网卡驱动中断处理 */
void net_rx_irq(struct net_device *dev)
{
spin_lock(&dev->rx_lock); /* 持有 rx_lock */
/* ... 接收数据包 ... */
spin_lock(&dev->stats_lock); /* 等待 stats_lock */
dev->stats.rx_packets++;
spin_unlock(&dev->stats_lock);
spin_unlock(&dev->rx_lock);
}
/* 驱动 B:网卡统计读取(进程上下文) */
void net_get_stats(struct net_device *dev)
{
spin_lock(&dev->stats_lock); /* 持有 stats_lock */
/* ... 准备统计 ... */
spin_lock(&dev->rx_lock); /* 等待 rx_lock —— 死锁! */
/* ... 读取统计 ... */
spin_unlock(&dev->rx_lock);
spin_unlock(&dev->stats_lock);
}
在这个案例中:
- 互斥: rx_lock 和 stats_lock 都是 spinlock,互斥访问。
- 持有并等待: 两个线程都在持有一个锁的同时等待另一个锁。
- 不可剥夺: spinlock 不可被强制解锁。
- 循环等待: 中断处理持有 rx_lock 等 stats_lock,统计线程持有 stats_lock 等 rx_lock。
修复方案是统一锁获取顺序:始终先获取 rx_lock,再获取 stats_lock,从而打破循环等待。
2.4 死锁条件与 lockdep 的对应关系
lockdep 的检测逻辑直接映射到四个必要条件:
| lockdep 检测机制 | 对应的死锁条件 | 检测原理 |
|---|---|---|
| 锁类(lock class) | 互斥 | 为每个锁分配唯一类,区分不同锁实例 |
| 锁依赖链记录 | 持有并等待 | 记录每个线程的锁获取序列(A→B→C) |
| 不可剥夺检查 | 不可剥夺 | 检测在不可睡眠上下文中获取可睡眠锁 |
| 循环依赖检测 | 循环等待 | 在锁依赖图中搜索闭合环 |