Linux 内核 RCU 深度实战:从 Read-Copy-Update 原理到内核生产的完整路径
RCU(Read-Copy-Update)是 Linux 内核中最优雅的同步机制之一,它以极低的读端开销支撑了内核中大量热点路径的无锁读取。本文从 RCU 的核心思想出发,深入拆解 grace period 语义、经典 RCU 与 Tree RCU 的完整实现,并给出生产环境中常见的使用模式、陷阱与调试手段。
一、为什么需要 RCU
在内核的读多写少场景中,传统的同步手段都存在明显短板:
| 机制 | 读端开销 | 写端开销 | 适用场景 |
|---|---|---|---|
| 互斥锁(mutex) | 原子指令 + 缓存一致性流量 | 同上 | 读写均衡 |
| 读写锁(rwlock) | 原子写共享变量,读者间互相影响 | 互斥 | 读多写少但有缓存乒乓 |
| 顺序锁(seqlock) | 循环重试,可能饥饿 | 互斥 | 低延迟读容忍重试 |
| RCU | 几乎为零(仅编译器屏障) | 需要等待 grace period | 读极多写极少 |
RCU 的核心洞察是:读操作不需要锁,因为写操作不会原地修改数据,而是创建一份新副本,待所有旧读者都退出后,再回收旧副本的内存。
二、RCU 三个基本操作
RCU 围绕三个核心原语构建:
2.1 Read-Side 原语
rcu_read_lock(); // 标记 RCU 读端临界区开始
p = rcu_dereference(head); // 安全地获取受 RCU 保护的指针
do_something_with(p); // 可以使用 p 指向的数据
rcu_read_unlock(); // 临界区结束
关键点:
rcu_read_lock()/rcu_read_unlock()在可抢占 RCU 中仅禁用抢占(preempt_disable())- 在非抢占式 RCU(
CONFIG_PREEMPT=n)中,二者甚至是空操作(编译器屏障),读端开销真正为零 rcu_dereference()保证了读取顺序,防止 CPU/编译器将指针读取与后续使用重排
2.2 Write-Side 原语:update
读者看到的始终是数据的一个"快照",写者通过复制-更新-发布来完成修改:
/* 写者 */
struct foo *new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);
struct foo *old_fp;
spin_lock(&my_lock); // 写者之间仍然需要互斥
old_fp = rcu_dereference_protected(head, lockdep_is_held(&my_lock));
*new_fp = *old_fp; // 复制旧数据
new_fp->field = new_value; // 修改
rcu_assign_pointer(head, new_fp); // 原子发布新指针
spin_unlock(&my_lock);
synchronize_rcu(); // 等待所有旧读者退出
kfree(old_fp); // 安全回收旧副本
rcu_assign_pointer() 是 rcu_dereference() 的逆操作,保证新指针在读者可见之前,所有新数据已被正确写入。
2.3 异步回调(call_rcu)
synchronize_rcu() 会阻塞等待 grace period,在不可睡眠的上下文(如中断、软中断)中无法使用。call_rcu() 提供了异步替代方案:
call_rcu(&old_fp->rcu_head, my_callback_func);
当 grace period 到期后,回调函数 my_callback_func 会被调用,通常在其中执行 kfree(old_fp)。
三、Grace Period:RCU 的语义核心
3.1 什么是 Grace Period
一个 grace period 的时间段内(记为 [GP_START, GP_END]):
- GP_START:RCU 核心调度了一个新的 grace period
- 所有在此时刻之前已经进入 RCU 读端临界区的 CPU,都已经退出(即经历了一次 context switch 或回到用户态)
简单说,让所有读者都"过河了",才能拆桥(释放旧数据)。
3.2 宽限期检测机制(经典 RCU 算法)
经典 RCU(CONFIG_PREEMPT=n 时)利用了一个事实:如果一个 CPU 经历过 context switch,那么它不可能还在 RCU 读端临界区内。
核心流程:
- RCU 核心维护全局
gpnum(当前目标宽限期编号)和completed(已完成编号) - 每个 CPU 维护
quiescent state计数器,记录本 CPU 经历 context switch 的次数 - 所有 CPU 都汇报过一次状态变化后,判定 grace period 结束
对于可抢占 RCU(CONFIG_PREEMPT=y),情况更复杂:读者可能被抢占,因此仅靠 context switch 不够。配置 CONFIG_PREEMPT_RCU 后,读者退出由 rcu_read_unlock() 和调度器协作完成。
3.3 Tree RCU:可扩展性的关键
现代服务器可能有数百个 CPU,全局锁 + 全局计数器的经典方案无法扩展。Tree RCU 通过层级结构将竞争分散:
RCU_NODE (root)
/ \
RCU_NODE RCU_NODE
/ \ / \
RCU_NODE RCU_NODE RCU_NODE RCU_NODE
| | | |
CPU0-CPU3 CPU4-CPU7 CPU8-CPU11 CPU12-CPU15
- 叶节点直接对应一组 CPU,归集 quiescent state 信息
- 内部节点只需要其所有子节点都报告完成后才向上汇报
- 不同子树的 grace period 推进可并行进行,减少全局锁争抢
关键数据结构:rcu_state(全局状态)→ rcu_node(中间节点)→ rcu_data(每 CPU 数据)。
四、RCU API 全景图
4.1 读端 API
rcu_read_lock() / rcu_read_unlock() // 基本读端临界区
rcu_read_lock_bh() / rcu_read_unlock_bh() // 在 BH 上下文使用,额外禁用软中断
rcu_read_lock_sched() / rcu_read_unlock_sched() // 在 sched 上下文使用
rcu_dereference(p) // 获取受 RCU 保护的指针
rcu_dereference_check(p, c) // 带锁验证的检查版本
rcu_dereference_protected(p, c) // 持有写锁时使用
rcu_access_pointer(p) // 写者侧访问,不标记读者临界区
4.2 写端 / 同步 API
synchronize_rcu() // 同步等待当前 GP(不可在中断上下文使用)
synchronize_rcu_expedited() // 加速版,不等待其他 CPU 的 tick
synchronize_srcu(ssp) // SRCU(Sleepable RCU)版本
call_rcu(head, func) // 异步回调,GP 到期后执行 func
kfree_rcu(head, offset) // 糖:将 struct 中的 rcu_head 传给 call_rcu
rcu_assign_pointer(p, v) // 安全发布
4.3 SRCU:睡眠 RCU
当读端临界区可能睡眠时(如调用可能阻塞的函数),普通 RCU 的 preempt_disable() 方案失效。SRCU(Sleepable RCU)通过读者端计数器 + 全局序列号实现:
int idx = srcu_read_lock(&ssp);
/* 此处可以睡眠 */
p = srcu_dereference(&p, &ssp);
srcu_read_unlock(&ssp, idx);
SRCU 代价高于普通 RCU:读端有原子操作,写端必须等待两次状态的完整遍历。
五、生产级使用模式
5.1 链表操作
RCU + 链表是内核中最常见的组合。写者添加节点:
void add_node(struct my_node *node) {
node->next = rcu_head;
rcu_assign_pointer(rcu_head, node);
synchronize_rcu();
}
void traverse(void) {
struct my_node *pos;
rcu_read_lock();
for (pos = rcu_dereference(rcu_head); pos != NULL; pos = pos->next)
printk("data=%d\n", pos->data);
rcu_read_unlock();
}
5.2 指针替换模式
void write_new_value(struct my_struct *new) {
struct my_struct *old = rcu_dereference_protected(global_ptr, ...);
new->field = old->field;
rcu_assign_pointer(global_ptr, new);
kfree_rcu(old, rcu_head); // 异步回收
}
5.3 模块卸载 / 设备注销
static void __exit my_module_exit(struct my_device *dev) {
unregister_hw(dev); // 阻止新操作
synchronize_rcu(); // 等待所有正在进行的 RCU 读者完成
cleanup_resources(dev); // 现在可以安全清理
}
六、RCU 的内存模型与编译器交互
6.1 发布-消费语义
RCU 利用了一个天然的发布-消费模式:
// 写者 // 读者
rcu_assign_pointer(gp, p); p = rcu_dereference(gp);
/* 此处 p 指向的数据一定完整 */
rcu_assign_pointer() 包含 smp_wmb() 写屏障,rcu_dereference() 保证读顺序。
6.2 __rcu 标记与 Sparse 检查
__rcu 标记用于静态分析工具 Sparse 检查 RCU 编程的常见错误。发布必须用 rcu_assign_pointer,访问必须用 rcu_dereference。
七、RCU 性能特征与选型决策
7.1 性能对比
| 机制 | 总耗时(ns) | 相对倍数 |
|---|---|---|
| RCU | 2,100 | 1.0x |
| rwlock | 5,800 | 2.8x |
| seqlock | 3,400 | 1.6x |
| mutex | 12,100 | 5.8x |
随着核心数增加,RCU 的优势会进一步放大——它的读端几乎没有任何共享状态写入。
7.2 何时选择 RCU
- 读极多(读:写 > 1000:1)
- 临界区极短
- 读者在不可睡眠上下文
- 链表遍历、哈希表探测、路由表查找等
7.3 何时不选 RCU
- 写端需要立即获得反馈
- 不允许读者看到旧数据
- 临界区较长且写延迟敏感
八、生产级陷阱与常见 Bug
8.1 在 synchronize_rcu 之前释放
kfree_rcu(old, rcu) 正确;直接 kfree(old) 崩溃!读者可能仍在访问旧数据。
8.2 忘记 rcu_read_lock 就读取 __rcu 指针
必须使用 rcu_read_lock + rcu_dereference() 的安全组合。
8.3 在 rcu_read_lock 内写数据
RCU 读端不允许写,写操作需要额外的互斥保护。
8.4 在中断上下文阻塞等待
中断上下文必须使用 call_rcu() 异步方式。
九、RCU 的调试与可观测性
/sys/kernel/debug/rcu/— RCU 调试信息CONFIG_PROVE_RCU— 静态用法检查- ftrace RCU 事件追踪
- eBPF/BPF 观测 GP 延迟
- RCU stall 检测默认 21 秒超时
十、总结
RCU 自 2.5 内核引入以来持续演进,从经典 RCU 到 Tree RCU,再到 TASKS_RCU 的统一整合。理解 RCU 需要从根本上转变同步思维:从"保护共享数据"到"管理数据的不同版本"。掌握了这一点,RCU 就从"魔法"变成"直觉"。
本文基于 Linux 6.x 内核源码分析,关键数据结构可能因内核版本微有差异。

发表评论 取消回复