Linux 内核 RCU 与 lockdep 协作机制:读侧临界区跟踪、*_held() 查询与 rcu_dereference 检查全解
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
本篇基于内核源码树中的 RCU and lockdep checking 一文,系统讲解 Linux 内核 RCU(Read-Copy-Update)与 lockdep 锁依赖检查器的协作机制:lockdep 如何感知每个任务进出各类 RCU 读侧临界区、六种保守型*_held()查询原语的语义,以及CONFIG_PROVE_RCU下rcu_dereference()家族宏的验证行为。读完本文,你将能在死锁排查中利用 lockdep 的 RCU 状态跟踪,并能正确选用rcu_dereference_check()、rcu_dereference_protected()等原语为并发代码标注合法的解引用条件。
1. 为什么 lockdep 需要"看见" RCU 读侧临界区
RCU 的读侧临界区(如rcu_read_lock()/rcu_read_unlock())并非传统意义上的锁,但它们在并发语义上承担着一类"共享资源持有"的角色:更新方必须等待读侧临界区结束才能释放数据。如果 lockdep 对这种持有状态一无所知,那么当死锁或锁序冲突恰好经由 RCU 临界区传导时,lockdep 的告警就会缺失关键上下文,调试会变得异常困难。
为此,内核为**每一种 RCU 类型(flavor)**都提供了独立的 lockdep 跟踪,使 lockdep 能够感知每个任务何时进入或离开任何类型的 RCU 读侧临界区,并将 RCU 状态纳入其依赖图,从而在调试死锁等并发问题时提供额外线索。文档中特别提示:2.6.32 及更早版本并非如此——早期内核中各 RCU flavor 共用跟踪状态,而当前源码树中已经做到了逐 flavor 独立跟踪。
从源码结构看,每个 RCU 类型对应一个独立的lockdep_map,定义在 kernel/rcu/update.c:
struct lockdep_map rcu_lock_map = { .name = "rcu_read_lock", .key = &rcu_lock_key, .wait_type_outer = LD_WAIT_FREE, .wait_type_inner = LD_WAIT_CONFIG, /* PREEMPT_RT implies PREEMPT_RCU */ }; struct lockdep_map rcu_bh_lock_map = { .name = "rcu_read_lock_bh", ... .wait_type_inner = LD_WAIT_CONFIG, /* PREEMPT_RT makes BH preemptible. */ }; struct lockdep_map rcu_sched_lock_map = { .name = "rcu_read_lock_sched", ... .wait_type_inner = LD_WAIT_SPIN, }; // Tell lockdep when RCU callbacks are being invoked. struct lockdep_map rcu_callback_map = STATIC_LOCKDEP_MAP_INIT("rcu_callback", &rcu_callback_key);这些 map 在 include/linux/rcupdate.h 中以extern声明供全局使用。rcu_read_lock()进入读侧临界区时通过rcu_lock_acquire()在 lockdep 中登记rcu_lock_map(见 include/linux/rcupdate.h 中rcu_lock_acquire()对lock_acquire()的封装),退出时以rcu_lock_release()注销,于是 lockdep 的每个任务锁依赖链中都能反映"当前正持有哪种 RCU 读侧临界区"。此外还有rcu_callback_map,用于在 RCU 回调执行期间告知 lockdep 当前处于回调上下文。
2. 六种*_held()查询原语:保守返回语义
在写并发代码或调试代码时,经常需要断言"当前必须处于某类 RCU 读侧临界区内"。文档列出了 RCU 提供的六个 lockdep 状态查询原语:
| 原语 | 检查目标 | 说明 |
|---|---|---|
rcu_read_lock_held() | 普通 RCU(vanilla RCU) | 是否持有rcu_read_lock() |
rcu_read_lock_bh_held() | RCU-bh | 是否持有 RCU-bh 读侧临界区 |
rcu_read_lock_sched_held() | RCU-sched | 是否持有 RCU-sched 读侧临界区 |
rcu_read_lock_any_held() | 任意 vanilla RCU | 覆盖普通 RCU、RCU-bh、RCU-sched |
srcu_read_lock_held() | SRCU | 是否持有 SRCU 读侧临界区 |
rcu_read_lock_trace_held() | RCU Tasks Trace | 是否处于 tracing 专用的 RCU 类型内 |
2.1 关键设计:保守返回,避免误报
文档强调了这些函数的保守性(conservative):当它们不能确定结果时(例如CONFIG_DEBUG_LOCK_ALLOC未开启、lockdep 尚未初始化等情形),一律返回 1("认为是持有")。这样做的直接收益是:像WARN_ON(!rcu_read_lock_held())这类断言,在 lockdep 关闭时不会打出假阳性(false positives)——否则每个未开 lockdep 的内核都会在这种断言处无差别报警,反而淹没真正的问题。
源码印证了这一分叉行为。在 include/linux/rcupdate.h 中,当CONFIG_DEBUG_LOCK_ALLOC未开启时,这些函数退化为廉价的静态内联版本:
static inline int rcu_read_lock_held(void) { return 1; } static inline int rcu_read_lock_sched_held(void) { return !preemptible(); } static inline int rcu_read_lock_any_held(void) { return !preemptible(); }注意一个细节:即便在禁用 lockdep 的退化路径下,rcu_read_lock_sched_held()和rcu_read_lock_any_held()仍会返回!preemptible()——即它们退化为"当前是否处于禁抢占状态"的近似判断,因为只有禁抢占区间才可能构成 RCU-sched 读侧临界区;而rcu_read_lock_held()则直接保守返回 1。
当CONFIG_DEBUG_LOCK_ALLOC开启时,同名函数变为真实查询,实现在 kernel/rcu/update.c:
int notrace rcu_read_lock_held(void) { bool ret; if (rcu_read_lock_held_common(&ret)) return ret; return lock_is_held(&rcu_lock_map); } int notrace rcu_read_lock_bh_held(void) { bool ret; if (rcu_read_lock_held_common(&ret)) return ret; return in_softirq() || irqs_disabled(); } int notrace rcu_read_lock_any_held(void) { bool ret; if (rcu_read_lock_held_common(&ret)) return ret; if (lock_is_held(&rcu_lock_map) || lock_is_held(&rcu_bh_lock_map) || lock_is_held(&rcu_sched_lock_map)) return 1; return !preemptible(); }几个值得注意的实现细节:
notrace标注:这些函数可能被 tracing 路径调用(tracing 基础设施自身就运行在 RCU 读侧临界区内),若再触发跟踪事件就会递归锁死系统,因此标注notrace排除跟踪。rcu_read_lock_held_common()短路:它先检查 CPU 是否处于空闲/离线等"不可能持有 RCU 锁"的状态,能短路就直接给出确定答案,避免在非法上下文中误判为持有。rcu_read_lock_bh_held()的 BH 判断:RCU-bh 读侧临界区等价于"软中断/中断被禁用的区间",因此用in_softirq() || irqs_disabled()判断,覆盖local_bh_disable()、local_irq_disable()等各种禁 BH 手段。debug_lockdep_rcu_enabled()双保险:真实查询前先调用该函数(kernel/rcu/update.c)确认"RCU 调度器已激活 &&debug_locks开启 && 非 lockdep 递归",防止开机早期(lockdep 尚未初始化)以及 lockdep 运行时被禁用两种时机打出假阳性。
SRCU 侧的srcu_read_lock_held()实现在 include/linux/srcu.h;tracing 专用的rcu_read_lock_trace_held()在 include/linux/rcupdate_trace.h。
2.2lockdep_assert_in_rcu_*()系列:从查询到断言
在查询原语之上,include/linux/rcupdate.h 提供了一组可直接用于WARN_ON_ONCE的断言宏:
#define lockdep_assert_in_rcu_read_lock() \ WARN_ON_ONCE(lockdep_assert_rcu_helper(!lock_is_held(&rcu_lock_map), RCU)) #define lockdep_assert_in_rcu_reader() \ WARN_ON_ONCE(lockdep_assert_rcu_helper(!lock_is_held(&rcu_lock_map) && \ !lock_is_held(&rcu_bh_lock_map) && \ !lock_is_held(&rcu_sched_lock_map) && \ preemptible(), RCU))文档级约束(写在宏注释里)有两点容易被误用:lockdep_assert_in_rcu_read_lock_bh()不接受local_bh_disable()之类的区域,必须持有真正的rcu_read_lock_bh();同理lockdep_assert_in_rcu_read_lock_sched()要求真正的rcu_read_lock_sched(),preempt_disable()不算。而更宽松的lockdep_assert_in_rcu_reader()则接受任何"不可抢占"状态,因为preempt_disable、local_bh_disable、local_irq_disable保护的区间都算 RCU 读侧临界区。
3. CONFIG_PROVE_RCU:rcu_dereference()原语的 lockdep 检查
文档指出,另一个独立的内核配置项CONFIG_PROVE_RCU启用了rcu_dereference()家族原语的 lockdep 状态检查。从 kernel/rcu/Kconfig.debug 可以看到其定义方式:
config PROVE_RCU def_bool PROVE_LOCKING config PROVE_RCU_LIST bool "RCU list lockdep debugging" depends on PROVE_RCU && RCU_EXPERT default n即PROVE_RCU由 lockdep 总开关PROVE_LOCKING自动派生,不需要手动设置;而PROVE_RCU_LIST(对 RCU 链表用法做 lockdep 检查)因尚有待转换的用户,默认关闭以避免假阳性告警。
3.1 完整原语清单
在CONFIG_PROVE_RCU开启时,文档列出的rcu_dereference()家族及其检查行为如下:
| 原语 | 检查行为 |
|---|---|
rcu_dereference(p) | 检查处于 vanilla RCU 读侧临界区 |
rcu_dereference_bh(p) | 检查处于 RCU-bh 读侧临界区 |
rcu_dereference_sched(p) | 检查处于 RCU-sched 读侧临界区 |
srcu_dereference(p, sp) | 检查处于 SRCU 读侧临界区 |
rcu_dereference_check(p, c) | 显式条件c+ 隐式rcu_read_lock_held() |
rcu_dereference_bh_check(p, c) | 显式条件c+ 隐式rcu_read_lock_bh_held() |
rcu_dereference_sched_check(p, c) | 显式条件c+ 隐式rcu_read_lock_sched_held() |
srcu_dereference_check(p, c) | 显式条件c+ 隐式srcu_read_lock_held() |
rcu_dereference_raw(p) | 不做任何检查(文档明确建议:能不用就不用) |
rcu_dereference_raw_check(p) | 完全不触发 lockdep(能不用就不用) |
rcu_dereference_protected(p, c) | 显式条件c,且省略所有屏障与编译器约束 |
rcu_access_pointer(p) | 返回指针值,省略所有屏障,但保留防止重复读取/合并的编译器约束 |
*_check变体的价值场景是:同一段代码既可能被 RCU 读侧调用,也可能被更新方(持有其他锁)调用。此时用一个布尔表达式把两种合法上下文都表达出来,让 lockdep 在两种情况下都不误报,且只在实际不满足任何条件时才报警。
3.2 宏展开层面的检查机制
所有*_check变体最终汇聚到 include/linux/rcupdate.h 的核心宏__rcu_dereference_check():
#define __rcu_dereference_check(p, local, c, space) \ ({ \ /* Dependency order vs. p above. */ \ typeof(*p) *local = (typeof(*p) *__force)READ_ONCE(p); \ RCU_LOCKDEP_WARN(!(c), "suspicious rcu_dereference_check() usage"); \ rcu_check_sparse(p, space); \ ((typeof(*p) __force __kernel *)(local)); \ })它做三件事:(1) 用READ_ONCE读取指针,防止编译器重复读取或合并两次读取;(2) 若条件c不满足,触发RCU_LOCKDEP_WARN();(3) 在 sparse(__CHECKER__)静态检查下验证指针带有__rcu注解。普通rcu_dereference()就是c传 0 的特例(见 include/linux/rcupdate.h:#define rcu_dereference(p) rcu_dereference_check(p, 0)),此时条件退化为纯rcu_read_lock_held()。
告警宏RCU_LOCKDEP_WARN()本身(include/linux/rcupdate.h)有一个精巧的"双重调用"结构:
#define RCU_LOCKDEP_WARN(c, s) \ do { \ static bool __section(".data..unlikely") __warned; \ if (debug_lockdep_rcu_enabled() && (c) && \ debug_lockdep_rcu_enabled() && !__warned) { \ __warned = true; \ lockdep_rcu_suspicious(__FILE__, __LINE__, s); \ } \ } while (0)注释说明了原因:在检查条件(c)之前先查一次debug_lockdep_rcu_enabled(),防止开机早期 lockdep 尚未初始化时的告警;在(c)之后再查一次,防止与"运行时禁用 lockdep"(如systemctl enable lockdown一类的echo 0 > /sys/kernel/lockdep操作)产生竞态导致的假阳性。__warned静态变量则保证同一处代码只 splat 一次,避免刷屏。未开启PROVE_RCU时,整个宏编译为空(do { } while (0 && (c)))。
另外,PROVE_RCU还附带一项读侧纪律检查:rcu_sleep_check()(include/linux/rcupdate.h)在每次内核上下文切换点检查"是否在 RCU 读侧临界区里非法切换了上下文",例如在 vanilla RCU 读侧临界区(CONFIG_PREEMPT_RCU未开启时)内可睡眠,或 RCU-sched 临界区内切换上下文,都会触发RCU_LOCKDEP_WARN。
4. 实战示例:一个"RCU 或锁或独占"的解引用
文档给出了一段经典的复合条件示例,它完整展示了rcu_dereference_check()的多上下文表达力:
file = rcu_dereference_check(fdt->fd[fd], lockdep_is_held(&files->file_lock) || atomic_read(&files->count) == 1);该表达式以 RCU 安全的方式取走指针fdt->fd[fd],并在CONFIG_PROVE_RCU下验证本次调用至少满足以下三种情形之一:
- 处于 RCU 读侧临界区内(这是隐含条件,由宏追加的
|| rcu_read_lock_held()提供)——指针以 RCU 方式安全取走; - 持有
files->file_lock——该锁阻止指针被并发修改; - 当前任务独占该
files_struct(count == 1)——没有其他任务可访问,同样阻止修改。
如果同一段代码只会被更新方代码调用(不可能落在 RCU 读侧临界区内),则更合适的写法是rcu_dereference_protected():
file = rcu_dereference_protected(fdt->fd[fd], lockdep_is_held(&files->file_lock) || atomic_read(&files->count) == 1);两者语义差异要点:
rcu_dereference_protected()只验证条件 #2 和 #3,不再把 RCU 读侧临界区视为合法上下文——若真在 RCU 读侧临界区内使用它而不满足这两个条件,lockdep 会抱怨。- 因为它省略了所有内存屏障和编译器约束(不产生
READ_ONCE),生成的代码更优;代价是"数据结构不能变"这一前提必须无条件成立——文档明确警告:只要 RCU 保护指针或其指向的数据可能被并发修改,使用rcu_dereference_protected()就是非法的。从源码看(include/linux/rcupdate.h),__rcu_dereference_protected()直接返回原指针而不做READ_ONCE,印证了它连"读取本身"都不加约束。 - 对比之下,
rcu_access_pointer()(include/linux/rcupdate.h)保留了编译器约束(防止重复/合并读取)但省去屏障,适合"只取指针值不解引用"的场景,例如与 NULL 比较;文档建议直接测试其返回值而非存入局部变量,以免日后无心的改动引入意外解引用。
5. 链表/哈希表遍历原语的可选 lockdep 表达式
文档最后一部分说明:与rcu_dereference()类似,当 lockdep 开启时,RCU 链表遍历原语(list_for_each_entry_rcu等)同样会检查是否处于 RCU 读侧临界区内;但它们额外接受一个可选的 lockdep 表达式参数——提供该表达式后,只有当"表达式为假且不在任何 RCU 读侧临界区内"时才会报警。
文档给出的现实例子是 workqueue 的for_each_pwq()宏:它的设计契约是"必须处于 RCU 读侧临界区内,或者持有wq->mutex"。当前源码树中的实现(kernel/workqueue.c)与文档描述一致:
/** * for_each_pwq - iterate through all pool_workqueues of the specified workqueue * @pwq: iteration cursor * @wq: the target workqueue * * This must be called either with wq->mutex held or RCU read locked. * ... * The if/else clause exists only for the lockdep assertion and can be * ignored. */ #define for_each_pwq(pwq, wq) \ list_for_each_entry_rcu((pwq), &(wq)->pwqs, pwqs_node, \ lockdep_is_held(&(wq->mutex)))宏把lockdep_is_held(&(wq->mutex))作为 lockdep 表达式传给list_for_each_entry_rcu(该宏定义于 include/linux/rculist.h),于是两种合法上下文(RCU 读锁 或wq->mutex)都不会触发 splat,只有两者都不满足时才会报告。这个模式在整棵源码树中被广泛使用:任何"RCU 或某把锁"二选一的迭代点,都可以把持锁判断写进第四个参数。
6. 适用前提与使用建议
综合文档内容与源码,使用这套机制时的注意事项如下:
- 配置前提:
CONFIG_PROVE_RCU由PROVE_LOCKING自动开启(见 kernel/rcu/Kconfig.debug),而PROVE_LOCKING依赖DEBUG_LOCK_ALLOC。生产内核若未开启这些选项,rcu_dereference()家族中的 lockdep 检查、以及第 2 节中*_held()的真实查询能力都不生效——*_held()退化为保守的静态判断(多数恒返回 1)。 - 保守返回是特性而非缺陷:
rcu_read_lock_held()等在不确定时返回 1,因此不要用它们做生产路径的功能控制流判断,它们专为调试断言(如WARN_ON(!rcu_read_lock_held()))设计。 *_check表达式可任意:rcu_dereference_check()的条件参数可以是任意布尔表达式,但通常应包含一个 lockdep 表达式(如lockdep_is_held()),使其在 lockdep 下获得真实的依赖检查能力。raw/protected变体要克制:文档对rcu_dereference_raw()、rcu_dereference_raw_check()的告诫是 "Use sparingly, if at all";rcu_dereference_protected()生成更优代码,但一旦并发修改可能发生就是数据竞争。tracing 路径因担心 RCU 检查引发递归锁死,需要的是rcu_dereference_raw_check()这类完全不查 lockdep 的变体(见 include/linux/rcupdate.h 注释)。- 版本语义:文档中的
*_check注释还提示,自 v5.0 起 vanilla RCU 宽限期同时等待local_bh_disable()与preempt_disable()区域,故synchronize_rcu()等语义已把rcu_read_lock_bh()、rcu_read_lock_sched()区域一并纳入——在当前源码树上阅读这些宏注释时,不要把它移植到 v5.0 之前的内核版本。
7. 小结
Documentation/RCU/lockdep.rst 所描述的机制,本质上是用 lockdep 把 RCU 读侧临界区"升格"为 lockdep 可见的依赖节点:一方面,各 flavor 的lockdep_map(kernel/rcu/update.c)让死锁分析纳入 RCU 上下文;另一方面,*_held()查询、RCU_LOCKDEP_WARN(include/linux/rcupdate.h)与rcu_dereference_*家族宏把"解引用 RCU 指针必须处于合法上下文"变成了运行时可验证的断言。掌握rcu_dereference_check()的显式条件表达式和链表遍历原语的可选 lockdep 参数,是编写既能通过 lockdep 审计、又保持并发正确性的内核代码的关键技能。
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考