4、RCU与读写锁混用风险:在RCU读端临界区中获取写锁、在写锁保护下调用synchronize_rcu()的典型死锁模式

RCU(Read-Copy-Update)与读写锁(rwlock)是内核中两种不同的同步机制,各自拥有独立的读者/写者语义。当开发者将两者在同一个代码路径中混合使用时,极易触发一种隐蔽且难以复现的死锁——RCU与读写锁交叉依赖死锁。本章深入剖析该死锁的触发条件、内核态调用栈特征以及工程排查方法。

4.1 死锁触发条件与核心矛盾

该死锁模式需要同时满足以下两个条件:

  1. 条件A:在RCU读端临界区(rcu_read_lock() / rcu_read_unlock())内部,尝试获取一个写锁(write_lock() / write_lock_irqsave())。
  2. 条件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之间的资源循环等待关系:

RCU与读写锁混用死锁依赖图 RCU与读写锁混用死锁依赖图 CPU-A(写锁持有者) 持有写锁 (rwlock) 调用 synchronize_rcu() 等待所有RCU读者退出 → 阻塞等待 CPU-B(RCU读者) 持有RCU读锁 在RCU临界区内尝试获取写锁 写锁已被CPU-A持有 → 阻塞等待 等待RCU读者退出 等待写锁释放 死锁循环: CPU-A 等 CPU-B 退出RCU → CPU-B 等 CPU-A 释放写锁 → 循环等待 资源1:写锁 (rwlock) — 被CPU-A持有,CPU-B请求 资源2:RCU宽限期完成信号 — 被CPU-B持有,CPU-A等待

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读端临界区 */
}
🔍 死锁触发时序:
  1. CPU-A 执行 update_data(),成功获取 my_rwlock 写锁。
  2. CPU-B 执行 read_and_write_data(),进入RCU读端临界区。
  3. CPU-A 调用 synchronize_rcu(),开始等待所有RCU读者退出。
  4. CPU-B 尝试获取 my_rwlock 写锁,发现写锁被CPU-A持有,CPU-B进入睡眠等待。
  5. CPU-B 因为阻塞在写锁上,无法退出RCU读端临界区,导致CPU-A的 synchronize_rcu() 永远无法完成。
  6. 系统死锁。

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 工程排查步骤

在工程实践中,建议按照以下步骤排查该死锁:

  1. 触发SysRq回溯:执行 echo l > /proc/sysrq-trigger 或使用 sysrq+l 获取所有CPU的调用栈。
  2. 识别关键栈帧:在栈回溯中搜索 synchronize_rcu 和 write_lock 关键字,确认是否存在交叉等待。
  3. 启用Lockdep:在内核启动参数中添加 lockdep=1,复现死锁时查看 /sys/kernel/debug/locking/lockdep 报告。
  4. 分析RCU宽限期:通过 /proc/rcu_sched 或 /proc/rcu_preempt 检查宽限期是否停滞,以及哪些CPU尚未完成宽限期。
  5. 代码审查:重点审查所有在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(异步化)的修复效果:

异步化修复策略流程图 异步化修复策略:用 call_rcu() 替代 synchronize_rcu() 修复前(死锁) write_lock() → synchronize_rcu() → 阻塞等待RCU读者 → 死锁 修复后(无死锁) write_lock() → call_rcu(callback) → 立即释放写锁 → 无阻塞 关键变化 call_rcu() 不阻塞当前执行路径,注册回调后立即返回 写锁在 call_rcu() 返回后即可释放,RCU宽限期在后台异步完成

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与锁的嵌套关系。