一、为什么需要 RCU
Linux 内核的路由表是一个被疯狂读取的数据结构——每个网络包进来都要查一次路由。同时,路由信息偶尔也会更新(网卡配置变更、路由协议收敛)。如果用读写锁保护路由表,读操作虽然可以并行,但获取读锁本身就有原子操作的开销,在每秒千万次读取的场景下,这个开销累积起来相当可观。
更糟糕的是写饥饿:如果读请求源源不断,写者永远拿不到写锁。路由更新被无限延迟,网络拓扑变了但路由表还是旧的。
传统的无锁方案也不好使。假设用原子指针替换,写者创建新路由表然后原子地切换指针——但旧路由表什么时候释放?某个 CPU 上的读线程可能还拿着旧指针在读。释放早了就是 use-after-free,释放晚了就是内存泄漏。
RCU(Read-Copy-Update)优雅地解决了这个问题:读者零开销(不加锁、不加原子操作),写者创建新副本后原子切换指针,旧版本等到所有读者都离开后再释放。这个「等到所有读者离开」的窗口叫作宽限期(Grace Period)。Linux 内核自 2002 年引入 RCU 以来,它已经成为内核中使用最广泛的同步机制之一。
二、现实类比
想象一个图书馆更换目录册。管理员发现旧目录有错误,于是复印一份,在复印件上改好,然后把新目录放到目录架上,旧目录收起来。正在看旧目录的读者不用被打断——他们手里的旧册子仍然有效。等所有读者都离开后,管理员才把旧册子销毁。关键在于:读者完全不知道目录被换过,也不需要做任何配合(不需要登记、不需要上锁)。
三、核心思想
RCU 的核心流程分三步:
- 读:读者直接访问共享数据,不加锁,不做原子操作
- 复制-修改:写者复制一份旧数据,在副本上修改
- 更新-等待:写者用原子操作将指针切换到新副本,然后等待宽限期过后释放旧副本
3.1 宽限期(Grace Period)
宽限期是 RCU 最关键的概念。从写者切换指针开始,到所有可能持有旧指针的读者都离开临界区为止,这段时间就是宽限期。Linux 内核通过检测每个 CPU 的上下文切换来判断宽限期是否结束——如果一个 CPU 发生了上下文切换,说明它一定不在 RCU 读临界区中(读临界区不允许睡眠)。
3.2 核心操作与复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 读进入(rcu_read_lock) | O(1) | 仅禁止抢占,无原子操作 |
| 读退出(rcu_read_unlock) | O(1) | 仅恢复抢占 |
| 发布(rcu_assign_pointer) | O(1) | 原子写 + 内存屏障 |
| 同步等待(synchronize_rcu) | O(宽限期) | 等待所有读者离开,通常几十毫秒 |
| 回调释放(call_rcu) | O(1) | 注册回调,宽限期后异步释放 |
synchronize_rcu 是阻塞式等待,适合可以睡眠的内核线程。中断处理程序等不能睡眠的上下文必须用 call_rcu 注册异步回调——宽限期结束后内核会调用回调函数释放旧数据。
四、变体与对比
| 特性 | RCU | MVCC | 读写锁 | SeqLock |
|---|---|---|---|---|
| 读开销 | 零(无锁无原子) | 低(读快照) | 低(原子加锁) | 极低(读序列号) |
| 写开销 | 高(复制 + 等宽限期) | 中(追加版本) | 中(互斥锁) | 低(改数据 + 序列号) |
| 读者阻塞写者 | 不阻塞 | 不阻塞 | 阻塞(读锁阻止写锁) | 不阻塞 |
| 写者阻塞读者 | 不阻塞 | 不阻塞 | 阻塞(写锁阻止读锁) | 不阻塞(读者重试) |
| 内存回收 | 延迟释放 | 压缩时回收 | 即时 | 即时 |
| 典型场景 | 内核链表、路由表 | 数据库事务 | 通用读写 | 时钟、统计计数器 |
RCU vs MVCC:两者都是「写时复制 + 延迟回收」的思路,但场景不同。RCU 是内核级机制,读者完全零开销,写者需要等宽限期;MVCC 是数据库级机制,用时间戳/事务 ID 管理版本可见性,读者需要选择快照。RCU 的宽限期由内核调度决定,MVCC 的版本回收由 GC 或压缩决定。
RCU vs SeqLock:SeqLock 让读者在检测到写冲突时重试,读者不会被阻塞但可能多读几次。RCU 的读者永远不重试,但写者需要复制整个数据结构。SeqLock 适合数据小、写频繁的场景;RCU 适合数据大、读远多于写的场景。
4.1 可睡眠 RCU(SRCU)
经典 RCU 要求读临界区不能睡眠,这限制了它的适用范围。Linux 内核提供了 SRCU(Sleepable RCU),允许读者在临界区内睡眠。代价是 srcu_read_lock/unlock 需要操作每 CPU 计数器,比经典 RCU 的零开销稍高。SRCU 适用于需要睡眠的内核模块(如设备驱动)。
五、多语言实现
5.1 Go 实现
package rcu
import ( "runtime" "sync")
// RCU 保护一个可替换的值,读者零开销,写者复制后原子替换type RCU[T any] struct { ptr *T // 当前值的指针 mu sync.Mutex // 保护写操作的互斥锁 waiters []func() // 等待释放的旧值回调}
// New 创建 RCU 保护的新值func New[T any](init T) *RCU[T] { return &RCU[T]{ptr: &init}}
// Read 读取当前值,无需加锁func (r *RCU[T]) Read() *T { return r.ptr // 直接读指针,无原子操作}
// Write 复制旧值,修改后原子替换,等待读者释放旧值func (r *RCU[T]) Write(fn func(*T) *T) { r.mu.Lock() defer r.mu.Unlock()
old := r.ptr newPtr := fn(old) // 调用者负责复制并修改 r.ptr = newPtr // 原子替换指针
// 模拟宽限期:等待所有 CPU 上下文切换 // Go 中没有真正的宽限期机制,这里用 goroutine 延迟释放 go func() { // 等待一个 GC 周期作为近似宽限期 runtime.GC() _ = old // 旧值在此之后可被 GC 回收 }()}Go 没有内核级的宽限期机制,上面的实现用 runtime.GC() 做近似。真正的 Go RCU 库(如 github.com/petar/GoLLRB 中的部分实现)通常用引用计数或 epoch-based reclamation 来模拟宽限期。
5.2 TypeScript 实现
// RCU 模式:读无锁,写时复制,延迟释放class RCU<T> { private current: T; private pendingCallbacks: Array<() => void> = []; private epoch = 0;
constructor(initial: T) { this.current = initial; }
// 读取当前值,零开销 read(): T { return this.current; }
// 写入:复制旧值,修改后替换,注册旧值清理回调 write(mutator: (old: T) => T, onRelease?: () => void): void { const old = this.current; this.current = mutator(old); // 调用者负责深拷贝并修改 this.epoch++;
// 注册延迟清理回调 if (onRelease) { this.pendingCallbacks.push(onRelease); }
// 模拟宽限期:下一轮微任务后执行清理 // 实际场景中需要等所有读者离开 Promise.resolve().then(() => { const callbacks = this.pendingCallbacks.splice(0); callbacks.forEach(cb => cb()); }); }}
// 使用示例:RCU 保护的路由表type RouteTable = Map<string, string>;
const routes = new RCU<RouteTable>(new Map([ ["10.0.0.0/8", "eth0"], ["192.168.0.0/16", "eth1"],]));
// 读者:无锁访问console.log(routes.read().get("10.0.0.0/8")); // "eth0"
// 写者:复制-修改-替换routes.write( old => { const next = new Map(old); // 深拷贝 next.set("172.16.0.0/12", "eth2"); // 添加路由 return next; }, () => console.log("旧路由表已释放"));TypeScript 环境下没有真正的宽限期概念,Promise.resolve().then() 只是粗略近似。在浏览器或 Node.js 中,真正的「宽限期」需要基于事件循环或引用计数来跟踪读者。
六、生产验证
Linux 内核 —— RCU 的诞生地
Linux 内核 中 RCU 无处不在。rcu_read_lock()/rcu_read_unlock() 仅操作当前任务的抢占计数(preempt count),开销极低。synchronize_rcu() 通过 rcu_gp_kthread 内核线程管理宽限期,检测所有 CPU 的静默状态(quiescent state)。经典使用场景包括:
- 路由表:
fib_info通过 RCU 保护,路由更新不阻塞转发 - 链表遍历:
list_for_each_entry_rcu无锁遍历,list_add_rcu/list_del_rcu安全修改 - 文件系统:dentry 缓存通过 RCU 实现快速路径查找
Userspace RCU(liburcu)
liburcu 是 Linux 内核 RCU 的用户态实现,由 Mathieu Desnoyers 维护。它提供了与内核 RCU 相同的语义,但运行在用户空间。QSBR(Quiescent-State-Based Reclamation)模式要求读者显式声明静默状态,开销最低;MB(Memory Barrier)模式自动管理,开销稍高但更安全。数据库(如 MySQL)和消息队列(如 Apache Kafka 的部分组件)使用 liburcu 来实现无锁读取。
io_uring —— Linux 异步 I/O
io_uring 使用 RCU 保护请求队列和文件引用。提交和完成队列的读取通过 rcu_read_lock() 保护,不需要互斥锁,这在每秒百万级 I/O 操作的场景下至关重要。
七、小结
什么时候用
- 读远多于写:路由表、配置数据、设备列表等读多写少的共享数据
- 读者不能有开销:热路径上的读取不能承受任何锁或原子操作的开销
- 数据可以整体替换:写者可以接受复制整个数据结构(或用指针间接减小复制粒度)
- 内核/系统编程:有宽限期机制的环境(Linux 内核、liburcu)
什么时候别用
- 写频繁:每次写都要复制 + 等宽限期,写多了比加锁还慢
- 数据结构很大:复制大对象的代价可能超过锁的开销,考虑用间接层(只复制指针)
- 需要强一致性:读者可能读到旧值(在宽限期结束前),不能容忍旧值就不要用
- 没有宽限期机制:纯用户态程序没有内核的宽限期检测,需要自己实现或用 liburcu
八、参考资料
- What is RCU, Fundamentally? - Paul E. McKenney, 2007, RCU 核心概念详解
- Linux 内核 RCU 实现 - Linux 内核树形 RCU 源码
- Userspace RCU (liburcu) - 用户态 RCU 库,支持 QSBR/MB 等多种模式
- RCU vs SeqLock vs RW Lock 对比 - McKenney, 2007, 三种内核同步机制的选择指南
- Is Parallel Programming Hard, and, If So, What Can You Do About It? - McKenney, 2023, 并行编程全书,RCU 专章
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






