Linux 内核 RCU 深度工程:Read-Copy-Update 从原理到生产实践

Read-Copy-Update 是 Linux 内核一项至关重要的无锁同步机制,它支撑了内核中几乎所有读多写少场景的高效运行。从路由表到文件描述符表,从 VFS dentry 缓存到内存管理数据结构,RCU 无处不在。然而,许多工程师对它的理解停留在"一种锁"的表面层次。


一、为什么需要 RCU

传统同步机制(spinlock、rwlock、seqlock)在 读远多于写 的场景下,存在不可接受的开销:

  • Spinlock:读写互斥,读端也需要串行
  • RWlock:读者虽可并发,但缓存行乒乓严重
  • Seqlock:读者可能需要重试,增加读延迟

RCU 的承诺是:读端零开销,不使用任何原子操作或内存屏障(弱序架构除外)。这意味着在高并发读场景下,RCU 的数据结构遍历可以像遍历普通无保护链表一样快。

代价与权衡

RCU 的核心代价是 延迟回收——被替换的旧数据必须等待所有读者完成之后才能释放。这引入了两个工程挑战:

  1. 宽限期(Grace Period) 的定义与等待
  2. 内存回收 的时机把握

二、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 的条件:

  1. 上下文切换:CPU 经历了进程切换
  2. 进入 idle 循环:CPU 进入 C1/C2/C3 低功耗状态
  3. 执行用户态代码:对于 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 时重点检查:

  1. 是否有 CPU 长期关中断
  2. 是否有硬循环卡在 RCU 读临界区
  3. 是否有 rcu_nocbs 配置导致回调无法处理

十、总结

RCU 是现代 Linux 内核中最精妙的同步原语。它的工程价值不仅在于性能,更在于它重新定义了"读保护"的语义——读者不需要通知任何人自己的存在。

理解 RCU 的几个核心心智模型:

  1. 读者不需要锁,只需声明自己的存在区间
  2. 宽限期基于 CPU 的 quiescent state,而非跟踪单个读者
  3. 写端的核心原子操作是 rcu_assign_pointer(),其余都是延迟回收的机制
  4. 在可以睡眠的读者场景,SRCU 是唯一选择

在 AI 基础设施、Web 服务器、实时音频处理等低延迟场景中,RCU 配合 rcu_nocbs 和 CPU 隔离技术,能够将内核同步开销降至纳秒级别。它不是一种锁,它是 Linux 内核告诉硬件的一种"默契":读侧无所畏惧,写侧耐心等待。


*本文基于 Linux 6.6+ 内核代码分析,参考 kernel/rcu/ 目录下核心实现。*

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部