linux内核RCU

RCU背景

RCU在2002年中增加到内核中的,是一种基于互斥的同步机制,当读多写少并且读性能要求较高时候能够达到最大的效果,它总体上属于一种空间换时间的方式,用短时间内占用额外的内存来保证快速的访问.
RCU允许多个读并发访问同一个对象,它一般是指针指向它,并且这个指针可能会被更新指向一个新的对象,当我们更新一个对象的时候,需要原子操作,一般只有不大于计算机字长的操作才有原子指令支持,所以我们操作的一般都是指针。旧的对象会使用延时释放的机制,当前使用者不再访问这个对象之后才释放旧的对象.而在指针更新后之后的读会访问新的对象.这样就不需要使用传统的同步机制,速度非常快,但是更新时需要做更多的延时工作.

普通的锁不允许并发操作数据,不管他是读还是写。读写锁在此基础上进行改进,允许多个读并发,但是仍然不允许读和写并发。
而RCU具有如下特性:
1.允许多个读并发进行,并且允许读和写同时进行
2.它不需要像自旋锁、信号量、读写锁那样针对每个数据对象定义一个锁,它只需要read_lock,read_unlock这种操作,在多核上可伸缩性强
3.额外的消耗内存,会存在大量RCU对象短时间内暴增的问题

RCU通过维护多个版本的对象同时存在即不立即释放,等到所有读临界区都退出后才会释放,它不再使用传统意义上的锁而是通过将使用者按照时间段来划分,简单比喻:有一个指针指向的数据对象可能在被使用,之后我们更新指针指向一个新的对象,之后需要释放旧的对象,假设我们写的代码最大使用数据对象的时间是10s,延时10s后可以释放旧的对象,这种做法比较傻笨,需要要求我们代码使用时间不大于10s。而RCU的做法和这个类似,通过延时释放来确保所有人都不在使用旧对象后才将其释放,避免内存访问错误,但是它使用了更加精确和灵活的期限来保证内存最快释放重用。

RCU组成

RCU有三个基本操作:read、update(insert/delete,modify)、free,这些就是RCU对外提供的基本api,同时封装了RCU版本的双向链表和hash链表。核心的api有:

RCU读临界区标定:
	rcu_read_lock
	rcu_read_unlock
	rcu_read_lock_bh
	rcu_read_unlock_bh
RCU对象引用:
	rcu_dereference
RCU对象指针更新:
	rcu_assign_pointer

RCU平静期结束,阻塞同步释放对象,只能在进程上下文
	synchronize_sched
	synchronize_rcu
RCU对象异步释放,可以用在中断上下文
	call_rcu
	call_rcu_bh
RCU等待异步释放callback结束
	rcu_barrier

RCU update

RCU最关键的一项属性就是在修改数据的同时还可以安全的使用数据。为了提供这种并发的插入操作,RCU使用了一种称为订阅-发布的机制,订阅对应读操作,发布对应修改操作。
1.底层机制包括内存屏障,编译器优化disable指令。
2.发布的时候也就是通常的写操作如果有可能并发修改,那么需要额外的机制来保证写互斥。

指令乱序问题

为什么需要内存屏障和编译器优化disable指令:下面例子,有一个全局指针gp初始值是NULL,之后会指向一个新初始化过的对象,代码示例:

  1 struct foo {
  2   int a;
  3   int b;
  4   int c;
  5 };
  6 struct foo *gp = NULL;
  7 
  8 /* . . . */
  9 
 10 p = kmalloc(sizeof(*p), GFP_KERNEL);
 11 p->a = 1;
 12 p->b = 2;
 13 p->c = 3;
 14 gp = p;

但是注意,在编译的时候会有编译器优化,在执行的时候有指令乱序,所以真正执行的时候可能并不像代码看到的这种顺序执行。可能优化成这样:在初始化p指针之前已经将gp指向p对象,读就看到了未初始化的值,得到错误的值。
所以需要使用内存屏障来保证他们顺序执行,但是内存屏障是出了名的难用,RCU把内存屏障封装到了rcu_assign_pointer中,它发布新的对象,上面的示例中最后4行写成下面这样,它能够保证编译器和cpu在p初始化之后再将gp指向这个对象。

  1 p->a = 1;
  2 p->b = 2;
  3 p->c = 3;
  4 rcu_assign_pointer(gp, p);

但是,只在更新时来强制排序是不够的,读的时候也需要强制排序。假如读代码现在写成这样:

  1 p = gp;
  2 if (p != NULL) {
  3   do_something_with(p->a, p->b, p->c);
  4 }

可能这个代码片段不会被乱序执行,但是在DEC alpha CPU上的编译器优化期间可能会在p为空的时候能够获取到他的值,编译器会猜测p是否会是空值,如果后面它会被赋值,会先fetchp->a, p->b, and p->c,然后再获取p的值来验证猜测是否正确 。这个可能是最简单的方式来看到值指定的编译器优化,编译器可能会猜p的值,获取p->a, p->b, and p->c,这种优化的排序相当激进。

在读的时候需要防止编译器和CPU的优化排序,rcu_dereference()原语封装了内存屏障指令和编译器指示指令来阻止了这种优化。

  1 rcu_read_lock();
  2 p = rcu_dereference(gp);
  3 if (p != NULL) {
  4   do_something_with(p->a, p->b, p->c);
  5 }
  6 rcu_read_unlock();

rcu_dereference()可以被当作特定对象的一个订阅者,保证在发布(rcu_assign_pointer)之后才能看到这个对象的内容。

总结1:RCU API封装了内存屏障和disable 编译器优化指令,需要使用rcu_assign_pointer和rcu_dereference来访问和修改RCU对象

RCU read

rcu_read_lock和rcu_read_unlock来定义RCU读临界区的范围,它并没有通过原子锁的方式,也不会阻塞,也不会影响list_add_rcu这种的并发执行,在没有CONFIG_PREEMPT配置的内核中,这个什么也不做。
要读取RCU保护的对象,需要使用rcu_dereference来订阅该对象的值。
临界区的作用在RCU free节进行说明。

RCU free

解决完修改和读的问题,那么旧的对象什么时候释放呢?我们需要追踪旧的对象使用状况吗?
有很多的方法等待事情完成,包括引用计数,读写锁,事件通知等等。RCU也是一种等待事件结束的方式,RCU的优越之处在于它可以同时追踪很多个不同的事件而不需要显示的追踪对象使用情况,不会引起性能和可扩展性问题,最重要的是他不会引起死锁。

在RCU场景中,等待的事件是RCU读临界区结束,一个RCU读临界区从rcu_read_lock开始以rcu_read_unlock结束。RCU读临界区可以嵌套,可以包含很多的代码,只要这些代码并不会显式的阻塞或者睡眠。如果遵守这些约定,你可以使用RCU来等待任何的临界区结束。

RCU通过间接的方式知道了这些读是什么时候结束的,它并没有采用传统的方式来追踪读对象,而是采用了时间区间的方式,彻底地改换了思路,用简单的方式来解决这个问题。
它逻辑上将时间分成一段段的区间,有长有短,每个读临界区最多横跨两个grace period,这样当回收对象的时候,最多只需要等待当前grace period结束就可以回收。grace period
如下图所示,RCU等待已经存在的RCU读临界区结束,在结束前保护这些临界区里的内存访问不会异常。
RCU grace period

Grace Period

Grace Period的意思是,从Writer开始修改受保护的数据结构后开始,到所有的Reader Lock都结束了至少一次的时间段。当调用synchronize_rcu或call_rcu时就会开启新的grace period。

假设我们称Grace Period开始的时间点是T:

如果一个Reader Lock时间横跨T,则Grace Period必然结束于这个Reader Lock结束之后。
如果一个Reader Lock开始于T之后,则Grace Period可能于这个Reader Lock的任意时间结束。也就是可能在Reader Lock开始之前结束,也可能在Reader Lock中间结束,也可能在Reader Lock结束之后才结束。

了解了上面这两个概念之后,我们可以通过简单的证明知道,在Grace Period之后,所有的Reader都不可能获取到在T时间之前的旧数据。所以在Grace Period之后,作为Writer是可以放心的删除旧数据的。

下面的伪代码展示了RCU是如何等待读结束
假如在对双链表进行一个元素的替换操作,使用synchronize_rcu等待先前已经存在的RCU读临界区结束。这里的关键是之后所有的RCU读临界区都不会再访问刚刚被替换掉的对象,之后进行资源清理,释放掉被替换的对象内存。

下面的代码片段展示了进行搜索操作后替换的操作:

  1 struct foo {
  2   struct list_head list;
  3   int a;
  4   int b;
  5   int c;
  6 };
  7 LIST_HEAD(head);
  8 
  9 /* . . . */
 10 
 11 p = search(head, key);
 12 if (p == NULL) {
 13   /* Take appropriate action, unlock, and return. */
 14 }
 15 q = kmalloc(sizeof(*p), GFP_KERNEL);
 16 *q = *p;				//read copy
 17 q->b = 2;			//update
 18 q->c = 3;			//update
 19 list_replace_rcu(&p->list, &q->list);	//替换
 20 synchronize_rcu();							//等待
 21 kfree(p);				//清理

19-21行实现了替换和清理操作,而16-19行做的就是RCU操作:读-拷贝-更新(read-copy update)

synchronize_rcu在等什么?它只是在等待所有当前RCU读临界区完成。

这里有个注意的点:经典的RCU读临界区中不允许长时间阻塞或睡眠。所以当一个给定的CPU进行上下文切换的时候,我们就知道上一个阶段的RCU读临界区已经结束了,意味着每个CPU进行了一次或多次上下文切换,所有先前的RCU读临界区已经完成,synchronize_rcu就可以返回了。

RCU经典的synchronize_rcu概念上简单的来说如下:

  1 for_each_online_cpu(cpu)
  2   run_on(cpu);

这里run_on切换当前线程到当前在线的所有CPU上都运行一次,也就是在所有CPU进行上下文切换一次,这样就保证所有先前的RCU读临界区完成。

RCU list

尽管rcu_assign_pointer和rcu_dereference理论上可以构建任何的RCU保护的数据结构,但是在内核中通常再它的基础上封装更高级别的对象,最常见的是将rcu_assign_pointer和rcu_dereference嵌入到了链表管理中,内核中有两个链表对象上:双链表和哈希链表,下面分析一下RCU是如何在双链表操作上生效的。
双链表的对象关系如下图,绿色的代表链表头,灰色的代表链表节点。
双链表结构
双链表对象的发布示例如下:

  1 struct foo {
  2   struct list_head list;
  3   int a;
  4   int b;
  5   int c;
  6 };
  7 LIST_HEAD(head);
  8 
  9 /* . . . */
 10 
 11 p = kmalloc(sizeof(*p), GFP_KERNEL);
 12 p->a = 1;
 13 p->b = 2;
 14 p->c = 3;
 15 list_add_rcu(&p->list, &head);

第15行一定需要一些同步机制来保证同时只有一个list_add操作。但是这样的同步操作并不会影响RCU读操作。

订阅一个RCU保护链表就相当简单:

  1 rcu_read_lock();
  2 list_for_each_entry_rcu(p, head, list) {
  3   do_something_with(p->a, p->b, p->c);
  4 }
  5 rcu_read_unlock();

list_add_rcu原语将一个链表元素加入到链表中,通过list_for_each_entry_rcu可以订阅到正确的对象。相对于普通的list提供的api,rcu版本的api相对会少很多,rcu操作的奥秘在于它的读在于从头到尾,它只使用了一个next指针,只是一个原子操作.

什么时候可以安全的释放掉旧的数据呢?就是我们怎么知道所有的读者都已经不再引用这些数据呢?

RCU也是一种等待事件结束的方式。当然,有很多的方法等待事情完成,包括引用计数,读写锁,事件通知等等。RCU的优越之处在于它可以同时追踪20000个不同的事件而不需要显示的追踪对象使用情况,不会引起性能和可扩展性问题,最重要的是他不会引起死锁。

在RCU场景中,等待RCU称作RCU读临界区,一个RCU读临界区从rcu_read_lock开始以rcu_read_unlock结束。RCU读临界区可以嵌套,可以包含很多的代码,只要这些代码并不会显式的阻塞或者睡眠。如果遵守这些约定,你可以使用RCU来等待任何的代码结束。

在此期间RCU是如何维护对象的两个版本?下面展示了删除链表元素时rcu是如何生效的:

  1 p = search(head, key);
  2 if (p != NULL) {
  3   list_del_rcu(&p->list);
  4   synchronize_rcu();
  5   kfree(p);
  6 }

链表的初始状态,包括指针p,如下图:
list init state
里面有三个元素分别是a,b,c。红色框表示可能有读着可能正在引用他们,因为读和写不必同步,在替换发生时可能正在并发读。在此忽略了其中的prev指针和尾部到头部的环。

在list_del_rcu完成后,5,6,7元素已经从链表中删除了。因为读并不需要和写进行同步,它还可能在进行链表扫描。这些并发操作可能看到,也可能看不到刚被移除的元素,取决于时间。但是读临界区在获取到刚才被移除的对象之后可能被其他操作延迟了(中断,ECC内存错误或者CONFIG_PREEMPT_RT实时内核中的抢占),它还会看到这个旧对象一段时间。所以我们现在我们有链表的两个版本,一个包含5,6,7元素而另一个不包含。它的框仍然是红色,表明可能有读者在引用它。
MultiVersionDel
在删除后,后续的读再也不允许对5,6,7进行引用。所以当synchronize_rcu返回后,所有先前已经存在的读必然已经结束了,所以不再有读者引用该对象,现在它由红框变为黑框,接下来我们只有一个版本的链表
MultiVersionDelete3
接下来这个对象就可以安全的释放掉了,这样我们就完成了一个对象的删除。
MultiVersionDelete4
RCU发布和订阅原语在下表中

CategoryPublishRetractSubscribe
Pointersrcu_assign_pointer()rcu_assign_pointer(…, NULL)rcu_dereference()
Listslist_add_rcu()
list_add_tail_rcu()
list_replace_rcu()
list_del_rcu()list_for_each_entry_rcu()
Hlistshlist_add_after_rcu()
hlist_add_before_rcu()
hlist_add_head_rcu()
hlist_replace_rcu()
hlist_del_rcu()hlist_for_each_entry_rcu()

RCU barrier

ref:Documentation/RCU/rcubarrier.txt

module中使用call_rcu来删除对象,如果此时将rcu_head挂到链表上,同时module要卸载会发生什么?
当call_rcu中的callback被调用时,它所指向的代码已经消失了,指令错误或者内存错误(๑•̀ㅂ•́)و✧
我们可以尝试在module卸载路径上放synchronize_rcu来等待同步,但是这并不能避免问题,synchronize_rcu只是等待当前grace period结束,并不等待callback结束。可能会放几个synchronize_rcu来避免这个问题,但是还是不能完全工作。如果有很多的RCU call等待执行,为了能够让正常进程能够执行,一些callback会被推迟到下一个周期。
所以就发明了一个新的rcu_barrier,不再等待grace period结束而是此时之前所有的callback执行完。如果当前没有callback那么无需等待直接就返回了,不需要等待当前的grace period结束
所以这样的内核时,通常的做法是:

	1.后面卸载路径中都不再使用call_rcu版本的释放方式
	2.rcu_barrier等待之前的callback完成
	3.继续卸载

call_rcu有好几个版本,如果代码中使用了call_rcu、call_rcu_bh等多个版本,那么卸载时需要使用rcu_barrier && rcu_barrier_bh来分别等待对应的callback的结束。

2.6.0版本的RCU回收过程

2.6.0的内核是2003年发布的,rcu还是初期阶段,rcuupdate.c总共才不到300行,远远没有现在看到的这么复杂,所有复杂的软件都是逐步进化出来的,而不是刚开始就这么复杂。一个软件被接受,使用,然后提出反馈意见和更多的要求,它才会越来越完善,也更加复杂和晦涩难懂。
2.6.0 rcu data struct
为rcu_data分配一个per-cpu对象,当使用call_rcu时会将对应的rcu_head对象挂到对应cpu的rcu_data->nxtlist链表上,然后他就返回了。。。在开启一个新的grace period时将nxtlist上的对象挪到curlist上,当所有cpu都经过了quiescent状态后,将curlist上的对象全都执行一遍,也就是回调.
而每个tick中断时每个cpu都会检查当前是否在quiescent状态,CPU状态可以分为idle和非idle,而非idle又可以分为用户态和内核态,而内核态中又可以分为进程上下文和中断上下文,在读临界区中禁止了抢占,当可以抢占(用户态或者cpu闲+非软中断+抢占计数为0,现在应该都不高)的时候标记为quiescent.
2.6 rcu grace period
上图分析了整个的回收过程:
1.首先阶段1,开始于call_rcu之后,它只是将任务对象挂到当前cpu的nxtlist上,后续会被RCU处理的.
2.周期性的tick会尝试处理rcu任务,发现当前的nxtlist上有对象,会将nxtlist对象挪到curlist上,开启新的grace_period,记录当时在线的cpu的位图,之后会被清零,当所有位图全部清零之后标志着可以开始处理当前的.
开启新的grace_period的时候cpu->batch = curbatch + 1,当所有cpu都经过quiescent就会curtach++,此时就可以将curlist上的对象摘下来,之后处理这些RCU任务.然后将nxtlist对象挂到curlist等待当前grace period结束的时候执行
3.当前处于grace period中时,也就是curlist上的对象还没有被执行,新的RCU对象会堆积到nxtlist上
整个过程一直循环,一直到curlist和nxtlist上为空,才会回归到初始状态.

RCU的不同版本

RCU api

原始表格数据:https://lwn.net/Articles/264090/

普通的rcu_read_lock和rcu_read_lock_bh版本的有什么不一样呢?
bh版本起始于Robert Olsson的网络过载压测,网络收发包的大量逻辑处于软中断中,所以长期处于下半部没有进行调度,内存很快消耗光了,所以需要一个更加快速版本的rcu来释放对象.

普通的rcu临界区禁止了抢占,所以在调度的时候就表明处于quiescent状态.而bh版本还额外禁止了软中断,而bh版本为什么比普通版本的要快呢?
读临界区除了禁止抢占之外,还禁止了软中断,所以当调度或者软中断执行完毕的时候标志着进入了quiescent状态,触发的时机比sched版本的要多,临界区就短,可以进行更快速的对象清除.

软中断有很多种,目前主流的有10种软中断,bh版本的读临界区禁止软中断,可以在软中断和进程上下文中使用.

当在进程上下文使用时,如果发生中断,它也不会执行软中断,所以软中断退出和调度可以当做临界区结束.
当在软中断中使用时,目前支持10种软中断,当每一种软中断退出的时候可以当做临界区未开始或已经结束.

RCU使用

1.rcu_read_lock rcu_read_unlock临界区中不能有睡眠操作和synchronize_rcu操作,但可以有call_rcu操作
2.不能允许多个写同时进行,当可能并发写时需要额外的锁来保证互斥
3.RCU保护的是指针,这一点尤其重要.因为指针赋值是一条单指令.也就是说是一个原子操作.因它更改指针指向没必要考虑它的同步.只需要考虑cache的影响.
4.读者是可以嵌套的.也就是说可以rcu_read_lock{rcu_read_lock, …rcu_read_unlock}… rcu_read_unlock这样嵌套调用.
5.在软中断上下文可以使用rcu_read_lock_bh版本,在中断中使用rcu_read_lock

link

RCU作者Paul McKenney收集的所有article
Documentation/RCU/
RCU and Unloadable Modules
What is RCU, Fundamentally
WikiPedia
RCU tree

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页