一、为什么需要 RCU — 从读写锁的瓶颈说起
并发编程中,读多写少场景占据绝大多数:路由表、文件系统 dentry cache、进程描述符访问、网络连接跟踪(conntrack)等。传统读写锁(rwlock)虽然允许并发读取,但存在三个根本性限制:
- 读侧开销:rwlock 的读锁/解锁需要原子指令,在 NUMA 架构下跨节点缓存同步造成性能损失。读侧开销会随 CPU 核心数线性增长。
- 写者饿死:在高读取频率下,写锁难以获取,导致写操作延迟不可控。
- 不支持在读侧上下文中睡眠:rwlock 持有读锁时不能调用可能睡眠的函数(如
kmalloc(GFP_KERNEL))。
RCU(Read-Copy-Update) 是 Linux 内核引入的无锁同步原语,核心思想是:读操作完全无锁、无原子指令、无缓存一致性开销;写操作通过"复制-修改-原子替换-回收旧版本"的协议保证一致性。
RCU 在读侧的性能优势是数量级的:在 128 核心 NUMA 系统上,RCU 读侧开销接近 0(无需同步原语),而 rwlock 读侧需要约 50-200ns 的缓存行 bouncing。
二、RCU 核心原理 — 三个基本操作
2.1 基本协议:读侧、写侧、回收
RCU 的核心协议由三部分组成:
- 读侧临界区(Read-Side Critical Section):通过
rcu_read_lock()/rcu_read_unlock()包裹,进入一个"不受写者回收影响"的保护域。在此区域内可以安全地访问 RCU 保护的指针。 - 写侧更新(Update):写者创建一个新副本,在副本上修改,然后通过原子操作将全局指针切换到新副本。旧版本的数据在确保所有读侧退出后回收。
- 宽限期(Grace Period):从写者之字发布(publish)开始,到所有曾经进入该临界区的读侧退出之间的时间窗口。只有宽限期结束后,旧数据才能安全回收。
2.2 API 核心函数族
读侧 API:
rcu_read_lock()/rcu_read_unlock():标记 RCU 读侧临界区边界(实际上是关闭内核抢占/屏障,防止编译器和 CPU 重排序)rcu_dereference(p):安全地解引用 RCU 保护的指针,带有编译器和 CPU 内存屏障语义,确保后续读操作不会跨越 lock 边界被外提
注意:
rcu_read_lock()本身不执行任何原子操作或锁申请。在经典 RCU(非抢占式)下,它实际上是一个preempt_disable()+ 编译器屏障(barrier())。这意味着 RCU 读侧是真正零开销的。
写侧 API:
rcu_assign_pointer(p, v):原子地将 RCU 保护的指针p设置为v,带有发布语义(release semantics),确保之前的所有写操作对后续读者可见synchronize_rcu():同步等待当前所有宽限期结束(阻塞调用)。禁止在中断上下文和持有 RCU 读锁时调用。call_rcu(callback, head):异步回调模式,注册一个在宽限期结束后执行的函数,是高频更新场景的首选
2.3 内存序协议 — 为什么读侧不需要 LOCK 前缀?
RCU 依赖两个内存序约束来保证正确性:
- 发布语义(Release Semantics):
rcu_assign_pointer()在 x86 上是普通 MOV(因为 x86 的 TSO 模型天然保证释放语义),在 ARM/RISC-V 上插入dmb ishst屏障,确保指针写入之前的所有修改对读者可见。 - 获取语义(Acquire Semantics):
rcu_dereference()确保指针解引用之前不会执行依赖该指针的后续读操作。在 ARM/RISC-V 上插入dmb ish数据依赖屏障。
写者-rcu_assign_pointer(释放)屏障;读者-rcu_dereference(获取)屏障。这两组屏障确保读者要么看到旧版本,要么看到新版本,绝不会出现中间状态。
三、Tree RCU — 工业级宽限期引擎
3.1 宽限期管理的基本原理
Tree RCU 是 Linux 内核的默认 RCU 实现(CONFIG_TREE_RCU),支持数十个 CPU 核心的高效宽限期管理。
核心数据结构:
struct rcu_state:全局 RCU 状态机,管理所有 CPU 的宽限期进度struct rcu_node:树的节点,每个节点追踪一组 CPU(或下级节点)的 quiescent state(静默态)struct rcu_data(per-CPU):记录每个 CPU 的 RCU 状态——当前是否在临界区、是否有待处理回调、是否通过了 quiescent state
3.2 Quiescent State(静默态)
每个 CPU 一次只能处于以下一个状态:
- 不在 RCU 读侧临界区:CPU 已经"静默"。
- 在 RCU 读侧临界区:CPU 正在读取 RCU 保护的数据。
- 发生了上下文切换:上下文切换本身证明 CPU 退出了 RCU 读侧临界区(因为进入/退出临界区会关闭抢占,而上下文切换需要抢占开启)。
Tree RCU 通过树形层级结构向上汇聚每个 CPU 的状态(类似锦标赛树/Tournament Tree):叶子节点追踪一个 CPU(或小组 CPU),中间节点追踪其子节点的聚合状态,直到根节点确认所有 CPU 都通过了至少一个 quiescent state → 宽限期结束。
3.3 宽限期检测流程
当 synchronize_rcu() 或 call_rcu() 注册回调时,宽限期检测启动:
synchronize_rcu()设置当前gpnum+1 作为目标宽限期(gpnum 和 completed 两个序列号是 Tree RCU 的核心追踪变量)- 每个 CPU 在上下文切换、用户态返回、idle enter 等关键点检查自己是否静默——如果静默就更新
rcu_data->qs_pending并在节点层汇报 - 节点层逐级向上聚合:当某个节点的所有 CPU/子节点都静默后,该节点的
->qsmask清零,转而向上汇报 - 根节点确认全系统静默后,标记
rcu_state->completed = gpnum,宽限期结束
3.4 Callback 处理 — softirq 与 RCU kthread
宽限期结束后,_pending 的 RCU 回调需要在合适的时机执行:
- RCU_SOFTIRQ:每个 CPU 的 softirq 在处理完硬中断后检查是否有待触发回调,直接调用(适合轻量回调如
kfree) - rcuop kthread:将回调按批次分派到每个 CPU 的
rcuop/%dworker 线程,避免在软中断中做大量工作 - rcuog kthread:高压回调过载时(积压回调 > 10,000),临时切换到 high-priority 线程加速消费
3.5 宽限期状态机转换
GP_EARLY_INIT → GP_INIT → GP_WAIT → GP_WAIT_FQS → GP_DOING_INIT → GP_IDLE → GP_WAIT
- IDLE:等待新的宽限期请求(synchronize_rcu / call_rcu)
- WAIT:等待所有 CPU 报告 quiescent state
- WAIT_FQS: Force Quiescent State 模式——逐 CPU 通过 IPI 发送远程请求加速检测(针对 nohz_full 等长时间静默的 CPU)
四、Sleepable RCU(SRCU)— 支持睡眠的读侧
经典 RCU 要求读侧临界区不能睡眠(因为依赖 preempt_disable() 阻止抢占)。SRCU(Sleepable RCU)解除了这个限制,允许读侧调用可能睡眠的函数。
关键差异:
| 特性 | 经典 RCU | SRCU |
|---|---|---|
| 读侧能否睡眠 | 否 | 是 |
| 读侧开销 | 极低(preempt_disable) | 稍高(per-cpu counter + atomic) |
| 宽限期检测 | Quiescent State(上下文切换推断) | 显式计数器——每个 CPU 记录进入/退出次数 |
| 独立实例 | 全局(所有 RCU 用户共享) | 每个 SRCU 结构独立跟踪 |
| 伸缩性 | 极佳(O(log N) 树形聚合) | 读取侧串行化程度较高 |
4.1 SRCU 使用模式
// 使用模式:per-结构体 SRCU
struct my_protected_data {
struct srcu_struct ss;
int shared_value;
};
void reader(struct my_protected_data *p) {
int idx = srcu_read_lock(&p->ss);
// 可以在这里睡眠!
int val = p->shared_value;
srcu_read_unlock(&p->ss, idx);
}
void writer(struct my_protected_data *p) {
p->shared_value = 42;
synchronize_srcu(&p->ss);
// 现在可以安全释废旧数据
}
4.2 何时选择 SRCU 而非经典 RCU
- 读侧必须调用可能睡眠的函数(如内存分配
kmalloc(GFP_KERNEL)、互斥锁mutex_lock()) - 数据结构需要每个实例独立的宽限期(多个独立保护域)
- 容忍略高的读侧开销换取灵活性
五、RCU Protected Pointer — 读写两侧的正确编程模式
5.1 发布-订阅模式的实现细节
写侧模板(Publication):
1. new_obj = kmalloc(sizeof(*new_obj), GFP_KERNEL);
2. memcpy(new_obj, old_obj, sizeof(*new_obj)); // 复制旧内容
3. new_obj->field = new_value; // 修改新副本
4. rcu_assign_pointer(global_ptr, new_obj); // 原子发布
5. synchronize_rcu(); 或 call_rcu(free_old, old_obj);
6. kfree(old_obj); // 回收旧版本(仅在同步模式下)
读侧模板(Subscription):
rcu_read_lock();
p = rcu_dereference(global_ptr);
if (p) {
// 可以安全使用 p 的所有字段(唯读)
// 但不能睡眠(经典 RCU 下)
val = p->field;
}
rcu_read_unlock();
5.2 rcu_dereference 与内存屏障
rcu_dereference(p) 不仅是类型安全的指针解引用,更重要的是防止 CPU 乱序执行导致的"读到半初始化对象"问题:
在 DEC Alpha 架构上(最弱内存模型),即使写者用了 rcu_assign_pointer,没有 rcu_dereference 的屏障,CPU 可能先将 p->field 的请求发到总线上(而此时指针地址尚未稳定)。rcu_dereference() 在 Alpha 上编译为 memory barrier,在弱内存模型上确保地址-数据的顺序依赖。
5.3 RCU 保护的链表 API
| 操作 | 函数 | 说明 |
|---|---|---|
| 遍历 | list_for_each_entry_rcu(p, head, member) | 安全遍历 RCU 保护链表 |
| 插入 | list_add_rcu(new, head) | 头部插入,不需要写锁(唯一写者保证即可) |
| 追加 | list_add_tail_rcu(new, next) | 尾部插入 |
| 替换 | list_replace_rcu(old, new) | 原子替换节点(发布语义) |
| 删除 | list_del_rcu(p) | 从链表移除,但必须等待宽限期后释放 |
RCU 链表的安全保证:读者和写者可以并发执行,因为:删除节点只修改邻居指针(原子操作),读者遍历要么看到旧版本链表,要么看到被修改后的链表。旧版本的节点要等到所有读者退出后才能通过 kfree_rcu() 释放。
六、典型应用场景
6.1 路由表(fib_lookup)
Linux IP 路由查找使用 RCU 保护路由缓存:
- ARP 解析、netfilter hook 频繁读取路由信息
- 路由更新频率低(通常只在外接路由协议变化或管理员修改时)
- 读侧在软中断上下文(NAPI 轮询),不能睡眠 → 经典 RCU
6.2 Dentry 缓存(dcache)
Linux VFS 的 dentry cache 是 RCU 的经典应用:
- 每次
open()或路径查找都要遍历 dentry lockless_d_lookup()在 RCU 保护下遍历父目录链- 若未命中,自动回退到带锁的 slow path(先退出 RCU 再获取锁)
- 这种"乐观 RCU + 悲观回退"模式大幅减少了文件操作中的锁竞争
6.3 进程管理(task list)
for_each_process()遍历进程列表时使用 RCU- Kernel thread 的
do_exit()从进程树中移除后延迟释放 task_struct(call_rcu(&task->rcu, delayed_put_task_struct)) - 即使进程已经 exit,其他 CPU 仍可能正在通过 pid 查找它
6.4 用户态 RCU — liburcu
liburcu(Userspace RCU)是 Linux 内核 RCU 的用户态移植,支持:
- QSBR(Quiescent State Based Reclamation):允许读侧零开销,周期性报告 quiescent state
- BP Buffpressure:读侧仅需写入 per-cpu 标志位(写本 CPU,读自己的缓存行)
- 典型应用:内存数据库(如 DPDK rte_hash)、高性能路由栈、lock-free _maps(HTM)
// liburcu QSBR 使用示例
#include <urcu-qsbr.h>
// 读侧(零开销!)
rcu_read_lock();
data = rcu_dereference(global_ptr);
// ... 读操作 ...
rcu_read_unlock();
// 周期性 quiescent state 报告
rcu_quiescent_state(); // 告诉 RCU 我已完成所有读操作(通常在主循环中调用)
// 写侧
new_data = malloc(...);
memcpy(new_data, data, ...);
rcu_assign_pointer(global_ptr, new_data);
synchronize_rcu(); // 等待所有 CPU 报告 quiescent state
free(old_data); // 安全回收
七、性能分析与调优
7.1 读侧开销基准测试
| 同步机制 | 单次读侧开销 (x86_64) | NUMA 跨节点开销 |
|---|---|---|
| 无(单线程) | ~0.3 ns | ~0.5 ns |
| rcu_read_lock/unlock | ~0.5 ns | ~0.5 ns (仅抢占计数) |
| rwlock 读侧 | ~3 ns | ~80-200 ns (缓存行 bouncing) |
| seqlock 读侧 | ~2-3 ns(无竞争时) | ~10-20 ns(重试时更高) |
| pthread mutex 读侧 | ~12 ns | ~100+ ns(竞争时更高) |
结论:RCU 读侧在所有同步原语中开销最低,且该开销不随核心数增长。
7.2 宽限期延迟
宽限期(Grace Period)延迟取决于系统中最"活跃"的 RCU 读用户:
- 典型空闲系统:~1-5 ms
- 典型负载系统:~5-20 ms
- nohz_full CPU + 深度空闲:可能达 100+ ms(需等待 FQS IPI 退休)
- 读侧在睡眠前未退出临界区 → 踢皮球给下一个任务唤醒后才结束
优化建议:将 synchronize_rcu()(同步阻塞)替换为 call_rcu()(异步),避免写者延迟。对于必须同步的场景,使用 synchronize_rcu_expedited()(加速宽限期,以更重 CPU 开销换取短时间)。
7.3 RCU 优先级反转与 RCU 读侧阻塞问题
RCU 读侧不允许睡眠不是因为 API 限制,而是因为宽限期检测依赖"CPU 退出临界区 → 上下文切换"这一关键假设。如果读侧睡眠,那么即使 sleep 中 CPU 可能被其他任务抢占——抢占者如果进入自己的 RCU 临界区,那宽限期会被持续延长。
内核中的典型反模式:
// 错误:在 RCU 读侧睡眠
rcu_read_lock();
ptr = rcu_dereference(dev->private);
mutex_lock(&ptr->lock); // 💀 BUG! 可能导致睡眠,宽限期无限延长
rcu_read_unlock();
八、RCU 与 Seqlock、读写锁的对比选型
| 维度 | RCU | Seqlock | rwlock_rwsem |
|---|---|---|---|
| 读侧并发 | ✅ 完全并发 | ✅ 并发(无冲突时) | ✅ 串行读侧(共享锁) |
| 读侧开销 | 极低(~0ns) | 低(~2ns + 重试开销) | 中等(~10-200ns) |
| 读侧能否睡眠 | 经典RCU ❌ / SRCU ✅ | 不能 | rwsem ✅ |
| 写侧开销 | 较高(等待宽限期) | 中(自旋锁 + 重试读侧) | 低(互斥锁) |
| 写者starvation | ❌ 不会(宽限期有限) | ✅ 可能(读者持续重试) | ✅ 可能 |
| 适用场景 | 读多写少、极高读取QPS | 读多写少、读侧可从头来 | 读临界区需要睡眠、读少写多 |
九、进阶话题
9.1 Hazard Pointer 与 RCU 的互补
Hazard Pointer(HP)是另一种无锁回收方案,每个读者声明一个"正在使用"的指针,写者扫描所有 CPU 的 HP 数组后才回收。HP 更适合:
- 少量热点对象需要保护(RCU 适合大量对象)
- 无法预测读者退出时间的场景
- 实际工程中,HP 与 RCU 混合使用——HP 保护核心元数据,RCU 保护主数据结构
9.2 RCU 与内存回收的交互
Linux 内核 OOM killer 与 RCU 的有趣交互:
- OOM 扫描进程内存时可能触发 RCU callback 回收
- RCU 写侧在
GFP_KERNEL下申请内存可能触发直接回收,导致宽限期回调延迟 → 死链(写者等宽限期 → 宽限期等内存 → 内存等回收 → 回收等写者) - 现代内核通过 RCU priority boosting 和"跳过内存厚重宽限期"策略规避
9.3 kfree_rcu — 简化内存回收
kfree_rcu(head, rhf) 是 call_rcu() 的封装,自动将 kfree() 注册为回调。特别适合在结构体中嵌入 struct rcu_head 的场景:
struct my_node {
int value;
struct rcu_head rcu; // 嵌入 RCU 头
};
// 删除时:
call_rcu(&node->rcu, my_node_free);
// 等价于:
kfree_rcu(node, rcu); // 更简洁
十、核心源码剖析(Linux 6.x 版本)
10.1 rcu_read_lock — 读侧进入
非抢占式 RCU:rcu_read_lock() → preempt_disable() + barrier()。关闭抢占确保当前 task 不会被切走从而阻止读侧退出推断。屏障防止编译器重排临界区外的指令进入。
抢占式 RCU(CONFIG_PREEMPT_RCU=y):rcu_read_lock() 原子增加 current->rcu_read_lock_nesting,这样即使被抢占,宽限期追踪也能跟踪"该 task 正在持有 RCU 读锁"。
10.2 call_rcu — 注册异步回调
call_rcu() 将回调函数加入当前 CPU 的 rcu_data->curlist(当前宽限期列表)或 nxtlist(下一宽限期列表),由 RCU softirq 或 kthread 在宽限期结束后消费。
10.3 rcu_gp_kthread — 宽限期主循环
内核线程 rcu_gp(每个 RCU 状态一个)负责驱动gp 状态机:
- 等待
synchronize_rcu()/call_rcu()触发 NEW REQUEST - 设置新 GP 序列号
rcu_state.gpnum += 1 - 遍历所有 rcu_node,检查 quiescent state
- 等待 FQS 阶段:对静默的 CPU 发 IPI 强制检查
- 处理积压的 RCU 缓冲区,触发回调
- 回到 IDLE 状态
rcu_state.completed = gpnum十一、总结与核心要点
RCU 是 Linux 内核中最精妙的同步原语之一,核心要点:
- 读侧零基同步:RCU 读侧无需任何缓存一致性操作,性能与单线程无异
- Copy-on-Write 哲学:写者不修改原对象,而是创建副本并原子切换指针,保证读者总是看到一致状态
- 宽限期递归:Tree RCU 的树形结构使得宽限期检测从 O(N) 降至 O(log N)
- 读侧禁止睡眠(经典 RCU)是性能与正确性的权衡——SRCU 以更高开销换取灵活性
- call_rcu 优于 synchronize_rcu:异步回调避免写者阻塞,是高吞吐系统的首选
- rcu_dereference 必须配合 rcu_assign_pointer:两侧的内存屏障必须配对使用,否则在弱内存模型上会出错
掌握 RCU,是理解 Linux 内核编写高并发无锁数据结构的必经之路。它不仅是一个同步原语,更代表了一类编程范式的转变——让读操作完全自由,让写者承担协调成本,从而实现理论上的最优读侧性能。
十二、关键参考资料
- 内核文档:
Documentation/RCU/— 最权威详解 - Paul E. McKenney — "What is RCU, Fundamentally?" (LWN.net, 2007) — RCU 白皮书
- Paul E. McKenney — "RCU Usage In the Linux Kernel: One Decade Later" (2013) — 十年 RCU 实践回顾
- 书籍:Is Parallel Programming Hard, And, If So, What Can You Do About It? — Paul E. McKenney 免费 PDF
- liburcu 源码:github.com/urcu/userspace-rcu — 用户态 RCU 实现
- 内核代码:
kernel/rcu/tree.c、kernel/rcu/tree_plugin.h、include/linux/rcupdate.h

发表评论 取消回复