先聊点实在的。RCU(Read-Copy Update)在并发编程圈子里名气不小,每次提到高性能读多写少场景,它基本都会被搬出来。但说句实话,我也见过不少人把RCU当万能药,遇到任何并发性能问题就往里套,结果写侧被宽限期拖到怀疑人生。
这个标题的关键词是“核心适用边界”。RCU不是一种“快”的同步原语,而是一种“读侧几乎零开销、写侧愿意付代价”的并发机制。它的名字已经说得很直白:读(Read)、拷贝(Copy)、更新(Update)。读的时候不加锁,写的时候先拷贝一份副本,在副本上修改,然后通过一次原子发布把新指针换上去,旧对象等所有读者都离开后再回收。整个过程读侧无等待、无锁竞争,代价全部由写侧扛。
那这个机制到底适合什么场景,不适合什么场景,临界区里能不能睡觉,写频繁到什么程度就该换锁,宽限期被无限拉长怎么办?这篇文章我把这几年实际用RCU的经验全部摊开,从模型原理讲到边界判断,再给一份内核和用户态的落地代码参考,尽量一次说透。
1. 先搞清楚 RCU 到底解决了什么
1.1 一个真实场景:读多写少的痛
你可能有这样的业务:一张配置表,几百个线程同时读,更新频率极低,可能一天就改几次。用读写锁(RW Lock)行不行?可以,读侧有共享锁,但每次读都要原子操作加锁解锁,在极端高并发下,锁本身会变成瓶颈,而且读写锁在读多写少的场景下还有“写饥饿”的隐患,一旦写者等待太久,延迟会抖动。
有人改用拷贝的方式:每次更新直接复制一份新数据,替换指针,旧数据留给读者慢慢读完再释放。这不就是RCU的思路吗?对,很多团队在遇到瓶颈后都会自然走向这个方向,但容易在“何时释放旧数据”上出问题——你没法知道还有没有线程正拿着旧指针在读取。直接释放,读者可能踩到野指针;不释放,内存泄漏。
RCU 的真正贡献,就是解决了“安全的延迟回收”问题。它不关心读者怎么读,只保证一件事:当写者要释放旧对象时,能确定所有可能还在读它的读者都已经离开了临界区。这句话是整个RCU机制的基石。
1.2 Read-Copy-Update 的三步模型
把一次更新拆开看,RCU 永远遵循三个步骤:
第一步,Read。写者把被更新的对象读出来,注意这个地方不需要加锁,因为读的是一个稳定的快照。
第二步,Copy。拷贝一份完整的新副本,注意是深拷贝,然后在副本上修改。原来的对象保持不动,这样正在读旧数据的读者不会受到任何干扰。
第三步,Update。用 rcu_assign_pointer 之类的发布操作,把新指针原子地替换掉旧指针。这一步之后,新的读者会看到新数据,而已经进入临界区、还在读旧数据的读者,仍然安全地引用旧对象。
这里有一个非常重要的点:更新完成后,写者不能立刻释放旧对象。必须等待宽限期(Grace Period)结束——也就是所有读者都离开临界区之后,才能回收旧对象。这个等待是 RCU 写侧最主要的成本。
我经常用一个类比:RCU 像书店换书。读者正在翻旧版书(读旧数据),书店管理员直接把新版书放到架子上(发布新指针),但不把旧书撤走,因为得等所有正在翻旧书的读者都起身离开(宽限期结束),才可以把旧书收走。如果管理员不等读者离开就直接收书,那读者手里就变成了一堆碎纸。
1.3 宽限期是 RCU 的灵魂
宽限期的判定是 RCU 里最核心也最微妙的机制。内核态的 RCU 实现基于“每个CPU都经历过一次静止状态(Quiescent State)”来判断:所谓静止状态,简单说就是CPU上不再有活跃的RCU读者临界区的时刻,典型如上下文切换、进入用户态、CPU空闲等。
写者调用 synchronize_rcu() 等待宽限期时,内核会在每个CPU上记录静止状态的经过情况。只有当所有CPU都至少经历了一次静止状态,内核才认为全域的读者都已经离开临界区,可以安全释放旧对象了。
这里有两个容易被忽视的点:
第一,静止状态不要求“所有读者都退出了”,只需“每个CPU保证在某个时刻没有读者”。因为读者一旦在某个CPU上运行,必然会在离开临界区后经历一次上下文切换或类似的静止点,这个静止点可以证明之前进入临界区的读者已经退出。
第二,读者临界区里如果调用了可能睡眠、可能长时间不调度、可能主动让出CPU的操作,宽限期会被无限拉长。这就是为什么 RCU 的读者临界区有一条铁律:不能睡眠、不能阻塞。睡眠不只是性能问题,而是会破坏整个宽限期检测模型。
2. 核心适用边界:什么场景下才算用对了
2.1 边界一:读写比例必须严重失衡
这是RCU的第一条适用边界,也是最硬的一条。RCU读侧几乎零成本,但写侧需要做拷贝、发布、等待宽限期这三件事。如果写频率高,写侧的成本会迅速累积,而且宽限期的等待有可能让写者长时间阻塞。
一个我实测过的例子:某服务用RCU维护路由表,读QPS大约100万,写平均每秒不到1次,稳定运行毫无压力。后来业务变更,写频率变成每秒500次,结果一堆写线程堆积在 synchronize_rcu() 上,旧对象迟迟不能回收,内存吃紧,延迟从微秒级直接跳到毫秒级。
RCU适合的形态是“读:写 ≈ 1000:1”甚至更高。如果写比例超过10%,也就是每10次操作里就有1次写,建议直接放弃RCU,用读写锁甚至互斥锁都更稳。因为锁的写侧成本是确定的,而RCU的写侧成本取决于宽限期长度,这个长度在极端情况下是难以预估的。
2.2 边界二:读者临界区必须“短平快”
RCU读者临界区里跑什么,直接决定了这个方案能不能用。两条硬性要求:不能睡眠,不能阻塞。更具体地说,在读者临界区内不能出现以下操作:
- 调用可能触发的调度器操作,比如内核态里访问可能睡眠的锁(mutex、信号量);
- 触发缺页异常,内核态访问可能不在内存的用户页,或调用 copy_to_user / copy_from_user(某些配置下会睡眠等待页);
- 调用可能导致显式调度的函数(cond_resched、schedule、msleep等);
- 执行耗时不确定的复杂计算,虽然技术上允许,但会把宽限期压得很长,间接影响写侧。
为什么这么严格?因为你一旦睡眠,当前CPU就停留在“可能存在活跃读者”的状态,宽限期检测无法跳过这个CPU,写者的 synchronize_rcu() 就会一直等待,直到你醒过来、调度走为止。想象一下,一个读者线程在临界区里睡了100毫秒,写者就得等100毫秒,如果同时有多个读者都阻塞,宽限期就是一个天文数字。
在实际开发中,RCU读者的标准姿势是:读取指针,解引用,复制需要的数据到栈上,然后立刻退出临界区。整个临界区通常只有几十纳秒到几微秒,不允许在这个窗口里做任何可能睡眠的操作。
2.3 边界三:能容忍短暂的旧值
RCU读侧不加锁,意味着一个读者可能在发布指针前就进入了临界区,它读到的还是旧数据。也就是说,RCU天然存在一个“新旧数据共存窗口”。这个窗口的时长不由写侧决定,而是由读者离开临界区的时机决定。
如果业务要求严格线性一致性,即任何时刻所有读者看到的都是同一份最新数据,那么RCU不适用。当然,可以通过额外手段(比如读侧再加一把锁)来强化,但那样读侧的开销优势就消失了,不如直接用读写锁。
实际中哪些场景能容忍旧值?配置类数据、路由表、缓存元数据、DNS记录、黑白名单、特征库,这些都可以接受短暂的旧值,因为业务本身有滞后容忍度。相反,余额查询、库存扣减、订单状态这类强一致场景,RCU显然不合适。
我做过的判断标准很简单:问自己一个问题——“如果某个读者看到的不是当前最新版本,而是几毫秒甚至几十毫秒前的版本,业务能接受吗?”能接受,RCU可以上;不能接受,就换机制。
2.4 边界四:内存与延迟的账要算得过来
RCU写侧要拷贝完整副本,这意味着每次更新都需要分配新内存。如果对象大、更新频率高,内存会反复分配、延迟释放,对GC类的语言尤其不友好,因为旧对象被宽限期拦截,无法及时回收,内存峰值会明显上浮。
我见过一个案例:一个团队在海量小对象的链表上使用RCU,每个节点只有几百字节,但每秒需要更新上万个节点。结果每个节点的宽限期等待和内存分配开销叠加,内存占用瞬间翻了好几倍。后来改成无锁链表加原子操作,性能反而更好。
还要算宽限期的延迟账。synchronize_rcu() 的最坏延迟没有硬性上界,虽然内核提供了 expedited 变体,也就是同步RCU,它会让每个CPU主动强制经历一次静止状态,速度大幅提升,但代价是系统整体的调度开销飙升,不适合频繁调用。
所以RCU的真实使用条件还包括:对象大小适中,更新频率低,内存充足,系统能接受宽限期带来的不确定延迟。如果其中任何一条不满足,都要谨慎。
2.5 边界五:实时与确定性场景要慎入
RCU的宽限期检测机制依赖内核的调度行为。在实时系统或对延迟有硬性要求的场景中,宽限期的不可预测性是一个致命伤。虽然PREEMPT_RT内核有专门的RCU调整,但读者临界区内不能睡眠的限制,在实时任务里往往很难满足,因为实时任务通常依赖可阻塞的通信原语。
即便在普通服务器上,如果某个CPU上跑着一个长时间不调度的任务(比如绑核的高优先级线程),宽限期会被持续拉长。我在生产环境就遇到过:一个CPU绑定了死循环采集任务,完全不让出CPU,结果所有写者都堵在 synchronize_rcu() 上,系统看起来像是死锁了。
如果要上的话,优先考虑可抢占内核,并且把读侧临界区的时长控制得极短,另外给宽限期设置超时保护或使用异步回收接口,避免写者完全被绑死。
3. 与常见并发方案的真实对比
3.1 RCU vs 读写锁 vs seqlock vs 原子变量
很多人问,RCU和读写锁、顺序锁(seqlock)、原子变量到底怎么选。这里直接给一张我从工程角度整理的对比表,参数基于x86-64平台实测范围:
| 机制 | 读侧开销 | 写侧开销 | 等待延迟 | 典型场景 | 主要风险 |
|---|---|---|---|---|---|
| 互斥锁 | 20~40ns | 20~40ns | 取决于竞争 | 低并发、临界区短 | 争抢激烈时性能雪崩 |
| 读写锁 | 15~30ns | 30~60ns | 写者可能饥饿 | 读多写少、临界区短 | 读锁也有原子操作,高并发仍有瓶颈 |
| seqlock | 5~10ns | 读可能重试,写侧极快 | 无等待 | 读多写少、数据小 | 读者可能反复重试,写频繁时读性能差 |
| 原子变量/CAS | 2~10ns | 10~30ns | 无 | 单个字段、计数类 | 只能处理单值,复杂结构需要叠加 |
| RCU | 1~5ns | 数百ns~数毫秒 | 受宽限期影响 | 读极多写极少、对象较大 | 宽限期不确定、内存延迟释放 |
这份表不能直接拿来当公式用,因为不同平台的原子操作、缓存一致性、调度开销差异很大。但有一个趋势非常明显:RCU的读侧开销是所有主流同步机制里最低的,而写侧开销是最不可控的。这也是“适用边界”一句话的浓缩——读得越多越划算,写得越少越安全。
3.2 选型判断清单
在实际项目里,我不会一上来就讨论RCU。我一般先画一张决策路径,逐个问题问下去:
第一,读写比例是多少?如果写占比超过10%,直接锁或原子操作,不用考虑RCU。
第二,读侧能接受多少延迟?如果读者要求稳定的微秒级响应,RCU是加分项;如果读者本身可以接受锁等待,那就没必要承担RCU的实现复杂度。
第三,数据可以接受短暂的旧值吗?不能,则不用RCU。
第四,读者临界区能写多短?内核态要求尤其严格,不能睡眠、不能调内核阻塞API。用户态虽然宽松些,但RCU读侧仍然需要在无锁条件下保证指针稳定性。
第五,团队对内存序、编译器屏障、生命周期管理熟悉吗?如果没人能解释清楚 rcu_dereference 和 rcu_assign_pointer 为什么要配对使用,那我建议先不要上RCU,这个机制写起来很简单,错起来也极其隐蔽。
这五条判断全部通过后,RCU才是值得考虑的方案。不要因为性能评测里显示RCU读侧最快就盲目引入,这里的快是有代价的。
4. 实现细节与代码落地:从内核到用户态
4.1 Linux 内核里的 RCU 典型用法
内核里RCU用得最典型的就是路径查找、网络命名空间、文件系统缓存这类读多写少的场景。一个标准的内核RCU数据订阅模型长这样:
读者侧:
rcu_read_lock(); item = rcu_dereference(g_ptr); if (item) // 只读访问,不能睡眠,不能阻塞 ... rcu_read_unlock();写者侧:
new_item = kmalloc(sizeof(*new_item), GFP_KERNEL); *new_item = *old_item; // 拷贝 new_item->key = new_value; // 修改副本 rcu_assign_pointer(g_ptr, new_item); // 发布 // 等待宽限期 synchronize_rcu(); kfree(old_item); // 安全释放这段代码的四个关键点必须全部吃透:
- rcu_read_lock / rcu_read_unlock:在可抢占内核中是抢占关闭与恢复,在非抢占内核中是空操作,主要起标记作用,告知RCU子系统这段区域内有活跃读者;
- rcu_dereference:负责做一次“依赖屏障”,确保解引用读取时一定拿到的是发布之前已完整初始化的内容,防止编译器或CPU重排;
- rcu_assign_pointer:是一个带 release 语义的原子写,确保新对象的初始化发生在指针发布之前;
- synchronize_rcu:等待宽限期,保证所有可能的读者退出临界区,之后才能释放旧对象。
很多人刚接触内核RCU时以为 rcu_read_lock 只是在标记,其实它背后有紧密的调度配合。在 CONFIG_PREEMPT_RCU 模式下,它实际会关闭内核抢占,防止读者中途被调度走,因为一旦调度走,该CPU就可能进入静止状态,而读者却还没退出临界区,这会导致宽限期被过快判定,进而提前释放旧对象。
如果希望写侧不被同步等待阻塞,可以使用异步回收:
rcu_assign_pointer(g_ptr, new_item); call_rcu(&old_item->rcu_head, old_item_free_cb);call_rcu 会在宽限期结束后由内核软中断上下文回调 old_item_free_cb,实现先发布、后回收的异步模式。代价是内存回收时机不再确定,旧对象存活时间可能更长,但写侧完全不阻塞,适合写频率较高的场景(前提仍然是读多写少)。
4.2 用户态 RCU:liburcu 的实测体验
内核态写代码门槛太高,那用户态能不能用RCU?能,而且有现成的高质量库:liburcu(Userspace RCU),由Mathieu Desnoyers主导开发,广泛应用于高并发用户态系统。
liburcu 提供两种常见读侧模式:
普通模式(默认),读者进入临界区不需要额外操作,性能极佳:
urcu_read_lock(); ptr = rcu_dereference(g_ptr); if (ptr) { // 使用数据 } urcu_read_unlock();内存屏障模式,读者之间通过 CPU 内存屏障异步同步,读侧开销更低,但对调用者要求更高。需要长临界区且有实时性要求时,也可以用 QSBR(Quiescent State Based Reclamation)模式,读者不需要进入临界区,只需周期性报告静止状态,写侧通过等待全局静止状态判断安全回收时机,这是很多高性能数据库在用的方案。
我实测下来,在x86-64上,liburcu 普通模式的读侧开销通常在1~3纳秒左右,写侧调用 synchronize_rcu() 的开销从几百纳秒到几十微秒不等,取决于线程数量和调度情况。相比内核RCU,用户态RCU的宽限期检测更依赖线程间通过共享内存传递的状态信息,所以对 cache line 的竞争仍然敏感,不适合在NUMA节点相距很远但又频繁同步的场景中无脑使用。
用户态RCU还有一个优势:没有“不能睡眠”的硬性内核约束。你可以设计稍微复杂一点的读者临界区,但仍要避免长时间阻塞写者,否则宽限期依旧会被拉长。
4.3 内存序与编译器屏障是隐藏的雷区
RCU最容易出错的地方不是宽限期,而是内存序和编译器重排。写者在发布指针前修改新对象,读者在拿到指针后读取对象,这一对操作在底层依赖 CPU 的内存屏障和编译器的优化屏障,而不是像锁那样的互斥结构。
内核里的 rcu_dereference 在不同架构上插入不同的屏障,x86因为强内存模型,主要是编译器屏障加READ_ONCE;而ARM/POWER这类弱内存模型架构,则需要额外的数据依赖屏障(dependent load barrier)。也就是说,RCU正确性在语义上是跨架构保证的,但代码若绕过了RCU专用接口,用普通的指针访问代替,就会在弱内存模型机器上出现诡异问题。
我在ARM服务器上就踩过坑:内核版本、架构不同,一个模块的读者侧代码用了普通解引用,本地测试一切正常,上生产后偶发读到半初始化的结构体。排查了很久,最终发现是漏了 rcu_dereference 的屏障语义。这种问题最麻烦的地方在于,它不是必现,而是特定CPU调度竞争下才出现,复现极难。
用户态也完全一样:绝不能用 volatile 代替原子操作,也不能在发布指针时使用非原子写。一个稳妥的做法是严格遵循“所有对共享指针的访问都走RCU接口”的原则,哪怕是只读访问,也规范地用 rcu_dereference,避免将来换架构或换编译器优化级别时踩坑。
5. 常见误用与排查实录
5.1 误用案例一:写多读少硬套 RCU
某流量路由服务,一开始读多写少,RCU表现很好。后来路由规则支持动态下发热更新,写频率暴涨到每秒几千次,结果大量线程堵在同步等待宽限期的函数上,内存也出现明显增长。整个系统的性能反而比之前用读写锁时差了一个数量级。
这个案例的问题不在RCU实现,而在“读写比例”这个前提被打破后,还继续沿用旧架构。排查时一条明显的线索是:压测期间,写线程的CPU使用率很高,但读线程却大面积空闲。如果用perf抓函数栈,能看到大量写线程停留在 synchronize_rcu 相关调用上。
后来我们调整为:热路径匹配用哈希表加原子更新,不再整体替换,只有冷启动时才走一次RCU加载配置。效果立刻回升。这里的经验是:RCU架构要跟着业务读写比例走,比例一旦发生结构性变化,就要及时调整方案,而不是让备份逻辑越走越歪。
5.2 误用案例二:读者临界区里睡了
一个网络转发模块,读者临界区内需要做超时判断,工程师图方便直接调用了带睡眠等待的时间函数。这个模块在写侧偶尔调用 synchronize_rcu() 做配置转发,结果只要配置一更新,整个模块的转发延迟就飙升。
排查方法是抓内核调度痕迹,观察宽限期等待的时间线,发现每次写者休眠等待时,都有一个读者临界区异常地持续了数百毫秒。顺着调用栈一查,果然在临界区内触发了阻塞调用。把这段超时判断改造成无等待轮询后,宽限期立刻恢复正常。
这件事给我的教训很直接:RCU的读者临界区,不管在文档里写得如何轻描淡写,实际代码里一定要严格审查。我建议在代码评审中明确一条规则:凡是进入 RCU 临界区的函数,必须递归检查其子函数调用,任何可能睡眠、弱阻、触发信号处理的调用都不允许进入。
5.3 误用案例三:忽略旧值窗口导致业务错误
一个有状态的服务在用户会话管理上用了RCU,发布新会话状态后,旧状态并不会立刻消失,一批读请求在一段不短的宽限期内还能拿到旧会话。业务方要求会话状态一旦更新,后续所有请求必须基于新状态,结果出现了部分请求读到旧会话、返回了过期数据的线上故障。
这种问题属于业务模型不匹配,不是RCU实现错误。排查时可以通过抓取读请求时间戳与发布时刻对比,观察到数据不一致的窗口,再结合业务容忍度判断。最终方案是改成读写锁加版本号,完全放弃RCU。
从这之后我定了一条规矩:上RCU之前,必须让业务方明确给出“新旧数据过渡窗口可接受时长”,如果业务方说“不能接受任何窗口”,那就别用RCU,方案直接换。
5.4 排查与验证技巧
遇到RCU相关问题,我一般按下面几步排查:
第一步,确认宽限期是否异常拉长。内核里可以用 ftrace 抓 synchronize_rcu 的调用,用户态可以在等待点打时间戳,观察每次宽限期的最大值和分布。如果最大等待时间和平均值相差几个数量级,基本可以断定有读者阻塞或长临界区。
第二步,检查读者临界区代码路径。把临界区内的函数全部展开,逐个判断是否有睡眠、阻塞、长时间运行的可能。特别要注意宏、内联函数、回调函数里隐藏的调度点。
第三步,检查内存回收是否及时。如果内存持续上涨,先把 call_rcu 或 kfree_rcu 的回调频率、队列深度、延迟释放的时间间隔全部拉出来看。必要时改用同步等待,缩小问题范围。
第四步,验证内存序问题。在弱内存序架构上复现、反复加压,或者用内核的CONFIG_PROVE_RCU、KCSAN这类工具配合检测。如果本地没有弱内存环境的硬件,至少要在代码规范上严格把关,把RCU访问接口当铁律执行。
第五步,做专门的压力测试:大量读者长时间持有临界区不退出,观察写侧是否被锁死;高频发布新指针,观察旧对象释放曲线;多个CPU同时做读操作抢占缓存行,观察读侧延迟是否放大。
6. 一些个人经验
RCU用到现在,我最大的感受是:它是一个需要“敬畏心”的机制,边界比优势更重要。它读侧确实快,但这个快建立在一整套精心设计的约束之上,一旦读者越界、比例失衡、业务模型不匹配,RCU带来的不是性能,而是一堆难以排查的隐性故障。
如果让我给一个务实的判断流程,那就是先画读写比例,再检查临界区,再问业务旧值容忍度,最后才考虑内存序和实现细节。绝大多数场景其实用不上RCU,锁就够了。真正该用RCU的,是那些读请求海量、写请求几乎可以忽略、且临界区能被压缩到几十条指令之内的核心热路径。
另外,无论是内核态还是用户态,建议先跑通一个最小demo,记住一次正常宽限期在你硬件平台上的耗时范围,这组数据会是你以后排查各种RCU性能问题时的基线。没有这条经验基线,边界判断就只是纸上谈兵。