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 区别于读写锁的核心特性。
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 调用者必须等待宽限期结束,无法被其他线程强制唤醒。但等待的是“所有读者退出”这一条件,而非某个具体锁。 |
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 风险更低,但组合使用时仍需警惕 |
3.7 工程实践:死锁排查清单
基于上述理论分析,在实际驱动调试中,建议按以下清单排查死锁:
- 检查互斥条件: 确认是否存在写-写并发?是否存在写-读并发?RCU 读者是否与更新者并发?
- 检查持有并等待: 是否存在锁升级(读锁→写锁)?RCU 更新者是否在等待宽限期时持有其他锁?
- 检查不可剥夺: 是否存在长时间持有锁而不释放?RCU 读者是否在临界区内执行阻塞操作(如 schedule、wait_event)?
- 检查循环等待: 使用 lockdep 检测锁顺序。RCU 与 spinlock 的组合使用是否形成了 A→B→A 的等待链?
调试建议: 在编写驱动代码时,遵循“RCU 读侧临界区内不获取任何阻塞锁”的原则,可以从根本上避免 RCU 相关的循环等待死锁。如果必须获取锁,优先使用 spinlock 而非 mutex,并确保锁顺序一致。