Linux 内核 RCU (Read-Copy-Update) 深度工程实战:从宽限期机制到生产级无锁设计

引言:为什么需要 RCU

在 Linux 内核的并发工具箱中,读写锁(rwlock)和顺序锁(seqlock)为读多写少的场景提供了解决方案,但它们各有致命缺陷:读写锁的写者会阻塞所有读者,在中断上下文中无法使用;顺序锁的读者必须重试,在写入频繁时性能急剧退化。

RCU (Read-Copy-Update) 是 Linux 内核中一个独特且精妙的同步机制,它允许多个读者完全不加锁地并发访问共享数据,而写者通过"复制-更新-延迟回收"的方式完成修改。在 6.x 内核中,RCU 相关的调用超过 60,000 次,几乎支撑了文件系统、网络栈、调度器、内存管理等核心子系统的性能关键路径。

本文将从 RCU 的基本语义出发,深入剖析宽限期(Grace Period)的底层实现、回调批处理机制、可抢占 RCU 的设计取舍,并结合内核真实代码给出生产级 RCU 编程范式与常见陷阱。

1. RCU 的三段式语义

1.1 核心 API 全景

RCU 的编程模型异常简洁,只有三个核心原语:

// 读者侧:标记读侧临界区
rcu_read_lock();
p = rcu_dereference(ptr);  // 获取受 RCU 保护的指针
/* 访问 *p ... */
rcu_read_unlock();

// 写者侧:更新受 RCU 保护的指针
new_ptr = kmalloc(...);
new_ptr->data = new_value;
rcu_assign_pointer(ptr, new_ptr);  // 原子替换
synchronize_rcu();                 // 等待所有现有读者退出
kfree(old_ptr);                    // 安全回收旧数据

1.2 rcu_read_lock/unlock 的真实代价

在经典配置(!CONFIG_PREEMPT)下,rcu_read_lock() 和 rcu_read_unlock() 是零开销的空操作——它们不修改任何内存,不执行任何屏障。读者侧的唯一成本来自 rcu_dereference() 插入的编译器屏障(compiler barrier),防止 CPU/编译器重排导致获取到旧的指针后又读取到旧数据。

在可抢占 RCU(CONFIG_PREEMPT_RCU)下,rcu_read_lock() 会增加当前任务的嵌套计数(current->rcu_read_lock_nesting),这使得调度器知道当前任务正处于读侧临界区,不会将其移出运行队列。

1.3 rcu_assign_pointer 的内存序保证

rcu_assign_pointer() 不仅仅是赋值——它保证:

  1. 所有对 new 的初始化写入在读侧可见之前完成(StoreStore 屏障)
  2. 指针替换是原子的(对 aligned pointer 天然原子,但语义上仍有 smp_wmb())
// include/linux/rcupdate.h
#define rcu_assign_pointer(p, v) \
    smp_store_release(&p, RCU_INITIALIZER(v))

这里使用 smp_store_release() 而非 smp_wmb(),因为现代 ARM/RISC-V 等弱内存模型架构需要 release store 语义来保证全局可见性。

2. 宽限期 (Grace Period) 的实现机制

2.1 什么是宽限期

宽限期是 RCU 的核心概念:从写者替换指针开始,到所有在替换前进入读侧临界区的读者都退出为止的时间段。宽限期结束后,写者可以安全回收旧数据。

关键性质:宽限期只关心"在替换前就已存在的读者",替换后进入的新读者看到的是新数据,因此无需等待。

2.2 静默状态 (Quiescent State) 检测

内核如何知道某个 CPU 上没有 RCU 读者?答案是通过静默状态检测:如果一个 CPU 经历了以下任一事件,它就处于 RCU 静默状态:

  • 执行了上下文切换(说明不再在 rcu_read_lock/unlock 之间)
  • 进入 idle 循环
  • 从用户态返回内核(在 USER_RCU 配置下)

内核为每个 CPU 维护一个 rcu_data 结构:

struct rcu_data {
    unsigned long gp_seq;        // 当前 GP 的序列号
    unsigned long rcu_qs_ctr_snap; // 快照时的 QS 计数器
    bool core_needs_qs;          // 是否需要报告 QS
    ...
    struct rcu_node *mynode;     // 指向 rcu_node 树中的节点
} ____cacheline_internodealigned_in_smp;

2.3 层级式 RCU 节点树

在大规模 NUMA 系统(数百个 CPU)上,每个 CPU 独立向全局变量报告静默状态会导致严重的缓存行弹跳(cache-line bouncing)。RCU 通过层级节点树解决此问题:

struct rcu_node {
    raw_spinlock_t lock;           // 节点锁
    unsigned long qsmask;          // 子节点 QS 位掩码
    unsigned long qsmaskinit;      // 初始 QS 掩码
    struct rcu_node *parent;       // 父节点
    ...
    unsigned long grpmask;         // 兄弟节点中的位掩码
    int grplo;                     // 该节点管的 CPU 起始编号
    int grphi;                     // 该节点管的 CPU 结束编号
};

叶子节点代表单个 CPU(或小批量 CPU),内部节点聚合子节点的 QS 状态。当一个节点的所有子节点都报告了静默状态时,该节点向父节点报告。最终根节点得知所有 CPU 都经历了至少一次静默状态时,宣告宽限期结束。

2.4 GP 状态机

RCU 使用一个精巧的状态机来管理宽限期:

enum rcu_state {
    RCU_STATE_IDLE = 0,      // 空闲状态
    RCU_STATE_WAIT_GPS,      // 等待 GP 开始确认
    RCU_STATE_GP,            // GP 进行中
    RCU_STATE_WAIT_FQS,      // 等待 FQS (Force QS) 循环
};

// GP 的序列号采用格雷码风格递增
// 每个 CPU 的 rcu_data->gp_seq 记录它已完成的最后一个 GP

内核启动一个 rcu_gp_kthread 内核线程来驱动状态机:当有写者调用 synchronize_rcu() 时,该线程推进 GP 状态,启动静默状态检测,并在所有 CPU 完成后通知等待者。

2.5 回调机制:call_rcu 的延迟回收

synchronize_rcu() 会阻塞写者直到宽限期结束。对于不能阻塞的上下文(如中断、软中断),内核提供 call_rcu():注册一个回调,在宽限期结束后异步执行。

call_rcu(&old_node->rcu_head, my_callback_func);
// 回调签名
void my_callback(struct rcu_head *head) {
    struct my_node *node = container_of(head, struct my_node, rcu_head);
    kfree(node);
}

内核维护一个 rcu_cblist(回调链表),使用四缓冲队列(4-bucket)设计实现高效的批处理:

struct rcu_cblist {
    struct rcu_head *head;
    struct rcu_head **tail;
    long len;              // 待处理回调计数
};

// 每个 rcu_data 有 4 个回调链表:
// [RCU_WAIT_TAIL]    - 等待当前 GP 完成
// [RCU_NEXT_READY_TAIL] - 当前 GP 已完成,等待下一个 GP
// [RCU_NEXT_TAIL]    - 已绑定到下一个待启动 GP
// [ DONE_TAIL ]       // 已完成,可以回调

3. 三种 RCU 变体

内核同时维护三种 RCU 域,阻塞其中一个域的读者不影响其他域的读者:

变体读者 API使用场景静默状态定义
rcu_schedrcu_read_lock()/unlock()通用内核代码上下文切换或进入 idle
rcu_bhrcu_read_lock_bh()/unlock_bh()下半部上下文无 BH 执行(非 softirq)
rcu_preemptrcu_read_lock()/unlock() (PREEMPT_RCU)实时代码上下文切换或进入 idle

选择原则:

  • 普通内核代码使用 rcu_read_lock(),在 PREEMPT_RCU 下映射到 rcu_preempt,否则映射到 rcu_sched
  • 在 softirq 或 tasklet 中使用 rcu_read_lock_bh(),synchronize_rcu_bh()(因为 synchronize_rcu() 不需要等待 BH 型读者,而 synchronize_rcu_bh() 需要等待 sched 型读者的静默状态)
  • 需要不允许读者被抢占的场景使用 synchronize_rcu_tasks(),等待所有任务通过用户态/syscall 退出临界区

4. 内核真实案例解析

4.1 文件描述符表的 RCU 保护

task_struct->files->fdt(文件描述符表)是 RCU 最经典的用例。fork 时共享文件表,写者(close_on_exec/扩展表)通过 RCU 替换实现无锁读取:

// fs/file.c
struct files_struct *get_files_struct(struct task_struct *task)
{
    struct files_struct *files;

    rcu_read_lock();
    files = task->files;        // RCU 保护读取
    if (files)
        atomic_inc(&files->count);
    rcu_read_unlock();

    return files;
}

// 写者侧:替换 fdt
expand_fdtable(files, nr) {
    new_fdt = alloc_fdtable(nr);
    copy(old_fdt, new_fdt);
    rcu_assign_pointer(files->fdt, new_fdt);
    call_rcu(&old_fdt->rcu, free_fdtable_rcu);
}

4.2 VFS 路径查找的 RCU 模式

Linux VDCACHE(DCACHE 中的 RCU-walk 模式)使用 RCU 加速路径查找:在大多数情况下不需要持有 dcache_lock,通过 rcu_read_lock() 即可遍历 dentries:

// fs/namei.c - rcu-walk 片段
static int lookup_fast(struct nameidata *nd, struct inode **inode)
{
    struct dentry *dentry;

    if (nd->flags & LOOKUP_RCU) {  // RCU 模式
        dentry = __d_lookup_rcu(parent, &nd->last, &seq);
        // 验证序列号(seqcount_latch)检测并发重命名
        if (!dentry || unlikely(...))
            return -EAGAIN; // 回退到 ref-walk
    }
}

当 RCU-walk 发现需要处理重命名、挂载点跨越等复杂情况时,它会返回 -EAGAIN 让调用者降级到传统的 ref-walk 模式(持有 dentry 引用和锁)。

4.3 网络协议栈:TCP 哈希表

内核 TCP 连接哈希表(tcp_hashinfo)使用 RCU 保护。查找 TCP socket 时走 RCU 路径:

// net/ipv4/tcp_ipv4.c
struct sock *tcp_v4_lookup(struct sk_buff *skb)
{
    // RCU 读取 establ/bind/listening hash 表
    sk = __inet_lookup_established(net, hashinfo, ...);
    // 检查 sk 有效性:refcount_inc_not_zero
    if (sk && likely(refcount_inc_not_zero(&sk->sk_refcnt)))
        return sk;
    return NULL;
}

// 关闭连接:将 socket 从 RCU hash 中移除
inet_unhash(struct sock *sk) {
    // 移除指针
    rcu_assign_pointer(..., NULL);
    synchronize_net();  // 等效于 synchronize_rcu() + barrier
    // 现在可以安全释放 sk
}

4.4 进程PID查找

find_task_by_pid_ns() 使用 RCU 保护 task list。pid_hash 中的任务链表用 RCU 分配和释放。

5. Sleepable RCU (SRCU)

标准 RCU 要求读者侧不可睡眠——因为静默状态检测依赖"如果 CPU 发生上下文切换则不在临界区"的假设。如果读者可能阻塞在 mutex/内存分配等可睡眠操作中,需要使用 SRCU:

// SRCU 读者可以睡眠
idx = srcu_read_lock(&my_srcu);
/* 此处可以睡眠、调度、分配内存 */
p = rcu_dereference(ptr);
srcu_read_unlock(&my_srcu, idx);

// 写者侧
synchronize_srcu(&my_srcu);  // 等待宽限期
...

SRCU 的实现比标准 RCU 复杂得多:每个 CPU 维护独立的嵌套计数器(srcu_struct 数组形式),通过追踪每个 CPU 不再持有读侧引用来判定宽限期结束。由于读者的嵌套计数可能被调度器保存/恢复,SRCU 宽限期延迟显著高于标准 RCU。

SRCU 典型用途:notifier chains、BPF 程序链表、设备驱动中的可睡眠遍历。

6. 生产级 RCU 编程范式

6.1 单指针替换模式

// 最常见的模式:用 RCU 保护一个全局指针
static struct my_config * __rcu *g_config_ptr;

// 读取
struct my_config *config;
rcu_read_lock();
config = rcu_dereference(*g_config_ptr);
if (config) {
    // 使用 config
    val = config->some_field;
}
rcu_read_unlock();

// 更新
struct my_config *new_config = kmalloc(sizeof(*new_config), GFP_KERNEL);
*new_config = *old_config;  // 复制
new_config->some_field = new_val;
rcu_assign_pointer(*g_config_ptr, new_config);
synchronize_rcu();
kfree(old_config);

6.2 链表遍历模式

// hlist (哈希表链表)
struct my_node {
    struct hlist_node rcu_head; // 注意:hlist 原生支持
    int key;
    int value;
};

// 遍历 (读侧)
hlist_for_each_entry_rcu(pos, head, rcu_head) {
    if (pos->key == target) {
        return pos->value;
    }
}

// 添加节点
struct my_node *new = kmalloc(...);
hlist_add_head_rcu(&new->rcu_head, head);

// 删除节点
hlist_del_rcu(&node->rcu_head);
kfree_rcu(node, rcu_head);  // 6.x: 自动释放,无需手动 call_rcu

6.3 kfree_rcu (6.x 新特性)

Linux 6.x 引入 kfree_rcu(),将内核对象的 rcu_head 与 kfree 延迟绑定,简化了常见的"RCU 保护的单次分配对象"模式:

struct my_obj {
    struct rcu_head rcu;  // 必须是第一个或最后一个成员(6.1+)
    int data;
};

// 不再需要自定义 callback
kfree_rcu(obj, rcu);  // 等效于 call_rcu(&obj->rcu, kfree)

内部实现使用特殊的 slab cache(rcu_kfree_deferred)批量回收。

6.4 读侧嵌套与中断处理

RCU 读侧临界区可以嵌套且支持中断处理中追加加锁:

rcu_read_lock();
    p1 = rcu_dereference(gp1);
    do_something(p1);
    // 中断中也可以再 lock
    rcu_read_lock();  // 嵌套
    p2 = rcu_dereference(gp2);
    rcu_read_unlock();
rcu_read_unlock();

但注意:标准 RCU 读者不可睡眠。如果在 rcu_read_lock/unlock 之间调用可能睡眠的函数(如 copy_from_user、kmalloc(GFP_KERNEL)),宽限期可能无法结束 → 死锁。

7. 宽限期延迟的实测与调优

7.1 延迟来源

synchronize_rcu() 的延迟取决于:

  • 最慢 CPU 经历静默状态的时间
  • CPU 是否在长时间关闭抢占(preempt_disable)或中断(irqs_disabled)
  • CPU 是否在 NMI handler 中执行
  • rcu_gp 线程的调度延迟

典型延迟范围:

  • 空闲系统:1-5 ms
  • 高负载系统:10-30 ms
  • 有 CPU 繁忙自旋(关中断循环):秒级甚至触发 RCU stall 告警

7.2 关键参数调优


/sys/module/rcutree/parameters/rcu_normal=1        # 加速 GP 结束(小代价)
/sys/module/rcutree/parameters/rcu_normal_after_boot=1  # 启动后自动加速

# 内核启动参数
rcu_nocbs=0-3       # offload RCU callback 到其他 CPU(实时场景)
rcu_nocb_poll       #  polling 模式(替代 irq 唤醒,低延迟)
rcutree.kthread_prio=50  # GP 线程优先级(提高以减少延迟)

7.3 RCU Stall 检测

内核 RCU 监视器会检测超过 RCU_STALL_TIMEOUT(默认 21 秒,含 GP 环节)未完成的宽限期,触发 Oops 式警告。常见原因:

  • CPU 死锁在不持有锁的无限循环中(关中断自旋)
  • b) 读者持有读侧临界区长睡眠
    c) 系统因内存回收抖动长时间无响应

调试方法:

dmesg | grep -i "rcu"  # 查看 stall 详细信息
cat/sys/kernel/debug/rcu/*  # 查看每个 CPU 的 RCU 状态
trace-cmd record -e rcu:*  # 追踪 RCU 事件

8. RCU 与内存序

8.1 配对规则总结

写入侧读取侧保证
rcu_assign_pointer()rcu_dereference()写入在指针可见前对所有读者可见
smp_store_release(&p, v)smp_load_acquire(&p)等价配对
call_rcu() / kfree_rcu()rcu_read_unlock()回调执行前所有读侧临界区已退出

8.2 常见错误模式


// 错误 1:未使用 rcu_dereference
rcu_read_lock();
p = global_ptr;  // BUG! 编译器/CPU 可能重排
rcu_read_unlock();

// 错误 2:读侧临界区中睡眠
rcu_read_lock();
copy_from_user(buf, ubuf, len);  // BUG! 可能触发缺页睡眠
rcu_read_unlock();

// 错误 3:在 create_list 中直接 kfree
new = kmalloc(...);
// 其他 CPU 已经可能在遍历...
list_add_rcu(...);
synchronize_rcu();
kfree(old);  // OK: GP 完成后没有读者会看到 old

// 错误 4:second read 模式
rcu_read_lock();
p1 = rcu_dereference(gp);
v1 = p1->field;
/* 写者此处替换 gp 并调用 synchronize_rcu() */
p2 = rcu_dereference(gp);  // 可能与 p1 不同!
v2 = p2->field;
// v1 和 v2 来自不同对象,可能新旧混淆
rcu_read_unlock();

8.3 双重检查模式 (Second-Look Pattern)

如果需要保证两次看到的是同一个对象:

rcu_read_lock();
do {
    seq = read_seqcount_begin(&p->seq);
    p1 = rcu_dereference(gp);
    v1 = p1->field;
    p2 = rcu_dereference(gp);
    v2 = p2->field;
} while (read_seqcount_retry(&p->seq, seq));  // 检测并发重排
rcu_read_unlock();

9. RCU 实现的未来演进

9.1 可睡眠 RCU (splim rcupdate)

社区正探索允许 rcu_read_lock() 内部睡眠的机制,核心思路是:让读者在睡眠时将 RCU read ownership 传递给一个"代理"(类似 optimistic spinning),避免单纯依赖静默状态。这能消除 SRCU 与标准 RCU 之间的性能鸿沟。

9.2 BPF 与 RCU 的融合

BPF verifier 现在可以验证 bpf_rcu_read_lock/unlock,允许 BPF 程序安全地遍历受 RCU 保护的内核数据结构。这对安全监控和性能分析工具至关重要。

9.3 Tree RCU 的性能持续优化

  • Lazy RCU:将call_rcu() 的处理延后,减少 GP 结束延迟
  • Direct Invoke:当回调数量少时直接调用,避免 kthread 调度开销
  • SECU (Sleepable Expedited) SRCU:折中方案,允许读者睡眠但比 SRCU 更轻量

10. 调试与工具

10.1 lockdep-like 的 RCU 检查

内核 CONFIG_PROVE_RCU 开启后,lockdep 会验证以下不变量:

  • rcu_dereference() 必须在 RCU read-side critical section 内调用
  • synchronize_rcu() 不能在持有 lock 且该 lock 可能被 RCU 读者持有时调用(死锁风险)
  • BCP:不要从 RCU callback 中调用 synchronize_rcu()(GP 不能嵌套)

10.2 ftrace 追踪

# 追踪宽限期生命周期
echo 1 > /sys/kernel/debug/tracing/events/rcu/rcu_grace_period/enable
echo 1 > /sys/kernel/debug/tracing/events/rcu/rcu_callback/enable
cat /sys/kernel/debug/tracing/trace_pipe

# 追踪 stall 前状态
echo 1 > /sys/kernel/debug/tracing/events/rcu/rcu_stall_warning/enable

10.3 drgn 脚本分析

# drgn 脚本:查看当前 GP 状态
prog["rcu_state.gp_seq"]
prog["rcu_state.gp_start"]
# 查看某 CPU 的 RCU 数据
for cpu in for_each_online_cpu(prog):
    rcu_data = prog["rcu_data"](per_cpu_ptr, cpu)

总结

RCU 是 Linux 内核并发编程皇冠上的明珠——它优雅地实现了"读者零开销"与"写者延迟回收"的统一。正确使用的关键在于严格遵守 API 语义:读者不可睡眠,写者需要等待宽限期才能回收旧数据。现代内核为不同场景提供了标准 RCU、SRCU、Tasks RCU 等多种变体,配合 kfree_rcu()、list_entry_rcu() 等便利 API,使得在绝大多数读多写少场景中都能获得接近无锁的读取性能。

然而,RCU 编程的隐性陷阱(读侧睡眠、second-look 不一致、回调区内嵌套 GP)也要求开发者对底层机建有深入理解。配合 CONFIG_PROVE_RCU 和 ftrace,可以在开发阶段捕获绝大多数错误,避免在生产环境出现不可预测的数据损坏。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部