mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2277 字
6 分钟
RCU 读时复制更新(Read-Copy-Update)
2026-06-13

一、为什么需要 RCU#

Linux 内核的路由表是一个被疯狂读取的数据结构——每个网络包进来都要查一次路由。同时,路由信息偶尔也会更新(网卡配置变更、路由协议收敛)。如果用读写锁保护路由表,读操作虽然可以并行,但获取读锁本身就有原子操作的开销,在每秒千万次读取的场景下,这个开销累积起来相当可观。

更糟糕的是写饥饿:如果读请求源源不断,写者永远拿不到写锁。路由更新被无限延迟,网络拓扑变了但路由表还是旧的。

传统的无锁方案也不好使。假设用原子指针替换,写者创建新路由表然后原子地切换指针——但旧路由表什么时候释放?某个 CPU 上的读线程可能还拿着旧指针在读。释放早了就是 use-after-free,释放晚了就是内存泄漏。

RCU(Read-Copy-Update)优雅地解决了这个问题:读者零开销(不加锁、不加原子操作),写者创建新副本后原子切换指针,旧版本等到所有读者都离开后再释放。这个「等到所有读者离开」的窗口叫作宽限期(Grace Period)。Linux 内核自 2002 年引入 RCU 以来,它已经成为内核中使用最广泛的同步机制之一。

二、现实类比#

想象一个图书馆更换目录册。管理员发现旧目录有错误,于是复印一份,在复印件上改好,然后把新目录放到目录架上,旧目录收起来。正在看旧目录的读者不用被打断——他们手里的旧册子仍然有效。等所有读者都离开后,管理员才把旧册子销毁。关键在于:读者完全不知道目录被换过,也不需要做任何配合(不需要登记、不需要上锁)。

三、核心思想#

RCU 的核心流程分三步:

  1. :读者直接访问共享数据,不加锁,不做原子操作
  2. 复制-修改:写者复制一份旧数据,在副本上修改
  3. 更新-等待:写者用原子操作将指针切换到新副本,然后等待宽限期过后释放旧副本
sequenceDiagram participant R1 as 读者 1 participant R2 as 读者 2 participant W as 写者 participant GP as 宽限期 R1->>W: 读取旧版本(无锁) R2->>W: 读取旧版本(无锁) W->>W: 复制旧版本,修改副本 W->>W: 原子切换指针到新版本 Note over R1: 仍在读旧版本(安全) W->>GP: 等待宽限期 R1->>W: 离开读临界区 R2->>W: 离开读临界区 GP->>W: 宽限期结束 W->>W: 释放旧版本

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)注册回调,宽限期后异步释放
Note

synchronize_rcu 是阻塞式等待,适合可以睡眠的内核线程。中断处理程序等不能睡眠的上下文必须用 call_rcu 注册异步回调——宽限期结束后内核会调用回调函数释放旧数据。

四、变体与对比#

特性RCUMVCC读写锁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

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

RCU 读时复制更新(Read-Copy-Update)
https://blog.souloss.com/posts/programming/concurrency/concurrency-rcu/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时