RCU是一组Linux内核API,实现了一种同步机制,允许多个读者与写者并发操作而不需要任何锁,这种同步机制可以用于保护通过指针访问的数据。RCU读者只需要很低的额外成本,在典型的服务器内核配置下甚至是0成本。如果可能有多个写者,写者之间需要其他同步机制,除了使用RCU API直接访问指针数据,更多的使用方式是封装API使其用于链表访问。
RCU适用于读取数据量大而且可以接收读取到旧数据的场景。
为什么只能保护通过指针进行访问的数据?
任何CPU架构下的Linux内核都可以保证指针操作的原子性,这是无锁并发的前提。也就是当CPUA在修改指针的时候,无论何时,CPUB读取到的要么是原来的数据,要么是更新的数据。不会是混合旧值不同的bit位的无意义的值。
读者0额外成本是怎么做到的?
在典型的服务器内核配置(非抢占内核配置并使用gcc编译)时进入临界函数,内存屏障并不会生成任何汇编代码,只是通知编译器对临界区内外代码不要做乱序并且进入临界区后会刷新寄存器(防止CPU乱序的内存屏障操作在其他API中添加),离开临界区的代码同样最后只有一个内存屏障,因此可以看做是0额外成本。
static inline void rcu_read_lock(void)
{
__rcu_read_lock();
__acquire(RCU);
rcu_lock_acquire(&rcu_lock_map);
rcu_lockdep_assert(rcu_is_watching(),
"rcu_read_lock() used illegally while idle");
}
static inline void __rcu_read_lock(void)
{
preempt_disable();
}
#define preempt_disable() barrier()
#define barrier() __asm__ __volatile__("": : :"memory")
如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方。
inline关键字实际上仅是建议内联而不是前置内联。在头文件中,定义内联函数需要加static,否则编译时会当
做全局的普通函数进行处理。在编译的时候可能会出现重定义的导致编译出错。在头文件定义函数,可以选择宏定义,也可以使用inline函数定义。inline函数在头文件中定义更加明确一点。
在RCU机制下写数据成本如何?
需要等到此前读者完成,需要等待,可以通过注册异步执行的函数的形式处理。
RCU原理分析:
RCU读取进入临界区的标志是调用rcu_read_lock,这个函数的代码是:
<include/linux/rcupdate.h>
static inline void rcu_read_lock(void)
{
__rcu_read_lock();
__acquire(RCU);
rcu_read_acquire();
}
该函数实现里面有三个函数调用,但是实质性的工作由第一个函数__rcu_read_lock()来完成,__rcu_read_lock通过调用,preempt_disable关闭内核可抢占性,但是中断是允许的,假设读取者正处于rcu临界区中且刚读取了一个共享数据区的指针p(但是还没有访问p的数据成员),发生了一个中断,而该中断例程ISR恰好需要修改p所指向的数据区,按照RCU的设计原则,ISR会新分配一个同样大小的数据区new_p,再把老数据区的数据拷贝过来,接着在new_p的基础上做修改,不存在对p的并发访问,因此RCU是一种免锁机制。ISR在把数据更新的工作做完之后,将new_p的值重新赋给p,最后注册一个回调函数用以在适当的时候释放老指针。因此老指针p上的所有引用就结束了,释放p不会有问题。当中断处理例程做完这些工作之后,返回,被中断的例程将依然访问到p空间上的数据,也就是老数据,这样的结果是RCU允许的。RCU规则造成的资源短暂性不一致问题是允许的。
最后就是释放老指针问题了?
所有对老指针的引用只可能发生在rcu_read_lock和rcu_read_unlock所包括的临界区,而在这个临界区中不可能发生进程切换,而一旦出了该临界区则不会有任何对该指针的引用了,这个规则要求读取者在临界区不能发生进程切换,因为一但进程切换,释放老指针的回调函数就有可能被调用,从而导致老指针被释放掉,当被切换的进程被重新调度运行的时候可能会引用被释放掉的空间。
只需要通过rcu_read_lock关闭内核可抢占性就行,因为他使得当前读者进程即使在临界区发生了中断,也不会切换到其他进程。如果在rcu的临界区中调用了一个函数,该函数可能睡眠,那么RCU的设计规则就遭到了破坏,系统将进入一种不稳定的状态。
和读写锁不同的是,这一机制自由度更高。RCU读取者在读取临界资源的时候,不需要考虑写着的感受。
rwlock中读者和写者自始至终都共享一份资源 ,相互存在制约。RCU应该用在大量读而更新操作相对较少的情况下,这种情况下,系统的性能大大提升,因为rcu操作相对其他有所同步机制中,减去了锁机制的开销。
实际使用中,共享资源是以链表的形式存在的,所以操作对象通常是指针。
在释放老指针方面,Linux提供了两种方式,供使用者使用,一个是call_rcu,另一个是调用synchronize_rcu。前者是异步的,call_rcu会将释放的老指针,的回调函数存放在一个节点中,然后将节点加入到当前正在运行的call_rcu的处理器的链表中,在时钟中断的部分,rcu软中断处理函数rcu_process_callbacks会检查当前处理器是否经历了一个休眠期,rcu内核代码实现在确定系统中所有的处理器都经历了一个休眠期之后(意味着处理器上都发生了进程的切换,因此老指针此时可以安全的释放了),将调用call_rcu提供的回调函数。
syschronize_rcu的实现则利用了等待队列,在它的实现过程中也会像call_rcu那样像之前处理器链表中加入节点,其回调函数是rcu_process_callbacks会检查当前处理器是否经历了一个休眠期,直到系统中的所有处理器都发生一次进程切换,因而wakeme_after_rcu被rcu_process_callbacks所调用以唤醒睡眠syschronize_rcu,被唤醒后,可以释放老指针了。
所以我们看到,call_rcu返回后其注册的回调函数可能还没被调用,因而也就意味着老指针还未被释放,而synchronize_rcu返回后老指针肯定被释放了。所以,是调用call_rcu还是synchronize_rcu,要视特定需求与当前上下文而定,比如中断处理的上下文肯定不能使用 synchronize_rcu函数了。