引言

在并发编程的世界中,读多写少场景始终是最棘手的难题之一。传统读写锁(rwlock)在超高并发读取时因缓存行弹跳(cache-line bouncing)而性能急剧恶化,seqlock 在写入密集时会导致读者不断重试。Linux 内核独创的 RCU(Read-Copy-Update)机制,以近乎零开销的读取侧性能,成为内核数据结构读多写少场景的事实标准——从进程描述符(task_struct)的遍历、路由表查询、文件系统的 dentry 缓存、模块热插拔的安全保障,到网络层的连接跟踪表(conntrack),RCU 无处不在。本文将从概念模型出发,深入剖析 RCU 的核心原理、宽限期调度算法、内核 API 使用范式,以及在实际工程中的高级应用和常见陷阱。

1. 为什么需要 RCU:同步机制的天花板

1.1 互斥锁的读取天花板

当多个读者并发访问同一数据结构时,看似无害的「读取」实际上也涉及缓存一致性协议的开销:

场景:64核系统,1个写者周期性地更新一个全局链表
      63个读者持续遍历链表

rwlock 行为:
- 读者获取 read lock → 持有 per-cpu refcount 或全局锁计数
- 每个 CPU 的缓存行在 lock 变量上反复失效/恢复(cache-line bouncing)
- 实测:当读者数 > 16 时,rwlock 吞吐量开始下降
- 读者越多,lock 变量上的缓存一致性流量越大

spinlock/mutex 更甚:读取也会被序列化

1.2 seqlock 的致命伤

seqlock 让读者无锁读取,通过序列号检测写入冲突,遇到写入则重试。对于「遍历链表」这类需要多个读操作保持一致的复合操作,seqlock 重试代价极高:

读者视角:
  seq1 = read_seqbegin(&lock);
  ptr = head->next;      // 读取指针 A
  data = ptr->data;       // 通过指针 A 读取数据
  if (ptr == NULL) ...    // 判断空链表
  // ... 更多遍历操作
  
  if (read_seqretry(&lock, seq1))
    // 重试!前面所有工作全部作废
    // 写入密集时,重试次数呈指数增长

1.3 RCU 的核心思路

RCU 的答案:让读者完全不写共享状态。

对比三大机制的核心差异:

              读者侧写入    写者序列化    读者失败重试
rwlock        lock计数       是           否
seqlock       seqretry计数   检测冲突     可能重试
RCU           零写入         等待宽限期   不可能

RCU 的读取侧没有任何原子操作、内存屏障或缓存一致性流量,
读取性能等同于单线程无锁编程。

2. RCU 三要素与核心概念

2.1 读侧临界区(Read-Side Critical Section, RSCS)

RCU 对读者的要求极为简单:在 rcu_read_lock() 和 rcu_read_unlock() 之间,不能睡眠、不能调度、不能调用可能导致上下文切换的函数。

// 典型的 RCU 读侧临界区
rcu_read_lock();
p = rcu_dereference(g_ptr);  // 安全地读取 RCU 保护的指针
if (p) {
    data = p->field;        // 在临界区内安全访问
    printk("value=%d\n", data);
}
rcu_read_unlock();

// rcu_read_lock/unlock 的实际实现(可抢占 RCU 内核):
// lock: preempt_disable()    // 仅抢占禁止
// unlock: preempt_enable()
// 
// 这也就是为什么 RCU 读者的开销近乎为零。

2.2 写者侧:发布-等待-回收三阶段

写者的核心循环是「替换指针 → 等待旧读者撤离 → 回收旧数据」:

写者视角的三阶段:

Phase 1: 发布新数据(Publish)
┌────────────┐
│ 分配新节点  │
│ 初始化数据  │
│ 复制旧节点  │
│ 修改新节点  │
└─────┬──────┘
      │ rcu_assign_pointer(g_ptr, new_node)
      ▼ 原子替换全局指针(带 release 语义)
      
所有后续读者看到新数据,
但旧读者可能仍在访问旧数据。

Phase 2: 等待宽限期结束(Wait)
      │ synchronize_rcu() 或 call_rcu()
      ▼
阻塞直到所有在 "替换指针之前" 进入 RSCS 的读者退出

Phase 3: 回收旧数据(Reclaim)
      │ kfree(old_node)
      ▼
保证没有读者持有旧数据的引用,安全释放

2.3 RCU 保护的指针和访问原语

// 写者发布:赋值 RCU 保护的指针
struct foo *new_p = kmalloc(sizeof(*new_p), GFP_KERNEL);
new_p->field = value;
rcu_assign_pointer(g_ptr, new_p);  // 原子写 + release 屏障

// 读者安全取址:读取 RCU 保护的指针
rcu_read_lock();
struct foo *p = rcu_dereference(g_ptr);  // 原子读 + consume/dependency 屏障
if (p)
    val = p->field;  // 安全:在 RSCS 内,p 不会被释放
rcu_read_unlock();

关键约束:除了持有锁或 RCU 读锁之外,任何访问 RCU 保护数据的地方都需要对应的同步机制。rcu_dereference() 确保读取不受编译器优化和 CPU 乱序影响。

3. 宽限期(Grace Period)机制

宽限期是 RCU 最核心的概念:指从某个时间点开始,到所有在该时间点之前开始的 RCU 读侧临界区都结束的时间段。只有当一个宽限期结束后,写者才能安全地释放旧数据。

3.1 静态图解析

时间轴 →

CPU 0: ── RSCS begin ────────── RSCS end ─── CPU idle ──────
CPU 1:  ──────── RSCS begin ─────── RSCS end ───────────────
CPU 2:  ──────────────── RCU idle ─────────────────────────

写者: ── rcu_assign_pointer ──────────┤synchronize_rcu() │ kfree()
                                      ▲                  ▲
                              替换指针时刻A          GP 结束时刻 B

宽限期 = 时刻 A 到时刻 B 的时间段

要求:在时刻 A 之前已在 RSCS 中的读者,必须在时刻 B 之前退出。
CPU 2 在时刻 A 之前无读者 → 不影响 GP。
CPU 1 在时刻 A 后有读者 → 不影响(只统计时刻 A 之前的读者)。

3.2 宽限期检测的核心算法

RCU 使用「静止状态」(quiescent state)作为宽限期结束的判据。一个 CPU 经历了静止状态,意味着该 CPU 不在任何 RCU 读侧临界区中。内核使用 rcu_data 结构体跟踪每个 CPU 的 RCU 状态:

struct rcu_data {
    unsigned long   gpnum;      /* GP 编号,用于检测是否完成当前 GP */
    unsigned long   completed;  /* 上一次 GP 完成时的编号 */
    bool            qs_flag;    /* 需要报告静止状态 */
    unsigned long   cpu_qs_ctr; /* 本 CPU 上的 QS 计数 */
    ...
};

宽限期结束判定条件:
对于所有 CPU,rcpu->completed >= 当前 GP 编号
即每个 CPU 都已经经历了一次静止状态。

3.3 三类 RCU 宽限期 API

// 1. 阻塞式同步宽限期
void synchronize_rcu(void);
// 写者阻塞,直到宽限期结束。
// 不允许在 RCU 读侧临界区内调用(会死锁)!
// 不允许在持有自旋锁时调用(可能睡眠导致问题)。

// 2. 睡眠安全版(可中断)
void synchronize_rcu_exclusive(void);
// 排他性 GP,等待所有已启动的 GP 完成

// 3. 异步回调式(RCU 主流用法)
void call_rcu(struct rcu_head *head, rcu_callback_t func);
// 不阻塞,注册回调函数,GP 结束后异步调用
// func(head) 中执行 kfree 等回收操作

3.4 静止状态的触发时机

内核在以下时刻判定一个 CPU 经历了静止状态:

  • 上下文切换:CPU 从进程切换到 idle 或其它进程(意味着走出了 RSCS)
  • CPU 进入 idle:CPU 执行 cpu_idle() 时报告静止状态
  • 时间检查点:RCU 软中断(rcu_softirq)周期性检查
// 上下文切换中的 RCU 检查(简化)
__schedule() {
    ...
    rcu_note_context_switch(prev);  // 标记 CPU 已离开 RSCS
    ...
}

// CPU idle 入口
rcu_idle_enter() {
    rcu_qs();  // 报告静止状态,如果这是本 CPU 唯一需要等待的 RSCS
}

4. RCU 变体与适用场景

4.1 Classic RCU(CONFIG_RCU_GENERIC)

最原始的实现。写者用 synchronize_rcu() 阻塞等待。每个 CPU 经历一次静止状态即算 GP 结束。缺陷:在极端情况下,若某 CPU 持续在 RSCS 中(例如关中断执行大块代码),所有写者都会饿死。

4.2 Preemptible RCU(CONFIG_PREEMPT_RCU)

支持内核抢占的 RCU 变体。读者使用 preempt_disable()/preempt_enable() 防止被抢占,允许读者在 RSCS 中保持极短窗口。在现代可抢占内核中,这是最常用的变体。

4.3 Sleepable RCU(SRCU)

允许读侧临界区睡眠,代价是读取开销略高(使用 per-cpu 计数器)。适用场景:需要mmap、copy_to_user等可能触发缺页异常的读取操作。

// SRCU 使用范式
DEFINE_SRCU(my_srcu);

// 读者(可以睡眠)
int idx = srcu_read_lock(&my_srcu);
// 这里可以睡眠、可以缺页!
data = protected_ptr->field;
srcu_read_unlock(&my_srcu, idx);

// 写者(需要额外处理)
synchronize_srcu(&my_srcu);  // 等待所有读者退出

4.4 Tree RCU(CONFIG_TREE_RCU)

现代内核默认的实现。通过分层树状结构管理 CPU 状态,解决了大规模 NUMA 系统中 GP 检测的可扩展性问题。每颗 CPU 树(rcu_node)管理一群 CPU,root node 汇总整系统的 GP 状态。

// Tree RCU 的核心数据结构
struct rcu_node {
    unsigned long gpnum;              /* 正在等待的 GP 编号 */
    unsigned long completed;          /* 已完成 GP 编号 */
    struct rcu_node *parent;          /* 向上指针 */
    unsigned long qsmask;             /* 子节点 QS 状态位掩码 */
    ...
};

多核层级示例(128 CPU 系统):
        Root rcu_node
       /     |     \
    Node0  Node1  Node2  Node3
    / \    / \    / \    / \
  CPU CPU CPU CPU CPU CPU ... CPU
  0-3  4-7  8-11 12-15 ... 124-127

GP 结束检测:每个节点需在所有子节点都 QS 后才能向上汇报,
最终 root 节点确认所有 CPU 已 QS → GP 结束。

5. RCU 在内核中的真实应用

5.1 进程描述符的 RCU 安全遍历

task_struct 的 PID哈希表使用 RCU 保护。父进程在遍历其子进程列表时不需要加锁:

// 内核实现(kernel/pid.c 简化)
struct pid *find_pid_ns(int nr, struct pid_namespace *ns) {
    struct pid *pid;
    
    rcu_read_lock();
    pid = idr_find(&ns->idr, nr);  // RCU 保护的 IDR 查找
    if (pid)
        pid = get_pid(pid);  // 增加引用计数
    rcu_read_unlock();
    
    return pid;
}

优势:find_pid() 可以与 fork()/exit() 完全并行,
读取耗时 < 10ns,无缓存行弹跳。
代价:写者(fork/exit)需要 call_rcu() 延迟释放。

5.2 Dentry 缓存的 RCU 路径查找

文件系统路径解析中的 dentry 哈希表(dcache)使用 RCU,使得 open() 等系统调用的平均开销降低到微秒级:

// 核心 RCU-ized dentry lookup
struct dentry *d_lookup(const struct dentry *parent, const struct qstr *name) {
    unsigned int hash = name->hash;
    struct hlist_bl_head *b = d_hash(parent, hash);
    struct dentry *found = NULL;
    
    rcu_read_lock();
    hlist_bl_for_each_entry_rcu(dentry, p, b, d_hash) {
        if (dentry->d_name.hash != hash)
            continue;
        if (dentry->d_parent != parent)
            continue;
        spin_lock(&dentry->d_lock);  // 锁仅用于字段比较
        if (dentry->d_name.len != name->len)
            goto next;
        if (memcmp(dentry->d_name.name, name->name, name->len))
            goto next;
        found = dget_dlock(dentry);  // 增加 dentry 计数
        spin_unlock(&dentry->d_lock);
        break;
next:
        spin_unlock(&dentry->d_lock);
    }
    rcu_read_unlock();
    return found;
}

5.3 nf_conntrack 的 RCU 查找

Netfilter 连接跟踪表使用 RCU 查找,在 100G 网络环境下可达数百万次/秒并发查询:

// net/netfilter/nf_conntrack_core.c
struct nf_conn *nf_conntrack_find_get(...) {
    struct nf_conn *ct;
    
    rcu_read_lock();
    hlist_nulls_for_each_entry_rcu(ct, n,
        &ct_hash[hash], hashnode) {
        if (nf_ct_tuple_equal(tuple, &ct->tuple) 
         && nf_ct_zone_equal(ct, zone)) {
            if (nf_ct_is_untracked(ct))
                continue;
            if (!atomic_inc_not_zero(&ct->use))
                continue;
            rcu_read_unlock();
            return ct;
        }
    }
    rcu_read_unlock();
    return NULL;
}

6. 多级 RCU 高级模式

6.1 RCU 链表操作 API

  • list_add_rcu():添加链表节点(仅修改单个指针)
  • list_del_rcu():删除链表节点(标记删除但读者可继续遍历)
  • list_replace_rcu():原子替换链表节点
  • list_for_each_entry_rcu():RCU 链表遍历宏
// RCU 链表添加
struct my_node *new = kmalloc(sizeof(*new), GFP_KERNEL);
new->data = 42;
spin_lock(&my_lock);
list_add_rcu(&new->list, &my_head);  // 先于头结点可见
spin_unlock(&my_lock);
// 注意:list_add_rcu 本身不需要 synchronize_rcu,
// 但 list_del_rcu 后的回收需要

// RCU 链表删除
spin_lock(&my_lock);
list_del_rcu(&del->list);
spin_unlock(&my_lock);
call_rcu(&del->rcu, my_node_free);  // 延迟释放

// RCU 链表遍历
struct my_node *pos;
list_for_each_entry_rcu(pos, &my_head, list) {
    // RCU 临界区内安全遍历
    printk("data=%d\n", pos->data);
    // 注意:pos 可能被并发删除,需谨慎使用
}

6.2 RCU 保护的哈希表(hlist_nulls)

内核的 hlist_nulls 变体与 RCU 配合,解决了并发遍历时的 TOCTOU 问题(Time-Of-Check-To-Time-Of-Use race):

标准 RCU hlist 的问题:
  rcu_read_lock()
  node = head->first;           // node != NULL?
  if (node) {
      // 此时写者可能 list_del_rcu(node)
      // 假设 node 被 call_rcu 释放
      val = node->field;          // 内存已释放!Use-After-Free!
  }
  rcu_read_unlock()

hlist_nulls 的解决方案:
  使用 NULLS_MARKER(特殊的低位标记值)区分"空槽"和"有效指针"
  即使节点被删除,标记值仍然存在,读者可以安全判断。

6.3 RCU 与 SLAB 分配器集成(SLAB_TYPESAFE_BY_RCU)

通过 kmem_cache_create(..., SLAB_TYPESAFE_BY_RCU) 创建的 SLAB 缓存,允许 RCU 读者在 GP 期间继续访问已释放的对象的内存区域,只要该区域尚未被分配给其他对象。这是 dentry 缓存和 mm_struct 的核心优化:

工作模式:
1. 读者通过 RCU 读取指针 p
2. 写者 kfree_rcu(p) 标记 p 为待释放
3. GP 结束后,p 的内存返回 SLAB
4. SLAB 可能将同一块内存分配给新对象
5. 但因为 GP 已经结束,步骤 1 的读者早已退出,不会产生混淆

关键不变量:读者在 RSCS 内看到的内存区域,在 GP 结束前不会被重新初始化。

7. RCU 实战陷阱与调试技术

7.1 在 RCU 读侧临界区中睡眠

这是最常见的 RCU 误用,会导致随机内核崩溃:

// 错误示例,千万不要这样做!
rcu_read_lock();
p = rcu_dereference(g_ptr);
copy_to_user(user_buf, p->data, p->len);  // 触发缺页 → 睡眠
rcu_read_unlock();

// 后果:调度期间另一个 CPU 执行 GP 和 kfree,
// 唤醒后发现 p 指向的内存已释放。

// 正确做法:使用 SRCU(当需要睡眠时)
idx = srcu_read_lock(&my_srcu);
copy_to_user(user_buf, p->data, p->len);  // SRCU 允许缺页
srcu_read_unlock(&my_srcu, idx);

7.2 在持有自旋锁时调用 synchronize_rcu()

synchronize_rcu() 需要让当前 CPU 经历静止状态。如果持有自旋锁,无法调度,当前 CPU 无法报告静止状态,导致死锁。解决方案:

// 错误
spin_lock(&lock);
old = g_ptr;
new = alloc_new();
*new = *old;
synchronize_rcu();  // 死锁!持有锁无法调度
kfree(old);
spin_unlock(&lock);

// 正确做法1:用 call_rcu 替代
spin_lock(&lock);
old = g_ptr;
rcu_assign_pointer(g_ptr, new);
call_rcu(&old->rcu, free_old_node);
spin_unlock(&lock);

// 正确做法2:延迟释放(worker 线程中)
spin_lock(&lock);
list_add_tail(&old->reclaim_list, &reclaim_head);
spin_unlock(&lock);
queue_work(reclaim_workqueue, &reclaim_work);  // 在 worker 中 synchronize_rcu

7.3 CONFIG_PROVE_RCU 静态检查

开启 CONFIG_PROVE_RCU 后,内核会在 rcu_dereference() 对应的 RCU 临界区缺失时发出 WARNING。

// 开启 PROVE_RCU 后的典型输出
WARNING: CPU: 2 PID: 1234 at kernel/rcu/update.c:345
rcu_scheduler = 0
rcu_read_lock_held() = 0  // 没有持有 RCU 读锁!

// 使用 RCU_LOCKDEP_WARN() 在代码中检查
#define my_rcu_dereference(p) \
    ({ \
        RCU_LOCKDEP_WARN(!rcu_read_lock_held(), \
            "my_rcu_dereference called without RCU read lock"); \
        rcu_dereference(p); \
    })

7.4 RCU Stall 检测

内核内置 RCU stall 检测器,当某个 CPU 长时间不报告静止状态时打印详细诊断信息:

// RCU stall 诊断输出示例
INFO: rcu_sched self-detected stall on CPU
rcu_sched:
        c=1853 g=1853... s=36 jiffies_gp=...
        CPU: 1 PID: 0 Comm: swapper/1 Not tainted 5.15.0
        Call Trace:
         <IRQ> dump_stack_lvl+0x4a/0x63
         rcu_check_gp_kthread+0x1a4/0x1b6
         rcu_cpu_kthread+0x8c/0x1e4
         smpboot_thread_fn+0xd2/0x18b

// 常见原因:
// 1. RSCS 中死循环或长时间阻塞
// 2. 关中断/禁用抢占时间过长
// 3. CPU 热插拔事件处理异常

8. 性能对比与生产基准

8.1 读取性能对比

环境:AMD EPYC 7763 64-Core,Linux 6.5

操作:指针解引用读取一个整数
读者核数:   rwlock        seqlock       RCU
1           ~12ns         ~10ns         ~1.2ns
4           ~20ns         ~15ns         ~1.2ns
16          ~80ns         ~25ns         ~1.2ns
64          ~320ns        ~200ns        ~1.2ns

RCU 读取性能是 rwlock 的 ~200 倍(核数越多优势越大)。

8.2 写入性能对比

操作:修改全局链表中一个节点的值
读者核数:    读写锁写者       RCU write+fallback
1            ~20ns          ~350ns (synchronize_rcu 开销)
16           ~30ns          ~420ns
64           ~120ns         ~550ns (大量 CPU QS 等待)

但注意:
RCU 写者的代价在所有 CPU 上分摊,不阻塞读者!
》实际系统吞吐量 RCU 远优于读写锁(读者永不阻塞)。

8.3 在 Redis/KeyDB 中的 RCU 化改造效果

数据库领域的类比实验表明,将读写锁替换为 RCU 后:

  • 读操作吞吐提升 3-5x(16 核以上环境)
  • P99 读延迟降低 80%
  • 写入延迟增加约 20-50%(synchronize_rcu 开销),但因写入频率远低于读取,系统总吞吐大幅提升

9. RCU 在用户态的应用

Linux 提供了用户态 RCU 库(liburcu),API 与内核类似,支持 QSBR(Quiescent State-Based Reclamation)和信号保护等多种策略:

// liburcu QSBR 使用模式
#include <urcu-qsbr.h>

// 全局初始化:每个线程注册
rcu_register_thread();

// 读侧(sleep-friendly)
rcu_read_lock();
ptr = rcu_dereference(g_ptr);
data = ptr->value;
rcu_read_unlock();

// 写者
synchronize_rcu();  // 等待所有线程经历 QS(线程自动报告)
kfree(old_ptr);

// 关键点:qsbr 要求线程定期调用 rcu_quiescent_state()
// 通常可以在「不会持有 RCU 指针」的任意安全检查点调用

liburcu 已用于生产系统的高性能场景:

  • PostgreSQL:查询计划缓存使用 URCU(Userspace RCU)
  • Seastar:C++ 高性能框架使用 RCU 替代共享锁
  • DPDK:用户态驱动中的 rte_rcu 实现加速路由查找

10. 总结:RCU 的适用边界

RCU 不是银弹,它有明确的最佳适用范围和代价约束:

好处(为什么用 RCU)                     代价(为什么不能全用)
─────────────────────────────────        ──────────────────────────────
读取侧零开销无缓存弹跳                     写入侧开销大(GP 等待)
读者永不阻塞读者                           写者不能频繁(<1000/s 为佳)
读取侧不需要写共享状态                     内存占用延迟回收
适合读多写少场景                           在 RSCS 中不能睡眠
适用于遍历类操作                           在持有锁时不能 synchronize_rcu
                                   
最佳实践区:读取占比 > 90%,写入频率 < 1次/秒,
           单次读取操作 < 1μs,容忍毫秒级 GP 延迟。

RCU 的哲学:「将并发问题从运行时转移到回收时」。读者获得了近乎完美的读取性能,代价是写者承担了更多的管理代价。在 Linux 内核多年演进中,RCU 从最初的简单实现发展为包含 Tree RCU、SRCU、QSBR 的完整生态圈,成为系统级编程中处理读多写少场景时不可或缺的工具。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部