关键要点
- RCU在读取路径上完全消除了锁相关开销,因此其读取性能是传统锁机制的10到30倍;不过这种机制会以占用更多内存和影响数据最终一致性为代价。
- RCU的工作流程分为三个阶段:读者可以无锁地访问数据,而写入操作则会原子性地完成数据的复制、修改和替换操作,并将内存回收操作推迟到一定时间之后进行,以确保所有读者都已经完成读取操作。
- RCU通过牺牲数据的一致性来提升系统的可扩展性。因此,在那些以读取操作为主的工作负载中,如果能够接受数据最终出现不一致的情况,RCU就是一种理想的选择。
- 当读写操作的比率超过10:1,并且可以容忍短暂的数据不一致现象时,就应该使用RCU。例如,Kubernetes的API服务、PostgreSQL的MVCC机制、Envoy代理以及DNS服务器等都采用了RCU这种设计模式。
- 然而,RCU也存在一定的风险——在退出临界区后仍然使用指针可能会导致“使用已释放的资源”这类错误;因此,对于那些需要强数据一致性或必须立即获取最新数据的系统来说,RCU并不适用。
引言
读写锁机制似乎是那些以读取操作为主的工作负载的理想解决方案:多个读者可以同时进行读取操作,而写入操作则需要独占访问权限。这种锁机制允许并发读取,但写入操作必须独占资源。读者们共享同一个锁,而写入者则会独占这个锁,不过这种方式其实隐藏着一定的成本。
我最近在一台M4 MacBook上对一个以读取操作为主的工作负载进行了测试——该工作负载的读写操作比率约为1000:1。使用pthread提供的rwlock实现时,我在5秒钟内完成了2340万次读取操作;而使用RCU机制后,我竟然完成了4920万次读取操作,性能提升了110%,而且工作负载本身并没有发生任何变化。

图1:RCU与读写锁机制的性能对比图。
那么,是什么导致了这种性能瓶颈呢?在读写锁机制中,读者们必须获取共享访问权限,这会触发原子操作以及跨CPU核心的缓存行失效处理。随着CPU核心数量的增加,这种开销也会呈指数级增长。而RCU通过完全消除读取路径中的锁相关机制,有效地解决了这个问题。
这并不是某种小众的内核优化技术。你每天使用的生产系统,比如Kubernetes的etcd、PostgreSQL的MVCC机制以及Envoy代理,都是依靠RCU的原理来实现可扩展性的。随着C++26标准对RCU的正式规范化(P2545R4),这种设计模式正在从一种特定于内核的技术,逐渐发展成为一种通用的编程机制。
本文解释了RCU的工作原理、它在何时能够带来显著的性能提升,以及如何判断在自己的系统中应用RCU的时机。
名称的含义:读取、复制、更新
“读取-复制-更新”这一名称准确地描述了这种机制的工作过程。让我们来详细分析一下。
基本架构
存在某种被多个线程同时访问的共享数据结构,比如配置文件、数据库记录或一组标志位。其中一些线程负责读取数据,而另一些线程则负责写入数据。关键在于,读取操作的次数远远多于写入操作——通常比例为百比一甚至千比一。对于大多数系统来说,这种比例都是现实存在的。毕竟,我们并不会每秒钟都更新那些标志位,而且配置信息的修改频率也远低于针对这些配置数据的读取请求次数。

图2:读取者和写入者访问共享资源。
读取操作
读取者在访问共享数据时不会获取任何锁。因此不存在等待、竞争或额外的开销——这就是这种机制的无锁优势所在。

图3:读取者可以在不获取任何锁的情况下访问资源。
复制操作
当写入者需要修改数据时,他们并不会直接修改原始数据,而是先创建一份数据的副本,然后对这份副本进行修改。这样一来,读取者仍然能够看到未修改的、一致的旧版本,而写入者则可以专注于准备新的版本。

图4:写入者创建副本以进行数据修改。
更新操作
一旦副本准备就绪,写入者就会原子性地将指针指向新版本。所谓“原子性”是指这个操作要么完全完成,要么根本不会执行;既不会出现部分更新的情况,也不会出现读取到旧版本和新版本混合的情况。从这一刻起,新的读取者将会看到已更新的版本,而现有的读取者则可以继续使用旧的版本而不会遇到任何问题。

图5:全局指针已更新为新数据,但旧的读取器仍会继续读取旧数据。
这种三阶段机制正是RCU实现无锁读取功能的关键所在:由于写入者永远不会修改当前正在使用的数据,因此读取器根本不需要等待。
问题所在:为什么传统的锁定机制在大规模应用中会失效
想象一下,你正在构建一个高流量的API网关。该网关需要配置路由规则,以便将传入的请求分配给相应的后端服务——比如哪个服务负责处理/api/users请求,哪个服务负责处理/api/orders请求,同时还需要设置每个路由的超时时间以及重试策略等等。这种配置机制通常如下:
- 每条请求都会被处理(每秒钟可能有成千上万甚至数百万条请求被处理)。
- 但这类配置很少会被修改(除非你部署了新的服务或更改了路由规则,可能每小时才会修改一次)。
当使用传统的读写锁机制时,会发生以下情况:
pthread_rwlock_t config_lock;
config_t *global_config;
// 每条请求都会执行以下操作:
void handle_request() {
pthread_rwlock_rdlock(&config_lock); // 获取读锁
route_t *route = lookup_route(global_config, request_path);
int timeout = route->timeout_ms;
pthread_rwlock_unlock(&config_lock); // 释放读锁
// ... 将请求转发给后端服务 ...
}
// 管理员需要修改配置:
void update_config(config_t *new_config) {
pthread_rwlock_wrlock(&config_lock); // 获取写锁
global_config = new_config;
pthread_rwlock_unlock(&config_lock);
}
从表面上看,读写锁机制似乎非常适合这种应用场景:多个读取器可以同时进行操作,而写入者则拥有对数据的独占访问权。但实际上,这种机制存在隐藏的性能开销,在大规模应用中这些问题会变得非常严重。
锁定操作的开销
尽管读写锁允许多个读取器同时执行操作,但它们仍然需要通过原子操作来获取锁。在一台拥有多颗CPU核心的繁忙服务器上:
- 每个读取器都必须执行原子级的比较并交换操作才能获取读锁。
- 这些原子操作会导致所有CPU核心中的缓存行内容失效。
- 包含锁信息的缓存行会在不同的CPU核心之间来回“跳跃”,每秒钟会发生数千次这样的操作。
- 当CPU核心的数量增加时(8核、16核、32核及以上),这种竞争现象会呈指数级加剧。
最终的结果就是:虽然各个读取器之间不会互相阻塞,但它们都会争夺同一条缓存行。本应在纳秒级别完成的路由查找操作,实际上会被锁定操作的开销所拖累,从而导致性能大幅下降。
这种情况就好比在一个图书馆里,读者们不需要互相等待,但在借书之前都必须先在一本共享的登记簿上签名。然而,这本登记簿本身却成了性能瓶颈,因为没有人会阻止其他人借书。
理解性能瓶颈的本质
<要理解为什么基于读写锁的机制在大规模应用中会遇到种种问题,我们就需要研究那些困扰所有多核系统中基于锁的同步机制的基本性缺陷。>
缓存一致性带来的开销
现代CPU的每个核心都拥有独立的缓存。当某个读操作获取锁时,它会执行一个原子操作来更新锁的状态(例如,增加读操作的数量)。这种状态更新会迫使CPU同步所有核心中的缓存数据,这个过程被称为“缓存行失效”。每当有读操作获取或释放锁时,包含该锁的缓存行就会在各个核心之间来回“跳跃”。在一个拥有十个核心、每秒要处理数千个请求的系统里,每次这样的“跳跃”都会耗费10到100纳秒的时间。随着核心数量的增加,这种开销会呈指数级增长。

图6:缓存行跳跃带来的开销。
写入操作时的竞争问题
在读写锁机制中,写操作需要获取独占锁,以防止读操作读取到部分更新的数据。一旦写操作获得了独占锁,所有读操作都必须等待,即使它们之间并不会发生冲突。在那些读操作非常频繁的场景中,这种独占锁的获取机制会导致“雷鸣兽群”现象——成千上万的读操作会排成一队,等待某个写操作完成操作。
优先级反转
由于“优先级反转”的存在,高优先级的读操作可能会被持有锁的低优先级写操作阻塞,从而导致性能不可预测,甚至引发系统不稳定。
排队现象
如果操作系统强行中断了一个正在持有锁的线程的执行,那么所有等待该线程的线程都会被阻塞,直到它重新开始执行。在负载较大的情况下,这种问题会严重降低系统的吞吐量。

图7:正常的多线程运行情况。

图8:被操作系统中断的多线程运行情况。
图7和图8中提到的这些问题,其实是基于锁的机制所固有的局限性。无论你如何优化读写锁的设计,都无法避免缓存一致性带来的开销、写入操作时的竞争问题,以及排队现象和优先级反转所带来的风险。
重新思考这个问题:我们真的需要锁吗?
解决复杂问题的关键在于提出正确的问题。根据我们对基于锁的瓶颈现象所了解到的知识,我们应该问问自己:我们是否用对了方法来解决当前面临的问题?
更直接地说,我们可以这样问:锁真的是我们解决这个问题的唯一工具吗?
让我们想想,在那些以读操作为主的系统中,我们实际上想要实现什么目标:
- 读者需要获取一致的数据(不能出现数据不一致的情况,也不能进行部分更新)。
- 读操作的数量远远多于写操作的数量(通常是千比一甚至更高)。
- 写操作必须能够安全地更新数据。
- 更新操作的频率很低(比如每小时只进行一次,而读操作的速度却可能达到每秒数百万次)。
传统的锁机制是通过阻止并发访问来解决这些问题的。它们要求所有程序在进行读写操作时都必须进行协调,但实际上很多时候这种协调并不是必需的。那么,如果我们换一种方法来解决这个问题会怎么样呢?如果读者根本不需要进行任何协调,会怎么样呢?
如果我们能够保证读者在任何情况下都能看到有效且一致的数据,而无需获取任何锁,会怎么样呢?如果把所有的协调工作都交给那些数量稀少的写操作者,而不是数量众多的读者,会怎么样呢?
正是为了解决这些问题,RCU应运而生。
RCU——三阶段机制
RCU代表了一种与传统基于锁的并发模型截然不同的解决方案。它不允许使用锁来保护共享数据,而是让读者能够在不获取任何锁的情况下直接访问这些数据。这一机制通过三个关键概念彻底颠覆了传统的处理方式。
第一阶段:无锁读取
读者在访问数据时不需要获取任何锁,只需获取当前数据的指针,然后就可以自由地使用这些数据,而无需担心数据会在他们使用期间被修改。这种无锁机制之所以可行,是因为写操作者从不会直接修改共享数据。
为了参与基于RCU的系统,读者需要在开始使用受RCU保护的数据时进行标记:
rcu_read_lock() ; // 标记:进入临界区
p = rcu_dereference(global_ptr); // 获取当前数据的指针,然后开始读取临界区的内容
rcu_read_unlock() ; // 标记:离开临界区
尽管这些函数的名称中包含了“锁”这个词,但实际上它们的实现非常轻量级——通常只是禁用内核的抢占机制,或者在用户空间中增加一个线程局部的计数器而已。根据具体的RCU实现方式,这些函数可能会使用原子操作,也可能会不使用;例如,在某些不允许抢占的内核配置中,它们就不会使用原子操作。
需要注意的一些重要规则:
- 指针仅在临界区内有效:
通过rcu_dereference()获取的指针,在rcu_read_unlock()之后就不能再使用了。一旦离开了临界区,这个指针可能会指向无效的内存地址——因为相关数据可能已经被释放了。 - 在临界区内,数据不能被释放
只要至少还有一个读者处于临界区内(即在调用rcu_read_lock()和rcu_read_unlock()之间),那么该读者正在访问的数据就不能被释放。这就引出了一个重要的问题:RCU是如何判断何时可以安全地释放旧数据的呢?答案就在于“宽限期”,我们稍后会详细讨论这一点。 - 禁止阻塞或进入睡眠状态
>读者在临界区内时绝对不能阻塞或进入睡眠状态:既不能获取锁,也不能进行I/O操作,更不能调用sleep函数。违反这一规则会导致RCU无法判断何时可以安全地释放内存。
第二阶段:复制与更新
当写入者需要修改数据时,它会创建一份新副本,对这份副本进行修改,然后通过原子操作交换全局指针,从而发布新的数据:
// 1. 分配新内存空间
config_t *new_config = malloc(sizeof(config_t));
// 2. 复制当前数据
config_t *old_config = global_config;
*new_config = *old_config;
// 3. 修改副本中的数据
new_config->max_connections = new_max_connections;
// 4. 进行原子指针交换
__atomic_store_n(&global_config, new_config, __ATOMIC_RELEASE);
// 旧的读取者仍然使用旧的数据指针
// 新的读取者则会使用新的数据指针
完成这种原子操作交换后:
- 新的读取者会立即看到更新后的数据。
- 现有的读取者可以继续安全地使用他们原有的数据指针。
- 两个版本的数据会暂时共存。
第三阶段:宽限期与内存回收
在写入者发布了新数据版本之后,旧版本的数据不能立即被释放。为什么呢?因为那些在更新发生之前就进入了临界区的读取者可能仍在使用旧数据。如果直接释放这些旧数据,就会导致“使用已释放内存”的错误。
因此,写入者必须等待一个“宽限期”结束后,才能回收这些旧内存。
写-写同步:多个写入者怎么办?
你可能会想知道:如果有两个写入者同时尝试修改数据,会发生什么?它们需要使用锁吗?数据更新会不会丢失呢?答案是:RCU确实能够处理读写并发问题,但多个写入者之间仍然需要通过传统的同步机制来进行协调。
上述三个阶段说明了RCU是如何避免读取者与写入者之间的冲突的。然而,RCU并不能自动解决多个写入者同时修改数据时可能产生的冲突。当有两个写入者试图同时进行更新时,它们通常会使用传统的锁(互斥锁或自旋锁)来确保更新的顺序性。
尽管如此,使用RCU仍然是一种可行的解决方案,因为:
- 在以读取操作为主的工作负载中,写入者之间很少会发生竞争。
- 与读操作带来的巨大性能提升相比,写入锁所带来的开销可以忽略不计。
- 读取操作完全不需要使用锁,而这正是RCU带来性能优势的关键所在。
宽限期的检测机制
什么是宽限期?
宽限期是指在这段时间内,所有在宽限期开始时正处于临界区的读取者都已经完成了对那些临界区的操作并退出了它们。换句话说,这个宽限期就是为了确保每一个可能还持有旧数据指针的读取者都已经停止使用这些旧数据而设定的。
__atomic_store_n(&global_config, new_config, __ATOMIC_RELEASE);
synchronize_rcu(); // 等待宽限期结束
free(old_data);
关键要点
虽然这一点可能不太明显,但宽限期并不会等待所有读者完成读取操作——在一个处于持续高负载状态的系统里,这样做是不可能的。宽限期只会等待那些在更新发生时仍在执行读取操作的读者。而在更新之后才开始阅读的读者会自动看到新版本的内容,因此无需对这些读者进行特别跟踪。
宽限期的工作原理:静止状态
要理解RCU机制,关键在于了解系统是如何判断宽限期是否已经结束的。这一判断过程依赖于“静止状态”这一概念。
所谓“静止状态”,是指线程在执行过程中处于这样一个阶段:此时可以确定该线程不会持有任何对受RCU保护的数据结构的引用。换句话说,从RCU的角度来看,这个线程此时处于“静止”状态。

图9:读者线程、写入线程以及宽限期的时间线概览。
内核RCU机制:上下文切换作为判断静止状态的手段
Linux内核的实现方式是利用上下文切换来检测线程是否处于静止状态。当调度器将控制权移交给另一个线程时,那个被移交控制的线程肯定不可能正在执行RCU保护区内的代码。需要记住的是,RCU保护区内不允许线程进行阻塞操作或进入睡眠状态,因此每次上下文切换都意味着该线程已经离开了可能位于RCU保护区内的任何代码段。
一旦系统中的所有CPU自更新发生以来都至少执行过一次上下文切换,那么之前存在的所有RCU保护区间就已经结束,此时宽限期也就宣告结束了。
用户空间RCU机制:其他检测方式
对于用户空间环境而言,由于无法控制调度器或可靠地检测上下文切换,因此它们会采用其他方法来检测线程是否处于静止状态。这些方法包括:
- 基于时间段的检测机制(如URCU、crossbeam-epoch),在这种机制中,线程会定期声明自己已经进入了一个新的“时间段”,这个时间戳表示它们已经安全地通过了某个关键点。
- 基于信号的通知机制:在RCU的信号通知版本中,写入线程会向所有读取线程发送信号(例如SIGUSR1),并等待这些线程确认收到信号。当有读取线程处理了这个信号时,就说明该线程当前没有处于RCU保护区内,此时就可以认为它处于静止状态。
- 显式跟踪机制:在这种机制中,线程会在进入或离开RCU子系统时主动进行注册,这样宽限期机制就能直接对这些线程进行跟踪。当某个线程的读取计数器值为零时,就说明该线程当前没有处于RCU保护区内,因此可以认为它处于静止状态。
尽管实现方式各不相同,但所有实现方案都遵循同一个基本原则:必须等到所有线程都到达一个安全状态,即它们不再持有旧的引用。具体的实现机制会根据不同环境中的可用资源而有所差异。
现在我们已经了解了核心问题以及RCU背后的原理,接下来让我们看看那些采用类似RCU机制的实际系统,并探讨它们是如何确定“宽限期”结束时间的。
生产环境中的RCU:实际应用案例
RCU的概念已经存在了大约二十年,其原理被应用于世界上一些最为关键、性能要求最高的系统中。
Linux内核
Linux内核是RCU的发源地,在过去二十多年里,RCU机制在Linux内核中得到了广泛的应用。该机制将原子的指针更新技术与基于调度器的宽限期检测机制相结合,利用上下文切换来判断何时可以安全地回收内存。
在Linux内核中,RCU机制在各种以读操作为主的数据结构中都表现出了出色的性能,这些数据结构包括:
- 网络路由表
路由查找过程是不需要锁保护的,因此数据包可以以最高速度被转发。正是这种设计使得Linux能够以线速处理数据包发送请求;每秒钟会有数百万次路由查找操作发生,而且整个过程都不需要使用锁。 - 文件系统的元数据,主要包括缓存的目录查询操作、挂载点的读取操作以及inode缓存相关的操作
- 那些能够在多颗CPU上安全地管理设备状态的设备驱动程序
PostgreSQL的多版本并发控制机制
PostgreSQL的多版本并发控制机制体现了RCU原理在数据库事务层面的应用。虽然有些专家会争论是否应该将MVCC称为“真正的RCU”,因为两者在时间尺度(秒与微秒)和跟踪机制(事务ID与静态状态)上存在差异,但它们的核心设计思路是相同的:通过版本控制实现无锁读取,并延迟内存回收操作。
当某条记录被更新时,PostgreSQL并不会覆盖原有的数据,而是会创建这条记录的新版本,并将旧版本标记为过时状态。每个事务都会获得一个“快照”,这个快照反映了数据库在某个特定时间点的状态,而这个时间点是由该事务的事务ID决定的。通过这种方式,不同事务可以看到不同的记录版本,从而避免了读操作阻塞写操作的情况发生。
旧的记录版本会不断累积,直到有一个名为VACUUM的后台进程判断出这些旧版本不再对任何活跃的事务可见——这一过程类似于RCU中的“宽限期”。只有当所有可能看到这些旧版本的事务都完成操作后,VACUUM才会回收这些旧版本。不过,这种机制也会导致某些事务在并发更新进行期间看到过时的数据。
Kubernetes与Etcd
Kubernetes这一广受欢迎的容器编排系统,将其主要数据存储机制设置为etcd。Etcd是一种分布式键值存储系统,它采用了与PostgreSQL类似的多版本并发控制机制。
当你更新Kubernetes中的某个资源时,比如一个Deployment或Service,实际上你并不是在修改etcd中已存在的数据,而是创建了这些数据的新版本。通过这种方式,API服务器等组件能够在处理更新请求的同时,始终使用数据的一致性快照来响应读请求。
然而,这样的历史记录可能会变得非常庞大,因此etcd会定期“压缩”这些历史数据,删除那些不再需要的旧版本。这一过程与RCU的数据复制机制有些类似,但也存在区别:与RCU不同,在RCU中写入操作会等待所有读取操作完成后再释放内存,而etcd则采用了一种混合机制。对于那些持续运行的读取操作,etcd会保护它们不被压缩(类似于RCU的缓冲期),但对于一次性进行的历史数据读取操作,则不会提供这种保护。如果客户端尝试读取已被压缩的数据版本,就会收到错误提示,必须重新尝试使用更新后的数据版本。这种设计将责任从系统层面转移到了客户端层面,即由客户端来处理因数据压缩而产生的错误。
服务网格:Envoy
Envoy是一种高性能代理组件,它是许多服务网格架构中的关键组成部分。Envoy的配置具有高度动态性,可以频繁地进行更新。为了防止在配置更新过程中阻塞网络流量,Envoy采用了类似RCU的机制。
当收到新的配置信息时,Envoy会首先在内存中创建该配置的新版本,然后原子性地替换掉原有的配置指针。这种处理方式使得代理能够在旧配置仍然有效的情况下继续转发请求,直到所有工作线程都切换到新配置后,才会安全地释放旧配置所占用的内存。
线程本地配置指针
每个工作线程都会维护自己独立的配置指针:
thread_local Config* my_config = nullptr;
void worker_main() {
while (true) {
// 检查全局配置是否发生了变化
Config* global = global_config.load(memory_order_acquire);
if (global != my_config) {
my_config = global; // 切换到新的配置版本
}
// 使用my_config来处理请求
process_requests(my_config);
}
}
这种设计模式带来了两个重要的好处。首先,它避免了在高频执行的代码路径中使用代价高昂的原子操作:每个工作线程在每次迭代中只进行一次原子加载操作(即global_config.load()),用于检查配置是否更新;之后,在该迭代过程中所有的请求都会使用缓存的my_config指针来处理。否则,如果每次调用process_requests()都需要进行原子加载操作,那么每秒钟可能会执行数百万次这样的操作。而通过线程本地缓存机制,工作线程每秒钟只需要执行几十次原子操作而已。
其次,对于RCU语义而言,线程局部指针起到了标识版本的作用——它告诉配置管理器每个工作进程当前使用的是哪个配置版本。当my_config仍然指向旧配置时,说明该工作进程仍在使用旧配置,因此无法释放该配置。而当my_config切换到新配置时,说明该工作进程已经完成了配置版本的切换,从而使系统更接近“宽限期”的结束。这种情况与RCU中的临界区机制完全类似:线程局部指针就是工作进程对某个配置版本的稳定引用。
基于年代值的宽限期检测机制
Envoy会记录每个工作进程当前所处的“版本阶段”或“年代值”:
struct Worker {
atomic config_epoch;
// ...
};
void waitForAllWorkersToTransition() {
int64_t target_epoch = global_epoch.load();
// 等待所有工作进程都进入新的版本阶段
for (auto& worker : workers) {
while (worker.config_epoch.load() < target_epoch) {
this_thread::yield(); // 等待
}
}
// 所有工作进程都完成了版本切换 → 可以安全地释放旧配置了
}
“年代值”本质上就是一个版本号,它是一个会随着每次配置更新而递增的计数器。每个工作进程的config_epoch记录着该工作进程所使用且已确认为有效版本的配置信息。这种机制为检测宽限期提供了简单高效的方式:当配置发生更新时,全局年代值会增加(例如从5增加到6)。那些仍在使用旧配置的工作进程,其config_epoch值为5;而已经切换到新配置的工作进程,其config_epoch值为6。
配置管理器可以通过检查所有工作进程是否都达到了目标年代值来确定是否可以安全地释放旧配置:一旦所有工作进程的config_epoch值都大于或等于6,我们就可以确定没有工作进程还在使用版本号为5的配置了。这就是Envoy在用户空间实现的RCU宽限期机制:与Linux RCU依赖内核上下文切换不同,Envoy使用了显式的年代值跟踪机制,而且这些数值可以由工作进程在用户空间中进行更新。
如果选择另一种方法——即追踪每个工作进程持有的具体配置指针——那么就需要使用复杂的内存屏障和指针比较操作;而年代值计数器则提供了一种更简单、更清晰的解决方案,因为它仅需要进行整数比较即可完成相关判断。
主线程中的延迟删除机制
负责处理配置更新的主线程会将删除操作推迟到宽限期结束之后再进行:
void updateConfig(Config* new_config) {
Config* old_config = current_config.exchange(new_config);
global_epoch.fetch_add(1); // 递增年代值
// 将删除操作推迟,直到所有工作进程都完成更新
main_thread.post([old_config, epoch = global_epoch.load()] {
waitForAllWorkersToAcknowledge(epoch);
delete old_config; // 现在可以安全地删除旧配置了
});
关键在于那个匿名函数——也就是传递给 `main_thread.post(): [old_config, epoch = global_epoch.load()] { ... }` 的 lambda 函数。这个 lambda 函数会保存对旧配置的引用以及当前的时期值,然后确定如何处理这些数据:等待所有工作线程确认新的时期值后,再删除旧配置。`main_thread.post()` 会安排这个 lambda 函数在主线程的事件循环中异步执行,而不会立即运行它。
这种非阻塞机制至关重要:配置更新会立即完成,而不需要等待所有工作线程完成状态切换。Envoy 会继续使用新配置处理请求,而 lambda 函数则会在后台等待规定的宽限期结束。只有当所有工作线程都完成了状态切换(通过它们的时期值计数器可以判断)后,lambda 函数才会执行并安全地删除旧配置。这种延迟删除的方式能够防止配置更新导致请求处理出现延迟。
一致性 trade-off
RCU 的高性能是有代价的——它放弃了即时一致性,以换取读操作的扩展性。要有效使用 RCU,就必须理解这一根本性的权衡。
当写入者使用 RCU 更新数据结构时,这些变更并不会立即被所有读取者看到。在更新发生时正在执行临界操作的读取者,会继续看到旧版本的数据,直到他们退出临界区域。因此,在任何时刻,不同的读取者都可能看到不同版本的数据。
这就是所谓的“最终一致性”:系统最终会达到一致的状态,但在这一过程中,读取者可能会看到过时的数据。对于许多应用来说,这种权衡是可以接受的。例如,如果你正在更新路由表,那么让少量数据包仍然使用旧路由表进行传输也是可以接受的,因为系统很快就会切换到新的路由表。
然而,如果你的应用要求严格的一致性,那么 RCU 就不是合适的选择。比如在开发银行应用程序时,你绝对不能允许不同的线程看到客户账户余额的不同版本。
总之,RCU 并非万能的解决方案。它确实是一种强大的工具,但并不能适用于所有情况。在决定使用 RCU 之前,你必须仔细考虑你的应用对一致性的要求。
常见的误区与操作注意事项
尽管 RCU 是一种非常有效的机制,但它也存在一些需要注意的问题。以下是一些常见的错误及操作建议:
在临界区域之外使用指针
使用 RCU 时最常出现的错误就是在读操作的临界区域内获取某个数据结构的指针,然后在临界区域之外使用这个指针。这种做法非常危险,因为一旦退出临界区域,该数据结构可能会被立即释放,从而导致“使用已释放的指针”这类难以调试的错误。
// 错误的做法
rcu_read_lock();
p = rcu_dereference(global_ptr);
rcu_read_unlock();
// 这里p可能会被释放!
use(p->data); // 这是一种“使用已释放内存”的错误做法!
// 正确的做法
rcu_read_lock();
p = rcu_dereference(global_ptr);
use(p->data); // 是安全的——此时仍在临界区内
rcu_read_unlock();
读侧临界区的阻塞问题
读侧临界区绝对不能造成阻塞。如果某个读取操作在临界区内被阻塞,就会导致“宽限期”永远无法结束。这样一来,任何内存都不会被释放,最终会导致内存不足的问题。
// 错误的做法——可能会导致系统挂起!
rcu_read_lock();
p = rcu_dereference(global_ptr);
sleep(1); // 这会阻止宽限期的检测机制正常工作!
rcu_readunlock();
内存开销问题
RCU的“写时复制”机制可能会导致内存消耗增加。如果数据结构很大,或者需要进行大量的更新操作,那么维护多个版本的数据所带来的人工成本就会很高。
写侧的复杂性
虽然RCU简化了读侧的操作流程,但却会增加写侧的复杂性。写入方需要确保正确地复制数据,而“宽限期”机制的实现也可能会遇到很多麻烦。
选择合适的宽限期机制
实现宽限期的方法有很多种。正确的选择取决于应用程序的具体需求。有些机制虽然简单,但性能较差;而另一些机制则较为复杂,但能提供更好的性能。
需要注意的是,了解这些潜在的问题是避免它们发生的第一步。在使用RCU时,仔细的设计和测试是非常必要的。幸运的是,内核提供了一些基于断言的调试机制,但完全依赖这些机制是很危险的。
决策框架:何时使用RCU
现在我们已经很好地了解了RCU是什么、它是如何工作的,以及它有哪些优缺点,因此我们可以建立一个决策框架,帮助你们决定在自己的系统中何时使用RCU。
以下是一些需要你自己思考的问题:
你的数据中读操作与写操作的比例是多少?RCU在以读操作为主的系统中效果最佳。一个常见的经验法则是:当读操作与写操作的比例达到10:1或更高时,使用RCU会比使用读写锁带来显著的性能提升;而在5:1到10:1之间,读写锁可能更为合适,而且实现起来也更加简单。当写操作非常频繁、比例低于5:1时,RCU的“写时复制”机制所带来的额外开销可能就不值得了——此时使用标准的读写锁或简单的互斥锁可能会更加合适,因为当写操作占主导地位时,性能差异会变得很小。
你的应用程序是否能够接受最终一致性?到目前为止,我们已经知道RCU能够提供最终一致性。但如果你的应用程序要求强一致性,那么RCU就不是合适的选择。
你的数据是否可以被指向新的版本,或者其版本信息能否被记录下来?RCU的工作原理是通过原子级别地更新指向数据新版本的指针来实现的。换句话说,你的数据必须以某种能够支持这种机制的方式来进行结构化设计。如果你的数据只是一个连续的、整体性的内存块,那么使用RCU可能会遇到困难。
你的数据结构是否复杂?数据结构越复杂,正确实现“写时复制”机制就越困难。对于列表和树这类简单的数据结构来说,实现RCU相对比较容易;而对于更复杂的数据结构而言,实现过程则会充满挑战。
你是否愿意承担定制RCU实现所带来的复杂性呢?虽然RCU的基本原理很简单,但一个可用于生产环境的完整实现方案可能会非常复杂。如果你不熟悉低层并发编程,那么使用现成的RCU库或那些内置了RCU功能的系统可能会更加合适。
C++26标准对RCU进行了规范,这一规定使得更多开发者能够使用RCU,因此RCU在更广泛的应用场景中得到采用的可能性也会大大增加。通过仔细考虑这些问题,你可以做出明智的决定,从而判断RCU是否适合解决你所面临的问题。
常见的RCU实现方案
如果你认为RCU适用于你的需求,那么目前已有几种可供使用的、成熟度较高的RCU实现方案。
适用于C/C++语言:
- liburcu库
这是最为成熟且应用最广泛的C/C++语言RCU库。Knot DNS、Netsniff-ng、GlusterFS以及ISC BIND等项目都在生产环境中使用了它。该库提供了多种针对不同使用场景优化的RCU实现版本,支持在Linux、FreeBSD、macOS等平台上运行。 - C++26标准库中的P2545R4模块
适用于Rust语言:
- crossbeam-epoch
它提供了基于“时代划分”的垃圾回收机制,有助于构建无锁数据结构。虽然这个库并未被明确标榜为RCU实现方案,但它采用了类似的原理,并提供了适合Rust语言使用的API,在Rust并发开发生态系统中得到了广泛的应用。
适用于Linux内核:
- 内核内置的RCU功能
Linux内核中内置了多种RCU实现版本,包括vanilla RCU、SRCU以及Tasks RCU等。这些机制在网络协议栈、文件系统以及设备驱动程序等领域得到了广泛的应用。详情请参阅内核文档。
对于大多数应用来说,从liburcu(适用于C/C++)或crossbeam-epoch(适用于Rust)开始使用RCU,就能够获得一个稳定且经过充分测试的并发处理基础。
结论
RCU是一种强大的并发处理机制,它通过放弃即时一致性来换取极高的读操作性能。由于消除了读路径中的锁机制,RCU使得系统能够在不降低性能的情况下处理海量的读操作负载。
需要记住的关键点:
- 读者通过访问不可变的版本来执行操作,因此无需使用锁。
- 写入者会创建新的版本,而不是直接修改原有数据。
- 通过设置缓冲期,可以确保在所有读者完成操作之后再回收资源,从而保障系统安全性。
- 为了获得无锁机制带来的高性能,就必须牺牲一定的一致性。
RCU并不适用于所有场景;在使用它之前,你需要仔细考虑自己的数据一致性要求、读写操作模式,以及对实现复杂性的容忍度。但是,当正确应用时,RCU确实能够显著提升以读取操作为主的应用系统的可扩展性。
无论你是在使用PostgreSQL、Kubernetes、Envoy,还是在开发自己的高性能系统,了解RCU的原理都能帮助你更有效地运用这些技术机制。