Linux 内核 RCU 同步机制深度工程实践:从宽限期原理到生产级无锁读路径构建

在现代多核系统中,读多写少的数据结构是最常见的并发场景之一。传统读写锁在读侧需要获取锁,不仅带来缓存一致性流量(cache-line bouncing),更在多核扩展时成为性能瓶颈。Linux 内核的 RCU(Read-Copy-Update)机制通过一种精巧的"延迟回收"设计,实现了真正的无锁读路径——读侧零开销,不需要任何原子操作、内存屏障或缓存同步。

本文将从 RCU 核心原理出发,逐步深入其底层实现机制,涵盖宽限期(Grace Period)判定算法、经典 API 用法、内存模型约束,并构建两个生产级实战案例:无锁路由表查找链表和内存热插拔场景下的 RCU 保护。

一、RCU 的设计哲学:"以时间换空间"的极致

RCU 的核心思想可以用一句话概括:写操作不是原地更新,而是创建修改副本,原子替换指针,然后等所有正在进行的读操作完成后回收旧副本。


读侧视角:
  rcu_read_lock()
  p = rcu_dereference(head)   // 读取指针,零开销
  do_something(p)
  rcu_read_unlock()

写侧视角:
  new = copy_of(old)
  modify(new)
  rcu_assign_pointer(head, new)  // 原子替换
  synchronize_rcu()              // 等待宽限期
  kfree(old)                     // 安全回收

这个设计带来了独特优势:

维度 rwlock seqlock RCU
读侧原子操作 需要(lock) 需要(seq计数器) 不需要
读侧内存屏障 full barrier 隐式 仅consume-load
读侧可睡眠 否(rwlock) 否 是
读侧可被抢占 否 否 是
写-写阻塞 是 是 是
读-写阻塞 写等读完成 写等读完成 完全无阻塞

RCU 的"超能力"来自一个关键不变量:在 rcu_read_lock() 和 rcu_read_unlock() 之间的代码可以引用 RCU 保护的数据,写者必须保证这些数据在宽限期内仍然有效。

二、宽限期(Grace Period):RCU 的灵魂

宽限期是 RCU 中最核心的概念。所谓宽限期,就是"每个 CPU 都经过至少一个 quiescent state(静止状态)"的时间段。

quiescent state 的定义很简单:任何不在 RCU 读侧临界区内的时刻。也就是说,如果一个 CPU 曾经退出过 rcu_read_lock/unlock 对,它就达到了 QS。

2.1 宽限期判定的底层算法

内核通过每 CPU 的_qs_counter 和_node 结构判定 GP 结束。经典 RCU(CONFIG_PREEMPT=n)的判定逻辑如下:


// kernel/rcu/tree.c 简化逻辑
static void rcu_gp_fqs(struct rcu_node *rnp)
{
    // 1. 等待每个 CPU 报告 QS
    for_each_leaf_node_cpu(rnp, cpu) {
        if (!per_cpu(rcu_qs_pending, cpu))
            return; // 还有 CPU 未报告 QS
    }
    // 2. 所有 CPU 的 QS 已收集 → GP 结束
    rcu_report_qs_rnp(mask, ...);
}

// CPU 报告 QS(在 context switch / user mode / idle 时触发)
void rcu_qs(void)
{
    struct rcpu_data *rdp = this_cpu_ptr(&rcu_data);
    rdp->cpu_qs_qs = true; // 标记本 CPU 已进入 QS
    smp_mb__before_atomic();
}

关键:GP 结束不需要忙等。内核注册了 tick 检查函数,在上下文切换、进入用户态或 idle 时自然"收割" QS 状态。

2.2 可抢占 RCU(PREEMPT_RCU):让任务也能睡眠

当 CONFIG_PREEMPT=y 时,在读侧临界区内可以被抢占(甚至睡眠),因为 RCU 读侧只禁止抢占在特定点。此时 GP 判定需要额外跟踪每个任务的 RCU 读侧嵌套层数:


// include/linux/rcupdate.h
#ifdef CONFIG_PREEMPT_RCU
static inline void rcu_read_lock(void)
{
    __rcu_read_lock();         // 增加 preempt_count
    __acquire(RCU);
    rcu_lock_acquire(...);
}

static inline void rcu_read_unlock(void)
{
    rcu_lock_release(...);
    __rcu_read_unlock();       // 减少 preempt_count
    rcuirq(...);
}

关键用法约束:在 rcu_read_lock() 和 rcu_read_unlock() 之间不能睡眠——这是 RCU API 的核心铁律,违反将导致 use-after-free。

三、RCU API 层次与内存模型约束

3.1 读侧原语


rcu_read_lock()        // 进入读侧临界区
rcu_read_unlock()      // 退出读侧临界区
rcu_dereference(p)     // 安全读取 RCU 保护的指针
rcu_dereference_protected(p, cond) // 在已持有锁时访问

rcu_dereference() 看似简单,实则隐藏了关键内存屏障:


#define rcu_dereference(p) \
    ({ typeof(p) _________p1 = READ_ONCE(p); \
       smp_dep_barrier(); /* 依赖屏障 */ \
       (_________p1); })

这个"依赖屏障"(dependent barrier,实际上是 consume-load 语义)保证:即使 CPU 乱序执行,也确保在 p 指向的数据被访问之前,p 自身的值已经被正确读取了。这在 Alpha 等弱序模型上至关重要。

3.2 写侧原语


rcu_assign_pointer(NULL, p) // 原子发布新指针
synchronize_rcu()           // 同步等待 GP(阻塞,可睡眠)
synchronize_rcu_expedited() // 加速等待 GP(更短延迟)
call_rcu(head, func)        // 异步回调回收(非阻塞)
kfree_rcu(p, rh)            // GP 结束后自动 kfree

synchronize_rcu() 和 call_rcu() 的区别是工程中最常见的迷思:

  • synchronize_rcu():阻塞直至 GP 结束。适用于必须立即回收结果的场景(如模块卸载前的资源清理)。
  • call_rcu():将回调函数排队到 GP 结束后执行。非阻塞,适用于高频写路径。

3.3 RCU API 演进对比

年代 特性 典型用法场景
2.5 经典 RCU 路由表、网络协议栈
2.6.32 rcu_dereference_check() Sparse 静态分析检查
3.x hlist_nulls + RCU 哈希表 lookup (UDP/TCP 连接查找)
4.x kfree_rcu() 避免手动 call_rcu + kfree 样板
5.x RCU-tasks 追踪系统中的 sleepable RCU 读侧

四、实战案例一:无锁路由 FIB 查找表

下面我们构建一个简化的 IPv4 路由表,展示 RCU 在生产级数据结构中的工程实践。


struct fib_entry {
    u32 prefix;
    u8  prefix_len;
    u32 next_hop;
    u8  metric;
    struct rcu_head rcu;  // 用于 call_rcu 回收
};

struct fib_table {
    struct fib_entry __rcu *root; // RCU 保护的根指针
    spinlock_t              write_lock; // 写侧互斥
};

// === 读侧:零开销查找 ===
u32 fib_lookup(struct fib_table *table, u32 dst_ip)
{
    struct fib_entry *entry;
    u32 matched_hop = 0;
    int matched_len = -1;

    rcu_read_lock();
    entry = rcu_dereference(table->root);
    
    // 此处可安全读 entry 的字段
    while (entry) {
        if ((dst_ip & prefix_mask(entry->prefix_len)) == entry->prefix &&
            entry->prefix_len > matched_len) {
            matched_hop  = entry->next_hop;
            matched_len  = entry->prefix_len;
            matched_metric = entry->metric;
        }
        entry = rcu_dereference(entry->next);
        // 链表中的每个 next 指针也用 rcu 保护
    }
    
    rcu_read_unlock();
    return matched_hop;
}

// === 写侧:Copy + Replace + Deferred Free ===
int fib_insert(struct fib_table *table, u32 prefix, u8 len, u32 hop)
{
    struct fib_entry *new, *old;
    
    new = kmem_cache_alloc(fib_entry_cache, GFP_KERNEL);
    if (!new)
        return -ENOMEM;
    
    new->prefix     = prefix;
    new->prefix_len = len;
    new->next_hop   = hop;
    new->metric     = DEFAULT_METRIC;

    spin_lock(&table->write_lock);
    
    // 1. 复制受影响子树(实际工程中可 COW)
    old = rcu_dereference_protected(table->root,
                                     lockdep_is_held(&table->write_lock));
    
    // 2. 构建新子树——new->next 指向旧子树的某个位置
    new->next = old;
    
    // 3. 原子替换全局指针
    rcu_assign_pointer(table->root, new);
    
    spin_unlock(&table->write_lock);
    
    // 4. 异步等待 GP 后回收旧头节点
    call_rcu(&old->rcu, fib_entry_destroy);
    return 0;
}

static void fib_entry_destroy(struct rcu_head *rh)
{
    struct fib_entry *old = container_of(rh, struct fib_entry, rcu);
    kfree(old);
}

关键设计决策:

  1. 写侧自旋锁:写者之间仍需互斥,但读侧完全不需要这把锁。
  2. call_rcu 替代 synchronize_rcu:在路由更新的热路径上,异步回收避免写入延迟被 GP 阻塞。
  3. rcu_assign_pointer 的发布语义:该宏保证新 entry 的所有字段写入在指针发布之前对其他 CPU 可见(release 语义)。
  4. 五、实战案例二:内存热插拔中的 RCU 保护

    内存热插拔场景是 RCU 的经典实战应用:系统需要将 memblock 列表从"bootmem"切换到"kmalloc管理",遍历列表的操作不能停,也不能被修改阻塞。

    
    struct mem_region {
        u64 start;
        u64 len;
        u32 flags;
        struct list_head list;         // 普通链表
        struct rcu_head rcu;
    };
    
    static LIST_HEAD(mem_region_list);      // RCU 保护的链表头
    static DEFINE_RWLOCK(mem_region_lock);  // 仅写侧使用
    
    // === 内存分配回调中的读侧:完全无锁 ===
    void mem_region_foreach(int (*cb)(struct mem_region *r))
    {
        struct mem_region *r;
        
        rcu_read_lock();
        list_for_each_entry_rcu(r, &mem_region_list, list) {
            // walk——读侧不需要任何锁
            if (cb(r))
                break;
        }
        rcu_read_unlock();
    }
    
    // === 热插拔事件中的写侧 ===
    int mem_region_add(u64 start, u64 len)
    {
        struct mem_region *r, *old;
        
        r = kzalloc(sizeof(*r), GFP_KERNEL);
        if (!r)
            return -ENOMEM;
        
        r->start = start;
        r->len   = len;
        r->flags = MEMF_PRESENT;
        
        write_lock(&mem_region_lock);         // 写-写互斥
        old = list_first_entry_or_null(&mem_region_list, ...);
        list_add_rcu(&r->list, &mem_region_list); // rcu-aware list add
        write_unlock(&mem_region_lock);
        
        // 异步回收
        if (old)
            call_rcu(&old->rcu, mem_region_free);
        return 0;
    }
    

    为什么这里不能用读写锁?

    在 NUMA 系统中,内存热插拔是高延迟操作(可能涉及 ECC 页面迁移、固件通知等)。如果读侧用 rwlock,每次 mem_region_foreach 都会触发跨 NUMA 节点的缓存同步,而在分配路径上每秒可能执行数百万次这样的查找。RCU 让读侧完全无感知。

    六、陷阱与最佳实践

    6.1 Use-after-free 的经典模式

    
    // 错误!p 在 rcu_read_unlock() 后可能被释放
    rcu_read_lock();
    p = rcu_dereference(head);
    rcu_read_unlock();
    printk("value=%d\n", p->field); // 💥 BUG:p 可能已被 kfree
    
    // 正确:所有访问必须在 rcu_read_lock 内部完成
    rcu_read_lock();
    p = rcu_dereference(head);
    if (p)
        printk("value=%d\n", p->field);
    rcu_read_unlock();
    

    6.2 双重宽限期回收

    
    // 错误:把 call_rcu 回调中的 kfree 和直接 kfree 混用
    kfree_rcu(old, rcu); // 已安排 call_rcu 回收
    // ... 如果此时又执行: kfree(old); // 💥 double-free!
    
    // 正确:资源生命周期只由一种机制管理
    

    6.3 RCU 读侧与 spinlock 的死锁组合

    
    // 潜在死锁(理论上 RCU read 不能睡眠,但某些路径可能被 migrate)
    rcu_read_lock();
    spin_lock(&some_lock);        // 安全,如果此锁不导致睡眠
    // ...
    spin_unlock(&some_lock);
    rcu_read_unlock();
    
    // 反向顺序是安全的
    spin_lock(&some_lock);
    rcu_read_lock();              // 安全
    // ...
    rcu_read_unlock();
    spin_unlock(&some_lock);
    

    6.4 频繁写路径下 call_rcu 的背压控制

    当写频率过高(每秒百万次更新),call_rcu 回调队列可能膨胀。解决方案:

    1. 使用 call_rcu_flush() 在特定路径同步等待。
    2. 使用 kfree_rcu 替代显式 call_rcu + kfree 以减少回调对象分配。
    3. 合并写操作,批量发布(如将 100 次更新合并为一次 pointer swap)。
    4. 七、性能基准:RCU vs seqlock vs rwlock

      在 64 核 x86 服务器上,读多写少场景(10000:1 读写比)的典型基准:

      方案 读侧延迟(ns) 写侧延迟(us) 写侧最大吞吐(ops/s)
      RCU 12 (一次 load) 45~200 (GP) 2,500
      rwlock 35 (LD+ADD) 30~150 4,000
      seqlock 28 (sequence load + retry) 15~50 8,000

      可以看出——虽然 RCU 写侧性能较差,但在极端读多写少的场景下,整体吞吐远超其他方案。

      八、总结

      RCU 是 Linux 内核并发工具箱中"看似简单、内部精妙"的典范。它的核心优势来自对"读"操作本质的理解:读取不应该与任何人协调。

      在实践中,RCU 最适合以下场景:

      • 查找操作频率远高于更新操作(路由表、VFS dentry cache、进程 PID 表)
      • 数据结构需要被 CPU 缓存热数据保持高命中率
      • 读侧路径不能容忍任何阻塞或竞态
      • 读者可能在内核态长时间持有引用(kmalloc-free 类操作)

      理解 RCU 的关键,是理解"宽限期"不是一个点而是一个区间——所有在 GP 开始前开始的读者,在 GP 结束后必然已经完成。这个不变量支撑起 RCU 的整个安全证明。

      
                          Time →
      Reader:  |─────── [rcu_read_lock ── rcu_read_unlock] ──────────|
      Writer:  ────────assign_pointer────[GP开始]────────[GP完成]─kfree──►
                                    |←──── Grace Period ────→|
      
      

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.420470s