一、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 报告流程:
- CPU 经历 context switch 或进入 idle → 设置 per-cpu 标志 → 向上汇报到所属 leaf node。
- leaf node 检查自己的 qsmask 是否清零(即所有下属 CPU 都报告了 QS)→ 往 parent 节点汇报。
- 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 RCU | rwlock / seqlock | 读路径零开销 + 内存释放安全 |
| 读 1000: 写 1,读者可睡眠 | ✅ SRCU rwlock | rwlock / rwsem | 读者不阻塞,仅原子递增开销 |
| 读+写 频率接近各半 | ❌ | mutex / percpu_rwlock | RCU 的延迟释放会让写路径变长 |
| 读极频繁,数据大结构 | ✅ Call_rcu | refcount + kfree | 避免 synchronize_rcu 在写路径阻塞 |
| 数据量小、更新频繁且需快速回收 | ❌ Per-CPU 数据 | cmpxchg 环形缓冲 | RCU GP 太慢,per-cpu 本地性更好 |
记住一句话:RCU 是一种"读优先到极致"的取舍——它放弃了写的即时释放权,换取读的零代价。如果你发现在 read_path 里放 rcu_read_lock/unlock 后 benchmark 只提升了 5%,检查一下是否读侧本来就不在 hottest cache line,或者写路径的 GP 等待正在吃掉你的吞吐。

发表评论 取消回复