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 读端临界区内。

核心流程:

  1. RCU 核心维护全局 gpnum(当前目标宽限期编号)和 completed(已完成编号)
  2. 每个 CPU 维护 quiescent state 计数器,记录本 CPU 经历 context switch 的次数
  3. 所有 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)相对倍数
RCU2,1001.0x
rwlock5,8002.8x
seqlock3,4001.6x
mutex12,1005.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 内核源码分析,关键数据结构可能因内核版本微有差异。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }