Linux 内核 RCU (Read-Copy-Update) 深度工程实战:从宽限期机制到生产级无锁设计
引言:为什么需要 RCU
在 Linux 内核的并发工具箱中,读写锁(rwlock)和顺序锁(seqlock)为读多写少的场景提供了解决方案,但它们各有致命缺陷:读写锁的写者会阻塞所有读者,在中断上下文中无法使用;顺序锁的读者必须重试,在写入频繁时性能急剧退化。
RCU (Read-Copy-Update) 是 Linux 内核中一个独特且精妙的同步机制,它允许多个读者完全不加锁地并发访问共享数据,而写者通过"复制-更新-延迟回收"的方式完成修改。在 6.x 内核中,RCU 相关的调用超过 60,000 次,几乎支撑了文件系统、网络栈、调度器、内存管理等核心子系统的性能关键路径。
本文将从 RCU 的基本语义出发,深入剖析宽限期(Grace Period)的底层实现、回调批处理机制、可抢占 RCU 的设计取舍,并结合内核真实代码给出生产级 RCU 编程范式与常见陷阱。
1. RCU 的三段式语义
1.1 核心 API 全景
RCU 的编程模型异常简洁,只有三个核心原语:
// 读者侧:标记读侧临界区
rcu_read_lock();
p = rcu_dereference(ptr); // 获取受 RCU 保护的指针
/* 访问 *p ... */
rcu_read_unlock();
// 写者侧:更新受 RCU 保护的指针
new_ptr = kmalloc(...);
new_ptr->data = new_value;
rcu_assign_pointer(ptr, new_ptr); // 原子替换
synchronize_rcu(); // 等待所有现有读者退出
kfree(old_ptr); // 安全回收旧数据
1.2 rcu_read_lock/unlock 的真实代价
在经典配置(!CONFIG_PREEMPT)下,rcu_read_lock() 和 rcu_read_unlock() 是零开销的空操作——它们不修改任何内存,不执行任何屏障。读者侧的唯一成本来自 rcu_dereference() 插入的编译器屏障(compiler barrier),防止 CPU/编译器重排导致获取到旧的指针后又读取到旧数据。
在可抢占 RCU(CONFIG_PREEMPT_RCU)下,rcu_read_lock() 会增加当前任务的嵌套计数(current->rcu_read_lock_nesting),这使得调度器知道当前任务正处于读侧临界区,不会将其移出运行队列。
1.3 rcu_assign_pointer 的内存序保证
rcu_assign_pointer() 不仅仅是赋值——它保证:
- 所有对
new的初始化写入在读侧可见之前完成(StoreStore 屏障) - 指针替换是原子的(对 aligned pointer 天然原子,但语义上仍有 smp_wmb())
// include/linux/rcupdate.h
#define rcu_assign_pointer(p, v) \
smp_store_release(&p, RCU_INITIALIZER(v))
这里使用 smp_store_release() 而非 smp_wmb(),因为现代 ARM/RISC-V 等弱内存模型架构需要 release store 语义来保证全局可见性。
2. 宽限期 (Grace Period) 的实现机制
2.1 什么是宽限期
宽限期是 RCU 的核心概念:从写者替换指针开始,到所有在替换前进入读侧临界区的读者都退出为止的时间段。宽限期结束后,写者可以安全回收旧数据。
关键性质:宽限期只关心"在替换前就已存在的读者",替换后进入的新读者看到的是新数据,因此无需等待。
2.2 静默状态 (Quiescent State) 检测
内核如何知道某个 CPU 上没有 RCU 读者?答案是通过静默状态检测:如果一个 CPU 经历了以下任一事件,它就处于 RCU 静默状态:
- 执行了上下文切换(说明不再在 rcu_read_lock/unlock 之间)
- 进入 idle 循环
- 从用户态返回内核(在 USER_RCU 配置下)
内核为每个 CPU 维护一个 rcu_data 结构:
struct rcu_data {
unsigned long gp_seq; // 当前 GP 的序列号
unsigned long rcu_qs_ctr_snap; // 快照时的 QS 计数器
bool core_needs_qs; // 是否需要报告 QS
...
struct rcu_node *mynode; // 指向 rcu_node 树中的节点
} ____cacheline_internodealigned_in_smp;
2.3 层级式 RCU 节点树
在大规模 NUMA 系统(数百个 CPU)上,每个 CPU 独立向全局变量报告静默状态会导致严重的缓存行弹跳(cache-line bouncing)。RCU 通过层级节点树解决此问题:
struct rcu_node {
raw_spinlock_t lock; // 节点锁
unsigned long qsmask; // 子节点 QS 位掩码
unsigned long qsmaskinit; // 初始 QS 掩码
struct rcu_node *parent; // 父节点
...
unsigned long grpmask; // 兄弟节点中的位掩码
int grplo; // 该节点管的 CPU 起始编号
int grphi; // 该节点管的 CPU 结束编号
};
叶子节点代表单个 CPU(或小批量 CPU),内部节点聚合子节点的 QS 状态。当一个节点的所有子节点都报告了静默状态时,该节点向父节点报告。最终根节点得知所有 CPU 都经历了至少一次静默状态时,宣告宽限期结束。
2.4 GP 状态机
RCU 使用一个精巧的状态机来管理宽限期:
enum rcu_state {
RCU_STATE_IDLE = 0, // 空闲状态
RCU_STATE_WAIT_GPS, // 等待 GP 开始确认
RCU_STATE_GP, // GP 进行中
RCU_STATE_WAIT_FQS, // 等待 FQS (Force QS) 循环
};
// GP 的序列号采用格雷码风格递增
// 每个 CPU 的 rcu_data->gp_seq 记录它已完成的最后一个 GP
内核启动一个 rcu_gp_kthread 内核线程来驱动状态机:当有写者调用 synchronize_rcu() 时,该线程推进 GP 状态,启动静默状态检测,并在所有 CPU 完成后通知等待者。
2.5 回调机制:call_rcu 的延迟回收
synchronize_rcu() 会阻塞写者直到宽限期结束。对于不能阻塞的上下文(如中断、软中断),内核提供 call_rcu():注册一个回调,在宽限期结束后异步执行。
call_rcu(&old_node->rcu_head, my_callback_func);
// 回调签名
void my_callback(struct rcu_head *head) {
struct my_node *node = container_of(head, struct my_node, rcu_head);
kfree(node);
}
内核维护一个 rcu_cblist(回调链表),使用四缓冲队列(4-bucket)设计实现高效的批处理:
struct rcu_cblist {
struct rcu_head *head;
struct rcu_head **tail;
long len; // 待处理回调计数
};
// 每个 rcu_data 有 4 个回调链表:
// [RCU_WAIT_TAIL] - 等待当前 GP 完成
// [RCU_NEXT_READY_TAIL] - 当前 GP 已完成,等待下一个 GP
// [RCU_NEXT_TAIL] - 已绑定到下一个待启动 GP
// [ DONE_TAIL ] // 已完成,可以回调
3. 三种 RCU 变体
内核同时维护三种 RCU 域,阻塞其中一个域的读者不影响其他域的读者:
| 变体 | 读者 API | 使用场景 | 静默状态定义 |
|---|---|---|---|
rcu_sched | rcu_read_lock()/unlock() | 通用内核代码 | 上下文切换或进入 idle |
rcu_bh | rcu_read_lock_bh()/unlock_bh() | 下半部上下文 | 无 BH 执行(非 softirq) |
rcu_preempt | rcu_read_lock()/unlock() (PREEMPT_RCU) | 实时代码 | 上下文切换或进入 idle |
选择原则:
- 普通内核代码使用
rcu_read_lock(),在 PREEMPT_RCU 下映射到rcu_preempt,否则映射到rcu_sched - 在 softirq 或 tasklet 中使用
rcu_read_lock_bh(),synchronize_rcu_bh()(因为synchronize_rcu()不需要等待 BH 型读者,而synchronize_rcu_bh()需要等待 sched 型读者的静默状态) - 需要不允许读者被抢占的场景使用
synchronize_rcu_tasks(),等待所有任务通过用户态/syscall 退出临界区
4. 内核真实案例解析
4.1 文件描述符表的 RCU 保护
task_struct->files->fdt(文件描述符表)是 RCU 最经典的用例。fork 时共享文件表,写者(close_on_exec/扩展表)通过 RCU 替换实现无锁读取:
// fs/file.c
struct files_struct *get_files_struct(struct task_struct *task)
{
struct files_struct *files;
rcu_read_lock();
files = task->files; // RCU 保护读取
if (files)
atomic_inc(&files->count);
rcu_read_unlock();
return files;
}
// 写者侧:替换 fdt
expand_fdtable(files, nr) {
new_fdt = alloc_fdtable(nr);
copy(old_fdt, new_fdt);
rcu_assign_pointer(files->fdt, new_fdt);
call_rcu(&old_fdt->rcu, free_fdtable_rcu);
}
4.2 VFS 路径查找的 RCU 模式
Linux VDCACHE(DCACHE 中的 RCU-walk 模式)使用 RCU 加速路径查找:在大多数情况下不需要持有 dcache_lock,通过 rcu_read_lock() 即可遍历 dentries:
// fs/namei.c - rcu-walk 片段
static int lookup_fast(struct nameidata *nd, struct inode **inode)
{
struct dentry *dentry;
if (nd->flags & LOOKUP_RCU) { // RCU 模式
dentry = __d_lookup_rcu(parent, &nd->last, &seq);
// 验证序列号(seqcount_latch)检测并发重命名
if (!dentry || unlikely(...))
return -EAGAIN; // 回退到 ref-walk
}
}
当 RCU-walk 发现需要处理重命名、挂载点跨越等复杂情况时,它会返回 -EAGAIN 让调用者降级到传统的 ref-walk 模式(持有 dentry 引用和锁)。
4.3 网络协议栈:TCP 哈希表
内核 TCP 连接哈希表(tcp_hashinfo)使用 RCU 保护。查找 TCP socket 时走 RCU 路径:
// net/ipv4/tcp_ipv4.c
struct sock *tcp_v4_lookup(struct sk_buff *skb)
{
// RCU 读取 establ/bind/listening hash 表
sk = __inet_lookup_established(net, hashinfo, ...);
// 检查 sk 有效性:refcount_inc_not_zero
if (sk && likely(refcount_inc_not_zero(&sk->sk_refcnt)))
return sk;
return NULL;
}
// 关闭连接:将 socket 从 RCU hash 中移除
inet_unhash(struct sock *sk) {
// 移除指针
rcu_assign_pointer(..., NULL);
synchronize_net(); // 等效于 synchronize_rcu() + barrier
// 现在可以安全释放 sk
}
4.4 进程PID查找
find_task_by_pid_ns() 使用 RCU 保护 task list。pid_hash 中的任务链表用 RCU 分配和释放。
5. Sleepable RCU (SRCU)
标准 RCU 要求读者侧不可睡眠——因为静默状态检测依赖"如果 CPU 发生上下文切换则不在临界区"的假设。如果读者可能阻塞在 mutex/内存分配等可睡眠操作中,需要使用 SRCU:
// SRCU 读者可以睡眠
idx = srcu_read_lock(&my_srcu);
/* 此处可以睡眠、调度、分配内存 */
p = rcu_dereference(ptr);
srcu_read_unlock(&my_srcu, idx);
// 写者侧
synchronize_srcu(&my_srcu); // 等待宽限期
...
SRCU 的实现比标准 RCU 复杂得多:每个 CPU 维护独立的嵌套计数器(srcu_struct 数组形式),通过追踪每个 CPU 不再持有读侧引用来判定宽限期结束。由于读者的嵌套计数可能被调度器保存/恢复,SRCU 宽限期延迟显著高于标准 RCU。
SRCU 典型用途:notifier chains、BPF 程序链表、设备驱动中的可睡眠遍历。
6. 生产级 RCU 编程范式
6.1 单指针替换模式
// 最常见的模式:用 RCU 保护一个全局指针
static struct my_config * __rcu *g_config_ptr;
// 读取
struct my_config *config;
rcu_read_lock();
config = rcu_dereference(*g_config_ptr);
if (config) {
// 使用 config
val = config->some_field;
}
rcu_read_unlock();
// 更新
struct my_config *new_config = kmalloc(sizeof(*new_config), GFP_KERNEL);
*new_config = *old_config; // 复制
new_config->some_field = new_val;
rcu_assign_pointer(*g_config_ptr, new_config);
synchronize_rcu();
kfree(old_config);
6.2 链表遍历模式
// hlist (哈希表链表)
struct my_node {
struct hlist_node rcu_head; // 注意:hlist 原生支持
int key;
int value;
};
// 遍历 (读侧)
hlist_for_each_entry_rcu(pos, head, rcu_head) {
if (pos->key == target) {
return pos->value;
}
}
// 添加节点
struct my_node *new = kmalloc(...);
hlist_add_head_rcu(&new->rcu_head, head);
// 删除节点
hlist_del_rcu(&node->rcu_head);
kfree_rcu(node, rcu_head); // 6.x: 自动释放,无需手动 call_rcu
6.3 kfree_rcu (6.x 新特性)
Linux 6.x 引入 kfree_rcu(),将内核对象的 rcu_head 与 kfree 延迟绑定,简化了常见的"RCU 保护的单次分配对象"模式:
struct my_obj {
struct rcu_head rcu; // 必须是第一个或最后一个成员(6.1+)
int data;
};
// 不再需要自定义 callback
kfree_rcu(obj, rcu); // 等效于 call_rcu(&obj->rcu, kfree)
内部实现使用特殊的 slab cache(rcu_kfree_deferred)批量回收。
6.4 读侧嵌套与中断处理
RCU 读侧临界区可以嵌套且支持中断处理中追加加锁:
rcu_read_lock();
p1 = rcu_dereference(gp1);
do_something(p1);
// 中断中也可以再 lock
rcu_read_lock(); // 嵌套
p2 = rcu_dereference(gp2);
rcu_read_unlock();
rcu_read_unlock();
但注意:标准 RCU 读者不可睡眠。如果在 rcu_read_lock/unlock 之间调用可能睡眠的函数(如 copy_from_user、kmalloc(GFP_KERNEL)),宽限期可能无法结束 → 死锁。
7. 宽限期延迟的实测与调优
7.1 延迟来源
synchronize_rcu() 的延迟取决于:
- 最慢 CPU 经历静默状态的时间
- CPU 是否在长时间关闭抢占(preempt_disable)或中断(irqs_disabled)
- CPU 是否在 NMI handler 中执行
- rcu_gp 线程的调度延迟
典型延迟范围:
- 空闲系统:1-5 ms
- 高负载系统:10-30 ms
- 有 CPU 繁忙自旋(关中断循环):秒级甚至触发 RCU stall 告警
7.2 关键参数调优
/sys/module/rcutree/parameters/rcu_normal=1 # 加速 GP 结束(小代价)
/sys/module/rcutree/parameters/rcu_normal_after_boot=1 # 启动后自动加速
# 内核启动参数
rcu_nocbs=0-3 # offload RCU callback 到其他 CPU(实时场景)
rcu_nocb_poll # polling 模式(替代 irq 唤醒,低延迟)
rcutree.kthread_prio=50 # GP 线程优先级(提高以减少延迟)
7.3 RCU Stall 检测
内核 RCU 监视器会检测超过 RCU_STALL_TIMEOUT(默认 21 秒,含 GP 环节)未完成的宽限期,触发 Oops 式警告。常见原因:
- CPU 死锁在不持有锁的无限循环中(关中断自旋)
b) 读者持有读侧临界区长睡眠c) 系统因内存回收抖动长时间无响应
调试方法:
dmesg | grep -i "rcu" # 查看 stall 详细信息
cat/sys/kernel/debug/rcu/* # 查看每个 CPU 的 RCU 状态
trace-cmd record -e rcu:* # 追踪 RCU 事件
8. RCU 与内存序
8.1 配对规则总结
| 写入侧 | 读取侧 | 保证 |
|---|---|---|
| rcu_assign_pointer() | rcu_dereference() | 写入在指针可见前对所有读者可见 |
| smp_store_release(&p, v) | smp_load_acquire(&p) | 等价配对 |
| call_rcu() / kfree_rcu() | rcu_read_unlock() | 回调执行前所有读侧临界区已退出 |
8.2 常见错误模式
// 错误 1:未使用 rcu_dereference
rcu_read_lock();
p = global_ptr; // BUG! 编译器/CPU 可能重排
rcu_read_unlock();
// 错误 2:读侧临界区中睡眠
rcu_read_lock();
copy_from_user(buf, ubuf, len); // BUG! 可能触发缺页睡眠
rcu_read_unlock();
// 错误 3:在 create_list 中直接 kfree
new = kmalloc(...);
// 其他 CPU 已经可能在遍历...
list_add_rcu(...);
synchronize_rcu();
kfree(old); // OK: GP 完成后没有读者会看到 old
// 错误 4:second read 模式
rcu_read_lock();
p1 = rcu_dereference(gp);
v1 = p1->field;
/* 写者此处替换 gp 并调用 synchronize_rcu() */
p2 = rcu_dereference(gp); // 可能与 p1 不同!
v2 = p2->field;
// v1 和 v2 来自不同对象,可能新旧混淆
rcu_read_unlock();
8.3 双重检查模式 (Second-Look Pattern)
如果需要保证两次看到的是同一个对象:
rcu_read_lock();
do {
seq = read_seqcount_begin(&p->seq);
p1 = rcu_dereference(gp);
v1 = p1->field;
p2 = rcu_dereference(gp);
v2 = p2->field;
} while (read_seqcount_retry(&p->seq, seq)); // 检测并发重排
rcu_read_unlock();
9. RCU 实现的未来演进
9.1 可睡眠 RCU (splim rcupdate)
社区正探索允许 rcu_read_lock() 内部睡眠的机制,核心思路是:让读者在睡眠时将 RCU read ownership 传递给一个"代理"(类似 optimistic spinning),避免单纯依赖静默状态。这能消除 SRCU 与标准 RCU 之间的性能鸿沟。
9.2 BPF 与 RCU 的融合
BPF verifier 现在可以验证 bpf_rcu_read_lock/unlock,允许 BPF 程序安全地遍历受 RCU 保护的内核数据结构。这对安全监控和性能分析工具至关重要。
9.3 Tree RCU 的性能持续优化
- Lazy RCU:将
call_rcu() 的处理延后,减少 GP 结束延迟 - Direct Invoke:当回调数量少时直接调用,避免 kthread 调度开销
- SECU (Sleepable Expedited) SRCU:折中方案,允许读者睡眠但比 SRCU 更轻量
10. 调试与工具
10.1 lockdep-like 的 RCU 检查
内核 CONFIG_PROVE_RCU 开启后,lockdep 会验证以下不变量:
rcu_dereference()必须在 RCU read-side critical section 内调用synchronize_rcu()不能在持有 lock 且该 lock 可能被 RCU 读者持有时调用(死锁风险)- BCP:不要从 RCU callback 中调用 synchronize_rcu()(GP 不能嵌套)
10.2 ftrace 追踪
# 追踪宽限期生命周期
echo 1 > /sys/kernel/debug/tracing/events/rcu/rcu_grace_period/enable
echo 1 > /sys/kernel/debug/tracing/events/rcu/rcu_callback/enable
cat /sys/kernel/debug/tracing/trace_pipe
# 追踪 stall 前状态
echo 1 > /sys/kernel/debug/tracing/events/rcu/rcu_stall_warning/enable
10.3 drgn 脚本分析
# drgn 脚本:查看当前 GP 状态
prog["rcu_state.gp_seq"]
prog["rcu_state.gp_start"]
# 查看某 CPU 的 RCU 数据
for cpu in for_each_online_cpu(prog):
rcu_data = prog["rcu_data"](per_cpu_ptr, cpu)
总结
RCU 是 Linux 内核并发编程皇冠上的明珠——它优雅地实现了"读者零开销"与"写者延迟回收"的统一。正确使用的关键在于严格遵守 API 语义:读者不可睡眠,写者需要等待宽限期才能回收旧数据。现代内核为不同场景提供了标准 RCU、SRCU、Tasks RCU 等多种变体,配合 kfree_rcu()、list_entry_rcu() 等便利 API,使得在绝大多数读多写少场景中都能获得接近无锁的读取性能。
然而,RCU 编程的隐性陷阱(读侧睡眠、second-look 不一致、回调区内嵌套 GP)也要求开发者对底层机建有深入理解。配合 CONFIG_PROVE_RCU 和 ftrace,可以在开发阶段捕获绝大多数错误,避免在生产环境出现不可预测的数据损坏。

发表评论 取消回复