读路径几乎不加锁,更新者还能删除对象,旧内存却不会被正在运行的 CPU 误用——这正是 RCU 最吸引人、也最容易被误解的地方。RCU 是 Read-Copy-Update 的缩写。它不是“完全没有同步”,而是把同步成本从高频读侧转移到低频写侧:读者快速读取已经发布的版本,更新者准备并发布新版本,然后等待所有可能仍持有旧指针的读者离开,最后回收旧对象。
本文只讲清一个核心问题:宽限期为什么能够证明旧对象可以释放。
一、先看普通读写锁为什么可能太贵
假设内核维护一张以链表组织的路由、设备或任务表。查询发生得非常频繁,增删却很少。使用读写锁当然正确,但每次读取仍要修改锁状态;多核系统上,这个共享缓存行会在 CPU 间来回迁移。
RCU 的思路不同。读者进入 rcu_read_lock() 后取得指针并访问对象,通常不需要争抢一把全局锁。更新者不能直接改坏读者眼前的对象,而要先构造新状态,再通过 RCU 指针原语发布。
这里得到第一个边界:RCU 适合“读多写少、读取时间短、允许读者看到旧版本”的数据,不是所有并发结构的通用替代品。
二、RCU 的三个动作:发布、等待、回收
可以把一次删除理解成三段。
第一段是逻辑删除。更新者持有写侧锁,把节点从共享结构摘除。此后新进入的读者已经找不到它。
第二段是等待。删除发生前进入的旧读者,手里可能仍保存该节点指针,所以更新者调用 synchronize_rcu(),或者用 call_rcu() 注册延迟回调。
第三段才是物理释放。一个宽限期结束后,旧读者全部离开临界区,节点才可 kfree()。
从链表摘掉节点,只阻止未来读者看到它;宽限期结束,才证明过去的读者也不再使用它。
三、宽限期到底在等待谁
宽限期不是固定的毫秒数,也不是“睡一会儿应该够了”。它等待的是:在宽限期开始前已经存在的所有相关 RCU 读侧临界区都结束。
假设 CPU0 已经进入读侧并拿到旧指针,CPU1 随后摘除节点并开始等待。即使 CPU2、CPU3 后来不断进入新的读侧,只要这些新读者不可能取得已摘除的旧节点,它们通常不妨碍旧节点的安全回收。真正关键的是 CPU0 这个跨越删除时刻的旧读者。
因此,“宽限期”描述的是并发关系,不是墙上时钟。系统繁忙、CPU 长时间不报告静止状态,宽限期可能变长;系统空闲时则可能很快结束。
四、什么叫静止状态
RCU 必须判断某个 CPU 不再运行先前的读侧临界区。具体判定与 RCU 类型、内核配置和执行上下文有关。上下文切换、用户态运行、空闲等状态可成为经典 RCU 推进宽限期的重要证据。
工程上不应把内部实现简化成“每个 CPU 计数减到零”。Linux 的 TREE_RCU 需要在大量 CPU 上高效汇总状态,并处理抢占、空闲、离线和中断等复杂情况。理解 API 时只需抓住承诺:宽限期结束后,开始前的读者已经结束;不要自行猜测具体某次调度是否足够。
五、读侧为什么仍要使用专用原语
典型读取代码如下:
rcu_read_lock();p = rcu_dereference(global_ptr);if (p) use(p);rcu_read_unlock();
rcu_read_lock() 与 rcu_read_unlock() 标记临界区;rcu_dereference() 不只是好看的包装,它还表达这是一个 RCU 保护的指针读取,并提供编译器与体系结构所需的排序约束。
如果直接读取普通指针,编译器可能重排或合并访问;弱内存序 CPU 也可能以不符合开发者直觉的顺序观察对象字段。RCU 的低开销建立在严格遵守原语之上,不意味着可以省略原语。
读侧还必须避免把对象指针带出保护区后继续使用。退出临界区后,更新者可能立即完成宽限期并释放对象。若确实需要长期持有,应在临界区内安全获取引用计数,再依靠引用计数管理后续生命周期。
六、更新侧为何通常还需要锁
RCU 主要解决“读者与回收者”的冲突,不自动序列化多个更新者。两个写者同时修改链表,仍可能破坏前后指针。因此常见结构是:
- 写者之间用 mutex 或 spinlock 串行化;
- 写者用
list_add_rcu()、list_del_rcu() 等原语发布结构变化;
“用了 RCU 就不需要锁”是最危险的误区之一。更准确的说法是:RCU 让读者通常不与写者争用同一把锁,但写写同步仍要单独设计。
七、synchronize_rcu() 与 call_rcu() 怎么选
synchronize_rcu() 会等待一个宽限期,因此只能放在允许睡眠的进程上下文。它逻辑直观:摘除对象,等待,再释放。但频繁同步等待会拉长写路径延迟。
call_rcu() 则把回收函数挂到回调队列,当前路径无需同步阻塞。宽限期完成后,内核异步调用回调,通常在回调中通过 container_of() 找回宿主对象并释放。
list_del_rcu(&obj->node);call_rcu(&obj->rcu, free_obj_rcu);
模块卸载还要注意回调是否全部执行完。仅仅摘除数据结构不代表延迟回调已经消失,必要时使用适合场景的屏障原语确保回调清空,否则模块代码卸载后回调再执行会跳入无效地址。
八、一个读多写少链表的完整删除路径
读者:
rcu_read_lock();list_for_each_entry_rcu(p, &head, node) { if (match(p)) { consume(p); break; }}rcu_read_unlock();
写者:
spin_lock(&update_lock);list_del_rcu(&victim->node);spin_unlock(&update_lock);call_rcu(&victim->rcu, free_victim);
关键顺序不能颠倒。先从结构中摘除,才能阻止新读者获得指针;再等待旧读者离开;最后释放。若 list_del() 后立刻 kfree(),压力小时也许看似正常,多核并发下就可能出现 use-after-free、随机链表损坏或难以复现的崩溃。
九、RCU 保护生命周期,不自动保护字段一致性
RCU 能保证读者访问的对象暂时不会被释放,但对象内部多个字段是否构成一致快照,是另一个问题。
若写者原地修改 addr、mask、flags 三个字段,读者可能看到新旧混合值。解决办法可以是复制整个对象后一次发布新指针,也可以为字段更新增加锁、序列计数或其他同步机制。
这正是名称中的 Copy 与 Update:许多适合 RCU 的设计避免在共享对象上进行复杂原地修改,而是创建新版本,把指针原子发布,旧版本留给既有读者。
十、最常见的三类错误
第一类是过早释放。节点摘除后立即 kfree(),没有等待宽限期。
第二类是指针逃逸。读者在 rcu_read_unlock() 后仍保存裸指针,随后异步工作继续访问。
第三类是混用原语。更新者用普通赋值发布新对象,读者用普通解引用读取,导致初始化字段的可见顺序没有得到保证。
另外,经典 RCU 读侧不等于可以任意阻塞。是否允许睡眠取决于所用 RCU flavor 和上下文,不能把普通 rcu_read_lock() 当成 SRCU。需要跨越可睡眠路径时,应重新选择生命周期方案。
十一、遇到 RCU stall 警告如何排查
内核报告 RCU CPU stall,说明宽限期长时间无法推进。常见原因包括:关闭抢占或中断太久、CPU 卡在死循环、超长的读侧临界区、实时任务长期霸占 CPU,以及时钟或中断异常。
排查时先保存完整 splat 和所有 CPU 栈,不要只截第一行。检查被点名 CPU 是否停在同一函数;查看是否存在长时间 local_irq_disable()、preempt_disable() 或自旋等待;结合 ftrace、lockup detector、调度跟踪确认 CPU 是否还能发生上下文切换。
在嵌入式 SoC 上,如果中断控制器、时钟源或 tick 配置异常,也可能让 RCU 表现为受害者。RCU stall 不等于 RCU 自身有 bug,它经常只是最早发现“某个 CPU 不再向系统前进”的监视器。
十二、RCU 与引用计数、读写锁有什么不同
三者解决的问题并不完全相同。读写锁要求读者与写者都参与同一套锁协议,优点是语义直接,写者拿到写锁后通常可以原地修改对象;代价是高频读侧仍会产生共享状态竞争。
引用计数回答“还有没有人长期持有这个对象”。每个使用者显式增加和减少计数,计数归零后才释放。它适合指针需要跨越函数、队列或异步任务长期保存的场景,但每次引用变化都要原子修改共享计数,同样会造成缓存行竞争。
RCU 回答的则是“删除时已经在读的那批短读者是否全部离开”。读者不必逐对象增加计数,因此高频遍历很便宜;代价是更新与回收更复杂,而且读者不能无限期保存裸指针。
实际内核代码经常组合使用:RCU 负责从全局表中快速查找到对象,在 RCU 临界区内尝试增加引用计数;成功后退出 RCU,后续生命周期交给引用计数。两种机制不是互斥选择,而是分别保护查找阶段与长期使用阶段。
十三、发布新指针时为何不能普通赋值
更新者通常先分配并完整初始化新对象,然后调用 rcu_assign_pointer() 发布。这个顺序保证读者通过 rcu_dereference() 看到新指针时,也能看到对象初始化完成后的字段。
new = kmalloc(sizeof(*new), GFP_KERNEL);new->port = port;new->state = READY;rcu_assign_pointer(global_ptr, new);
若用普通赋值,源代码看起来是“先初始化、后发布”,但编译器和弱内存序处理器可能让其他 CPU 以不同顺序观察写入。偶发的空字段、旧标志或未初始化内容,往往只在特定架构和压力下出现。
同理,READ_ONCE()、WRITE_ONCE() 只能约束单次访问,不自动提供完整的 RCU 发布语义。不要因为它们都与并发访问有关,就随意互换。
十四、如何验证自己的 RCU 设计
第一步画生命周期图,标出对象何时加入共享结构、何时从结构摘除、谁可能保存指针、最后在哪个回调释放。若无法画出这条线,代码通常也没有真正想清楚。
第二步启用内核调试配置。CONFIG_PROVE_RCU 能帮助发现不在正确读侧保护下的解引用;lockdep 相关检查能暴露一些锁与 RCU 规则错误;KASAN 更适合捕获已经发生的 use-after-free。不同检查覆盖范围不同,不能只开一个就认为安全。
第三步做并发压力:多个 CPU 高频查询,同时循环添加、替换和删除对象,混入 CPU 热插拔、抢占和模块卸载路径。RCU 错误常在正常功能测试中沉默,却会在对象复用速度变快时暴露。
第四步检查回收速度。异步回调若堆积过多,会占用大量旧对象内存。可以观察 RCU 相关 tracepoint、回调积压和内存变化,确认系统不仅逻辑正确,也能在目标负载下及时回收。
十五、嵌入式系统中尤其容易踩的坑
单核开发板上测试成功,不代表多核量产配置正确。单核环境减少并发窗口,一些缺失的排序和生命周期错误很难出现;换到多核 i.MX、RK 或其他 SoC 后,缓存一致性和调度交错会把问题放大。
另一个坑是驱动卸载。设备 remove 时先阻止新访问,再摘除共享对象,等待进行中的读者和异步回调,最后释放寄存器映射与私有内存。如果顺序反了,RCU 回调可能在 iounmap() 或模块代码卸载后仍访问资源。
电源管理也会改变时序。CPU 进入深度空闲、tick 停止或某个实时线程长期运行,都可能影响宽限期推进。看到 stall 时应同时检查调度、时钟、中断和电源状态,而不是只盯着调用 rcu_read_lock() 的那几行。
十六、什么时候不要使用 RCU
如果读者必须看到多个字段的强一致最新值,更新频繁到复制成本很高,或者对象会被读者长期保存,RCU 可能不是最简单的方案。小型低并发结构使用 mutex,往往更容易审查和维护。
选择同步机制时,不应以“无锁更高级”为目标。应先写出访问比例、临界区长度、一致性要求、执行上下文和生命周期,再比较锁、序列计数、引用计数与 RCU。正确而简单的锁,优于难以证明正确的 RCU。
十七、把 RCU 记成一条因果链
读者进入临界区并取得旧指针 ↓写者构造新对象并发布新指针 ↓新读者只能看到新版本 ↓等待开始前的旧读者全部离开 ↓宽限期完成,旧对象安全回收
RCU 的核心不是“无锁”两个字,而是把可见性与生命周期分开管理:发布原语决定新状态何时可见,宽限期决定旧状态何时可释放。
只要每次设计都问清四个问题——谁发布指针、谁保护读侧、谁等待宽限期、谁最终释放——大多数 RCU 代码就不再神秘。真正危险的代码,往往正是其中某个角色没有明确答案。