一、为什么需要 RCU

在多核系统中,读写联系是最常见的并发模式——多个 CPU 同时读取同一份数据,像是查询配置表、遍历链表、读取文件索引节点。传统的 lock/unlock 不能满足这种需求:读者之间不应相互阻塞,同时写者必须等待所有读者的生命周期结束后才能回收旧数据的内存。

Linux RCU(Read-Copy-Update)正是为解决这个问题而生。它的核心思想是:分离"读"与"改",让读者无锁仅避开干扰写者的回收时机。 这一本质使 RCU 不是锁,而是一种允许"干净快速读取 + 延迟安全回收"的同步原语。

与传统重量级同步原语的对比:

  • spinlock / rwlock:读者间是同步的,导致多核竞争
  • seqlock:读者间无争用,但读者可能被写者强迫重试
  • RCU:读者完全无锁,写者不需要锁定读者,回收经过 grace period

二、RCU 读端:rcu_read_lock / rcu_read_unlock

RCU 的读端临界区由 rcu_read_lock() 和 rcu_read_unlock() 界定。在非 PREEMPT 内核中这两个调用是纯空函数(无实际操作),在允许抢占的内核中仅修改 preempt_count 中与 RCU 相关的位。

关键点:在 rcu_read_lock() 与 rcu_read_unlock() 之间严禁睡眠或上下文切换。 原因很直接:RCU 宽限期(Grace Period)依赖"所有 CPU 经历一次 quiescent state(通常为 context switch 或 user mode)"来判断读者退出。如果读者在 RCU 读端临界区内睡眠, gp 检测会超时或错误回收仍在使用的内存。

// 正确的读端模式
rcu_read_lock();
p = rcu_dereference(gp);   // 使用 rcu_dereference 读指针
process_data(p);           // 仅读取 p 指向的数据
rcu_read_unlock();

// 错误:在 RCU 读端临界区内睡眠
rcu_read_lock();
p = rcu_dereference(gp);
copy_to_user(buf, p, size);  // 可能触发 page fault → sleep!
rcu_read_unlock();

三、写端:call_rcu 与宽限期机制

RCU 写者的核心挑战是:确定何时可以安全回收旧版本数据。RCU 的解决方案是引入 Grace Period(宽限期):从写者发布更新开始,到所有在更新前已进入 RCU 读端临界区的 CPU 都退出后,这一段时间就是一个完整的 Grace Period。只有在 Grace Period 结束后,旧数据的内存才是安全的。

两个核心写端接口:

// 方式 1:使用 call_rcu(通用回调方式)
call_rcu(&old_obj->rcu_head, my_callback_func);
// my_callback_func 在 Grace Period 结束后被调用
// 通常在其中执行 kfree(old_obj)

// 方式 2:使用 synchronize_rcu(同步等待)
synchronize_rcu();  // 阻塞等待当前 Grace Period 结束
// 通常用于非抢占上下文中必须等待的场景

Grace Period 的实现原理:Linux 内核在每个 CPU 的调度器 tick 中检查该 CPU 是否经历了 quiescent state(idle、user mode 或 context switch)。当所有已在 Grace Period 开始之前注册过 quiescent state 的 CPU 都报告了一次 QS(Quiescent State)后,该 GP 结束并调度回调执行。

值得注意的是:Grace Period 的等待不会阻塞读者等待写者,也不会阻塞写者等待写者(仅 call_rcu 回调的排队会受限)。这是 RCU 性能的根基——读端的扩展性几乎是线性的,因为读者间完全无同步。

四、cu_dereference 家族与内存屏障语义

rcu_dereference() 不仅仅是类型转换——它在弱内存序架构上放置了读端内存屏障(Read Dependency Barrier 或 Read-After-Dependency Barrier),确保指针解引用的顺序不被 CPU/编译器乱序。在 x86-TSO 模型上,它仅需防止编译器重排;在 ARM64/PowerPC 上会附带数据依赖屏障。

常用家族函数:

// 读端解引用(在 rcu_read_lock/unlock 内部使用)
p = rcu_dereference(gp);

// 写端发布(需要持有自旋锁或适当同步)
rcu_assign_pointer(gp, new_p);

// 受保护写端解引用(写者在持有锁后可直接访问)
p = rcu_dereference_protected(gp, lockdep_is_held(&mutex));

// 初始化 RCU 指针
RCU_INIT_POINTER(gp, NULL);

五、Call RCU 与 Softirq 回调机制

call_rcu() 的内部实现非常精巧。它将回调函数挂入 per-CPU 的回调链表,由 RCU 核心(kernel/rcu/)在合适的时机执行。Linux 内核提供两种执行模型:

  • Softirq 上下文(默认):回调在 rcu_softirq 中执行,速度快但可能阻塞后续回调
  • 内核线程 (krcud)(使用 call_rcu[_bh/_sched]):在专用内核线程中执行回调,允许睡眠,适用于可能阻塞的回收逻辑
// 不同上下文的 call_rcu
call_rcu(head, func);           // 默认 Softirq 方式
call_rcu_bh(head, func);        // 禁用软中断的回调链
call_rcu_sched(head, func);     // Sched 上下文链
kfree_rcu(head, offset);        // 直接在内联的 head 后偏移字段释放(Linux 4.0+)

kfree_rcu() 是最常用的快捷方式,它会自动从结构体内的 rcu_head 偏移计算出基地址并调用 kfree(),比单独定义回调函数简便得多:

struct my_struct {
    int data;
    struct rcu_head rcu;  // 内嵌的 RCU head
};

// 回收时:
kfree_rcu(old_p, rcu);  // 等价于 call_rcu(&old_p->rcu, kfree_callback)

六、SRCU:可睡眠版 RCU

SRCU(Sleepable RCU)允许在 RCU 读端临界区内睡眠,代价是读端有原子操作(每个 CPU 计数器增减)的开销,不适合高频读取场景。适用于读端需要执行块 IO 、等待锁、或可能触发缺页的场景。

// SRCU 接口
int srcu_read_lock(struct srcu_struct *ssp);
void srcu_read_unlock(struct srcu_struct *ssp, int idx);
void synchronize_srcu(struct srcu_struct *ssp);
void call_srcu(struct srcu_struct *ssp, struct rcu_head *rhp, func);

// 典型使用:per-CPU 或模块全局
static DEFINE_SRCU(my_srcu);

// 读端(可以睡眠)
idx = srcu_read_limit(&my_srcu);
p = rcu_dereference(gp);
might_sleep();
process(p);
srcu_read_unlock(&my_srcu, idx);

七、RCU 在内核中的真实使用场景

RCU 在 Linux 内核中使用极其广泛,几乎无处不在:

  1. 路由子系统:FIB(Forwarding Information Base)、邻居表、路由缓存的读端几乎全部使用 RCU 保护
  2. 文件系统与 VFS:inode 链表、super_block 链表、mount 表的遍历
  3. 进程管理:PID 哈希表、task_struct 共享部分
  4. 模块与驱动:设备驱动模型、class、bus 上的驱动链表
  5. 网络协议栈:TCP/UDP 哈希表 (ehash)、PACM 表
  6. 内存管理:VMA 的红黑树(通过 mmap_lock 配合 RCU)
  7. cgroup 子系统:cgroup 级别的资源计数与配置

八、性能分析与选型指南

RCU 的性能优势在于零读竞争,但其代价是:

  • 写端频率受限:call_rcu 的回调链路可能积压
  • 宽限期延迟:回收需要等待所有 CPU 经过 QS,无法立即释放
  • 内核配置影响:CONFIG_PREEMPT 越高,GP 检测的 softirq 开销越大

选型决策树:

读取频率远高于写入? → RCU 是最佳选择
读写频率相当? → 考虑 rwlock 或 seqlock
写入频率高于读取? → spinlock + list 更简单
需要在读端睡眠? → 考虑 SRCU 或 mutex
需要 O(1) 确定性延迟? → 考虑 Hazard Pointer(用户态)或 RCU-like

九、RCU Stall 诊断与排错

RCU Stall 是内核中较难诊断的问题之一。dmesg 中常见的 RCU stall 消息格式:

INFO: rcu_sched self-detected stall on CPU
 rcu_sched: 
        0: c1893110 ....  (t=2500 jiffies g=18446744073709 f=0)
  (detected by 3, t=2501 jiffies)
常见原因:
  • CPU 在 RCU 读端临界区内死循环或长时间执行
  • CPU 在 RCU 读端内睡眠(违反了 RCU 契约)
  • Softirq 被长时间禁止(BH 抢占)
  • CPU 热插拔或 offline 状态下的 timeout
诊断方法:
  • 使用 ftrace 的 rcu: event 跟踪 GP 进度
  • trace_printk() 在被怀疑的临界区标记时间戳
  • /sys/kernel/debug/rcu/ 查看各 CPU 状态
  • stall_timeout 参数调整 stall 容忍时间

十、小结

RCU 是 Linux 内核中最优雅且实用的同步原语之一。它用"延迟回收"换取了"零竞态读取",特别适合读多写少的场景。正确使用 RCU 需要遵守其基本契约:

  1. 读端临界区内不睡眠、不阻塞
  2. 用 rcu_dereference() 安全读取指针
  3. 用 rcu_assign_pointer() 发布新版本
  4. 用 call_rcu() 或 kfree_rcu() 安全回收
  5. 调试时使用 ftrace 跟踪 GP 行为

掌握 RCU 不仅帮助理解 Linux 内核的数万处 RCU 调用点,更能在用户态并发数据结构设计中获得灵感——例如 Java 的 ConcurrentHashMap、用户态无锁数据结构库借鉴了 RCU 的核心思想。在极端性能要求下,合理选择 RCU 可以带来 10 倍甚至 100 倍于传统 rwlock 的读吞吐。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部