Linux 内核 RCU 同步机制深度实战:从宽限期到无锁读路径的完整实现

引言

Read-Copy-Update(RCU)是 Linux 内核中最重要也最独特的同步机制之一。与传统的互斥锁、读写锁不同,RCU 实现了读端完全无锁(lockless)的并发访问,在读多写少的场景下提供了近乎零开销的读性能。从内核的路由表、文件系统 dentry 缓存,到进程 PID 映射、cgroup 子系统,RCU 几乎渗透到内核的每一个核心子系统。

本文将从 RCU 的基本思想出发,深入剖析宽限期(Grace Period)的实现原理、经典 API 的使用模式、内存序与编译器屏障的精确控制,并通过生产级案例展示如何在实际内核模块中正确运用 RCU。

1. RCU 的核心思想

1.1 读写矛盾的终极解法

传统同步机制的核心问题是:读者和写者不能同时访问共享数据。读写锁允许读者并发,但写者必须独占,且读者的读锁获取本身就有原子操作开销。

RCU 的突破在于将问题重新定义:读者永远不需要等待写者,写者通过"复制-替换-延迟释放"的方式实现更新。其核心策略是:

1) 读端:读者在临界区内可以自由读取 RCU 保护的指针,无需获取任何锁,不使用原子操作(除内存序保证外)。

2) 写端:写者并不原地修改数据,而是创建数据的一个副本,修改副本后,用一次性原子操作将全局指针指向新副本。

3) 回收:旧数据不能立即释放,必须等待所有已经存在的读者完成读取(即等待宽限期结束后),才能安全释放旧数据。

1.2 为什么 RCU 的读端可以完全无锁

RCU 读端之所以无锁,依赖于两个关键前提:

第一,指针赋值是原子的。在主流架构上,对齐的指针读写天然是原子的,读者要么读到旧指针,要么读到新指针,不会读到"半新半旧"的中间状态。

第二,数据结构的不可变性。在 RCU 保护的指针被替换之前,指向的数据不会被修改。读者通过旧指针访问的数据在整个读临界区内保持有效(即使写者已经替换了指针)。

基于这两个前提,读者只需要保证:进入临界区时记录当前上下文(rcu_read_lock),退出时通知系统(rcu_read_unlock),期间的数据访问不需要任何同步原语。

2. RCU API 详解与使用范式

2.1 读端原语

读端的核心 API 极其简洁:

// 进入 RCU 读临界区(禁止阻塞/睡眠)
rcu_read_lock();

// 将指针安全地解引用为 RCU 保护的指针
p = rcu_dereference(ptr);

// ... 使用 p 指向的数据 ...

// 退出 RCU 读临界区
rcu_read_unlock();

rcu_read_lock() 和 rcu_read_unlock() 本质上是抢占禁止/启用标记(在非抢占式内核中是空操作)。RCU 读临界区的关键约束是:绝对不能阻塞或睡眠。因为宽限期的检测依赖于每个 CPU 经过一次安静状态(quiescent state),如果读者睡眠,可能导致宽限期无限等待。

rcu_dereference() 不仅是类型转换,它还在弱内存序架构(如 ARM、PowerPC)上插入了必要的内存屏障,确保指针解引用的顺序性。在内联汇编层面,GCC 的 __attribute__((noderef)) 配合 barrier() 宏确保编译器不会将指针加载重排到 rcu_read_lock() 之前。

2.2 写端原语

写端的核心操作涉及指针替换和旧数据回收:

// 1. 创建新数据副本
struct item *new_item = kmalloc(sizeof(*new_item), GFP_KERNEL);
*new_item = *old_item;       // 复制旧数据
new_item->field = new_value; // 修改需要更新的字段

// 2. 原子替换全局指针(读者看到的是旧指针或新指针,不会是中间态)
rcu_assign_pointer(global_ptr, new_item);

// 3. 等待所有现存读者完成后,释放旧数据
synchronize_rcu();           // 同步等待宽限期
kfree(old_item);             // 安全释放

rcu_assign_pointer() 是使用 smp_wmb()(写内存屏障)确保新数据的初始化在指针发布之前完成。这对 DEC Alpha 等极端弱序架构至关重要。

synchronize_rcu() 是同步等待接口,它会阻塞当前上下文直到所有已经存在的 RCU 读者完成。这意味着 写者不能在 RCU 读临界区内调用 synchronize_rcu(),否则会造成死锁。

2.3 异步回收:call_rcu

在很多场景下,写者不能阻塞等待宽限期。call_rcu() 提供了异步回收机制:

// 注册回调函数,宽限期结束后自动执行
call_rcu(&old_item->rcu_head, my_callback);

// my_callback 在宽限期结束后被调用
void my_callback(struct rcu_head *head)
{
    struct item *old = container_of(head, struct item, rcu_head);
    kfree(old);
}

call_rcu() 将回调挂载到每-CPU 的待处理回调链表中。当某个 CPU 经过安静状态时,内核的 RCU 软中断(softirq)就会执行这些回调。这个机制使得写者可以在中断上下文等不能阻塞的场景中安全更新数据。

需要注意,大量并发的 call_rcu() 会产生回调积压。内核通过 rcutree 和 rcuoc (RCU offloaded)等机制来处理高负载场景。

3. 宽限期(Grace Period)的实现原理

3.1 安静状态检测算法

宽限期的本质是确认"所有在宽限期开始前开始的 RCU 读临界区都已经结束"。Linux 内核使用安静状态(quiescent state) 检测来实现这一目标。

每个 CPU 的安静状态发生在以下时刻:

• 离开 RCU 读临界区时(rcu_read_unlock 中标记)

• 执行上下文切换时(因为调度器本身不持有 RCU 读锁)

• CPU 进入空闲状态(idle)时

内核为每个 CPU 维护一个计数器 rcu_data.rcu_qs_ctr,当 CPU 经过安静状态时递增。RCU 核心逻辑通过检查所有 CPU 的计数器确认是否经过了一次完整宽限期。

在 CONFIG_RCU_FAST_NO_HZ 配置下,内核会对空闲 CPU 进行特殊优化,避免为等待安静状态而反复唤醒低功耗 CPU,这对电池供电设备至关重要。

3.2 经典 RCU(Classic RCU) vs Tree RCU

Linux 2.6 时代的经典 RCU 使用全局状态位,所有 CPU 共享一个掩码。在 CPU 数量较少时工作良好,但在 4096+ CPU 的大型 SMP 系统中,全局锁竞争成为瓶颈。

现代内核使用 Tree RCU(kernel/rcu/tree.c),它采用分层的 rcu_node 树结构:

• 叶子节点覆盖少量 CPU(通常 16 颗)

• 内部节点聚合下层节点的状态

• 根节点判断全系统的宽限期完成

这种树形层级结构将宽限期确认的开销从 O(N) 降低到 O(log N),支持数万 CPU 的扩展。_rcu_node 树在启动时根据 \\ CONFIG_RCU_FANOUT 和 \\ CONFIG_RCU_FANOUT_LEAF 自动构建。

3.3 加速宽限期与回调优先级

内核提供了两种宽限期变体:

• synchronize_rcu():标准宽限期,等待所有 CPU 经过安静状态。

• synchronize_rcu_expedited():加速宽限期,通过发送跨 CPU 的 IPI 中断强制 CPU 快速报告安静状态。代价是 CPU 开销大,适用于更新非常低频的场景(如模块卸载)。

synchronize_rcu_expedited() 的典型实现会发送 rcu_ipi(处理器间中断)到所有在线 CPU,使其立即通过软中断报告安静状态。这避免了标准宽限期中需要"等待所有 CPU 调度一次"的不可预测延迟。

4. 内存序与编译器屏障

RCU 的正确性严重依赖于精确的内存序控制。下面剖析 x86_64 和 ARM64 两种架构上的屏障实现差异。

4.1 x86_64: TSO 模型下的轻量屏障

x86_64 的 TSO(Total Store Order)模型天然保证了:

• 写-写有序(不会重排写操作)

• 读-读有序(不会重排读操作)

• 读可以在写之前被重排(StoreLoad 重排可能存在)

因此在 x86 上,rcu_assign_pointer() 主要是一个编译器屏障(barrier()),不需要 CPU 级别屏障:

// x86 上的实现(简化)
#define rcu_assign_pointer(p, v)                     \
    ({                                               \
        smp_wmb();  // 在 x8p 上是 barrier()         \
        WRITE_ONCE(p, v);                            \
    })

// x86 的 smp_wmb 是编译器屏障,不产生 mfence
#define smp_wmb() barrier()

rcu_dereference() 在 x86 上同样是编译器屏障,但必须防止编译器将指针加载重排到 rcu_read_lock() 之前。

4.2 ARM64: 弱序架构的严格屏障

ARM64 允许更激进的内存重排,RCU 操作需要显式dmb指令:

// ARM64 的 rcu_assign_pointer(简化)
#define rcu_assign_pointer(p, v)                     \
    ({                                               \
        smp_dmb(ISHST);  // dmb ishst              \
        WRITE_ONCE(p, v);                            \
    })

// dmb ishst: 确保存储在 Inner Shareable 域内

smp_dmb(ISHST) 确保所有在它之前的写入操作在 Inner Shareable 域内对后续写操作可见。对于 rcu_dereference(),ARM64 需要 smp_load_acquire()(使用 ldar 指令)保证后续的数据访问不会被重排到指针加载之前。

这体现了 RCU 设计的精心之处:在强序架构上最小化开销,在弱序架构上提供足够的屏障保证。

5. RCU 在生产环境中的实战案例

5.1 案例一:可扩展路由表查找

在大规模路由器的路由表管理中,RCU 是实现高性能 FIB(Forwarding Information Base)查找的关键。

struct fib_table {
    struct rcu_head rcu;
    struct htable *ht;
};

struct htable {
    unsigned int size;
    struct fib_bucket __rcu *buckets;
};

struct fib_bucket {
    struct fib_entry __rcu *entries;
};

struct fib_entry {
    __be32 dest;
    __be32 next_hop;
    struct rcu_head rcu;
};

// 读者查找路径(无锁,可并行)
int fib_lookup(__be32 dest, __be32 *next_hop)
{
    struct fib_entry *entry;
    unsigned int hash;

    rcu_read_lock();
    hash = hash_32(dest, htable_order);
    entry = rcu_dereference(htable->buckets[hash].entries);
    while (entry) {
        if (entry->dest == dest) {
            *next_hop = entry->next_hop;
            rcu_read_unlock();
            return 0;
        }
        entry = rcu_dereference(entry->next);
    }
    rcu_read_unlock();
    return -ENETUNREACH;
}

// 写者更新路由(复制替换)
int fib_update(__be32 dest, __be32 next_hop)
{
    struct fib_entry *new_entry, *old_bucket_head;
    struct htable *new_ht, *old_ht;
    unsigned int hash;

    // 创建新条目
    new_entry = kzalloc(sizeof(*new_entry), GFP_KERNEL);
    new_entry->dest = dest;
    new_entry->next_hop = next_hop;

    rcu_read_lock();
    old_ht = rcu_dereference(global_fib->ht);
    hash = hash_32(dest, htable_order);

    // 插入到旧桶头(不影响正在进行的查找)
    old_bucket_head = rcu_dereference(old_ht->buckets[hash].entries);
    new_entry->next = old_bucket_head;
    rcu_assign_pointer(old_ht->buckets[hash].entries, new_entry);
    rcu_read_unlock();

    // 等待所有查看旧数据的读者完成后,才能释放(如果需要替换表)
    synchronize_rcu();

    return 0;
}

这个案例展示了 RCU 的核心优势:数万并发查找数据包的路由时,查找路径完全无锁,性能接近线性扩展。

5.2 案例二:模块释构中的安全卸载

内核模块在卸载时必须确保没有代码仍在执行模块内的函数。RCU 的 synchronize_rcu() 常用于实现引用计数与宽限期相结合的卸载安全保证:

struct my_module_state {
    int active_users;      // 原子引用计数
    struct rcu_head rcu;
};

static struct my_module_state __rcu *global_state;

void my_module_get(void)
{
    struct my_module_state *state;
    rcu_read_lock();
    state = rcu_dereference(global_state);
    if (state)
        atomic_inc(&state->active_users);
    rcu_read_unlock();
}

void my_module_put(void)
{
    struct my_module_state *state = ...;
    if (atomic_dec_and_test(&state->active_users))
        kfree(state); // 引用计数归零,可以释放
}

void module_exit(void)
{
    struct my_module_state *old;
    
    // 将全局指针置为 NULL,新调用者无法获取状态
    old = rcu_xchg_pointer(global_state, NULL);
    
    // 等待所有已经持有 old 的读者完成
    synchronize_rcu();
    
    // 引用计数方案:等待 active_users 归零
    wait_event(wait_queue, atomic_read(&old->active_users) == 0);
    
    kfree(old);
}

注意 rcu_xchg_pointer() 是原子交换并返回旧值的宏。synchronize_rcu() 保证所有通过 rcu_dereference(global_state) 获取了 old 指针的读者都已经退出读临界区。结合引用计数,确保没有任何代码在访问模块数据后,模块才能安全卸载。

6. RCU 的性能边界与陷阱

6.1 读端临界区内的禁止操作

RCU 读端内绝对不能执行以下操作:

1) 调度/睡眠:schedule()、mutex_lock()、wait_event() 等会导致写者的 synchronize_rcu() 无限等待,因为被抢占的 CPU 可能长时间无法经过安静状态。

2) 绑定到其他 CPU:set_cpus_allowed() 会影响每 CPU 状态的跟踪。

3) 长时间循环:虽然没有硬性时间限制,但应在微秒级完成。特别是 CONFIG_PREEMPT=y 配置下,rcu_read_lock() 禁止抢占,长时间临界区会严重增加调度延迟。

违反这些规则的最典型症状是:内核报告 RCU: INFO: RCU_sched self-detected stall on CPU,并伴随调用栈显示 CPU 卡在 RCU 读临界区内。

6.2 写者的内存开销

每次 RCU 更新的写者都必须保留旧数据。如果写者频繁更新且每次更新的间隔小于宽限期,会导致旧数据堆积(特别是使用 call_rcu() 时)。这种情况在"乒乓更新"模式中非常常见:

// 反模式:高频更新导致回调积压
for (i = 0; i < 1000000; i++) {
    struct config *new_cfg = kmalloc(...);
    *new_cfg = *old_cfg;
    new_cfg->counter = i;
    rcu_assign_pointer(global_cfg, new_cfg);
    call_rcu(&old_cfg->rcu, free_cfg);  // 100万个回调堆积!
}

解决方案是使用批量更新或队列限流:通过 rcu_bar() 检查待处理回调数量,超过阈值时调用 synchronize_rcu() 阻塞等待。

6.3 RCU 与 MODULES 释放的特殊情况

存在一种称为 RCU_TOCTOU(Time Of Check To Time Of Use)的安全性问题:读者通过 rcu_dereference() 获取了有效指针,但在解引用之前,另一个 CPU 替换了指针并释放了旧数据。

然而,标准的 RCU 协议保证了:

  1. 读者在 rcu_read_lock() 和 rcu_read_unlock() 之间。
  2. 写者在 call_rcu() / synchronize_rcu() 之后才释放旧数据。
  3. 宽限期保证:在宽限期结束前,所有在宽限期开始前开始的读者都已退出临界区。
  • 因此,只要读者的整个使用过程在 rcu_read_lock/unlock 之间,TOCTOU 不可能发生——旧数据在读者退出前一定有效。
  • 7. 内核 RCU 子系统的调试工具

    Linux 内核提供了丰富的 RCU 调试工具,帮助开发者定位问题:

    CONFIG_RCU_STALL_WARN:检测长时间 RCU 读临界区,默认阈值 21 秒(可调整)。

    CONFIG_RCU_TRACE:记录宽限期起始/结束事件,可用于分析宽限期延迟。

    RCU lockdep 检查:lockdep 可以在运行时检测"在 RCU 读临界区睡眠"的违规模式,通过 might_sleep() 和 lockdep_assert(!rcu_read_lock_held()) 断言。

    debugfs 接口:/sys/kernel/debug/rcu/ 目录下的文件可以查看每 CPU 的 RCU 状态、回调数量、最近宽限期时间等。

    特别的,rcu_pending() 函数和 GP_STALL 计数器是诊断 RCU 性能瓶颈的关键指标。如果 rcu_data.blocked_tasks 非空,说明有读者长时间阻止宽限期推进。

    8. 总结

    RCU 的优雅之处在于它深刻理解了"读多写少"场景的本质:读者不需要保护数据不被修改,只需要保证自己看到的数据在访问期间有效即可。通过将释放延迟到宽限期之后,RCU 在同步协议层面实现了读端的零开销。

    正确运用 RCU 的关键记忆点:

    • rcu_read_lock/unlock:包围读者的读临界区,期间禁止睡眠。

    • rcu_dereference:在弱序架构上提供内存屏障,安全获取指针。

    • rcu_assign_pointer:在弱序架构上确保数据初始化完成后再发布指针。

    • synchronize_rcu:同步等待宽限期,阻塞调用者,不可在中断上下文使用。

    • call_rcu:异步回调回收,可在任何上下文使用,但需注意回调积压。

    RCU 的应用远不止本文所展示的范围。Linux 内核的 SRCU(Sleepable RCU)变体允许在 RCU 读临界区内睡眠,适用于需要 I/O 操作的读者场景。而 hlist_nulls 系列 API 结合 nulls marker 技术,使得哈希表的 RCU 遍历可以在初始化时检查终止条件而不依赖指针是否为 NULL。

    在容器化和微服务时代,RCU 的核心思想——"写时复制、延迟回收"——也被广泛应用于用户态的无锁数据结构实现(如无锁队列 Folly、Managed指针等),成为一种普适的并发控制设计哲学。理解 RCU 不仅是掌握一项内核技术,更是建立高并发系统设计的关键心智模型。

    点赞(0) 打赏

    评论列表 共有 0 条评论

    暂无评论
    立即
    投稿

    微信公众账号

    微信扫一扫加关注

    发表
    评论
    返回
    顶部