Linux 内核 RCU 深度实战:从宽限期检测到无锁读端的工程艺术

在 Linux 内核中,读多写少(read-mostly)场景的性能瓶颈始终是系统级编程的核心挑战。传统的读写锁(rwlock)在读者远多于写者的情况下,虽然允许并发读取,但写者获取锁时仍需等待所有读者完成,且缓存行 bouncing 问题严重。RCU(Read-Copy-Update)的出现彻底改变了这一范式——它让读端完全无锁(甚至不在内存中写入任何标记),而写者通过"复制-更新-回收"三阶段策略,将同步代价转移到写者侧。

本文将从 RCU 的核心设计理念出发,逐层剖析 Classic RCU、Tree RCU、Sleepable RCU(SRCU)三种实现变体,结合 Linux 内核源码(kernel/rcu/ 目录)深入讲解宽限期(Grace Period)检测机制、Quiescent State 追踪、call_rcu 回调调度、以及内存屏障(Memory Barrier)在无锁读端正确性中的决定性作用。最后通过 dns_cache、VFS dentry cache、模块卸载等真实内核子系统的 RCU 使用案例,展示如何将 RCU 应用于你的内核模块与驱动开发。

第一部分:RCU 的核心设计哲学

1.1 为什么需要 RCU

考虑一个典型的读多写少场景:Linux 内核的路由缓存(dst_cache)被每个数据包转发路径高频读取,但路由表更新(写操作)相对罕见。若使用读写自旋锁(rwlock_t):

// 读写锁方案的问题
rwlock_t route_lock;

// 读端(频繁路径)
read_lock(&route_lock);
// 访问路由缓存...
read_unlock(&route_lock);

// 写端(偶尔更新)
write_lock(&route_lock);
// 修改路由表...
write_unlock(&route_lock);

问题在于:(1) read_lock 必须执行原子操作(如 lock addl),在 NUMA 系统中引发缓存行 bouncing;(2) 即使99.99%的操作是读取,写端的 read_lock 仍然会在所有 CPU 的缓存同步上产生延迟;(3) 读端不能在中断上下文或持有自旋锁时使用(可能导致死锁)。

RCU 的核心洞察是:读端不需要互斥——只需要保证在访问期间对象不被释放。写者通过"复制修改 + 原子替换指针 + 延迟回收"三个步骤,让读端始终看到一致性状态(旧状态或新状态,但不会是中间状态)。

1.2 RCU 三阶段协议

RCU 写端操作分为三个明确的阶段:

阶段1: 复制(Copy)——分配新对象,从旧对象复制数据
阶段2: 更新(Update)——在新对象上应用修改
阶段3: 发布(Publish)——用新对象指针原子替换全局指针

步骤示例(链表节点替换):
struct node *new_node = kmalloc(sizeof(*new_node), GFP_KERNEL);
*new_node = *old_node;       // 复制
new_node->value = 42;         // 更新
new_node->next = old_node->next;
rcu_assign_pointer(head->next, new_node);  // 发布(带内存屏障)

// 延迟回收旧节点
call_rcu(&old_node->rcu_head, node_reclaim_callback);

关键点是 rcu_assign_pointer() ——它是一个带内存屏障的指针赋值宏,确保新对象的所有字段在全局指针可见之前已经初始化。对应的读端使用 rcu_dereference() 来安全地读取指针。

1.3 读端原语:rcu_read_lock / rcu_read_unlock

RCU 读端只需要标记一个"读端临界区"(read-side critical section):

// RCU 读端(完全无锁!没有原子操作,没有缓存行 bouncing)
rcu_read_lock();
struct node *p = rcu_dereference(head);
while (p != NULL) {
    printk("value=%d\n", p->value);
    p = rcu_dereference(p->next);
}
rcu_read_unlock();

在可抢占 RCU 配置下,rcu_read_lock() 仅禁止内核抢占(preempt_disable),rcu_read_unlock() 重新启用抢占(preempt_enable)。在不可抢占配置下,它们甚至可以是空操作(因为非抢占调度天然提供 Quiescent State)。这意味着在服务器级 Linux 配置(CONFIG_PREEMPT_NONE)中,RCU 读端的开销是零。

第二部分:宽限期机制与 Quiescent State

2.1 宽限期(Grace Period)的定义

宽限期是 RCU 正确性的核心概念。定义如下:

一个宽限期是一段时间段,在此期间,所有在宽限期开始前就已开始的 RCU 读端临界区都已结束。宽限期结束后,可以安全释放旧版本对象。

关键点:RCU 不关心读端何时开始——只关心在宽限期开始前已经开始的读端是否已经结束。新的读端(在宽限期开始后才开始的)可以自由访问新对象。

2.2 Quiescent State(静止状态)

每个 CPU 通过报告"Quiescent State"(静止状态)来声明:我当前不在任何 RCU 读端临界区中。常见的 Quiescent State 触发条件包括:

  • 上下文切换(context switch)——CPU 必然不在 RCU 读端临界区
  • 用户态执行——内核不在 RCU 读端临界区
  • 空闲循环(idle loop)——不在 RCU 读端临界区
  • 显式调用 rcu_quiescent_state()

Tree RCU 的核心优化是分层归约:每 N 个 CPU 的 Quiescent State 在内核红黑树的叶节点合并,逐层向上直到根节点报告所有 CPU 都经过了 Quiescent State,宽限期即告结束。这使得 RCU 子系统可以支持成千上万个 CPU(如大型 NUMA 系统),而无需全局原子操作。

2.3 宽限期状态机

RCU 宽限期状态机:

GP_START → GP_WAIT_CPU → GP_DONE

GP_START:   发起宽限期(call_rcu 累积到批次后触发)
GP_WAIT_CPU: 等待所有 CPU 报告 Quiescent State
GP_DONE:    宽限期结束,调度回调函数执行内存释放

// kernel/rcu/tree.c
void rcu_gp_init(struct rcu_state *rsp)
{
    // 切换到新宽限期
    // 对所有 CPU 设置 gp_flags 要求报告 QS
}

// 每个 CPU 在 Quiescent State 时调用
void rcu_report_qs_rdp(struct rcu_data *rdp)
{
    // 逐层向上归约
    // 当所有 CPU 都报告QS,触发 GP_DONE
}

第三部分:Linux 内核 RCU 实现变体

3.1 Classic RCU(CONFIG_PREEMPT_RCU=n)

最原始的 RCU 实现,适用于不可抢占内核。读端仅需禁用抢占(或什么都不做)。缺点是读端临界区不能睡眠或阻塞,否则会无限期延迟宽限期。

3.2 Tree RCU(CONFIG_PREEMPT_RCU=y,默认配置)

现代 Linux 内核(x86_64/ARM64)的标准实现。核心数据结构:

// kernel/rcu/tree.h
struct rcu_node {
    raw_spinlock_t __private lock;     // 节点锁
    unsigned long gpnum;               // 本节点当前宽限期号
    unsigned long completed;            // 本节点最近完成的宽限期号
    unsigned long qsmask;               // CPU Quiescent State 位掩码
    struct rcu_node *parent;           // 父节点
    // ... (构成树形层级结构)
};

struct rcu_data {
    unsigned long gpnum;              // CPU 已知的最新宽限期号
    unsigned long rcu_qs_ctr_snap;    // 上次报告的QS计数
    bool qs_pending;                  // 有待报告的QS
    // ...
} ____cacheline_internodealigned_in_smp;

Tree RCU 将 CPU 组织为多层的 rcu_node 树。每个叶节点管理一个小型 CPU 组(通常 4-16 个 CPU),内部节点逐层归约子节点状态。当根节点的 qsmask 清零时,宽限期完成。这种层级结构在 4096 CPU 系统中只需要 log₄(4096) = 6 层树。

3.3 Sleepable RCU(SRCU — Sleepable Read-Copy-Update)

SRCU 允许在 RCU 读端临界区内睡眠、调度或调用可能阻塞的 API。这是 Classic RCU 的严格超集——代价是每个 SRCU 结构维护独立的阻塞状态追踪:

// SRCU 使用示例(适用于可能睡眠的读端路径)
struct my_srcu {
    struct srcu_struct ss;
};

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); // 同步等待宽限期

// 典型使用场景:Binder 驱动中的进程查找
static int binder_thread_write(...) {
    idx = srcu_read_lock(&binder_proc_srcu);
    proc = find_proc_by_pid_under_rcu(pid);
    // proc 在 srcu_read_unlock 前始终有效
    srcu_read_unlock(&binder_proc_srcu, idx);
}

SRCU 的实现使用 per-CPU 计数器数组和原子累加策略来追踪读写并发,比 Classic RCU 更重,但允许读端在任意上下文(包括可以睡眠的上下文)中执行。

3.4 Reclaimable RCU(ReclRCU / RCU-Tasks)

RCU-Tasks 是专门针对任务级(而非中断级别)的变体,允许在追踪点(如上下文切换)以外的任意位置检查 Quiescent State。主要用于 TASKS_RCU、TASKS_RUDE_RCU 和 TASKS_TRACE_RCU 场景(BPF 程序和 tracing 基础设施依赖此变体)。

第四部分:call_rcu 回调机制与内存释放

4.1 call_rcu 的工作原理

当写者需要释放旧版本对象时,不直接 kfree(),而是通过 call_rcu() 注册一个回调函数:

// 基本 API
void call_rcu(struct head *head, rcu_callback_t func);

// 示例:释放旧路由缓存条目
static void dst_entry_reclaim(struct head *head)
{
    struct dst_entry *entry = container_of(head, struct dst_entry, rcu);
    kmem_cache_free(dstp_cachep, entry);
}

// 写端
struct dst_entry *new_dst = dst_alloc(dst_ops);
// 复制并修改...
rcu_assign_pointer(rt->dst, new_dst);
call_rcu(&old_dst->rcu, dst_entry_reclaim);  // 延迟释放

4.2 回调批处理与 kthread 调度

call_rcu() 将回调累加到 per-CPU 批次(rcu_data->nxtlist)。两种触发条件将批次提交到宽限期等待队列:

  • 批次满:累计达到 DEFAULT_MAX_RCU_QUEUE_ENTRIES(通常为 10,000)个回调
  • 定时触发:jiffies 周期回调(rcu_check_callbacks)检测到有待处理项

宽限期结束后,RCU 内核线程(rcu_gp、rcu_par_gp、rcu_preempt)调度回调在实际执行上下文(进程上下文,而非软中断)中执行。这种解耦确保回调执行不会阻塞实时任务路径。

4.3 kfree_rcu:释放内存的简化接口

Linux 4.0+ 引入的 kfree_rcu() 是 call_rcu 的简化封装,专门用于释放动态分配的内存:

// 自动推断结构体中 rcu_head 字段位置
void kfree_rcu(struct head *ptr, rcu_callback_t off);

// 示例
struct config *old_cfg = global_cfg;
struct config *new_cfg = kmalloc(sizeof(*new_cfg), GFP_KERNEL);
memcpy(new_cfg, old_cfg, sizeof(*config));
new_cfg->threshold = new_threshold;
rcu_assign_pointer(global_cfg, new_cfg);
kfree_rcu(old_cfg, rcu);  // rcu 是 struct config 中 head 字段名

第五部分:内存屏障与无锁读端的正确性

5.1 为什么读端需要内存屏障

考虑以下场景:CPU 0(写者)执行 rcu_assign_pointer(gp, new_val) 的同时,CPU 1(读者)执行 rcu_dereference(gp)。如果没有内存屏障,处理器可能会重排内存访问,导致读者读到更新后的指针但旧的数据字段。

rcu_assign_pointer() 在 x86 上编译为 asm volatile("mov %1, %0" : "=m"(*p) : "r"(v) : "memory")(编译器屏障即足够,因为 x86 的 TSO 模型不允许 Store-Load 重排序),在 ARM/ARM64 上编译为 dmb ish(数据内存屏障指令,确保 Store 完成后 Load 才能执行)。

5.2 READ_ONCE / WRITE_ONCE 与 RCU 的关系

RCU 读端的原语依赖 READ_ONCE() 来确保编译器不会将指针读取优化多次(否则第二次读取可能因 GC 导致 use-after-free)。rcu_dereference() 实际上是 READ_ONCE() + 内存屏障的组合:

// include/linux/rcupdate.h
#define rcu_dereference(p) \
    rcu_dereference_check(p, rcu_read_lock_held())

#define rcu_dereference_check(p, c) \
    __rcu_dereference_check((p), (c) || rcu_lockdep_is_held(&rcu_lock_map), __rcu)

static inline void *__rcu_dereference_check(void *p, bool c, const char *cfun)
{
    if (c)
        lockdep_rcu_suspicious(cfile, cline, cfun);
    return smp_load_acquire(&p);  // 加载-获取语义
}

5.3 Store-Buffer 问题与 StoreStore 屏障

在弱内存模型(如 ARM/PowerPC)上,Store 操作可能进入 Store Buffer 而对其他核心不可见。如果写者在更新新对象的字段后立即发布指针,其他核心可能看到新指针但旧字段。rcu_assign_pointer() 使用 StoreStore(smp_wmb())确保:

执行顺序保证:
[新对象字段写入] → [StoreStore Barrier] → [全局指针更新]

读端对应地:
[读取全局指针] → [LoadLoad Barrier] → [读取新对象字段]

第六部分:RCU 在内核子系统中的实战

6.1 VFS dentry Cache(dcache)

Linux VFS 的目录项缓存(dentry cache)是 RCU 最广为人知的用例之一。路径查找(path walk)的最后组件使用 rcu-walk 模式——在 RCU 读端临界区内遍历 dentry 的哈希链表:

// fs/namei.c
struct dentry *lookup_fast(struct nameidata *nd, struct inode **inode)
{
    struct dentry *dentry = __d_lookup_rcu(parent, &nd->last, &seq);
    if (!dentry)
        return ERR_PTR(-ECHILD);  // 降级到 ref-walk
    // dentry 在 rcu_read_lock 期间有效
    return dentry;
}

rcu-walk 的优势:路径查找的最后组件完全不需要原子操作、不需要引用计数增减、不需要自旋锁。只有当 RCU 遍历发现 dentry 状态变化(通过 seqcount 检测到并发重命名/删除)时,才降级到传统的 ref-walk(获取引用计数)。

6.2 dns_resolver / keyring

Linux 内核的 DNS 解析器缓存使用 call_rcu() 来安全释放过期的 DNS 查询结果。读者在 RCU 读端保护下读取缓存条目,写者在更新后通过 call_rcu() 调度旧条目的 kfree:

// net/dns_resolver.c
static void dns_resolver_free(struct rcu_head *rcu)
{
    struct dns_resolver *dns = container_of(rcu, struct dns_resolver, rcu);
    kfree(dns->payload);
    kfree(dns);
}

void dns_resolver_update(struct dns_resolver *new_entry)
{
    struct dns_resolver *old = rcu_access_pointer(cache_entry);
    rcu_assign_pointer(cache_entry, new_entry);
    if (old)
        call_rcu(&old->rcu, dns_resolver_free);
}

6.3 模块卸载与模块参数

内核模块卸载是 RCU 最严格的使用场景:模块代码本身不能被释放,直到所有正在执行的模块代码路径退出。MODULE_STATE_LIVE → MODULE_STATE_GOING 的转换依赖 synchronize_rcu() 等待所有引用退出:

模块参数通过 RCU 暴露给用户空间(sysfs)。读取时由 RCU 保护防止在读取过程中模块被卸载而触发 use-after-free。

6.4 BPF 与 TASKS_TRACE_RCU

BPF 程序的 tracing 附件点(kprobe、tracepoint)依赖 TASKS_TRACE_RCU 来安全访问被追踪的数据结构。BPF 验证器会自动在追踪函数周围插入 rcu_read_lock_trace()/rcu_read_unlock_trace(),确保 BPF 程序在 RCU 读端保护下访问内核数据结构。

第七部分:实战——在你的内核模块中正确使用 RCU

7.1 基本 RCU Hash Table

#include <linux/rcupdate.h>
#include <linux/slab.h>
#include <linux/hashtable.h>

struct my_hash_node {
    struct hlist_node hlist;
    struct rcu_head rcu;
    int key;
    int value;
};

DECLARE_HASHTABLE(my_table, 8);

// 读端(无锁并发)
int hash_lookup(int key)
{
    struct hash_node *node;
    unsigned int hash = hash_min(key, HASH_BITS(my_table));
    
    rcu_read_lock();
    hlist_for_each_entry_rcu(node, &my_table[hash], hlist) {
        if (node->key == key) {
            int val = node->value;
            rcu_read_unlock();
            return val;
        }
    }
    rcu_read_unlock();
    return -ENOENT;
}

// 写端(同步更新)
void hash_update(int key, int value)
{
    struct hash_node *new_node = kmalloc(sizeof(*new_node), GFP_KERNEL);
    new_node->key = key;
    new_node->value = value;
    
    // 查找旧节点
    struct hash_node *old = hash_lookup_full(key);
    hash_add_rcu(my_table, &new_node->hlist, hash_min(key, HASH_BITS(my_table)));
    
    if (old)
        kfree_rcu(old, rcu);
}

// 销毁 hash table
void hash_destroy(void)
{
    struct hash_node *node;
    struct hlist_node *tmp;
    int bkt;
    
    hash_for_each_safe(my_table, bkt, tmp, node, hlist) {
        hash_del_rcu(&node->hlist);
        kfree_rcu(node, rcu);
    }
    // 所有 kfree_rcu 回调会在宽限期后执行
}

7.2 常见陷阱与调试技巧

使用 RCU 时最常见的错误:

  1. 在 rcu_read_lock 之外调用 rcu_dereference:指针可能在 rcu_read_lock 之前就被回收
  2. 在 rcu_read_lock 期间睡眠:违反 RCU 约束,导致无限期宽限期延迟
  3. 忘记调用 synchronize_rcu 就 kfree:可能还有读端在访问该内存,触发 use-after-free
  4. rcu_dereference 结果被缓存后解锁:指针仅在 rcu_read_lock 期间有效,解锁后访问是 use-after-free
  5. 嵌套的 call_rcu 回调链导致宽限期堆积:大批量更新时应考虑 throttole 或分批

调试工具:

  • CONFIG_PROVE_RCU:lockdep 扩展,静态检查 RCU API 的正确使用
  • CONFIG_RCU_STALL_COMMON:检测宽长于预期的宽 Stall)
  • rcu_sched_clock_stall:检测到宽限期超时时打印所有 CPU 状态

7.3 性能测量

使用 RCU 的性能收益(对比读写锁,80 核 x86 服务器,95% 读/5% 写):

  • 读写锁(rwlock):读端 ~150ns/次(原子操作 + 缓存行 bouncing)
  • Classic RCU:读端 ~0ns/次(无标记写操作,仅 preempt_disable)
  • 调用 call_rcu 释放:~1-10μs(宽限期等待时间)

RCU 在读端几乎无代价的理论优势,在大型 NUMA 系统中尤其显著。

第八部分:RCU 的最新演进

8.1 RCU-tasks rude(TASKS_RUDE_RCU)

Linux 6.x 引入的"粗粒度" RCU-Tasks 变体,用于需要极轻量级读端但不需要严格宽限期保证的场景(如 BPF task iterator)。它通过检测 voluntary context switch 来触发 Quiescent State,而不需要完整的调度器集成。

8.2 高性能 call_rcu 实现

Linux 6.4+ 重新设计了 call_rcu() 的回调调度,引入 lazy call_rcu 模式,将同类型回调批量合并,减少了宽限期触发频率,在处理每秒数百万次回调的高频场景(如网络数据包处理)中显著降低 CPU 开销。

8.3 可睡眠 SRCU 的改进

内核持续提升 SRCU 在大规模系统上的可扩展性:per-CPU 计数器改为 per-CPU 分片计数器、区块化宽限期检测、以及静态 SRCU 结构(DEFINE_STATIC_SRCU)减少运行时初始化开销。

总结

RCU 是 Linux 内核中最优雅且强大的同步原语之一。它的核心洞察——将同步代价从读端转移到写端——重新定义了读多写少场景的性能上限。理解宽限期机制、掌握 rcu_read_lock/call_rcu/synchronize_rcu 的 API 边界、避免在 RCU 读端中阻塞,是编写高性能内核代码的关键能力。

无论是 TCP/IP 协议栈的路由缓存、VFS 的 dentry 遍历,还是 BPF 程序的 tracing 路径,RCU 都在幕后默默提供着零开销的读端保护。当你需要在自己的内核模块或驱动中实现高频读低并发写的数据结构时,RCU 应是你考虑的第一选项。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部