引言
在并发编程的世界中,读多写少场景始终是最棘手的难题之一。传统读写锁(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 的完整生态圈,成为系统级编程中处理读多写少场景时不可或缺的工具。

发表评论 取消回复