一、RCU 的本质:为什么需要第三种同步原语

Linux 内核并存的三大同步原语各有明确的哲学分野:mutex 通过互斥保证写者独占;atomic 依赖 CPU 的原子指令实现无锁读写的CAS 竞争;而 RCU (Read-Copy-Update) 走了一条完全不同的路——它让读者完全不阻塞。

经典场景是路由表查找、文件描述符表读取、进程权限检查、进程调度域遍历——这些操作的读频率可能是写频率的 1000 倍以上。spinlock 即使只拿读侧也会因缓存行弹跳 (cache-line bouncing) 在多核上产生 O(N) 开销;rwlock 虽然允许并发读,但写者到来时仍要让所有读者排队。RCU 的读侧开销在 fast-path 上可以降为 零——在 x86 上 rcu_read_lock() 编译后只是一个编译器屏障 (barrier()),不产生任何原子指令。

二、核心模型:发布-订阅与宽限期

RCU 的正确性建立在两个半协议之上:

  • Publishing:写者构造一份数据的完整新副本,然后用 rcu_assign_pointer() 原子地替换全局指针。旧副本的读者可能还在访问——这正是"Copy"的由来。
  • Grace Period (GP):从指针替换那一刻起,内核必须等待所有在替换前就已经进入临界区的读者退出,之后才能释放旧副本的内存。这段时间叫宽限期。
  • Quiescent State (QS):CPU 如果不在 RCU 临界区内,就处于"静止态"。当 每个 CPU 都经历过至少一次 QS(context switch、idle loop、或用户态执行),一个 GP 就算结束。

用代码展示经典的链表替换:

/* 经典 RCU 链表更新:替换节点 */
struct my_node {
    int data;
    struct my_node __rcu *next;
};

struct my_node __rcu *head;  /* 全局链表头 */

/* 读侧:零开销进入临界区 */
void reader(void) {
    struct my_node *p;
    rcu_read_lock();
    p = rcu_dereference(head);  /* 带内存屏障的指针读取 */
    while (p) {
        printk("data=%d\n", p->data);
        p = rcu_dereference(p->next);
    }
    rcu_read_unlock();  /* x86 上仅为 barrier() */
}

/* 写者:Copy + Update + 延迟 Free */
void updater(void) {
    struct my_node *old, *new = kmalloc(sizeof(*new), GFP_KERNEL);
    new->data = 42;
    spin_lock(&update_lock);
    old = rcu_dereference_protected(head, lockdep_is_held(&update_lock));
    new->next = old->next;
    rcu_assign_pointer(head, new);  /* 原子发布新头 */
    spin_unlock(&update_lock);
    synchronize_rcu();  /* 等待宽限期结束 */
    kfree(old);          /* 此时保证无读者持有 old */
}

关键细节:rcu_assign_pointer() 在弱内存模型 (ARM/POWER) 上会插入 wmb(),保证 new->data = 42 和 new->next = old->next 的写入对后续读者可见 在 指针更新之前。没有这个屏障,读者可能看到一个半初始化的节点。

3. 宽限期检测:Tree RCU 状态机

早期 Flat RCU 用一个全局 cpumask 追踪每 CPU 的 QS 状态,O(N) 的开销在 102 核 NUMA 机器上成为瓶颈。Linux 4.0+ 的 Tree RCU 用层级二叉树替代——每颗 rcu_node 负责一组 CPU 的 QS 汇报。

核心数据结构 (kernel/rcu/tree.h):

struct rcu_node {
    raw_spinlock_t lock;
    unsigned long gpnum;        /* 本节点当前 GP 编号 */
    unsigned long completed;    /* 本节点上次完成的 GP 编号 */
    unsigned long qsmask;       /* 哪些下级节点已报告 QS */
    unsigned long qsmaskinit;   /* 初始时在线的 mask */
    struct rcu_node *parent;    /* 父节点 */
    struct list_head blkd_tasks; /* 需要唤醒的 task */ 
} ____cacheline_internodealigned_in_smem;

/* 每 N 个 CPU 归到一个 node,每 node 再归到上一 node,
   最终汇聚到 root node —— 一个 GP 完成的标志是 root->qsmask == 0 */

QS 报告流程:

  1. CPU 经历 context switch 或进入 idle → 设置 per-cpu 标志 → 向上汇报到所属 leaf node。
  2. leaf node 检查自己的 qsmask 是否清零(即所有下属 CPU 都报告了 QS)→ 往 parent 节点汇报。
  3. root node 的 qsmask 归零 → 调用 rcu_gp_cleanup(),唤醒所有等待 synchronize_rcu() 的写者。

这套机制让 GP 检测的通讯开销从 O(N·CPUS) 降到 O(N·log CPUS)。在 256 核 ARM 服务器上,Tree RCU 将宽限期唤醒延迟降低了 4-8 倍。

四、四类 API 语义矩阵

API 变体读侧临界区期望的 GP 等待典型用途
Classic RCU不可睡眠(禁止抢占/关抢占)synchronize_rcu() — 忙等或调度等待网络路由表、VFS dcache、进程凭证
RCU bh不可睡眠 + 禁止软中断synchronize_rcu_bh() — 等 bh 型 QS网络协议栈 (softirq 密集场景)
RCU Sched可睡眠但仍禁止抢占synchronize_rcu_sched() — 等所有 CPU QS调度器、cgroup
SRCU (Sleepable RCU)可睡眠synchronize_srcu() — 用 per-cpu 计数器追踪读者文件系统 inode、模块参数读取

为什么需要 SRCU?因为 Classic RCU 假设临界区极短(几个内存访问,百纳秒级)。如果读者需要持有 mutex、触发 page fault 或与用户态交互(典型如 sysfs show 操作),Classic RCU 的不可睡眠假设会被破坏——你可能在持有 RCU 锁时被抢占,卡住全系统 GP 前进。SRCU 通过 per-cpu 计数器做两阶段验证,允许读者睡眠,代价是读侧多出一次原子递增 (~3-5ns)。

五、内存屏障语义的精确视图

RCU 的正确性极度依赖正确放置内存屏障。内核文档 Documentation/memory-barriers.txt 给出了严格的偏序关系:

[smp_mb() before rcu_read_lock()]  →  rcu_read_lock()
                                      ↓  (读侧,无任何屏障)
                                     rcu_dereference(p)
                                      ↓  (依赖 barrier,保证 load-load 有序)
                                     *val = load(p->field)
[smp_mb() after rcu_read_unlock()] ←  rcu_read_unlock()

写者侧:
  store(p->x, 1);            // 更新新副本
  smp_wmb();                 // 保证新数据可见先于指针更新
  rcu_assign_pointer(gp, p); // 原子发布 (store-release)

在 Linux 的 ACCESS_ONCE/READ_ONCE 抽象层中,rcu_dereference() 展开为 READ_ONCE() + 屏障依赖:

/* include/linux/rcupdate.h */
#define __rcu_dereference_check(p, c, space) \
    ({ \
        typeof(*p) *________p1addr; \
        rcu_lockdep_assert(c, "RCU-check failed"); \
        rcu_check_sparse(p, space); \
        smp_check_barrier(); \
        ((typeof(*p) __force __kernel *)({ \
            typeof(*p) _________p1 = READ_ONCE(p); \
            smp_barrier_depends(); \
            _________p1; \
        })); \
    })

smp_barrier_depends() 在 x86 上是空操作(TSO 天然保证依赖序),但在 DEC Alpha 这种疯狂重排序的架构上是必需的。这是"一次编写,处处正确"的工程典范。

六、生产级经典使用场景

6.1 VFS 的 dentry 缓存

路径查找时,我们对 dentry 的引用计数用 RCU 保护。lookup_fast() 在 RCU 临界区内遍历 hash 桶;如果 miss,才退回到 lookup_slow() 拿 i_lock spinlock——这就是 VFS 的 "锁分层" 策略:RCU → per-dentry spinlock → mutex,按代价递增逐级退避。

6.2 进程凭证 (cred)

get_current_cred() 用 rcu_dereference_check(current->cred, 1) 获取当前进程的凭证,完全无锁。setuid() 时则会 clone 一份新 cred,rcu_assign_pointer(current->cred, new) 之后 synchronize_rcu() 等待旧读者退出。这个机制让 uid 切换的"读取凭证"路径在 fast-path 上零开销。

6.3 网络:NAPI poll list

NAPI (New API) 模式下poll_list经常有读取(NAPI 调度),偶尔有写入(设备添加/移除)。用 RCU 保护列表遍历,netif_napi_add() 用 list_add_rcu()、netif_napi_del() 用 list_del_rcu() + synchronize_net()。

6.4 eBPF Map 的 RCU 化改造

Linux 4.20 将 BPF_MAP_TYPE_HASH 的查找路径从 spinlock 改成 RCU:bpf_map_lookup_elem() 经过 rcu_read_lock() 进入临界区后,链表遍历零开销。这是 eBPF 在低延迟网络场景 (XDP ~5ns/packet) 的关键优化。

七、SLAB 释放:call_rcu 与 kfree_rcu

synchronize_rcu() 同步等待 GP 在中断上下文不可用——你必须用 call_rcu(),它把回调注册到 GP 完成队列,在非阻塞上下文中执行。这是 RCU 使用者最常用的 API:

/* 经典用法:RCU 释放内存 */
void my_node_free_callback(struct rcu_head *rcu) {
    struct my_node *p = container_of(rcu, struct my_node, rcu);
    kfree(p);
}

/* 写者 */
void update(void) {
    ...
    call_rcu(&old->rcu, my_node_free_callback);
    /* 立即返回,不在临界区阻塞 */
}

/* 内核 4.20+ 封装:kfree_rcu() */
kfree_rcu(old, rcu);  /* 自动用 `__rcu` 字段偏移定位回调 */

call_rcu() 还有一个容易被忽略但极重要的功能:回调合并 (callback batching)。在高写负载下,1000 次 call_rcu() 会被批量成几个 GP 处理,大幅降低 GP 发起频率。kernel 参数 rcu_normal 和 rcu_expedited 控制回调是等待自然 GP (节能,吞吐优先) 还是强制启动"快速 GP" (低延迟优先)。

八、SRCU:可睡眠的 RCU 变体

SRCU 解决"读者可能 block"的场景。实现机制不再是 QS 报告,而是 per-cpu 计数器:

/* 读侧 */
int idx = srcu_read_lock(&my_srcu);  // ++ssp->percpu_ref[idx]
/* 这里可以睡眠!临界区长度无限制 */
srcu_read_unlock(&my_srcu, idx);     // --ssp->percpu_ref[idx]

/* 写者侧 */
synchronize_srcu(&my_srcu);  // ① 切换 idx (0→1 或 1→0)
                             // ② 等待旧 idx 的 per-cpu 计数器归零
                             // ③ GP 完成,安全释放旧数据

"切换序号"的巧妙在于:当写者切换 idx 后,新读者走新序号,旧读者仍在旧序号。写者只等旧序号归零——这就是两阶段等待。Linux 4.21 增加的 SRCU notifier 允许在 GP 即将结束时让写者提前准备 (avoid-starving mechanism),解决了高写负载下读者饿死 SRCU GP 的问题。

九、Rust 中的 RCU 抽象

Linux 6.x 引入 Rust 支持后,rcu 抽象在 rust/kernel/sync/rcu.rs 给出安全绑定:

use kernel::sync::rcu;

// 安全 RCU 临界区
let guard = rcu::read_lock();
let ptr = unsafe { rcu::dereference(node) };
// guard drop 时自动 unlock

// Wrapper 类型:用 RCU 保护的可变共享数据
pub struct RcuProtected(UnsafeCell, RcuLock);

在用户空间,crossbeam-epoch 库提供了类似的 epoch-based reclamation,API 设计几乎经典 RCU 一致——这是 RCU 思想"从内核溢出到用户态"的明证。

十、性能调优与调试

10.1 GP 耗时监控

# 查看每个 GP 的耗时
cat /sys/kernel/debug/rcu/rcu_preempt/rcugp
# 输出:gpnum=1234 completed=1233  # 差1表示有GP正在进行

# 查看 Tree RCU 的节点状态
cat /sys/kernel/debug/rcu/rcu_preempt/rcu_node*
# qsmask=0x00 表示该节点 CPU 都已报 QS

10.2 常见性能陷阱

  • GP 停滞:CPU 在非 idle 状态下长期不报 QS → 该 CPU 上的 RCU 读者临界区卡住(可能睡眠、可能死锁)。用 rcu_cpu_stall_timeout 参数配置超时检测。
  • 回调风暴:短期内大量 call_rcu() 占满 per-cpu 回调队列。调整 rcupdate.rcu_cpu_stall_suppress=0 暴露问题。
  • 虚假共享:多 CPU 频繁写同一 rcu_node 的 qsmask 导致缓存行弹跳 —— Tree RCU 的层级设计就是为了摊销。
  • srcu 读侧太重:srcu_read_lock() 是原子递增,如果单次读取大于 1000 读者并发,计数器可能溢出(实际每个 counter 是 int,够用)。

10.3 eBPF 追踪点

# 列出所有 RCU 相关 tracepoint
bpftrace -l 'tracepoint:rcu:*'

# 追踪 GP 启动/完成
bpftrace -e 'tracepoint:rcu:rcu_grace_period {
    printf("GP %s gpnum=%d\n", comm, args->gpnum);
}'

# 追踪读者进入/退出 (极大开销,仅调试)
bpftrace -e 'tracepoint:rcu:rcu_utilization { printf("%d\n", skbargs->dummy); }'

十一、工程取舍与设计清单

场景选择 RCU?备选方案决策理由
读 1000: 写 1,读者不可睡眠✅ Classic RCUrwlock / seqlock读路径零开销 + 内存释放安全
读 1000: 写 1,读者可睡眠✅ SRCU rwlockrwlock / rwsem读者不阻塞,仅原子递增开销
读+写 频率接近各半❌mutex / percpu_rwlockRCU 的延迟释放会让写路径变长
读极频繁,数据大结构✅ Call_rcurefcount + kfree避免 synchronize_rcu 在写路径阻塞
数据量小、更新频繁且需快速回收❌ Per-CPU 数据cmpxchg 环形缓冲RCU GP 太慢,per-cpu 本地性更好

记住一句话:RCU 是一种"读优先到极致"的取舍——它放弃了写的即时释放权,换取读的零代价。如果你发现在 read_path 里放 rcu_read_lock/unlock 后 benchmark 只提升了 5%,检查一下是否读侧本来就不在 hottest cache line,或者写路径的 GP 等待正在吃掉你的吞吐。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部