基本逻辑
RCU主要解决多个CPU core读写一个数据时的同步问题,一般情况下,我们都是加锁做同步,
RCU这种锁使得读写core可以最大的并行,对于读多写少的场景可以大幅提升性能。
RCU在需要更改数据的时候,不直接更新到现有数据上,而是先拷贝一份数据,在拷贝的数据
上先把数据更新好,然后再原子的更新数据,当数据是用指针访问时,只要用一个原子操作
更新掉指针就好。更改数据按照这样的逻辑,可以不断的进行,这样系统里就会出现多个数据
的版本,但是每个版本上的数据是一致的,只是不同版本的数据之间不一致。读数据时,就
直接读当前最新的数据就好,读到的数据使用完成,当没有其它的core还是使用时,就可以把
这个版本的数据释放掉,因为这个版本的数据可能被新的写操作更新了,系统里最新的数据
已经不是这个版本了。
下图是一个RCU的示意图:
1 | -------> time data can be released |
其中data是要同步的对象。每次更新data,都是在一个新拷贝上做更新,对于之前的旧值,
如果还有reader在用就不能释放,直到没有reader还在用老值时,就可以把老值释放掉。
reader总是使用当前最新的版本。所以,可以看出来RCU实现最核心的地方是老值的释放逻辑。
Linux内核中的RCU API
我们先看下使用RCU保护一个全局变量的具体使用方法。比如,我们要保护如下全局变量:
1 | struct data { |
程序里用一个指针struct data __rcu *p 访问这个全局变量,那么这个变量的访问、修改
和释放的代码大概是:
1 | /* 访问数据 */ |
我们从朴素的逻辑出发看看这个问题,首先rcu_dereference和rcu_assign_pointer之间保持
互斥,就可以保证对指针p的更新和引用是互斥的。考虑synchronize_rcu的实现,这个函数
需要在老值不再使用的时候把他们释放掉,最直观的思路就是把data的所有版本的指针都记录
起来,对于其中的每个值使用引用计数的办法记录是否还有用户使用,可以在lock的时候引用
计数加1,在unlock的时候引用计数减1,在synchronize_rcu里定期扫描全部指针的引用计数,
如果为0,表示没有reader访问了,就可以把对应这个版本的data释放掉。
显然内核里不是按如上的逻辑实现的,这样实现每个RCU锁使用的资源太多了。经典的内核RCU
实现中,lock/unlock只是做关内核抢占/开内核抢占的操作,synchronize_rcu中使用cpu_mask
记录每个CPU的调度情况,当每个CPU都调度过一次后,之前做RCU read的CPU一定已经离开
关抢占的临界区,这样可以认为所有对新版本数据的读者都已经离开临界区了。
需要注意的是,synchronize_rcu同步等待的是所有老值使用者的退出,而不是rcu_assign_pointer
换上的最新值。
1 | -------> time old can be released |
如上,rcu_assign_pointer new之后的synchronize_rcu是等待被替换的old没有用户在使用。
对于任意一个CPU,使用old可能有几种情况:1. 没有使用过old,2. 使用过old,但是已经
使用完结,3. 使用old一直持续到new已经更新进来。因为在old使用过程中关了内核抢占,
old使用过程是不会发生调度的,所以只要在synchronize_rcu里检测到一个CPU上发生了调度,
就可以保证整个CPU对old数据的引用已经退出。
有些问题还没有想明白。RCU检测所有CPU都调度一次,有的CPU并没有做RCU read,那么理论
上都不需要检测,如何做到这个?
内核里提供了基于RCU的基础数据结构,比如,内核里RCU保护的链表的实现逻辑的代码位置在:
linux/include/linux/rculist.h。
RCU用户态库
有各种RCU用户态库支持在用户态使用RCU,比如,liburcu。