3、死锁理论:死锁的四个必要条件在 RCU 与读写锁中的体现

死锁是并发编程中最棘手的故障模式之一。在 Linux 内核中,RCU 与读写锁虽然设计目标不同,但若使用不当,同样会触发死锁。理解死锁的四个必要条件(互斥、持有并等待、不可剥夺、循环等待)在这些锁机制中的具体表现形式,是进行死锁排查的理论基础。

核心认知: 死锁的四个条件缺一不可。只要破坏其中一个条件,死锁即可预防。RCU 与读写锁在“不可剥夺”和“循环等待”维度上存在本质差异,这也是 RCU 在某些场景下能天然避免死锁的原因。

3.1 死锁四个必要条件回顾

死锁的正式定义由 Coffman 等人于 1971 年提出,以下四个条件必须同时满足:

条件 定义 类比(资源分配)
互斥 资源一次只能被一个线程/CPU 持有 会议室一次只能被一个团队使用
持有并等待 线程持有资源的同时,等待获取其他资源 团队 A 占用会议室 1,同时等待会议室 2
不可剥夺 资源只能由持有者主动释放,外部无法强制剥夺 会议室钥匙在团队 A 手中,管理员不能强行收回
循环等待 存在一组线程,每个线程都在等待下一个线程持有的资源 团队 A 等会议室 2,团队 B 等会议室 1
排查要点: 在内核死锁检测(如 lockdep)中,系统通过检测“循环等待”来判定死锁。但“不可剥夺”条件在 RCU 与读写锁中的表现截然不同,这是理解两者死锁差异的关键。

3.2 互斥条件在 RCU 与读写锁中的体现

读写锁(rwlock/rwsem): 严格满足互斥条件。写者与写者之间互斥,写者与读者之间互斥。读者与读者之间不互斥(允许并发读)。

RCU: 在更新侧(synchronize_rcu/call_rcu)与读者侧(rcu_read_lock)之间,不满足互斥条件。RCU 读者可以与被 RCU 保护的旧数据指针的回收操作并发执行。这是 RCU 区别于读写锁的核心特性。

互斥条件对比:读写锁 vs RCU 读写锁 (rwlock) 写者 A 写者 B 读者 C 读者 D 写-写互斥 | 写-读互斥 | 读-读不互斥 RCU 更新者 A 更新者 B 读者 C 读者 D 更新-更新互斥 | 更新-读不互斥

3.3 持有并等待条件在 RCU 与读写锁中的体现

读写锁: 典型场景:线程 A 持有读锁,然后尝试获取写锁。此时线程 A 处于“持有读锁并等待写锁”的状态。如果另一个线程 B 持有写锁并等待读锁,则形成死锁。

RCU: RCU 读者(rcu_read_lock)不阻塞更新者(synchronize_rcu)。读者持有 RCU 读侧临界区时,更新者只需等待所有已存在的读者退出,而不会被当前读者阻塞。因此,RCU 在“持有并等待”维度上表现较弱——读者不等待更新者,更新者等待的是“所有读者退出”这一条件,而非某个特定线程持有的锁。

常见误区: 认为 RCU 完全不存在“持有并等待”。实际上,RCU 更新者(如 synchronize_rcu)会等待宽限期(grace period)结束,这本质上是一种“等待”。但等待的对象是“所有读者退出”这一全局条件,而非某个具体锁的持有者。这种差异使得 RCU 在避免循环等待方面具有天然优势。

3.4 不可剥夺条件在 RCU 与读写锁中的体现

这是 RCU 与读写锁差异最大的维度。

锁类型 不可剥夺性 说明
读写锁(写锁) 严格不可剥夺 写锁一旦被持有,除非持有者主动释放,否则其他任何线程(包括读者)都无法获取该锁。即使写者被调度出去,锁仍然被持有。
读写锁(读锁) 严格不可剥夺 读锁被持有期间,写者无法获取写锁。读者可以无限期持有读锁,导致写者饥饿。
RCU 读侧 可剥夺(抢占式内核) 在 PREEMPT_RCU 内核中,RCU 读者可以被抢占。但 RCU 机制保证:只要读者被抢占,宽限期会相应延长,直到该读者重新被调度并退出临界区。
RCU 更新侧 不可剥夺(等待宽限期) synchronize_rcu 调用者必须等待宽限期结束,无法被其他线程强制唤醒。但等待的是“所有读者退出”这一条件,而非某个具体锁。
不可剥夺条件对比:读写锁 vs RCU 读写锁 写者持有锁 → 被调度出去 其他线程无法获取该锁(死锁) RCU 读者被抢占 → 宽限期延长 更新者等待,但不会死锁

3.5 循环等待条件在 RCU 与读写锁中的体现

循环等待是死锁检测(lockdep)的核心检测目标。在读写锁中,循环等待很容易形成:

// 读写锁循环等待示例
CPU 0: read_lock(&lock);   // 持有读锁
       write_lock(&lock);  // 等待写锁(被 CPU 1 持有?)

CPU 1: read_lock(&lock);   // 持有读锁
       write_lock(&lock);  // 等待写锁(被 CPU 0 持有?)

// 结果:CPU 0 等 CPU 1 释放读锁,CPU 1 等 CPU 0 释放读锁
// 形成循环等待 → 死锁

在 RCU 中,循环等待的形成条件更为苛刻:

// RCU 循环等待示例(需要配合其他锁)
CPU 0: rcu_read_lock();    // 进入 RCU 读侧
       spin_lock(&lock);   // 获取自旋锁
       // ... 等待 CPU 1 释放 lock

CPU 1: spin_lock(&lock);   // 获取自旋锁
       synchronize_rcu();  // 等待所有 RCU 读者退出
       // ... 等待 CPU 0 退出 RCU 读侧

// 结果:CPU 0 持有 RCU 读锁,等待 spin_lock
//       CPU 1 持有 spin_lock,等待 RCU 读者退出
//       形成循环等待 → 死锁
关键洞察: RCU 本身不会与 RCU 自身形成循环等待(因为读者不等待更新者,更新者不等待特定读者)。但 RCU 与其他锁(如 spinlock、mutex)组合使用时,循环等待条件极易被触发。这是 RCU 死锁排查中最常见的陷阱。

3.6 四个条件在 RCU 与读写锁中的综合对比

必要条件 读写锁 RCU 死锁风险
互斥 写-写、写-读互斥 仅更新-更新互斥 RCU 风险更低
持有并等待 常见(读锁升级写锁) 罕见(更新者等待宽限期) RCU 风险更低
不可剥夺 严格不可剥夺 读者可被抢占 RCU 风险更低
循环等待 容易形成(锁顺序反转) 需与其他锁配合形成 RCU 风险更低,但组合使用时仍需警惕
死锁四个条件在 RCU 与读写锁中的风险矩阵 互斥 读写锁:高 RCU:低 持有并等待 读写锁:高 RCU:中 不可剥夺 读写锁:高 RCU:中 循环等待 读写锁:高 RCU:中 死锁风险总结 读写锁: 四个条件均容易满足,死锁风险高。 - 典型场景:读锁升级写锁、锁顺序反转。 - 检测手段:lockdep 可直接检测循环等待。 RCU: 自身不易形成死锁,但组合使用时风险上升。 - 典型场景:RCU 读侧内部获取 spinlock,spinlock 持有者调用 synchronize_rcu。 - 检测手段:lockdep 需配合 PROVE_RCU 选项。

3.7 工程实践:死锁排查清单

基于上述理论分析,在实际驱动调试中,建议按以下清单排查死锁:

  1. 检查互斥条件: 确认是否存在写-写并发?是否存在写-读并发?RCU 读者是否与更新者并发?
  2. 检查持有并等待: 是否存在锁升级(读锁→写锁)?RCU 更新者是否在等待宽限期时持有其他锁?
  3. 检查不可剥夺: 是否存在长时间持有锁而不释放?RCU 读者是否在临界区内执行阻塞操作(如 schedule、wait_event)?
  4. 检查循环等待: 使用 lockdep 检测锁顺序。RCU 与 spinlock 的组合使用是否形成了 A→B→A 的等待链?
调试建议: 在编写驱动代码时,遵循“RCU 读侧临界区内不获取任何阻塞锁”的原则,可以从根本上避免 RCU 相关的循环等待死锁。如果必须获取锁,优先使用 spinlock 而非 mutex,并确保锁顺序一致。