Linux 内核 RCU:Read-Copy-Update 并发机制深度工程实战
在 Linux 内核的并发工具箱中,RCU(Read-Copy-Update)是最精妙也最令初学者困惑的机制。它允许多个读者完全不加锁地访问共享数据,而写者则在副本上修改后原子替换指针,并延迟回收旧数据。这种"读多写少"场景下的极致设计,使 RCU 成为内核网络栈、文件系统、路由子系统的核心基础设施。本文将从硬件内存模型出发,逐层拆解 RCU 的实现原理、API 体系、场景陷阱与工程决策。
一、RCU 的问题定义与理论基础
1.1 读写锁的根本性缺陷
经典的 rwlock 在读者持有期间完全排斥写者——当存在大量读者时,写者面临严重的饥饿问题。即使在读多写少的场景下,rwlock 的原子操作(至少是一次 atomic increment)也会在多核之间引发缓存行乒乓(cache-line bouncing),导致可扩展性崩溃。
RCU 的哲学完全不同:读者不承担任何写操作。读取路径只需要禁用内核抢占(在经典 RCU 中),本质上零开销。
1.2 内存模型与happens-before
理解 RCU 必须先建立内存屏障的心智模型:
- Publication(发布):写者更新全局指针时,必须保证新数据的初始化对读者可见。
smp_wmb()(写屏障)确保发布前所有写入完成。 - Subscription(订阅):读者在解引用指针前,必须保证能看到完整数据。
smp_rmb()(读屏障)确保指针读取后再读取所指内容。 - Grace Period(宽限期):从写者发布新指针开始,到"所有在发布前开始的读临界区都结束"的时间窗口。
时间线:
Writer: |--publish(new_ptr)--|--synchronize_rcu()--|
Reader-1: |--rcu_read_lock--| |--rcu_read_unlock--|
Reader-2: |--rcu_read_lock-----------------|--rcu_read_unlock--|
Grace Period: = 所有旧读端临界区的最大跨度
二、RCU 核心 API 详解
2.1 经典 RCU 读端 API
// 进入读临界区(仅禁止内核抢占,不产生原子操作)
rcu_read_lock();
// 安全地解引用受 RCU 保护的指针
void *p = rcu_dereference(gp);
if (p)
do_something_with(p);
// 退出读临界区
rcu_read_unlock();
rcu_dereference() 在非 DEC Alpha 架构上是一个简单的 barrier() + 类型转换;在 α 平台则插入完整的读内存屏障。它返回的是"获取那一刻"的指针值,编译器不能将其后面的内存读取重排到它前面。
2.2 写端 API
┌────────────────────────────────────┐
写入端 │ 1. kmalloc / 初始化新数据 │
│ 2. 修改新数据的各字段 │
│ 3. rcu_assign_pointer(gp, new) │
│ 4. synchronize_rcu() 或 call_rcu() │
│ 5. kfree(old) │
└────────────────────────────────────┘
rcu_assign_pointer():带有写内存屏障的指针赋值,确保 new 的所有字段初始化在指针发布之前完成。
两种等待宽限期的策略:
| API | 行为 | 使用场景 |
|---|---|---|
synchronize_rcu() |
阻塞等待所有当前读者离开读临界区 | 模块卸载、路径不能睡眠 |
call_rcu(head, func) |
异步回调,宽限期结束后执行 func | 高频写入、不可阻塞上下文 |
2.3 SRCU(Sleepable RCU)
当读临界区可能睡眠(如访问用户内存、执行 I/O)时,必须使用 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 的开销高于经典 RCU——它在读端维护了一个 per-CPU 计数器阵列,且 synchronize_srcu() 的开销随 CPU 数量线性增长。但在需要睡眠的路径上,这是唯一的选择。
三、RCU 的内核实现机制
3.1 拓扑感知的宽限期推进
Linux 4.x 以后,RCU 采用树状拓扑(rcu_node 树)跟踪各 CPU 上的读者状态:
rcu_node (root)
/ \
rcu_node(0) rcu_node(1)
/ \ / \
CPU0..3 CPU4..7 CPU8..11 CPU12..15
每个叶子节点监控一批 CPU,记录它们是否通过了 quiescent state(静止状态,即 CPU 离开读临界区或发生上下文切换)。只有所有 CPU 都通过一次静止状态,宽限期才算结束。
3.2 dyntick-idle 优化
在 NO_HZ_IDLE 系统中,当 CPU 空闲时停止周期性时钟中断,此时 CPU 处于 dyntick-idle 模式——它自然处于静止状态。RCU 利用这一特性,无需等待时钟 tick 即可确认空闲 CPU 的静止状态,大幅缩短宽限期判定延迟。
3.3 Tree RCU 的状态机
每个 CPU 的 rcu_data:
├── completed: 已完成的 GP 编号
├── gpnum: 当前正在经历的 GP 编号
└── qs[4]: 每级 rcu_node 的 QS 确认位
GP 推进流程:
1. GP 开始:设置 gpnum,等待所有 CPU 报告 QS
2. 叶子层收集 QS,逐层向根节点上报
3. 根节点确认后,触发回调执行
3.4 call_rcu 的回调批处理
频繁的 call_rcu 不会立刻触发宽限期检测。内核将回调组织在 rcu_data->nxtlist 链表中,通过 RCU_SOFTIRQ 在适当的时机批量执行,减少 GP 起始次数并摊销 softirq 开销。
四、经典 RCU 模式:受保护链表的增删改查
4.1 RCU 链表遍历
struct my_node {
int key;
void *data;
struct rcu_head rcu; // 嵌入其中供 call_rcu 使用
struct list_head node;
};
// 读者:完全无锁遍历
struct my_node *find(struct list_head *head, int key)
{
struct my_node *p;
rcu_read_lock();
list_for_each_entry_rcu(p, head, node) {
if (p->key == key) {
rcu_read_unlock();
return p;
}
}
rcu_read_unlock();
return NULL;
}
4.2 RCU 安全删除
void delete_node(struct list_head *head, int key)
{
struct my_node *p, *tmp;
struct my_node *to_free = NULL;
write_lock(&my_lock); // 写者之间仍需要互斥
list_for_each_entry_safe(p, tmp, head, node) {
if (p->key == key) {
list_del_rcu(&p->node);
to_free = p;
break;
}
}
write_unlock(&my_lock);
if (to_free) {
synchronize_rcu(); // 等待所有读者安全离开
kfree(to_free); // 再无读者访问,安全释放
}
}
4.3 RCU 替换(Update)
void replace_node(struct list_head *head, int key, void *new_data)
{
struct my_node *old, *new;
new = kmalloc(sizeof(*new), GFP_KERNEL);
new->key = key;
new->data = new_data;
write_lock(&my_lock);
old = find_locked(head, key); // 在写锁保护下找到旧节点
list_replace_rcu(&old->node, &new->node);
write_unlock(&my_lock);
call_rcu(&old->rcu, free_node_callback);
}
static void free_node_callback(struct rcu_head *rh)
{
struct my_node *old = container_of(rh, struct my_node, rcu);
kfree(old);
}
五、RCU 在内核子系统中的工程应用
5.1 网络路由缓存(dst/route)
Linux 路由查找采用 RCU 保护的路由缓存。路由表频繁被查找(每包一次),但更新频率相对极低。RCU 使得 ip_route_output() 路径上几乎零开销:
__ip_route_output_key()
rcu_read_lock()
rt = rcu_dereference(rp->table) // 获取当前路由表
... 计算哈希、查找 ...
rcu_read_unlock()
5.2 文件系统 inode 缓存
Linux VFS 的 inode_hash_table 受 RCU 保护。find_inode_fast() 在 RCU 模式下遍历哈希链,无锁查找 inode:
// fs/inode.c
static struct inode *find_inode_fast(struct super_block *sb,
struct hlist_head *head, unsigned long ino)
{
struct inode *inode;
hlist_for_each_entry_rcu(inode, head, i_hash) {
if (inode->i_ino == ino && inode->i_sb == sb) // BC-ing ipref
return inode;
}
return NULL;
}
iput() 路径中 inode 从活跃链表移除后使用 call_rcu 延迟释放。
5.3 进程热插拔与 CPU Mask
cpu_online_mask 使用 RCU 保护。CPU online/offline 事件触发 mask 修改时,读者(如调度器、cpuset)通过 cpumask_test_cpu 等 RCU 安全接口访问。
5.4 内核模块引用计数
try_module_get() 路径避免了原子操作开销,而模块卸载使用 synchronize_rcu() 确保所有引用者退出。
5.5 Netfilter 规则链
iptables/nftables 的 rule chain 在查找时使用 RCU 遍历,写入时使用 synchronize_rcu + 替换规则的新版本。
六、RCU vs Mutex vs Rwlock:性能工程决策
6.1 标准微基准测试
在 128 核 ARM64 服务器上的典型结果(读多写少,读:写=1000:1):
┌──────────────┬────────────────┬──────────────────┬─────────────────┐
│ 同步机制 │ 读者延迟(ns) │ 写者延迟(ns) │ 总吞吐量(Mops/s) │
├──────────────┼────────────────┼──────────────────┼─────────────────┤
│ RCU │ ~5 │ ~50000 + GP时间 │ ~1250 │
│ rwlock │ ~25 │ ~26 │ ~850 │
│ mutex │ ~55 │ ~56 │ ~620 │
│ seqlock │ ~15 (无重试) │ ~20 │ ~1100 │
│ rwsem │ ~30 │ ~32 │ ~780 │
└──────────────┴────────────────┴──────────────────┴─────────────────┘
6.2 决策矩阵
| 场景 | 推荐机制 | 理由 |
|---|---|---|
| 读极为频繁,写罕见,不能阻塞读者 | RCU | 读者零开销,可扩展到千核 |
| 读多写少,偶尔需重试 | Seqlock | 写者优先,读者可重试 |
| 写稍频繁,容忍读者短暂阻塞 | rwlock/rwsem | 实现简单 |
| 读写相当,临界区小 | spinlock | 最简语义 |
| 读者需睡眠 | SRCU 或 mutex | 避免死锁 |
| 临界区递归 | mutex | 唯一支持递归的 |
6.3 RCU 不适用场景
- 写远比读多:宽限期成本每次都需支付,总开销大于锁
- 临界区内需获取其他锁(可能死锁的顺序):RCU 读临界区获取 spinlock 是合法的;但获取 mutex 可能导致睡眠死锁(经典 RCU 中禁止睡眠)
- 超大临界区的实时路径:宽期可能阻塞太久
七、高级 RCU 变体
7.1 RCU Tasks Trace(RT-RCU)
专为 tracing 设计,读端仅需 preempt_disable()(甚至不需要),宽限期采用 IPI(处理器间中断)精确追踪。Tasks-RCU 家族的变体包括 RCU-Tasks、RCU-Tasks-Rude、RCU-Tasks-Trace,各有不同的延迟目标。
7.2 RCU Priority Boosting
在 RT(实时)内核中,RCU 读者可能阻塞实时写者。内核引入优先级继承机制:当高优先级任务等待宽限期,而低优先级任务持有读临界区时,临时提升低优先级任务的优先级。
7.3 BPF 中的 RCU
eBPF map 操作(如 bpf_map_lookup_elem)在内核中依赖 RCU 保护。BPF 程序可以自由调用 RCU 辅助函数,因为 BPF 上下文本身就是 RCU 读临界区。
八、调试与故障排查
8.1 CONFIG_PROVE_RCU
启用 CONFIG_PROVE_RCU 后,RCU 锁验证器严格检查每一条规则:
WARNING: suspicious rcu_dereference_check() usage
vfs_read+0x45/0x120
RCU-protected pointer used outside of RCU read-side critical section
这会捕获在 RCU 读临界区之外使用 rcu_dereference() 的危险操作。
8.2 RCU Stall 检测
CONFIG_RCU_STALL_COMMON 启用宽限期超时检测。当宽限期拖延超过 rcu_stall_kernel_timeout(默认约 21 秒,可通过 sysctl 调整)时,内核打印调用栈:
INFO: RCU GPdetected stall on CPU 3
rcu_sched kthread starving for 26436 jiffies!
常见原因: - CPU 长时间关中断(如死循环) - CPU 长时间处于 dyntick-idle 但 RCU 不认可 - 单个读者持有读临界区过长
8.3 rcu_dereference_sparse / __rcu 标记
Sparse 静态分析工具配合 __rcu 标记检查类型安全:
struct foo __rcu *gp; // 告诉 sparse 这个指针需要 rcu_dereference
错误的直接解引用会触发稀疏警告。
8.4 常见陷阱一:忘记同步
// 危险!没有等待宽限期就释放旧数据
list_del_rcu(&p->node);
kfree(p); // BUG! 读者可能还在访问 p
正确做法:使用 synchronize_rcu() 或 call_rcu(&p->rcu, free_callback)。
8.5 常见陷阱二:经典 RCU 中睡眠
rcu_read_lock();
copy_from_user(buf, ubuf, len); // 可能触发缺页 → 睡眠 → 死锁!
rcu_read_unlock();
正确做法:在外面临时拷贝用户内存,或改用 SRCU。
8.6 常见陷阱三:写者之间未同步
// RCU 只保护读者-写者,不保护写者-写者!
// 并发调用 this function 将导致数据竞争
list_add_rcu(&new_node->node, head);
正确做法:写者之间仍需使用 spinlock 或 mutex。
九、实战:实现一个 RCU 保护的并发哈希表
#define HT_BUCKETS 256
struct ht_entry {
u32 key;
void *value;
struct hlist_node hlist;
struct rcu_head rcu;
};
struct ht_bucket {
struct hlist_head head;
spinlock_t lock;
};
static struct ht_bucket ht_buckets[HT_BUCKETS];
// 查询 — 纯 RCU 无锁
void *ht_lookup(u32 key)
{
struct ht_entry *e;
u32 hash = jhash_1word(key, 0) & (HT_BUCKETS - 1);
void *val = NULL;
rcu_read_lock();
hlist_for_each_entry_rcu(e, &ht_buckets[hash].head, hlist) {
if (e->key == key) {
val = e->value;
break;
}
}
rcu_read_unlock();
return val;
}
// 插入 — 需 spinlock
int ht_insert(u32 key, void *value)
{
struct ht_entry *e = kmalloc(sizeof(*e), GFP_KERNEL);
u32 hash;
if (!e) return -ENOMEM;
e->key = key;
e->value = value;
hash = jhash_1word(key, 0) & (HT_BUCKETS - 1);
spin_lock(&ht_buckets[hash].lock);
hlist_add_head_rcu(&e->hlist, &ht_buckets[hash].head);
spin_unlock(&ht_buckets[hash].lock);
return 0;
}
// 删除 — spinlock + call_rcu
void ht_delete(u32 key)
{
struct ht_entry *e;
u32 hash = jhash_1word(key, 0) & (HT_BUCKETS - 1);
spin_lock(&ht_buckets[hash].lock);
hlist_for_each_entry(e, &ht_buckets[hash].head, hlist) {
if (e->key == key) {
hlist_del_rcu(&e->hlist);
spin_unlock(&ht_buckets[hash].lock);
call_rcu(&e->rcu, ht_free_entry);
return;
}
}
spin_unlock(&ht_buckets[hash].lock);
}
static void ht_free_entry(struct rcu_head *rh)
{
struct ht_entry *e = container_of(rh, struct ht_entry, rcu);
kfree(e);
}
十、RCU 与硬件内存模型进阶
10.1 ARM64 的 RCU 屏障生成
ARM64 弱序模型要求更谨慎的内存屏障使用。rcu_assign_pointer() 在 ARM64 上映射为 store-release(stlr 指令),确保所有之前的写入对随后加载新指针的 CPU 可见。
rcu_dereference() 映射为 load-acquire(ldar),可以与写入端的 release 配对。
10.2 PowerPC 的 lwsync
PowerPC 使用 lwsync(轻量同步)实现 RCU 屏障——这是一种比完整 sync 更高效但仍能保证 RCU 顺序性的指令。
10.3 x86 的 TSO 和隐式屏障
x86 的 TSO(Total Store Order)内存模型天然保证:写者写指针的操作不会被读者看到。因此 rcu_assign_pointer() 在 x86 上退化为仅仅防止编译器重排序。
十一、RCU 与 eBPF 的协同
eBPF 程序在 RCU 读临界区内执行(从 syscall entry 退出到 do_softirq 之前)。这意味着 BPF 程序可以直接使用 bpf_rcu_read_lock() / bpf_rcu_read_unlock() 保护对 RCU 保护内核结构的安全访问。
// BPF 程序中使用 RCU
SEC("kprobe/ht_lookup")
int BPF_PROG(trace_ht_lookup, u32 key)
{
bpf_rcu_read_lock();
// 安全地访问内核中 RCU 保护的哈希表
void *val = bpf_map_lookup_elem(&user_ht, &key);
if (val) {
bpf_printk("found value %p for key %u\n", val, key);
}
bpf_rcu_read_unlock();
return 0;
}
十二、未来方向:URCU 与 Rust RCU
12.1 Userspace RCU (URCU)
LTTng、 dyninst 等项目使用用户态 RCU(liburcu)实现与内核 RCU 类似的无锁读取。URCU 采用 qsbr(Quiescent State Based Reclamation)模型:读者显式声明静止状态,写者扫描所有读者确认后回收。
12.2 Rust for Linux 中的 RCU
Rust 内核模块可以通过 kernel::rcu 模块安全绑定 RCU API:
use kernel::rcu;
struct MyData {
value: u64,
}
impl MyData {
fn read() -> Result<u64> {
let guard = rcu::read_lock(); // 进入读临界区
let p = unsafe { rcu::dereference(self.gp) };
Ok(unsafe { (*p).value })
// guard drop 时自动调用 rcu_read_unlock
}
}
利用 Rust 的 RAII 和生命周期保障,可消除忘记配对的 rcu_read_lock/unlock 问题。
十三、总结与工程决策清单
RCU 不是万能的同步原语,而是在特定场景下(读多写少 + 读者不可阻塞)无与伦比的性能引擎。工程中使用 RCU 的决策清单:
□ 读路径是否频繁?(> 1000:1 读写比)
□ 读者是否可以承受零开销?(答案是 RCU 的核心优势)
□ 读者是否可能在临界区内睡眠?(是则必须使用 SRCU)
□ 写者之间是否已另有互斥保护?(RCU 不管写者-写者竞争)
□ 宽实时延是否可接受?(`synchronize_rcu()` 在繁忙系统可达数十毫秒)
□ 旧数据的回收是否可以延迟?(必须能等待宽限期结束)
□ 是否在 debug 环境启用了 CONFIG_PROVE_RCU?(应该)
掌握 RCU,意味着拿到了通往 Linux 内核高性能区域的路由钥匙。从网络包处理到文件查找,从路由表到热插拔——凡追求"读者感知不到存在"的地方,RCU 就在那里安静地工作。

发表评论 取消回复