原创 谢宝友 Linux阅码场 2017-11-04node
本文简介
本文介绍Linux RCU的基本概念。这不是一篇单独的文章,这是《谢宝友:深刻理解Linux RCU》系列的第3篇,前序文章:linux
谢宝友: 深刻理解Linux RCU之一——从硬件提及
谢宝友:深刻理解Linux RCU:从硬件提及以内存屏障编程
做者简介
谢宝友,在编程一线工做已经有20年时间,其中接近10年时间工做于Linux操做系统。在中兴通信操做系统产品部工做期间,他做为技术总工参与的电信级嵌入式实时操做系统,得到了行业最高奖----中国工业大奖。同时,他也是《深刻理解并行编程》一书的译者。
联系方式: mail:scxby@163.com 微信:linux-kernel数组
RCU主要用于对性能要求苛刻的并行实时计算。例如:天气预报、模拟核爆炸计算、内核同步等等。
假设你正在编写一个并行实时程序,该程序须要访问随时变化的数据。这些数据多是随着温度、湿度的变化而逐渐变化的大气压。这个程序的实时响应要求是如此严格,须要处理的数据量如此巨大,以致于不容许任何自旋或者阻塞,所以不能使用任何锁。
幸运的是,温度和压力的范围一般变化不大,所以使用默认的数据集也是可行的。当温度、湿度和压力抖动时,有必要使用实时数据。可是温度、湿度和压力是逐渐变化的,咱们能够在几分钟内更新数据,但不必实时更新值。
在这种状况下,可使用一个全局指针,即gptr,一般为NULL,表示要使用默认值。偶尔也能够将gptr指向假设命名为a、b和c的变量,以反映气压的变化。
传统的软件可使用自旋锁这样的同步机制,来保护gptr指针的读写。一旦旧的值不被使用,就能够将旧指针指向的数据释放。这种简单的方法有一个最大的问题:它会使软件效率降低数个数量级(注意,不是降低数倍而是降低数个数量级)。
在现代计算系统中,向gptr写入a、b、c这样的值,并发的读者要么看到一个NULL指针要么看到指向新结构gptr的指针,不会看到中间结果。也就是说,对于指针赋值来讲,某种意义上这种赋值是原子的。读者不会看到a、b、c以外的其余结果。而且,更好的一点,也是更重要的一点是:读者不须要使用任何代价高昂的同步原语,所以这种方法很是适合于实时使用。
真正的难点在于:在读者得到gptr的引用时,它可能看到a、b、c这三个值中任意一个值,写者什么时候才能安全的释放a、b、c所指向的内存数据结构?
引用计数的方案颇有诱惑力,但正如锁和顺序锁同样,引用计数可能消耗掉数百个CPU指令周期,更致命的是,它会引用缓存行在CPU之间的来回颠簸,破坏各个CPU的缓存,引发系统总体性能的降低。很明显,这种选择不是咱们所指望的。
想要理解Linux经典RCU实现的读者,应当认真阅读下面这段话:
一种实现方法是,写者彻底不知道有哪些读者存在。这种方法显然让读者的性能最佳,但留给写者的问题是:如何才能肯定全部的老读者已经完成。
最简单的实现是:让线程不会被抢占,或者说,读者在读RCU数据期间不能被抢占。在这种不可抢占的环境中,每一个线程将一直运行,直到它明确地和自愿地阻塞本身(现实世界确实有这样的操做系统,它由线程本身决定什么时候释放CPU。例如大名鼎鼎的Solaris操做系统)。这要求一个不能阻塞的无限循环将使该CPU在循环开始后没法用于任何其余目的,还要求还要求线程在持有自旋锁时禁止阻塞。不然会造成死锁。
这种方法的示意图下所示,图中的时间从顶部推移到底部,CPU 1的list_del()操做是RCU写者操做,CPU二、CPU3在读端读取list节点。
Linux经典RCU的概念便是如此。虽然这种方法在生产环境上的实现可能至关复杂,可是玩具实现却很是简单。缓存
1 for_each_online_cpu(cpu) 2 run_on(cpu);
for_each_online_cpu()原语遍历全部CPU,run_on()函数致使当前线程在指定的CPU上执行,这会强制目标CPU执行上下文切换。所以,一旦for_each_online_cpu()完成,每一个CPU都执行了一次上下文切换,这又保证了全部以前存在的读线程已经完成。
请注意,这个方法不能用于生产环境。正确处理各类边界条件和对性能优化的强烈要求意味着用于生产环境的代码实现将十分复杂。此外,可抢占环境的RCU实现须要读者实际作点什么事情(也就是在读临界区内,禁止抢占。这是Linux经典RCU读锁的实现)。不过,这种简单的不可抢占的方法在概念上是完整的,有助于咱们理解RCU的基本原理。安全
2、RCU是什么?
RCU是read-copy-update的简称,翻译为中文有点别扭“读-复制-更新”。它是是一种同步机制,有三种角色或者操做:读者、写者和复制操做,我理解其中的复制操做就是不一样CPU上的读者复制了不一样的数据值,或者说拥有同一个指针的不一样拷贝值,也能够理解为:在读者读取值的时候,写者复制并替换其内容(后一种理解来自于RCU做者的解释)。它于2002年10月引入Linux内核。
RCU容许读操做能够与更新操做并发执行,这一点提高了程序的可扩展性。常规的互斥锁让并发线程互斥执行,并不关心该线程是读者仍是写者,而读/写锁在没有写者时容许并发的读者,相比于这些常规锁操做,RCU在维护对象的多个版本时确保读操做保持一致,同时保证只有全部当前读端临界区都执行完毕后才释放对象。RCU定义并使用了高效而且易于扩展的机制,用来发布和读取对象的新版本,还用于延后旧版本对象的垃圾收集工做。这些机制恰当地在读端和更新端并行工做,使得读端特别快速。在某些场合下(好比非抢占式内核里),RCU读端的函数彻底是零开销。
Seqlock也可让读者和写者并发执行,可是两者有什么区别?
首先是两者的目的不同。Seqlock是为了保证读端在读取值的时候,写者没有对它进行修改,而RCU是为了多核扩展性。
其次是保护的数据结构大小不同。Seqlock能够保护一组相关联的数据,而RCU只能保护指针这样的unsigned long类型的数据。
最重要的区别还在于效率,Seqlock本质上是与自旋锁同等重量级的原语,其效率与RCU不在同一个数量级上面。
下面从三个基础机制来阐述RCU到底是什么?
RCU由三种基础机制构成,第一个机制用于插入,第二个用于删除,第三个用于让读者能够不受并发的插入和删除干扰。分别是:
发布/订阅机制,用于插入。
等待已有的RCU读者完成的机制,用于删除。
维护对象多个版本的机制,以容许并发的插入和删除操做。性能优化
一、发布/订阅机制
RCU的一个关键特性是能够安全的读取数据,即便数据此时正被修改。RCU经过一种发布/订阅机制达成了并发的数据插入。举个例子,假设初始值为NULL的全局指针gp如今被赋值指向一个刚分配并初始化的数据结构。以下所示的代码片断:微信
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;
“发布”数据结构(不安全)
不幸的是,这块代码没法保证编译器和CPU会按照编程顺序执行最后4条赋值语句。若是对gp的赋值发生在初始化p的各字段以前,那么并发的读者会读到未初始化的值。这里须要内存屏障来保证事情按顺序发生,但是内存屏障又向来以难用而闻名。因此这里咱们用一句rcuassign pointer()原语将内存屏障封装起来,让其拥有发布的语义。最后4行代码以下。数据结构
1 p->a = 1; 2 p->b = 2; 3 p->c = 3; 4 rcu_assign_pointer(gp, p);
rcu_assign_pointer()“发布”一个新结构,强制让编译器和CPU在为p的各字段赋值后再去为gp赋值。
不过,只保证更新者的执行顺序并不够,由于读者也须要保证读取顺序。请看下面这个例子中的代码。并发
1 p = gp; 2 if (p != NULL) { 3 do_something_with(p->a, p->b, p->c); 4 }
这块代码看起来好像不会受到乱序执行的影响,惋惜事与愿违,在DEC Alpha CPU机器上,还有启用编译器值猜想(value-speculation)优化时,会让p->a,p->b和p->c的值在p赋值以前被读取。
也许在启动编译器的值猜想优化时比较容易观察到这一情形,此时编译器会先猜想p->a、p->b、p->c的值,而后再去读取p的实际值来检查编译器的猜想是否正确。这种类型的优化十分激进,甚至有点疯狂,可是这确实发生在剖析驱动(profile-driven)优化的上下文中。
然而读者可能会说,咱们通常不会使用编译器猜想优化。那么咱们能够考虑DEC Alpha CPU这样的极端弱序的CPU。在这个CPU上面,引发问题的根源在于:在同一个CPU内部,使用了不止一个缓存来缓存CPU数据。这样可能使用p和p->a被分布不一样一个CPU的不一样缓存中,形成缓存一致性方面的问题。
显然,咱们必须在编译器和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();
其中rcuread lock()和rcu_read_unlock()这对原语定义了RCU读端的临界区。事实上,在没有配置CONFIG_PREEMPT的内核里,这对原语就是空函数。在可抢占内核中,这这对原语就是关闭/打开抢占。
rcu_dereference()原语用一种“订阅”的办法获取指定指针的值。保证后续的解引用操做能够看见在对应的“发布”操做(rcu_assign_pointer())前进行的初始化,即:在看到p的新值以前,可以看到p->a、p->b、p->c的新值。请注意,rcu_assign_pointer()和rcu_dereference()这对原语既不会自旋或者阻塞,也不会阻止listadd rcu()的并发执行。
虽然理论上rcu_assign_pointer()和rcu_derederence()能够用于构造任何能想象到的受RCU保护的数据结构,可是实践中经常只用于构建更上层的原语。例如,将rcu_assign_pointer()和rcu_dereference()原语嵌入在Linux链表的RCU变体中。Linux有两种双链表的变体,循环链表struct list_head和哈希表structhlist_head/struct hlist_node。前一种以下图所示。
对链表采用指针发布的例子以下:
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);
RCU发布链表
第15行必须用某些同步机制(最多见的是各类锁)来保护,防止多核list_add()实例并发执行。不过,同步并不能阻止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_foreach entry_rcu()能够订阅到同一项条目。
Linux的其余链表、哈希表都是线性链表,这意味着它的头结点只须要一个指针,而不是象循环链表那样须要两个。所以哈希表的使用能够减小哈希表的hash bucket数组一半的内存消耗。
向受RCU保护的哈希表发布新元素和向循环链表的操做十分相似,以下所示。
1 struct foo { 2 struct hlist_node *list; 3 int a; 4 int b; 5 int c; 6 }; 7 HLIST_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 hlist_add_head_rcu(&p->list, &head); 和以前同样,第15行必须用某种同步机制,好比锁来保护。 订阅受RCU保护的哈希表和订阅循环链表没什么区别。 1 rcu_read_lock(); 2 hlist_for_each_entry_rcu(p, q, head, list) { 3 do_something_with(p->a, p->b, p->c); 4 } 5 rcu_read_unlock();
表9.2是RCU的发布和订阅原语,另外还有一个删除发布原语。
请注意,list_replace_rcu()、list_del_rcu()、hlist_replacercu()和hlist del_rcu()这些API引入了一点复杂性。什么时候才能安全地释放刚被替换或者删除的数据元素?咱们怎么能知道什么时候全部读者释放了他们对数据元素的引用?
二、等待已有的RCU读者执行完毕
从最基本的角度来讲,RCU就是一种等待事物结束的方式。固然,有不少其余的方式能够用来等待事物结束,好比引用计数、读/写锁、事件等等。RCU的最伟大之处在于它能够等待(好比)20,000种不一样的事物,而无需显式地去跟踪它们中的每个,也无需去担忧对性能的影响,对扩展性的限制,复杂的死锁场景,还有内存泄漏带来的危害等等使用显式跟踪手段会出现的问题。
在RCU的例子中,被等待的事物称为“RCU读端临界区”。RCU读端临界区从rcu_read_lock()原语开始,到对应的rcu_read_unlock()原语结束。RCU读端临界区能够嵌套,也能够包含一大块代码,只要这其中的代码不会阻塞或者睡眠(先不考虑可睡眠RCU)。若是你遵照这些约定,就可使用RCU去等待任何代码的完成。
RCU经过间接地肯定这些事物什么时候完成,才完成了这样的壮举。
如上图所示,RCU是一种等待已有的RCU读端临界区执行完毕的方法,这里的执行完毕也包括在临界区里执行的内存操做。不过请注意,在某个宽限期开始后才启动的RCU读端临界区会扩展到该宽限期的结尾处。
下列伪代码展现了写者使用RCU等待读者的基本方法。
1.做出改变,好比替换链表中的一个元素。
2.等待全部已有的RCU读端临界区执行完毕(好比使用synchronize_rcu()原语)。这里要注意的是后续的RCU读端临界区没法获取刚刚删除元素的引用。
3.清理,好比释放刚才被替换的元素。
下图所示的代码片断演示了这个过程,其中字段a是搜索关键字。
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; 17 q->b = 2; 18 q->c = 3; 19 list_replace_rcu(&p->list, &q->list); 20 synchronize_rcu(); 21 kfree(p);
标准RCU替换示例
第1九、20和21行实现了刚才提到的三个步骤。第16至19行正如RCU其名(读-复制-更新),在容许并发读的同时,第16行复制,第17到19行更新。
synchronize_rcu()原语能够至关简单。然而,想要达到产品质量,代码实现必须处理一些困难的边界状况,而且还要进行大量优化,这二者都将致使明显的复杂性。理解RCU的难点,主要在于synchronize_rcu()的实现。
三、维护最近被更新对象的多个版本
下面展现RCU如何维护链表的多个版本,供并发的读者访问。经过两个例子来讲明在读者还处于RCU读端临界区时,被读者引用的数据元素如何保持完整性。第一个例子展现了链表元素的删除,第二个例子展现了链表元素的替换。
例子1:在删除过程当中维护多个版本
1 p = search(head, key); 2 if (p != NULL) { 3 list_del_rcu(&p->list); 4 synchronize_rcu(); 5 kfree(p); 6 }
以下图,每一个元素中的三个数字分别表明字段a、b、c的值。红色的元素表示RCU读者此时正持有该元素的引用。请注意,咱们为了让图更清楚,忽略了后向指针和从尾指向头的指针。
等第3行的list_del_rcu()执行完毕后,“五、六、7”元素从链表中被删除。由于读者不直接与更新者同步,因此读者可能还在并发地扫描链表。这些并发的读者有可能看见,也有可能看不见刚刚被删除的元素,这取决于扫描的时机。不过,恰好在取出指向被删除元素指针后被延迟的读者(好比,因为中断、ECC内存错误),就有可能在删除后还看见链表元素的旧值。所以,咱们此时有两个版本的链表,一个有元素“五、六、7”,另外一个没有。元素“五、六、7”用黄色标注,代表老读者可能还在引用它,可是新读者已经没法得到它的引用。
请注意,读者不容许在退出RCU读端临界区后还维护元素“五、六、7”的引用。所以,一旦第4行的synchronize_rcu()执行完毕,全部已有的读者都要保证已经执行完,不能再有读者引用该元素。这样咱们又回到了惟一版本的链表。
此时,元素“五、六、7”能够安全被释放了。这样咱们就完成了元素“五、六、7”的删除。
例子2:在替换过程当中维护多个版本
1 q = kmalloc(sizeof(*p), GFP_KERNEL); 2 *q = *p; 3 q->b = 2; 4 q->c = 3; 5 list_replace_rcu(&p->list, &q->list); 6 synchronize_rcu(); 7 kfree(p);
链表的初始状态包括指针p都和“删除”例子中同样。
RCU从链表中替换元素
和前面同样,每一个元素中的三个数字分别表明字段a、b、c。红色的元素表示读者可能正在引用,而且由于读者不直接与更新者同步,因此读者有可能与整个替换过程并发执行。请注意咱们为了图表的清晰,再一次忽略了后向指针和从尾指向头的指针。下面描述了元素“五、二、3”如何替换元素“五、六、7”的过程,任何特定读者可能看见这两个值其中一个。第1行用kmalloc()分配了要替换的元素。此时,没有读者持有刚分配的元素的引用(用绿色表示),而且该元素是未初始化的(用问号表示)。第2行将旧元素复制给新元素。新元素此时还不能被读者访问,可是已经初始化了。第3行将q->b的值更新为2,第4行将q->c的值更新为3。如今,第5行开始替换,这样新元素终于对读者可见了,所以颜色也变成了红色。此时,链表就有两个版本了。已经存在的老读者可能看到元素“五、六、7”(如今颜色是黄色的),而新读者将会看见元素“五、二、3”。不过这里能够保证任何读者都能看到一个无缺的链表。随着第6行synchronize_rcu()的返回,宽限期结束,全部在list_replace_rcu()以前开始的读者都已经完成。特别是任何可能持有元素“五、六、7”引用的读者保证已经退出了它们的RCU读端临界区,不能继续持有引用。所以,再也不有任何读者持有旧数据的引用,,如第6排绿色部分所示。这样咱们又回到了单一版本的链表,只是用新元素替换了旧元素。等第7行的kfree()完成后,链表就成了最后一排的样子。不过尽管RCU是因替换的例子而得名的,可是RCU在内核中的主要用途仍是用于简单的删除。