4、RCU与读写锁混用风险:在RCU读端临界区中获取写锁、在写锁保护下调用synchronize_rcu()的典型死锁模式
RCU(Read-Copy-Update)与读写锁(rwlock)是内核中两种不同的同步机制,各自拥有独立的读者/写者语义。当开发者将两者在同一个代码路径中混合使用时,极易触发一种隐蔽且难以复现的死锁——RCU与读写锁交叉依赖死锁。本章深入剖析该死锁的触发条件、内核态调用栈特征以及工程排查方法。
4.1 死锁触发条件与核心矛盾
该死锁模式需要同时满足以下两个条件:
- 条件A:在RCU读端临界区(
rcu_read_lock()/rcu_read_unlock())内部,尝试获取一个写锁(write_lock()/write_lock_irqsave())。 - 条件B:在另一个执行路径上,已经持有该写锁,然后调用
synchronize_rcu()等待所有RCU读端临界区完成。
当条件A和条件B同时发生时,系统进入死锁:
- 持有写锁的CPU(CPU-A)调用
synchronize_rcu(),阻塞等待所有RCU读者退出。 - 持有RCU读锁的CPU(CPU-B)尝试获取写锁,但写锁已被CPU-A持有,CPU-B阻塞。
- CPU-B因为阻塞在写锁上,无法退出RCU读端临界区,导致CPU-A的
synchronize_rcu()永远无法完成。
⚠ 关键风险点: 该死锁不依赖特定硬件平台,在SMP(对称多处理)系统上普遍存在。由于RCU读端临界区通常非常短,且写锁持有者调用
synchronize_rcu()的场景较少,该死锁在低负载下极难复现,但在高并发或特定时序下会突然触发系统hang。
4.2 死锁调用栈与资源依赖关系
下面通过一张SVG依赖图展示死锁发生时,两个CPU之间的资源循环等待关系:
4.3 典型代码模式与触发路径
以下代码片段展示了该死锁的典型触发模式。假设内核中存在一个全局读写锁 my_rwlock 和一个RCU保护的数据结构 my_data。
4.3.1 写锁路径(CPU-A)
/* 写锁路径:先获取写锁,再调用 synchronize_rcu() */
void update_data(void)
{
write_lock(&my_rwlock); /* 持有写锁 */
/* ... 修改数据 ... */
synchronize_rcu(); /* 等待所有RCU读者完成 — 死锁点 */
/* ... 后续清理 ... */
write_unlock(&my_rwlock);
}
4.3.2 RCU读路径(CPU-B)
/* RCU读路径:在RCU临界区内获取写锁 */
void read_and_write_data(void)
{
rcu_read_lock(); /* 进入RCU读端临界区 */
/* ... 读取RCU保护的数据 ... */
write_lock(&my_rwlock); /* 尝试获取写锁 — 死锁点 */
/* ... 在RCU临界区内执行写操作 ... */
write_unlock(&my_rwlock);
rcu_read_unlock(); /* 退出RCU读端临界区 */
}
🔍 死锁触发时序:
- CPU-A 执行
update_data(),成功获取my_rwlock写锁。 - CPU-B 执行
read_and_write_data(),进入RCU读端临界区。 - CPU-A 调用
synchronize_rcu(),开始等待所有RCU读者退出。 - CPU-B 尝试获取
my_rwlock写锁,发现写锁被CPU-A持有,CPU-B进入睡眠等待。 - CPU-B 因为阻塞在写锁上,无法退出RCU读端临界区,导致CPU-A的
synchronize_rcu()永远无法完成。 - 系统死锁。
4.4 死锁特征与排查方法
当系统因该模式死锁时,可以通过以下特征进行识别:
| 特征维度 | 具体表现 | 排查手段 |
|---|---|---|
| CPU状态 | 至少一个CPU处于 D 状态(不可中断睡眠)或 R 状态但长期不调度 |
cat /proc/stat 查看上下文切换次数;top 观察CPU使用率 |
| 调用栈特征 | 一个CPU的栈顶为 synchronize_rcu → wait_rcu_gp;另一个CPU的栈顶为 write_lock → __raw_write_lock_slowpath |
echo t > /proc/sysrq-trigger 或 sysrq+l 查看所有CPU栈 |
| 锁依赖 | 写锁的持有者与RCU宽限期等待者重叠 | lockdep 报告 possible deadlock 或 inconsistent lock state |
| RCU状态 | rcu_sched 或 rcu_preempt 宽限期长时间未完成 |
cat /proc/rcu_sched 查看 gp_seq 和 completed 是否停滞 |
4.5 工程排查步骤
在工程实践中,建议按照以下步骤排查该死锁:
- 触发SysRq回溯:执行
echo l > /proc/sysrq-trigger或使用sysrq+l获取所有CPU的调用栈。 - 识别关键栈帧:在栈回溯中搜索
synchronize_rcu和write_lock关键字,确认是否存在交叉等待。 - 启用Lockdep:在内核启动参数中添加
lockdep=1,复现死锁时查看/sys/kernel/debug/locking/lockdep报告。 - 分析RCU宽限期:通过
/proc/rcu_sched或/proc/rcu_preempt检查宽限期是否停滞,以及哪些CPU尚未完成宽限期。 - 代码审查:重点审查所有在RCU读端临界区内获取写锁的代码路径,以及所有在持有写锁时调用
synchronize_rcu()的代码路径。
💡 工程建议: 在驱动开发中,应严格遵循“在RCU读端临界区内绝不获取任何写锁”的原则。如果确实需要在RCU临界区内修改数据,考虑使用
call_rcu() 异步回调机制替代 synchronize_rcu(),或者将写锁获取操作移到RCU临界区之外。
4.6 修复策略与最佳实践
针对该死锁模式,有以下几种修复策略:
| 策略 | 实现方式 | 适用场景 | 风险 |
|---|---|---|---|
| 策略A:异步化 | 将 synchronize_rcu() 替换为 call_rcu() 回调 |
写锁路径不需要立即等待RCU完成 | 增加内存开销,回调上下文受限 |
| 策略B:锁降级 | 在RCU临界区内使用 spin_lock 替代 write_lock |
写操作非常短,且不需要睡眠 | 可能引入自旋锁竞争 |
| 策略C:锁顺序反转 | 先获取写锁,再进入RCU读端临界区 | 读路径可以容忍写锁延迟 | 可能降低读路径并发性 |
| 策略D:RCU宽限期分离 | 在释放写锁后再调用 synchronize_rcu() |
写锁保护的数据与RCU保护的数据独立 | 需要重构数据保护逻辑 |
下面通过一张SVG流程图展示策略A(异步化)的修复效果:
4.7 总结与避坑清单
RCU与读写锁混用死锁是驱动开发中较为隐蔽的并发问题。以下是需要牢记的避坑清单:
- ❌ 禁止:在
rcu_read_lock()/rcu_read_lock_bh()保护的临界区内调用write_lock()、write_trylock()或任何可能睡眠的写锁变体。 - ❌ 禁止:在持有写锁(
write_lock系列)的代码路径中调用synchronize_rcu()、synchronize_rcu_expedited()或synchronize_sched()。 - ✅ 推荐:在RCU临界区内需要写保护时,优先使用
spin_lock或raw_spin_lock替代读写锁。 - ✅ 推荐:如果必须使用
synchronize_rcu(),确保调用时没有持有任何写锁,或者将同步操作移到写锁释放之后。 - ✅ 推荐:在开发阶段始终开启
CONFIG_PROVE_LOCKING(Lockdep),它能在死锁发生前通过splat警告提前暴露问题。
⚠ 生产环境注意事项: 该死锁在单核CPU上也可能发生(如果启用了抢占)。在嵌入式或实时系统中,由于RCU宽限期可能被抢占延长,死锁窗口更大。建议在代码审查阶段使用静态分析工具(如
sparse 或 smatch)扫描RCU与锁的嵌套关系。