Linux 内核 RCU 深度工程:Read-Copy-Update 从原理到生产实践
Read-Copy-Update 是 Linux 内核一项至关重要的无锁同步机制,它支撑了内核中几乎所有读多写少场景的高效运行。从路由表到文件描述符表,从 VFS dentry 缓存到内存管理数据结构,RCU 无处不在。然而,许多工程师对它的理解停留在"一种锁"的表面层次。
一、为什么需要 RCU
传统同步机制(spinlock、rwlock、seqlock)在 读远多于写 的场景下,存在不可接受的开销:
- Spinlock:读写互斥,读端也需要串行
- RWlock:读者虽可并发,但缓存行乒乓严重
- Seqlock:读者可能需要重试,增加读延迟
RCU 的承诺是:读端零开销,不使用任何原子操作或内存屏障(弱序架构除外)。这意味着在高并发读场景下,RCU 的数据结构遍历可以像遍历普通无保护链表一样快。
代价与权衡
RCU 的核心代价是 延迟回收——被替换的旧数据必须等待所有读者完成之后才能释放。这引入了两个工程挑战:
- 宽限期(Grace Period) 的定义与等待
- 内存回收 的时机把握
二、RCU 核心原语与 API
2.1 三种基本读端原语
// 经典 RCU 读端临界区
rcu_read_lock();
p = rcu_dereference(head->next); // 获取受 RCU 保护的指针
process(p);
rcu_read_unlock();
// RCU BH:禁止软irq,适用于可能被软irq 打断的上下文
rcu_read_bh_lock();
p = rcu_dereference_bh(head->next);
rcu_read_bh_unlock();
// RCU Sched:禁止抢占(也是默认的 rcu_read_lock)
rcu_read_sched_lock();
p = rcu_dereference_sched(head->next);
rcu_read_sched_unlock();
rcu_dereference() 是关键——它确保编译器不会重排读操作,在弱序架构(ARM、PowerPC)上会插入必要的内存屏障。在 x86-TSO 上它退化为一个屏障标记,几乎零开销。
2.2 写端关键 API
// 方式一:使用 RCU 保护的指针赋值
struct my_node *new_node = kmalloc(sizeof(*new_node), GFP_KERNEL);
new_node->data = 42;
new_node->next = old_head->next;
rcu_assign_pointer(head->next, new_node); // 原子发布新节点
synchronize_rcu(); // 等待所有读者完成
kfree(old_node); // 安全回收
// 方式二:使用 call_rcu 异步回调回收
call_rcu(&old_node->rcu_head, my_node_free);
// my_node_free 将在宽限期结束后被调用
2.3 `synchronize_rcu()` vs `call_rcu()` vs `kfree_rcu()`
| API | 行为 | 适用场景 |
|---|---|---|
synchronize_rcu() |
阻塞等待宽限期结束 | 模块卸载、必须同步完成的写操作 |
call_rcu() |
注册回调,宽限期后执行 | 高频写路径、不可阻塞上下文 |
kfree_rcu() |
call_rcu 的封装,自动 kfree | 简单结构体释放 |
synchronize_rcu_expedited() |
强制加速宽限期(可能引发 IPI) | 模块卸载等不得等待的场景 |
三、宽限期:RCU 的心脏
3.1 什么是宽限期
宽限期(Grace Period)是 RCU 概念中最核心也最容易误解的部分。定义如下:
一个宽限期是指一段时期,在此期间内,每一个 RCU 读端临界区都至少遇到了一个 quiescent state(静止状态)。
关键洞察: RCU 不追踪单个读者,而是追踪 CPU。当每个 CPU 都经历了一个 quiescent state(注意到自己不再在 RCU 读临界区中),宽限期就算结束。
3.2 Quiescent State 的定义
一个 CPU 处于 quiescent state 的条件:
- 上下文切换:CPU 经历了进程切换
- 进入 idle 循环:CPU 进入 C1/C2/C3 低功耗状态
- 执行用户态代码:对于
rcu_read_lock()而言,用户态不在临界区内
// kernel/rcu/tree.c:RCU 核心状态机
void rcu_irq_exit_irqson(void)
{
// 从 IRQ 退出时检查是否经过 quiescent state
rcu_qs();
}
// 报告一个 quiescent state
void rcu_report_qs_rdp(struct rcu_data *rdp)
{
rdp->qs_ctr++;
if (rdp->qs_ctr >= rdp->gpnum) {
// 当前宽限期已结束
rcu_advance_node_gp_numbers(rdp);
}
}
3.3 GP 状态机(Tiny RCU 视角)
CPU0 报告 qs CPU1 报告 qs
↓ ↓
Start GP ──────────────────────────────→ End GP
↑ ↑
rcu_read_lock() rcu_read_lock()
每个 CPU 维护当前的 gpnum(当前宽限期编号)和 completed(已完成的宽限期编号)。当 gpnum == completed 时,RCU 核心知道没有未完成的读者。
四、RCU 在生产环境中的经典模式
4.1 链表替换模式(最常见)
// include/linux/rculist.h:内核 RCU 链表 API
// rcu 安全的链表遍历
#define list_for_each_entry_rcu(pos, head, member) \
for (pos = list_entry_rcu((head)->next, typeof(*pos), member); \
&pos->member != (head); \
pos = list_entry_rcu(pos->member.next, typeof(*pos), member))
// rcu 安全的 hlist 遍历
#define hlist_for_each_entry_rcu(tpos, pos, head, member) \
for (pos = rcu_dereference(hlist_first_rcu(head)); \
pos && \
({ tpos = hlist_entry(pos, typeof(*tpos), member); 1;}); \
pos = rcu_dereference(hlist_next_rcu(pos)))
VFS dentry 缓存 使用 hlist_bl_head + RCU 做全局 dentry 查找,这是 RCU 最成功的工程应用之一——路径解析不再需要全局锁。
4.2 引用计数 + RCU 模式(RCU + refcnt)
// 典型模式:RCU 定位 + 引用计数保护生命周期
struct io_ring_ctx *ctx;
rcu_read_lock();
ctx = rcu_dereference(current->io_uring);
if (ctx) {
// try_get_rcu 检查 ctx 是否正在被销毁
if (percpu_ref_tryget_live(&ctx->refs))
found = true;
}
rcu_read_unlock();
if (found) {
do_work(ctx);
percpu_ref_put(&ctx->refs);
}
这就是 Linux 内核中 io_uring 的实现方式——RCU 用于快速定位,percpu_ref 用于保护对象不被提前释放。
4.3 数据中心可扩展模式(RCU_NOCB)
在超大规模系统上,RCU 回调处理可能成为瓶颈。内核提供了 rcu_nocb 模式来解耦:
// 配置无回调模式的 RCU 内核线程
// kernel/rcu/tree_nocb.h
// 让特定 CPU 不处理 RCU 回调,减少干扰
// 命令行参数:rcu_nocbs=0-3,5-7
// CPU 0-3, 5-7 不注册 RCU 回调,将工作 offload 到其它 CPU
这是高频交易系统中的典型配置:隔离 CPU 上的实时任务不被 RCU 回调打断。
五、RCU 与内存回收:SLAB_TYPESAFE_BY_RCU
5.1 问题:Kfree 后读者还在访问
RCU 保证读者能在宽限期结束前安全访问旧对象,但如果读者持有指针的副本,在读者尚未释放引用时,slab 分配器已将内存重分配给新对象——这会导致类型混淆。
5.2 解决方案
// 方案1:构造函数缓存(SLAB_TYPESAFE_BY_RCU)
// mm/slab.h
struct kmem_cache *cache = KMEM_CACHE(my_obj, SLAB_TYPESAFE_BY_RCU);
// 分配时调用构造函数初始化关键字段,不释放全部内容
// 读者可以通过 obj->valid 字段区分新旧对象
// 方案2:独立 RCU 头 + 引用计数
struct my_obj {
struct rcu_head rcu;
struct refcount_head refs;
void *data;
};
void my_obj_free(struct rcu_head *rcu)
{
struct my_obj *obj = container_of(rcu, struct my_obj, rcu);
kfree(obj);
}
SLAB_TYPESAFE_BY_RCU 在 dentry_cache 中使用——新分配的 dentry 复用旧 slab 槽位但不重置内容,因为读者可能正在基于 d_inode 字段做比较。
六、RCU 性能分析:工具与指标
6.1 内核配置
# RCU 调试配置
CONFIG_RCU_TRACE=y # 启用 GP 事件追踪
CONFIG_RCU_FANOUT=64 # 每个 RCU 节点管理的 CPU 数
CONFIG_RCU_NOCB_CPU=y # 无回调模式
CONFIG_RCU_NOCB_CPU_ALL=y # 所有 CPU 无回调(极端场景)
6.2 运行时观测
# /sys/kernel/debug/rcu 状态
cat /sys/kernel/debug/rcu/rcu_preempt/gp_stats
# 显示每个宽限期的开始/结束时间
# 事件追踪
cat /sys/kernel/debug/tracing/trace_pipe | grep rcu_
# 输出:rcu_grace_period, rcu_callback, rcu_quiescent_state
6.3 常见性能问题
| 问题 | 现象 | 解决方案 |
|---|---|---|
| GP 延迟过高 | synchronize_rcu() 等待太久 |
使用 call_rcu() 替代 |
| RCU 回调集中 | 某 CPU rcuoc 线程 100% | 配置 rcu_nocbs 分摊 |
| 宽限期饥饿 | 持续有读者进入临界区 | 检查是否存在长时间读锁 |
| Tree RCU 开销 | fanout 层级过多 | 调整 CONFIG_RCU_FANOUT |
七、RCU 在 BPF 与 io_uring 中的现代工程应用
7.1 BPF_MAP_TYPE_RINGBUF 的 RCU 语义
BPF ringbuf 使用一种特殊的 非对称 RCU 模式:
// kernel/bpf/ringbuf.c
void *bpf_ringbuf_reserve(struct bpf_ringbuf *rb, u32 size, u64 flags)
{
// 生产者:最终提交时通过 smp_store_release 发布数据
WRITE_ONCE_hdr->len = size;
smp_store_release(&rb->consumer_pos, ...); // 隐式发布
}
BPF_CALL_2(bpf_ringbuf_discard, void *, sample, u64, flags)
{
// 消费者:读取时通过 smp_load_acquire 获取已提交数据
smp_load_acquire(&rb->producer_pos);
}
这种模式不是经典 RCU,因为读者(用户态 BPF 消费者)可能永远不会进入内核"quiescent state"。它依赖于 每-CPU 缓冲位置发布——一旦消费者读取了 consumer_pos,生产者就知道可以覆盖对应区间。
7.2 io_uring 的 Registered Buffers + RCU
io_uring 的 fixed buffer 注册涉及 RCU 保护:
// io_uring/uring_cmd.c
static int io_uring_cmd(struct io_ring_cmd *cmd)
{
struct io_ring_ctx *ctx;
rcu_read_lock();
ctx = idr_find(&ctx_idr, cmd->ctx_id); // RCU 保护的 IDR 查找
if (likely(percpu_ref_tryget(&ctx->refs))) {
rcu_read_unlock();
return do_work(ctx);
}
rcu_read_unlock();
return -EIO;
}
_idr_(内核的整数 ID 分配器)内部使用 RCU 保护的 Radix Tree,因此 idr_find 可以在 RCU 读端无锁安全执行。
八、Fault Injection:RCU 错误路径工程
8.1 RCU 锁持有检测
内核的 CONFIG_PROVE_LOCKING 可以检测 RCU 读端临界区违规:
// 检测代码(简化)
void rcu_read_lock(void)
{
__rcu_read_lock();
#ifdef CONFIG_PROVE_RCU
current->rcu_lock_nesting++;
#endif
}
void rcu_read_unlock(void)
{
#ifdef CONFIG_PROVE_RCU
if (WARN_ONCE(current->rcu_lock_nesting == 0,
"RCU read lock not held"))
return;
current->rcu_lock_nesting--;
#endif
__rcu_read_unlock();
}
8.2 SRCU:休眠安全 RCU
当读者可能阻塞(如等待 IO、分配内存)时,必须使用 Sleepable RCU (SRCU):
// 声明一个 SRCU 结构
DEFINE_STATIC_SRCU(my_srcu);
void reader_fn(void)
{
int idx = srcu_read_lock(&my_srcu);
// 这里可以 schedule()、kmalloc(GFP_KERNEL) 等
void *p = srcu_dereference(objp, &my_srcu);
do_work(p);
srcu_read_unlock(&my_srcu, idx);
}
void writer_fn(void)
{
// 替换对象
void *new_obj = kmalloc(...);
synchronize_srcu(&my_srcu); // 等待所有读者退出
kfree(old_obj);
}
SRCU 的实现使用每-CPU 计数器跟踪读者,synchronize_srcu 必须等待所有 CPU 的计数器归零。开销比经典 RCU 大,但在可以睡眠的场景不可替代。
九、RCU 的生产调优实战
场景一:容器化环境中的 RCU 风暴
问题:大量容器快速生成/销毁(K8s Pod 批量调度),导致同一时刻synchronize_rcu 请求激增,宽限期排队。
解决:
# /etc/sysctl.conf
# 调大 RCU 批次大小,减少 gp 启动频率
kernel.rcu_normal=1 # 非加速模式,使用 kthread 处理回调
kernel.rcu_expedited=0 # 禁用加速 GP,避免 IPI 风暴
场景二:实时任务受 RCU 回调干扰
问题:PTS 实时测量时,被 rcuc/X 内核线程的 RCU 回调打断。
解决:在实时 CPU 上禁用 RCU 回调:
# 隔离 CPU 4-7,不处理 RCU 回调
grub: ... rcu_nocbs=4-7 nohz_full=4-7 isolcpus=4-7
# 验证:查看 rucb 线程是否已迁移
ps aux | grep rcu
# rcuos/rcuob 线程现在在其他 CPU 上运行
场景三:RCU 锁检测与调试
问题:内核模块在 synchronize_rcu() 调用路径中阻塞。
# 开启 RCU stall 检测
CONFIG_RCU_STALL_COMMON=y
# 默认超时 21 秒
# 若触发 RCU stall:
dmesg | grep "RCU GP stall"
# [ 21.000000] INFO: rcu_sched self-detected stall on CPU
# [ 1.332044] rcu_sched:
分析 stall 时重点检查:
- 是否有 CPU 长期关中断
- 是否有硬循环卡在 RCU 读临界区
- 是否有
rcu_nocbs配置导致回调无法处理
十、总结
RCU 是现代 Linux 内核中最精妙的同步原语。它的工程价值不仅在于性能,更在于它重新定义了"读保护"的语义——读者不需要通知任何人自己的存在。
理解 RCU 的几个核心心智模型:
- 读者不需要锁,只需声明自己的存在区间
- 宽限期基于 CPU 的 quiescent state,而非跟踪单个读者
- 写端的核心原子操作是
rcu_assign_pointer(),其余都是延迟回收的机制 - 在可以睡眠的读者场景,SRCU 是唯一选择
在 AI 基础设施、Web 服务器、实时音频处理等低延迟场景中,RCU 配合 rcu_nocbs 和 CPU 隔离技术,能够将内核同步开销降至纳秒级别。它不是一种锁,它是 Linux 内核告诉硬件的一种"默契":读侧无所畏惧,写侧耐心等待。
*本文基于 Linux 6.6+ 内核代码分析,参考 kernel/rcu/ 目录下核心实现。*

发表评论 取消回复