1. 从一次锁竞争说起:为什么要聊RCU
并发编程里,锁是最直觉的解决方案,但也是最容易让人头疼的东西。你加了一把读写锁,读操作倒是可以并行了,可一旦写者来了,读者全部被挡在门外。数据库场景下这叫读写互斥,放在操作系统内核里、放在高性能网络库里、放在搜索引擎的索引更新里,都一样的尴尬:读多写少的场景明明占绝大多数,却要为极少数写操作付出“所有读者全部停顿”的代价。
我之前在一个项目里做过一个全局配置表,读请求每秒好几万次,写请求可能一天都不到十次。刚开始图省事直接上sync.RWMutex,结果压测一跑,读者一多,锁竞争照样把CPU时间全烧在缓存一致性上。后来把读路径改成无锁快照,情况立刻好了很多。这件事让我意识到一个道理:在特定场景下,与其想方设法优化锁,不如直接绕过锁。
这也正是Read-Copy-Update(RCU)这套机制存在的意义。RCU不是一种新的语言特性,也不是某个数据库的专有功能,而是一种通用的同步技术,在Linux内核里被大规模使用,从路由表到文件系统、从进程管理到虚拟内存,到处都有它的影子。它的核心思路听起来甚至有点“笨”:读的时候完全不管并发,直接访问数据;写的时候不直接改原数据,而是先拷贝一份,在副本上改好了再原子地切换指针。就这么个思路,硬是把“并发读”做到了近乎零开销。
这篇文章我会从RCU的基本原理讲起,然后逐个拆解它的关键实现细节,包括宽限期、发布-订阅机制、内存屏障这些概念,最后结合常见的误区和坑,聊聊RCU到底适合什么样的场景。无论你是做内核开发、中间件,还是单纯对高并发架构感兴趣,这篇文章应该都能给你一些可以落地的思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制拆解:RCU是怎么做到无锁读的
2.1 三个角色:读者、写者、回收者
先建立一个整体认知。RCU把并发访问者分成了三类角色,各干各的事,互不阻塞,这是它和传统锁机制最本质的区别。
- 读者(Reader):负责读取共享数据。在RCU里,读者不需要加任何锁。它只需要进入一个“读端临界区”,把要访问的指针纳入自己的视野,然后开始读。读的过程中不会被写者打断,也不会阻塞写者。
- 写者(Writer):负责修改共享数据。写者不能直接改正在被读者读取的数据。它的标准动作是:分配一块新内存,把旧数据拷贝过来,在新副本上完成修改,然后执行一次原子指针替换,让读者后续访问到新版本。
- 回收者(Reclaimer):负责释放旧版本的内存。这一步是最容易忽略、也最容易出问题的。指针切换之后,旧数据还可能被之前的读者握着,不能立刻
free。回收者必须等到所有可能的读者都离开旧版本之后,才能安全释放。
在Linux内核的实现里,第三个角色通常由call_rcu()注册的回调来承担,延迟到宽限期结束后执行内存释放。这一层抽象让写者不需要自己阻塞等待,只需要把释放动作“托管”给内核。
这里可以打个比方。图书馆里有一本热门书,大家都坐着看(读者)。现在管理员要更新书的内容,他不会去抢读者手里的书,而是去复印一份,在复印件上改好,再放回书架,换掉原来的版本。至于旧那本被翻烂的书,等所有正在看的人都不看了,再收走销毁。管理员就是写者,收书销毁的人就是回收者。
2.2 读端临界区:为什么读者真的不需要锁
几乎所有RCU文章都会谈到“读端临界区”,但很少有人讲清楚:为什么读者进入这个临界区,就真的可以放心读。
关键在于RCU对读端临界区的定义非常轻量。在Linux内核的老版本里,读者只需要“保证自己不睡眠、不主动触发调度”即可,甚至在更早的实现里,连计数器都不用更新。后来内核引入了可抢占RCU,读者需要在进入和退出临界区时各做一次对preempt_count的原子操作,但也仅此而已,没有锁的获取、释放,没有缓存行 bouncing,更没有等待队列。
读者不需要锁的底气,来自于一个约定:写者承诺在读者可能仍然持有旧指针的期间,不会释放旧内存。这个承诺靠什么落地?靠RCU的宽限期检测机制。读者不需要主动告诉写者“我在读”,写者也不需要知道究竟有哪些读者在读。写者只需要等一个足够长的时间段,确保在这个时间段开始之前进入临界区的读者全部退出了,就可以认为旧版本无人使用。
这就像你发了一条朋友圈,然后在不确定谁看过的情况下想撤回。你不需要知道具体哪些人看过,只需要等一段时间,等到所有人都大概率已经刷完朋友圈了,再删掉。RCU的宽限期就是这个“等待时间”,只不过它做的更精确,靠的是调度器状态而不是“大概率”。
2.3 发布-订阅机制:指针切换为什么必须用rcu_assign_pointer
写者改完副本之后,要做一次指针切换。这个切换不是普通赋值,而是必须用RCU提供的专用宏,在内核里是rcu_assign_pointer(),在用户态库(比如liburcu)里对应rcu_assign_pointer()。为什么不能用普通赋值?这里涉及一个并行编程里非常核心的概念:内存序。
现代CPU为了提升性能,不会严格按照代码顺序执行内存操作。编译器也可能为了优化调整指令顺序。如果写者只是简单地执行gp = new_version;,那这个“发布”操作之后的初始化写入(对副本内容的修改)有可能被CPU乱序执行到指针发布之后,读者就可能看到一个已经发布但内容还没完全就绪的新版本。
rcu_assign_pointer()在发布指针之前会插入一道写屏障,确保所有对数据块的修改在指针发布之前对其它CPU可见。对应的,读者端使用rcu_dereference()来读取指针,它插入一道读屏障,确保在拿到指针之后,对指针所指内容的读取不会被乱序到指针读取之前。一读一写,这叫“发布-订阅”配对。
这个模式在C++11里就是std::memory_order_release和std::memory_order_acquire,在Java里就是volatile的写和读。理解RCU的指针切换,本质上就是理解内存序在无锁编程里的应用。
2.4 宽限期(Grace Period):RCU的“时间标尺”
宽限期是RCU设计里最巧妙、也最复杂的部分。但它的定义其实很朴素:从某个时刻开始,到所有在该时刻之前就已经处于读端临界区的读者都退出为止,这段时间就是一个宽限期。
为了更精确地判断“所有旧读者都退出了”,RCU需要跟踪每个CPU上线程的上下文状态。Linux内核的实现方式很聪明:它利用调度器在每个CPU上都会周期性调度的特性。只要某个CPU上发生了一次上下文切换,那RCU就可以认为该CPU上之前处于读端临界区的读者已经退出了。当所有CPU都经历过一次上下文切换之后,RCU就能确信:旧版本数据已经没有任何读者引用了。
这个机制的代价非常低。每个CPU在调度时只需要更新一个本地的标志位,不需要全局同步。RCU内核线程只需要等到所有CPU的“已调度”标志都被置位,宽限期就结束了。
从用户态角度看,synchronize_rcu()这个接口就是同步等待一个宽限期结束,它返回之后,写者就可以安全释放旧内存。而call_rcu()则把这个等待和释放动作延后到后台,由内核的回调机制在宽限期结束后自动执行。
3. 实操:从零到一构建一个RCU风格的数据结构
理论讲多了容易飘,这一节我把RCU落到具体代码上。我以C语言和liburcu库为例,因为它对用户态程序最友好,跨平台也没问题。内核态的RCU编程接口略有差异,但核心思想完全一致。
3.1 环境准备与库安装
liburcu是Mathieu Desnoyers维护的一个用户态RCU实现库,C/C++项目中可以直接使用。安装方式很简单:
bash复制# Debian/Ubuntu
apt-get install liburcu-dev
# macOS (Homebrew)
brew install liburcu
# 也可以直接从源码编译
git clone git://git.liburcu.org/urcu.git
cd urcu
./bootstrap && ./configure && make && sudo make install
编译时需要链接-lurcu,如果使用了urcu-memb或urcu-qsbr等变种,链接对应的库名。
3.2 基础数据结构:无锁读的全局链表
下面我用一个全局链表的例子,展示RCU的三个核心动作:读、写、回收。这是内核里RCU最典型的应用场景之一。
c复制#include <stdio.h>
#include <stdlib.h>
#include <urcu.h>
struct node {
int data;
struct node *next;
};
static struct node *g_head = NULL;
/* 读:无锁遍历 */
void read_list(void)
{
struct node *p;
rcu_read_lock();
rcu_dereference(p, g_head);
while (p) {
printf("%d ", p->data);
p = rcu_dereference(p->next);
}
rcu_read_unlock();
}
/* 写:在头部插入新节点 */
void insert_node(int val)
{
struct node *new_node, *old_head;
new_node = malloc(sizeof(*new_node));
new_node->data = val;
rcu_read_lock();
old_head = rcu_dereference(g_head);
new_node->next = old_head;
rcu_assign_pointer(g_head, new_node);
rcu_read_unlock();
}
/* 回收:把整个链表替换成空,并安全释放 */
void clear_list(void)
{
struct node *old_head;
old_head = rcu_dereference(g_head);
rcu_assign_pointer(g_head, NULL);
/* 等待宽限期结束,确保没有读者还在用旧链表 */
synchronize_rcu();
while (old_head) {
struct node *tmp = old_head;
old_head = old_head->next;
free(tmp);
}
}
这里有几个细节值得注意:
- 读路径用
rcu_read_lock()/rcu_read_unlock()包裹,这在liburcu用户态实现里并不是零开销,但代价比一般的读写锁小一两个数量级。 rcu_dereference()和rcu_assign_pointer()是配套使用的,缺一不可。前面讲了内存序的原因,这里就不重复了。- 插入节点时,
new_node->next = old_head;必须在rcu_assign_pointer()之前完成,这是发布-订阅模式的核心约束。 synchronize_rcu()会阻塞写者直到宽限期结束。如果写者不想阻塞,可以用call_rcu()替换。
3.3 写者不想等:call_rcu() 异步回收
如果写路径处于性能关键路径,不能接受同步等宽限期的开销,call_rcu()就是替代方案。它的用法类似async回调:
c复制struct rcu_head {
struct rcu_head *next;
void (*func)(struct rcu_head *head);
};
static void free_node_rcu(struct rcu_head *head)
{
struct node *n = container_of(head, struct node, rcu);
free(n);
}
void clear_list_async(void)
{
struct node *old_head;
old_head = rcu_dereference(g_head);
rcu_assign_pointer(g_head, NULL);
while (old_head) {
struct node *tmp = old_head;
old_head = old_head->next;
call_rcu(&tmp->rcu, free_node_rcu);
}
}
call_rcu()只是把节点挂到回收队列上,然后立即返回。内核后台线程会在宽限期结束后自动执行回调。这样写路径完全没有阻塞,吞吐量更高。
代价是回调的执行时机有延迟,而且如果你有顺序要求(比如A节点的释放必须在B节点的释放之后),需要用call_rcu()的顺序保证机制,或者干脆回到synchronize_rcu()。
3.4 读者和写者之间的内存模型约定
很多初学者把RCU理解为“读写锁的一种改进版”,然后按读写锁的习惯去用,结果踩坑。
RCU的读者和写者之间不是互斥关系。读者读旧版,写者写新版,两者同时进行,完全没有问题。真正的约束在回收者:它必须保证在任何一个读者可能还在引用旧版时,不释放旧版的内存。这个“可能还在引用”的判断标准,就是宽限期。
所以,RCU本质上不是一种“锁”,它是一套“延迟回收”机制。你把“锁”的概念放下,才能理解RCU为什么能比锁快这么多。
4. RCU在真实世界里的应用:从Linux内核到用户态数据库
4.1 Linux内核:路由表、文件系统和更广阔的场景
Linux内核是RCU最大的用户,这话一点不夸张。路由表查找是网络路径上的高频操作,早期用读写锁,性能瓶颈明显。改成RCU之后,读路径完全无锁,转发性能大幅提升。文件系统里的dentry缓存、inode缓存也大量使用RCU,因为路径解析这种操作“读多写极少”的特征和RCU简直绝配。
内核里的RCU还做了大量优化,比如sched RCU、preemptible RCU、expedited RCU等变种。不同的变种在“宽限期检测的粒度”和“读者是否可睡眠”上有不同的权衡。比如rcu_read_lock_bh()版本专门用于下半部上下文,防止软中断抢占读端临界区。
我不是内核开发者,平时写用户态程序更多,但读内核代码时经常看到RCU的影子,每看到一个都能加深一层理解。
4.2 用户态数据库和中间件:无锁读路径的实践
用户态世界里,RCU同样有一席之地。
- 数据库并发控制:某些内存数据库(比如Redis的某些模块、SQLite的WAL模式在特定场景下的读快照)会使用类似RCU的机制来提供MVCC读快照。读事务不阻塞写事务,写事务也不阻塞读事务,这本质上就是RCU“多版本并行”的思想。
- 配置中心/注册中心:服务发现场景下,客户端会频繁读取服务列表,而服务列表的变更频率极低。用RCU替代读写锁,可以显著降低读请求的时延和CPU开销。
- 框架和中间件:
liburcu本身的用户群包括不少高性能网络库和内存缓存组件。
有人可能会问:RCU和MVCC有什么区别?简单说,MVCC是一套更高层的数据库并发控制协议,它需要在事务级别维护多个版本、处理可见性判断;RCU则是一个底层的同步原语,不涉及事务的概念。你可以把RCU理解为MVCC的“原料”之一。
4.3 RCU在面试和架构设计中的价值
“高并发”是Java后端面试的高频题目,很多人能背出ConcurrentHashMap、CopyOnWriteArrayList,但提到RCU,能讲清楚的候选人不多。如果你能在面试里讲清楚RCU的设计思路、适用场景、内存序意义,会是一个很加分的亮点。
更实际的价值在于架构设计的选型。当你面对一个“读多写极少”的系统,比如配置热更新、黑白名单、路由表、限流规则,你要做的第一选择不一定是一把锁,而是思考能否用RCU思想来做快照发布。这种思维方式,才是RCU给开发者最大的财富。
5. 常见问题与排查心得:RCU不是银弹
5.1 问题:读者临界区里睡眠
这是RCU最经典的错误用法。在Linux内核里,如果读者在rcu_read_lock() / rcu_read_unlock()之间睡眠,会导致宽限期无法正常结束,因为RCU依赖调度器来标记“读者退出”。一旦睡眠,调度器可能不会产生预期的状态更新,后续的call_rcu()回调可能延迟很久才执行,甚至触发内核警告。
用户态liburcu虽然限制稍微放宽一些,但也不建议在临界区里做阻塞操作。原则很简单:读端临界区要短、要快,不要睡眠,不要调用可能睡眠的函数。
5.2 问题:数据竞争并不可见
RCU解决的是“结构更新”过程中的同步问题,但它并不能帮你自动保证“结构内部字段”的数据一致性。如果你在RCU保护的链表节点里,存放了一个多个写者都会修改的计数器,那这些写者之间依然需要锁或原子操作来保护。RCU不等于data-race-free的万能钥匙。
5.3 问题:宽限期延迟造成的“回收不及时”
call_rcu()延迟释放内存,意味着内存占用高峰可能会持续一段时间。如果一个写者持续地高频修改,旧版本内存不断堆积,可能造成内存暴涨。解决办法是:如果写路径频率很高,评估是否适合RCU;如果适合,则考虑批量更新、合并更新,避免每个更新都产生一个旧版本。
5.4 问题:调试很困难
RCU相关的Bug通常很难复现,因为它是并发下才出现的时序问题。我的经验是:一是尽量用ThreadSanitizer等工具在早期阶段检测数据竞争;二是可以在开发环境把宽限期调大,增加问题暴露的概率;三是遵循“发布-订阅”的内存序规则,不要为了“看起来没问题”而省略rcu_dereference()。
我个人的体会是,RCU这类无锁技术,真正难的不是理解原理,而是改变思维习惯。大多数人的第一反应还是加锁,遇到并发问题就想着怎么把锁做细、做快。RCU教我的最重要的一点是:有些场景,你压根不需要让读操作去“等待”任何东西,你只需要在正确的时间点发布新版本,然后让旧版本自然消亡。这个思路,放到架构设计、缓存设计、配置管理里,其实都是通用的。
