深入理解 RCU 实现

http://blog.jobbole.com/106856/

深入理解RCU实现

——基于内核2.6.21 RCU实现(lvyilong316)

RCU(Read-Copy Update),顾名思义就是读-拷贝修改,它是基于其原理命名的。对于被RCU保护的共享数据结构,读者不需要获得任何锁就可以访问它,但写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。那么这个“适当的时机”是怎么确定的呢?这是由内核确定的,也是我们后面讨论的重点。

RCU原理

RCU实际上是一种改进的rwlock,读者几乎没有什么同步开销,它不需要锁,不使用原子指令,而且在除alpha的所有架构上也不需要内存栅(Memory Barrier),因此不会导致锁竞争,内存延迟以及流水线停滞。不需要锁也使得使用更容易,因为死锁问题就不需要考虑了。写者的同步开销比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其它写者的修改操作。

读者必须提供一个信号给写者以便写者能够确定数据可以被安全地释放或修改的时机。有一个专门的垃圾收集器来探测读者的信号,一旦所有的读者都已经发送信号告知它们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。

RCU与rwlock的不同之处是:它既允许多个读者同时访问被保护的数据,又允许多个读者和多个写者同时访问被保护的数据(注意:是否可以有多个写者并行访问取决于写者之间使用的同步机制),读者没有任何同步开销,而写者的同步开销则取决于使用的写者间同步机制。但RCU不能替代rwlock,因为如果写比较多时,对读者的性能提高不能弥补写者导致的损失。

读者在访问被RCU保护的共享数据期间不能被阻塞,这是RCU机制得以实现的一个基本前提,也就说当读者在引用被RCU保护的共享数据期间,读者所在的CPU不能发生上下文切换,spinlock和rwlock都需要这样的前提。写者在访问被RCU保护的共享数据时不需要和读者竞争任何锁,只有在有多于一个写者的情况下需要获得某种锁以与其他写者同步。

写者修改数据前首先拷贝一个被修改元素的副本,然后在副本上进行修改,修改完毕后它向垃圾回收器注册一个回调函数以便在适当的时机执行真正的修改操作。等待适当时机的这一时期称为grace period,而CPU发生了上下文切换称为经历一个quiescent state,grace period就是所有CPU都经历一次quiescent state所需要的等待的时间。垃圾收集器就是在grace period之后调用写者注册的回调函数来完成真正的数据修改或数据释放操作的。

要想使用好RCU,就要知道RCU的实现原理。我们拿linux 2.6.21 kernel的实现开始分析,为什么选择这个版本的实现呢?因为这个版本的实现相对较为单纯,也比较简单。当然之后内核做了不少改进,如抢占RCU、可睡眠RCU、分层RCU。但是基本思想都是类似的。所以先从简单入手。

首先,上一节我们提到,写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。而这个“适当的时机”就是所有CPU经历了一次进程切换(也就是一个grace period)。为什么这么设计?因为RCU读者的实现就是关抢占执行读取,读完了当然就可以进程切换了,也就等于是写者可以操作临界区了。那么就自然可以想到,内核会设计两个元素,来分别表示写者被挂起的起始点,以及每cpu变量,来表示该cpu是否经过了一次进程切换(quies state)。就是说,当写者被挂起后,

1)重置每cpu变量,值为0。

2)当某个cpu经历一次进程切换后,就将自己的变量设为1。

3)当所有的cpu变量都为1后,就可以唤醒写者了。

下面我们来分别看linux里是如何完成这三步的。

从一个例子开始

我们从一个例子入手,这个例子来源于linux kernel文档中的whatisRCU.txt。这个例子使用RCU的核心API来保护一个指向动态分配内存的全局指针。

如上代码所示,RCU被用来保护全局指针struct foo *gbl_foo。foo_get_a()用来从RCU保护的结构中取得gbl_foo的值。而foo_update_a()用来更新被RCU保护的gbl_foo的值(更新其a成员)。

首先,我们思考一下,为什么要在foo_update_a()中使用自旋锁foo_mutex呢? 假设中间没有使用自旋锁.那foo_update_a()的代码如下:

假设A进程在上图—-标识处被B进程抢点.B进程也执行了goo_ipdate_a().等B执行完后,再切换回A进程.此时,A进程所持的old_fd实际上已经被B进程给释放掉了.此后A进程对old_fd的操作都是非法的。所以在此我们得到一个重要结论:RCU允许多个读者同时访问被保护的数据,也允许多个读者在有写者时访问被保护的数据(但是注意:是否可以有多个写者并行访问取决于写者之间使用的同步机制)

说明:本文中说的进程不是用户态的进程,而是内核的调用路径,也可能是内核线程或软中断等。

RCU的核心API

另外,我们在上面也看到了几个有关RCU的核心API。它们为别是:

其中,rcu_read_lock()和rcu_read_unlock()用来保持一个读者的RCU临界区.在该临界区内不允许发生上下文切换。为什么不能发生切换呢?因为内核要根据“是否发生过切换”来判断读者是否已结束读操作,我们后面再分析。
rcu_dereference():读者调用它来获得一个被RCU保护的指针。
rcu_assign_pointer():写者使用该函数来为被RCU保护的指针分配一个新的值。

synchronize_rcu():这是RCU的核心所在,它挂起写者,等待读者都退出后释放老的数据。

RCU API实现分析 

rcu_read_lock()和rcu_read_unlock()

rcu_read_lock()和rcu_read_unlock()的实现如下:

其中__acquire(),rcu_read_acquire(),rcu_read_release(),__release()都是一些选择编译函数,可以忽略不可看。因此可以得知:rcu_read_lock(),rcu_read_unlock()只是禁止和启用抢占.因为在读者临界区,不允许发生上下文切换

rcu_dereference()和rcu_assign_pointer()

rcu_dereference()和rcu_assign_pointer()的实现如下:

它们的实现也很简单.因为它们本身都是原子操作。只是为了cache一致性,插上了内存屏障。可以让其它的读者/写者可以看到保护指针的最新值.

synchronize_rcu()

synchronize_rcu()在RCU中是一个最核心的函数,它用来等待之前的读者全部退出.我们后面的大部份分析也是围绕着它而进行.实现如下:

我们可以看到,它初始化了一个本地变量,它的类型为struct rcu_synchronize.调用call_rcu()之后.一直等待条件变量rcu.competion的满足。

在这里看到了RCU的另一个核心API,它就是call_run()。它的定义如下:

该函数也很简单,就是将参数传入的回调函数fun赋值给一个struct rcu_head变量,再将这个struct rcu_head加在了per_cpu变量rcu_data的nxttail 链表上。

rcu_data定义如下,是个每cpu变量:
DEFINE_PER_CPU(struct rcu_data, rcu_data) = { 0L };

接着我们看下call_rcu注册的函数,我们也可以看到,在synchronize_rcu()中,传入call_rcu的函数为wakeme_after_rcu(),其实现如下:

我们可以看到,该函数将条件变量置真,然后唤醒了在条件变量上等待的进程。

由此,我们可以得知,每一个CPU都有一个rcu_data.每个调用call_rcu()/synchronize_rcu()进程的进程都会将一个rcu_head都会挂到rcu_data的nxttail链表上(这个rcu_head其实就相当于这个进程在RCU机制中的体现),然后挂起。当读者都完成读操作后(经过一个grace period后)就会触发这个rcu_head上的回调函数来唤醒写者。整个过程如下图所示:

深入理解 RCU 实现_第1张图片

看到这里,也就到了问题的关键,内核是如何判断当前读者都已经完成读操作了呢(经过了一个grace period)?又是由谁来触发这个回调函数wakeme_after_rcu呢?下一小节再来分析。

从RCU的初始化说起 

那究竟怎么去判断当前的读者已经操作完了呢?我们在之前看到,不是读者在调用rcu_read_lock()的时候要禁止抢占么?因此,我们只需要判断所有的CPU都进过了一次上下文切换,就说明所有读者已经退出了。要彻底弄清楚这个问题,我们得从RCU的初始化说起。

RCU的初始化开始于start_kernel()–>rcu_init()。而其主要是对每个cpu调用了rcu_online_cpu函数。

rcu_online_cpu

这个函数主要完成两个操作:初始化两个per cpu变量;注册RCU_SOFTIRQ软中断处理函数rcu_process_callbacks。我们从这里又看到了另一个per cpu变量:rcu_bh_data.有关bh的部份之后再来分析.在这里略过这些部分。 下面看下rcu_init_percpu_data()的实现。

rcu_init_percpu_data

调用这个函数的第二个参数是一个全局变量rcu_ctlblk。 在继续向下分析之前我们先看下这些函数用到的一些重要数据结构。

重要数据结构

说明:下列数据结构只列出了主要成员。

struct rcu_ctrlblk的主要作用就是定义上节提到的全局变量rcu_ctlblk,这个变量在系统中是唯一的。另外说明一下,为了记录方便,内核将从启动开始的每个grace period对应一个数字表示。

这里的cpumask是为了标识当前系统中的所有cpu,以便标记哪些cpu发生过上下文切换(经历过一个quiescent state)。而cur,completed,则用来同步。我们可以这样理解,cur和completed是系统级别的记录信息,也即系统实时经历的grace编号,一般情况下,新开一个graceperiod等待周期的话,cur会加1,当graceperiod结束后,会将completed置为cur,所以通常情况下,都是completed追着cur跑。

那么我们可能会猜测,是不是如果complete= curr -1 的时候,就表示系统中graceperiod还没有结束?当completed= curr的时候,就表示系统中不存在graceperiod等待周期了?我们姑且这么理解,实际上有些许差别,但设计思想都是一样的。

上面的结构,要达到的作用是,跟踪单个CPU上的RCU事务

(1) passed_quiesc:这是一个flag标志,表示当前cpu是否已经发生过抢占了(经历过一个quiescent state),0表示为未发送过切换,1表示发生过切换;

说明:我们一直强调发生过一次cpu抢占就是经历过一个quiescent state,其实这是不严格的说法。为什么呢?因为自系统启动,各种进程频繁调度,肯定每个cpu都会发生过抢占。所以我们这里说的“发生过抢占”是指从某个特殊的时间点开始发生过抢占。那么这个特殊的时间点是什么时候呢?就是我们调用synchronize_rcu将rcu_head挂在每cpu变量上并挂起进程时,我们后面分析就会证实这一点。

(2) batch:表示一个grace periodid,表示本次被阻塞的写者,需要在哪个graceperiod之后被激活;

(3) quiescbatch:表示一个grace periodid,用来比较当前cpu是否处于等待进程切换完成。

下面我们介绍剩下的三组指针,但首先注意这三组链表上的元素都是struct rcu_head 类型。这三个链表的作用还要结合整个RCU写者从阻塞到唤醒的过程来看。

rcu写者的整体流程,假设系统中出现rcu写者阻塞,那么流程如下:

(1) 调用synchronize_rcu,将代表写者的rcu_head添加到CPU[n]每cpu变量rcu_data的nxtlist,这个链表代表有需要提交给rcu处理的回调(但还没有提交);

(2) CPU[n]每次时钟中断时检测自己的nxtlist是否为null,若不为空,因此则唤醒rcu软中断;

(3) RCU的软中断处理函数rcu_process_callbacks会挨个检查本CPU的三个链表。

下面分析RCU的核心处理函数,也就是软中断处理函数rcu_process_callbacks。

RCU软中断

在“RCU API实现分析”一节我们知道调用synchronize_rcu,将代表写者的rcu_head添加到了CPU[n]每cpu变量rcu_data的nxtlist。

而另一方面,在每次时钟中断中,都会调用update_process_times函数。

update_process_times 

在每一次时钟中断,都会检查rcu_pending(cpu)是否为真,如果是,就会为其调用rcu_check_callbacks()。

rcu_pending

rcu_pending()的代码如下:

同上面一样,忽略bh的部份,我们看 __rcu_pending。

 __rcu_pending

可以上面有四种情况会返回1,分别对应:

A. 该CPU上有等待处理的回调函数,且已经经过了一个batch(grace period).rdp->datch表示rdp在等待的batch序号;

B. 上一个等待已经处理完了,又有了新注册的回调函数;

C. 等待已经完成,但尚末调用该次等待的回调函数;

D. 在等待quiescent state.

如果rcu_pending返回1,就会进入到rcu_check_callbacks(),rcu_check_callbacks()中主要工作就是调用raise_softirq(RCU_SOFTIRQ),触发RCU软中断。而RCU软中断的处理函数为rcu_process_callbacks,其中分别针对每cpu变量rcu_bh_data和rcu_data调用__rcu_process_callbacks。我们主要分析针对rcu_data的调用。

__rcu_process_callbacks

1) 先看nxtlist里有没有待处理的回调(rcu_head),如果有的话,说明有写者待处理,那么还要分两种情况:

1.1)如果系统是第一次出现写者阻塞,也即之前的写者都已经处理完毕,那么此时curlist链表一定为空(curlist专门存放已被rcu检测到的写者请求),于是就把nxtlist里的所有成员都移动到curlist指向,并把当前CPU需要等待的grace period id:rdp->batch设置为当前系统处理的grace period的下一个grace周期,即rcp->cur+ 1。由于这算是一个新的grace period,即start_rcu_batch,所以还接着需要增加系统的grace period计数,即rcp->cur++,同时,将全局的cpusmask设置为全f,代表新的grace period开始,需要检测所有的cpu是否都经过了一次进程切换。代码如下:

接着跳转至1.2。

1.2) 如果系统之前已经有写者在被rcu监控着,但还没来得及经过一个grace period,这个时候curlist不为空,nxtlist也不为空,写者会被加入nxtlist中。由于curlist不为空,说明上个rcu周期的写者还没有处理完,于是不会将本次阻塞的写者加入curlist,一直到上次的curlist里的rcu_head被处理完(都移动到了donelist),才会将后来的写者纳入RCU考虑(移动到curlist)(假如这个期间又来了多个写者,则多个写者的rcu_head共享下一个grace period,也就是下一个grace period结束后这多个写者都会被唤醒)。进入下一步。

2) rcu_process_callbacks调用每CPU函数rcu_check_quiescent_state开始监控,检测所有的CPU是否会经历一个进程切换。 这个函数是如何得知需要开始监控的? 答案在于quiescbatch与全局的rcp->cur比较。 初始化时rdp->quiescbatch =rcp->completed = rcp->cur。 由于1.1有新grace period开启,所以rcp->cur已经加1了,于是rdp->quiescbatch和rcp->curr不等,进而将此cpu的rdp->passed_quiesc设为0,表示这个周期开始,我要等待这个cpu经历一个进程切换,等待该CPU将passed_quiesc置为1。即与前面讲到的passed_quiesc标志置0的时机吻合。最后将rdp->quiescbatch置为 rcp->cur,以避免下次再进入软中断里将passed_quiesc重复置0。

3) 本次软中断结束,下次软中断到来,再次进入rcu_check_quiescent_state进行检测,如果本CPU的rdp->passed_quiesc已经置1,则需要cpu_quiet将本CPU标志位从全局的rcp->cpumask中清除,如果cpumask为0了,则说明自上次RCU写者被挂起以来,所有CPU都已经历了一次进程切换,于是本次rcu等待周期结束,将rcp->completed置为rcp->cur,重置cpumask为全f,并尝试重新开启一个新的grace period。我们可以看到RCU用了如此多的同步标志,却少用spinlock锁,是多么巧妙的设计,不过这也提高了理解的难度。

4)下次再进入rcu软中断__rcu_process_callbacks,发现rdp->batch已经比rcp->completed小了(因为上一步骤中,后者增大了),则将rdp->curlist上的回调移动到rdp->donelist里,接着还会再次进入rcu_check_quiescent_state,但是由于当前CPU的rdp->qs_pending已经为1了,所以不再往下清除cpu掩码。__rcu_process_callbacks

代码变成了:

你可能感兴趣的:(Linux,Linux,kernel)