一、为什么需要 RCU — 从读写锁的瓶颈说起

并发编程中,读多写少场景占据绝大多数:路由表、文件系统 dentry cache、进程描述符访问、网络连接跟踪(conntrack)等。传统读写锁(rwlock)虽然允许并发读取,但存在三个根本性限制:

  1. 读侧开销:rwlock 的读锁/解锁需要原子指令,在 NUMA 架构下跨节点缓存同步造成性能损失。读侧开销会随 CPU 核心数线性增长。
  2. 写者饿死:在高读取频率下,写锁难以获取,导致写操作延迟不可控。
  3. 不支持在读侧上下文中睡眠: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 依赖两个内存序约束来保证正确性:

  1. 发布语义(Release Semantics):rcu_assign_pointer() 在 x86 上是普通 MOV(因为 x86 的 TSO 模型天然保证释放语义),在 ARM/RISC-V 上插入 dmb ishst 屏障,确保指针写入之前的所有修改对读者可见。
  2. 获取语义(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() 注册回调时,宽限期检测启动:

  1. synchronize_rcu() 设置当前gpnum+1 作为目标宽限期(gpnum 和 completed 两个序列号是 Tree RCU 的核心追踪变量)
  2. 每个 CPU 在上下文切换、用户态返回、idle enter 等关键点检查自己是否静默——如果静默就更新 rcu_data->qs_pending 并在节点层汇报
  3. 节点层逐级向上聚合:当某个节点的所有 CPU/子节点都静默后,该节点的 ->qsmask 清零,转而向上汇报
  4. 根节点确认全系统静默后,标记 rcu_state->completed = gpnum,宽限期结束

3.4 Callback 处理 — softirq 与 RCU kthread

宽限期结束后,_pending 的 RCU 回调需要在合适的时机执行:

  • RCU_SOFTIRQ:每个 CPU 的 softirq 在处理完硬中断后检查是否有待触发回调,直接调用(适合轻量回调如 kfree)
  • rcuop kthread:将回调按批次分派到每个 CPU 的 rcuop/%d worker 线程,避免在软中断中做大量工作
  • 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)解除了这个限制,允许读侧调用可能睡眠的函数。

关键差异:

特性经典 RCUSRCU
读侧能否睡眠否是
读侧开销极低(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、读写锁的对比选型

维度RCUSeqlockrwlock_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 状态机:

  1. 等待 synchronize_rcu() / call_rcu() 触发 NEW REQUEST
  2. 设置新 GP 序列号 rcu_state.gpnum += 1
  3. 遍历所有 rcu_node,检查 quiescent state
  4. 等待 FQS 阶段:对静默的 CPU 发 IPI 强制检查
  5. 确认全系统静默,标记rcu_state.completed = gpnum
  6. 处理积压的 RCU 缓冲区,触发回调
  7. 回到 IDLE 状态

十一、总结与核心要点

RCU 是 Linux 内核中最精妙的同步原语之一,核心要点:

  1. 读侧零基同步:RCU 读侧无需任何缓存一致性操作,性能与单线程无异
  2. Copy-on-Write 哲学:写者不修改原对象,而是创建副本并原子切换指针,保证读者总是看到一致状态
  3. 宽限期递归:Tree RCU 的树形结构使得宽限期检测从 O(N) 降至 O(log N)
  4. 读侧禁止睡眠(经典 RCU)是性能与正确性的权衡——SRCU 以更高开销换取灵活性
  5. call_rcu 优于 synchronize_rcu:异步回调避免写者阻塞,是高吞吐系统的首选
  6. 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
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部