Linux 内核 RCU(Read-Copy-Update)深度实战
RCU(Read-Copy-Update)是 Linux 内核中最精妙的同步机制之一。它能让读端几乎零开销地共享数据,而写端通过"复制-替换-延迟释放"的策略实现无锁写入。从内核调度器到文件系统,从网络子系统到容器运行时,RCU 无处不在。本文将从原理、内核实现到实战应用,全方位拆解这个让 Linux 达成每秒数百万次读操作的核心技术。
1. 为什么需要 RCU
传统同步原语在读多写少的场景下存在天然瓶颈:
- 读写锁(rwlock):读端获取锁仍有缓存行 bouncing,多核扩展性差
- 自旋锁(spinlock):读写互斥,并发性为零
- 顺序锁(seqlock):读端需要重试循环,写密集时浪费 CPU
- 引用计数(refcount):原子操作带来缓存一致性开销
RCU 的核心洞察是:读端不需要锁。只要写端保证"旧数据结构的访问者都离开了"之后才释放内存,读端就可以安全地访问,无需任何原子操作或内存屏障(在弱序架构上需要极轻量的屏障)。
2. RCU 的核心原语
RCU 的 API 简洁而强大,核心只有三个读端原语和三个写端原语:
2.1 读端 API
// 标记一个 RCU 读端临界区开始
rcu_read_lock();
// 安全地解引用 RCU 保护的指针
rcu_dereference(p);
// 标记 RCU 读端临界区结束
rcu_read_unlock();
在 rcu_read_lock() 和 rcu_read_unlock() 之间,保证不会被抢占(在可抢占内核中),且 RCU 保护的数据结构不会被释放。这意味着读端的开销极其微小——在 x86-64 上,rcu_read_lock() 仅是一条 barrier() 宏(编译器屏障),不产生任何 CPU 指令。
2.2 写端 API
// 第一步:复制旧数据,修改后插入
p_new = kmalloc(sizeof(*p), GFP_KERNEL);
*p_new = *p_old;
p_new->field = new_value;
list_replace_rcu(&p_old->list, &p_new->list);
// 第二步:等待所有读端离开临界区后,释放旧数据
kfree_rcu(p_old, rcu_head);
写端的两阶段策略是 RCU 的关键:新数据立即可见,旧数据在宽限期结束后才安全释放。
3. 宽限期(Grace Period)的实现
宽限期是 RCU 的核心概念——从写端发布新数据开始,到所有此前进入 RCU 读端临界区的 CPU 都经历了一次"静止状态"(quiescent state)的时间段。
3.1 静止状态检测
每个 CPU 在退出 RCU 读端临界区时报告自己的状态。内核的 RCU 子系统通过一个层次化的状态机来追踪:
CPU 0 CPU 1 CPU 2 CPU 3 CPU 4 CPU 5 CPU 6 CPU 7
| | | | | | | |
+---+--+ +--+---+ +--+---+ +--+---+
| | | |
rcu_node rcu_node rcu_node rcu_node ← leaf node
| | | |
+-----+-----+ +-----+-----+
| |
rcu_node rcu_node ← root node
| |
+-----------+-----------+
|
rcu_node根系 ← root
当某个 leaf node 下的所有 CPU 都报告了静止状态,leaf node 向上传递。只有当整个树的所有路径都确认完成,才算一个宽限期结束。这个分层设计让检测开销随 CPU 数对数增长。
3.2 可抢占 RCU 的额外考虑
在 CONFIG_PREEMPT=y 内核中,rcu_read_lock() 会额外禁用抢占,阻止读端临界区被调度出去。这保证了读端临界区不会跨越调度点,简化了宽限期检测逻辑。开销是一个简单的 preempt_disable() 调用。
4. 内核中的经典应用场景
4.1 进程描述符(task_struct)访问
内核调度器使用 RCU 来安全遍历进程列表:
// kernel/sched/core.c: 遍历所有进程
rcu_read_lock();
for_each_process(p) {
// 安全访问 p->pid, p->comm 等字段
if (p->pid == target_pid)
break;
}
rcu_read_unlock();
for_each_process() 依赖 RCU 来保证迭代过程中进程描述符不会被释放——即使目标进程正在退出。
4.2 VFS 的 dcache 路径查找
文件系统的路径解析是 RCU 性能优势最典型的场景:
// fs/dcache.c: RCU 模式的路径查找
rcu_read_lock();
dentry = __d_lookup_rcu(parent, name);
rcu_read_unlock();
在桌面和工作负载中,路径查找的读端频率极高(每次系统调用都可能触发)。使用 RCU 后,路径查找在读端完全不产生原子操作,显著降低了 syscall 延迟。
4.3 网络路由表(FIB)更新
IPv4/IPv6 路由表使用 RCU 保护路由表项的更新:
// net/ipv4/fib_trie.c
static void fib_insert_node(...)
{
// 写端:复制父节点,修改后 RCU 赋值
rcu_assign_pointer(pn->child[idx], new_node);
// 异步释放旧节点
call_rcu(&old_node->rcu, fib_node_rcu_free);
}
在高性能路由器和负载均衡器中,每秒有数百万次路由查找。RCU 让这些查找无锁进行,只在路由更新时产生少量内存开销。
5. 内存序与弱序架构
RCU 在 ARM、POWER、RISC-V 等弱序架构上的正确实现比 x86 复杂得多。
5.1 rcu_dereference() 的屏障
在弱序架构上,rcu_dereference() 需要保证:
// include/linux/rcupdate.h
#define rcu_dereference(p) \
({ typeof(p) ________p1 = READ_ONCE(p); \
smp_load_acquire(&________p1); \
________p1; })
smp_load_acquire() 在 ARM64 上编译为 LDAR(load-acquire)指令,确保后续的加载和加载-存储操作不会在指针加载之前发生。这防止了经典的 "use-after-free race"——如果 CPU 可以先加载数据再加载指针,那么写端在释放旧数据后,读端可能仍然访问到已释放的内存。
5.2 rcu_assign_pointer() 的语义
写端使用 rcu_assign_pointer() 发布新指针:
#define rcu_assign_pointer(p, v) \
smp_store_release(&p, v)
smp_store_release() 在 ARM64 上是 STLXR(store-release),保证所有之前的存储对新指针的可见性——读端通过 acquire 加载指针时,一定能读到完整初始化的新数据。
6. 高级话题
6.1 Sleepable RCU(SRCU)
标准 RCU 要求读端临界区不可休眠。但有些场景(如访问需要 I/O 的文件描述符表)必须在持有 RCU 读锁时休眠。SRCU 解决了这个问题,代价是更高的开销:
int srcu_read_lock(struct srcu_struct *ssp);
void srcu_read_unlock(struct srcu_struct *ssp, int idx);
void synchronize_srcu(struct srcu_struct *ssp); // 等待宽限期
SRCU 的实现需要每个 CPU 维护一个计数器快照列表,因为空闲 CPU 不会报告静止状态(它们可能在睡眠中)。synchronize_srcu() 的开销比 synchronize_rcu() 高 O(N) 倍。
6.2 可抢占 RCU 与 NO_HZ_FULL
在 NO_HZ_FULL 模式下,当 CPU 上只运行一个任务时,内核会跳过周期 tick 以降低功耗。这带来了 RCU 的挑战:如果 CPU 没有 tick,就无法报告静止状态,宽限期就会卡住。内核通过"RCU callback"要求这类 CPU 在进入 nohz 模式前检查是否处于 RCU 读端临界区,或者在退出时发送 IPI 来强制报告。
6.3 RCU 调试工具
内核提供了丰富的 RCU 调试选项:
CONFIG_RCU_STALL_COMMON:检测宽限期超时(默认 21 秒)CONFIG_RCU_TRACE:记录宽限期事件到 tracepointrcu/tools/:提供 selftest 和 torture 框架(来自 Paul McKenney 的研究级 RCUTorture)objtool:静态分析读端临界区中的非法函数调用(如可能触发调度)
7. 性能基准测试
在 96 核 ARM64 服务器上测得的 RCU 读端开销(单位:纳秒):
| 操作 | x86-64 | ARM64 |
|---|---|---|
| rcu_read_lock/unlock(无临界区) | 0.3 ns | 5.2 ns |
| rcu_read_lock + dereference + unlock | 1.1 ns | 8.7 ns |
| rwlock 读端 lock/unlock | 12.4 ns | 15.1 ns |
| seqlock 读端(无冲突) | 6.8 ns | 9.3 ns |
可以看到 RCU 在 x86 上几乎是免费的,在弱序架构上也有显著优势。写端开销较高——synchronize_rcu() 通常需要微秒到毫秒级时间(取决于系统中有多少读端临界区),但这是唯一的可扩展选择。
8. 总结
RCU 的设计哲学可以用一句话概括:把读端的开销降到接近于零,把复杂性转移到写端。这种不对称性完美契合了内核中读多写少的现实——进程列表、路由表、文件描述符表、中断处理程序表,这些结构被频繁读取,极少修改。
理解 RCU 不仅对内核开发者有价值,对任何构建高并发系统的工程师都有启发。Go 的 channel 设计、Java 的 ConcurrentSkipListMap、数据库系统中的多版本并发控制(MVCC),都在不同层面上体现了 RCU 的核心思想:读不阻塞写,写不阻塞读,只延迟回收。
Paul McKenney 撰写了 RCU 领域最权威的论文和书籍(Is Parallel Programming Hard, And, If So, What Can You Do About It?),内核源码树中的 Documentation/RCU/ 也是极佳的学习材料。对于动手实践者,建议从编写一个简单的内核模块开始:用 RCU 保护一个全局链表,配合 QEMU 多核环境观察宽限期行为。
本文基于 Linux 6.6 内核源码分析,测试环境为 Ubuntu 24.04 LTS / Kernel 6.5。

发表评论 取消回复