目录
Chapter 6 Partitioning and Synchronization Design
6.1 分区练习
6.2 设计准则
6.3 同步粒度
6.4 并行快速路径
6.5 超越党派分歧
6.6 分区、并行和优化
Chapter 7 Locking
7.1 活命
7.2 锁的类型
7.3 锁定实施问题
7.4 基于锁的存在性保证
7.5 锁定:英雄还是反派?
7.6 摘要
Chapter 8 Data Ownership
8.1 多个进程
8.2 部分数据所有权和pthre ads
8.3 产品运输
8.4 指定螺纹
8.5 私有化
8.6 数据所有权的其他用途
Chapter 9 Deferred Processing
9.1 运行示例
9.2 参考计数
9.3 危险指针
9.4 顺序锁
9.5 读取复制更新(RCU)
9.6 选择哪个?
9.7 更新怎么样?
Chapter 6 Partitioning and Synchronization Design
分而治之。
马其顿的菲利普二世
本章介绍如何利用现代商品化的多核系统的优势,通过使用惯用法或“设计模式”[Ale79,GHJV95,SSRB00]来平衡性能、可扩展性和响应时间。正确划分的问题可以产生简单、可扩展且高性能的解决方案,而划分不当的问题则会导致缓慢且复杂的解决方案。本章将帮助你设计分区方法。 将这些内容融入你的代码中,同时讨论批处理和弱化的问题。“设计”这个词非常重要:你应该先进行分区,然后是批处理,接着是弱化,最后才是编码。改变这个顺序通常会导致性能和可扩展性下降,还会带来极大的挫败感。
1.对经典“哲学家用餐”问题的限制,要求所有哲学家能够同时用餐。
2.基于锁的双端队列实现,当队列中有很多元素时,它可以在给定队列的两端提供并发操作,但当队列中只有少数元素时,它仍然能正确工作。(或者,就此而言,没有元素。)
3.仅用几个数字来总结并发算法的粗略质量。
5.当前设计适用于未完全分区的应用程序。
为此,第6.1节 给出了分区练习,第6.2节 评审可分割性设计标准,第6.3节 讨论同步粒度选择,第6.4节 概述了重要的并行快速路径设计模式,这些模式在使用更简单的“慢路径”回退来处理异常情况的同时,在常见情况下提供了速度和可伸缩性,最后是第6.5节 简要地看一下分区之外的内容。
6.1 分区练习
每当一种理论出现在你面前,作为唯一可能的理论时,把它当作一个信号,表明你既没有理解这个理论,也没有理解它想要解决的问题。
卡尔·波普尔
尽管分区比20世纪初更广为人知,但其价值仍被低估。第6.1.1节 因此对经典的“哲学家用餐”问题和第6.1.2节进行了更深入的探讨 重新访问双端队列。
图6.1 展示了一个经典的“哲学家用餐”问题[Dij71]的图表。这个问题涉及五个哲学家,他们除了思考和吃一种“非常难吃的意大利面”外什么都不做,而这种意大利面需要两把叉子才能吃完。 一个哲学家只被允许使用他或她左右的叉子,但不会放下一个叉子,直到他或她满足为止。
目标是构建一个算法,真正防止饥饿。一种饥饿的情况是所有哲学家同时拿起他们最左边的叉子。因为没有人会在吃完饭之前放下他们的叉子,而且没有人可以在至少一个哲学家吃完饭后才拿起第二个叉子,所以他们都饿了。请注意,仅仅允许至少有一个哲学家吃饭是不够的。如图6.2所示。 研究表明,即使是少数哲学家的饥饿也应避免。
迪杰斯特拉的解决方案使用了一个全局信号量,这在减少微不足道的通信延迟方面是有效的,但这一假设在20世纪80年代末或早期变得无效
1990s . 3 最近的解决方案将分支编号,如图6.3所示。 每位哲学家依次拿起自己盘子旁编号最低的叉子,然后拿起另一只叉子。图中位置最高的哲学家首先拿起最左边的叉子,接着是右边的叉子;而其他哲学家则先拿起自己的右边的叉子。由于有两位哲学家会尝试先拿1号叉子,但只有其中一位能成功,因此四位哲学家将共有五个叉子可用。至少有一位哲学家会有两把叉子,从而能够进食。
这种按数字顺序对资源进行编号和获取的通用技术被大量用作防止死锁的技术。然而,很容易想象一系列事件,即使所有哲学家都饿了,也会导致只有一个哲学家同时进食:
1. P2拾取叉子1,防止P1取叉子。
2. P3拾取叉2。
3. P4拿起叉子3。
4. P5拾取叉4。
5. P5拿起叉子5并吃。
6. P5适用于小学四年级和五年级。
7. P4拿起叉子4并吃起来。
简而言之,这个算法只能让一个哲学家在某一时刻进食,即使五个哲学家都饿了,尽管有足够的叉子让两个哲学家同时进食。应该有可能做得更好!
一种方法如图6.4所示 这包括四位哲学家而不是五位,以便更好地说明分区技术。这里最上方和最右侧的哲学家共享一对叉子,而最下方和最左侧的哲学家则共享另一对叉子。如果所有哲学家同时感到饥饿,至少会有两位能够同时进食。此外,如图所示,现在可以将叉子捆绑在一起,使得一对叉子可以同时被拿起和放下,简化了获取和释放算法。
这是“横向并行性”[Inm85]或“数据并行性”的一个例子,之所以这样命名是因为哲学家之间的关系没有相互依赖。在横向并行的数据处理系统中,给定的数据项只会被复制的软件组件集中的一个处理。
双端队列是一种数据结构,包含一个元素列表,可以从中任一端插入或移除[Knu73]。有观点认为,基于锁的实现允许同时在双端队列两端进行并发操作是困难的[Gro07]。本节将展示如何通过分区设计策略实现一个相对简单的实现方法,接下来将探讨三种通用的方法。但首先,我们该如何验证一个并发的双端队列?
6.1.2.1双端队列验证
一个好的起点是不变量。例如,如果元素a被推入双端队列的一端,然后从另一端弹出,这些元素的顺序必须保持不变。同样地,如果元素被推入队列的一端,然后从同一端弹出,这些元素的顺序必须反转。任何从队列中弹出的元素都必须是最近才被推入该队列的,如果队列清空,所有被推入队列的元素都必须已经被弹出。
并发双端队列测试套件(“deqtorture.h”)的初始版本提供了以下检查:
1.CHECK_SEQUENCE_PAIR()提供的元素排序检查。
2.检查最近由melee()提供的元素是否被弹出。
3.检查在队列清空之前是否已将被压入的元素弹出,也由melee()提供。
本套件包括顺序测试和并发测试。虽然这个套件对于教科书代码来说已经足够好,但是您应该对用于生产环境的代码进行更彻底的测试。第11章和第12章介绍了大量的验证工具和技术。
但是,有了原型测试套件,我们已经准备好在接下来的章节中研究双端队列算法。
6.1.2.2 左手和右手锁
一种看似简单的处理方法是使用双向链表,左端队列和出队操作使用左锁,右端操作使用右锁,如图6.5所示。 然而,这种方法的问题在于当列表中元素少于四个时,双锁域必须重叠。这种重叠是因为移除任何一个给定元素不仅会影响该元素本身,还会对其左右相邻的元素产生影响。这些域在图中用颜色表示,蓝色带向下条纹表示左锁域,红色带向上条纹表示右锁域,紫色(没有条纹)表示重叠域。尽管可以设计出这样的算法,但考虑到它至少有五个特殊情况,这应该是一个很大的警示信号,尤其是在列表另一端的并发活动随时可能将队列从一个特殊情况转移到另一个特殊情况的情况下。因此,考虑其他设计要好得多。
6.1.2.3复合双端队列
强制不重叠的锁域的一种方法如图6.6所示。 两个双端队列并行运行,每个双端队列都由自己的锁保护。这意味着元素必须偶尔从一个双端队列转移到另一个双端队列,在这种情况下,必须同时持有两个锁。可以使用简单的锁层次结构
为了避免死锁,例如,在获取右侧锁之前总是先获取左侧锁。这比在同一双端队列上应用两个锁要简单得多,因为我们可以无条件地将元素左移入左侧队列,右移入右侧队列。主要的复杂情况出现在从空队列出队时,此时需要:
1.如果握着右手锁,松开它并获得左手锁。
2.获取右侧锁。
3.重新平衡两个队列中的元素。
4.如果存在,则删除所需元素。
5.释放两个锁。
生成的代码(locktdeq.c)相当直接。重新平衡操作可能会将某个元素来回传递于两个队列之间,浪费时间,可能需要依赖工作负载的启发式方法来获得最佳性能。尽管在某些情况下这可能是最好的方法,但尝试设计一个更确定性的算法仍然很有意义。
确定性地划分数据结构最简单且有效的方法之一是对其进行哈希处理。可以轻松地对双向队列进行哈希处理,通过根据每个元素在列表中的位置分配一个序列号来实现,使得第一个左入空队列的元素编号为零,第一个右入空队列的元素编号为一。一系列左入其他空队列的元素会被赋予递减的数字(-1,-2,-3,.. .),而一系列右入其他空队列的元素则会被赋予递增的数字(2,3,4,.. .)。关键在于,无需实际表示给定元素的编号,因为这个编号会由其在队列中的位置隐含。
根据这种方法,我们为左索引分配一个锁,为右索引分配一个锁,为每个哈希链分配一个锁。图6.7 显示了给定四个哈希链的结果数据结构。请注意,锁域d不重叠,并且
1 | 结构 | pdeq { | |
2 | spinlock_t | ||
3 | intlidx; | ||
4 | rlock; | ||
5 | intridx; | ||
6 | 结构解压 | bkt[ PDEQ_N_ BKTS]; | |
7 | }; | ||
通过在锁冲突之前获取索引锁,并且一次只获取一个给定类型的锁(索引或链),可以避免这种死锁。 每个哈希链本身就是一个双端队列,在这个例子中,每个链都保存了每第四个元素。图6.8的最上面部分 展示了在单个元素(“R 1”)被右队列后,状态的变化,此时右指针已递增以指向哈希链2。同一图的中间部分显示了再有三个元素被右队列后的状态。可以看出,索引已恢复到初始状态(见图6.7 ),然而,每个h链现在都是非空的。该图的下半部分显示了在三个额外元素被左队列和一个额外元素被右队列之后的状态。 从图6.8中显示的最后一个状态, 左出队操作会返回元素“L-2”,并留下左指针引用的哈希链2,此时该链仅包含一个元素(“R 2”)。在这种状态下,与右进队同时进行的左进队会导致锁竞争,但通过使用更大的哈希表,这种竞争的概率可以降低到任意低的水平。 图6.9 展示了如何在四个哈希桶并行双端队列中组织16个元素。每个底层单锁双端队列持有整个并行双端队列的四分之一。 清单6.1 显示了相应的C语言数据结构,假设存在一个现有的结构deq,它提供了一个简单的锁定双端队列实现。这个数据结构在第2行包含了左侧锁, 第3行的左手指数, 线路4上的右侧锁 (在实际实现中是缓存对齐的),第5行右侧索引 ,最后,第6行是基于简单锁的双端队列的哈希数组。 高性能实现当然会使用填充或特殊对齐指令来避免错误共享。 清单6.2 (锁头c)显示了队列和出队函数的实现。4 讨论将集中在左手操作上,因为右手操作很容易从它们中推导出来。 第1行– 13 showpdeq_pop_l(),如果可能,将左出队并返回一个元素,否则返回NULL。第6行 获取左手旋转锁,以及第7行 计算要从队列中出队的索引。第8行 出队元素,如果行9 |
发现结果为非NULL,第10行记录新的左手索引。不管怎样,第11行 释放锁,最后,第12行 如果存在元素,则返回该元素,否则返回NULL。
第29行– 38 showpdeq_push_l(),将指定元素放入队列。第33行 获取左手锁,以及第34行 拾起左手食指。第35行 将指定元素左移并放入由左索引索引的双队列中。行36 然后更新左侧索引和第37行 释放锁。
如前所述,右侧操作与左侧操作完全类似,因此分析这部分内容留待读者自行完成。
本节重新审视复合双端队列,使用一个简单的重新平衡方案,将所有元素从非空队列移动到当前空队列。
与上一节中介绍的哈希实现不同,复合实现将基于一个不使用锁或原子操作的双向队列的顺序实现。
清单6.3 显示了实现。与哈希实现不同,这种复合实现是非对称的,因此我们必须分别考虑pdeq_pop_l()和pdeq_pop_r()实现。
pdeq_ pop_l()的实现如第1行所示 – 16 图中的线条5 获取左侧锁,该线14 发布。第6行 尝试从左端的双端队列中左出一个元素,如果成功,则跳过行8 – 13 简单地返回此元素。否则,第8行 获取右侧锁,第9行 从右队列中删除一个元素,以及行10 将右手队列中的任何剩余元素移动到左手队列中,第11行 初始化右侧队列,以及第12行 释放右侧锁。如果存在元素,则在第9行从队列中出队将被退回。
pdeq_ pop_r()的实现如第18行所示 – 38 图中的数字。和以前一样,第22行 获取右手锁(和第36行 发布i t),以及第23行 尝试从右队列中右出一个元素,如果成功,则跳过第25行 – 35仅仅返回此元素。但是,如果第24行 确定没有元素要出队,第25行 释放右侧锁和线路26 – 27按正确的顺序获取两个锁。第28行 然后尝试从右列表中再次右出队一个元素,如果第29行 确定此第二次尝试失败,第30行 从左队列(如果有可用的)中右出队一个元素,第31行 将左队列中剩余的元素移动到右队列中,并行32 初始化左侧队列。无论如何,第34行 释放左手锁。
1结构cds_list_head*pdeq_pop_l(结构pdeq*d) 2{ 3 structcds_list_head*e; 4 5spin_lock(&d->锁); 6 e = deq_ pop_l(&d->≤); 8spin_lock(&d->rlock); 9 e= deq_ pop_l(&d-> rdeq); 10cds_list_splice(&d->rdeq.chain、&d->l deq链); 11 CDS_ INIT_ LIST_ HEAD(&d-> rdeq. chain); 14spin_unlock(&d->锁); 17 18结构cds_list_head*pdeq_pop_r(结构pdeq *d)19{ 20 structcds_list_head*e; 21 22spin_lock(&d->rlock); 24如果(e==NULL){ 25spin_unlock(&d->rlock); 27spin_lock(&d->rlock); 29如果(e==NULL){ 30 e= deq_ pop_r(&d->≤); 31cds_list_splice(&d->ldeq.chain,&d->rdeq.链); 32 CDS_ INIT_ LIST_ HEAD(&d-> ldeq. chain);33} 34spin_unlock(&d->锁);35} 37返回;38} 39 40空pdeq_push_l(structcds_list_head*e,struct p deq*d)41{ 42spin_lock(&d->锁); 43deq_push_l(e、&d->ldeq);44spin_unlock(&d->llock);45} 46 47 void pdeq_push_r(structcds_list_head*e,struct pdeq*d)48{ 49spin_lock(&d->rlock); |
Thepdeq_push_l()实现如第40行所示 – 45见清单6.3。 第42行 获取左手旋转锁,行43 left-en将元素放入左侧队列,最后行44 释放锁。Thepdeq_push_r()实现(在第47行显示 – 52)非常相似。
6.1.2.6 双端队列讨论
与第6.1.2.4节中介绍的哈希变体相比,复合实现更为复杂。 但仍然相当简单。当然,更智能的重新平衡方案可以任意复杂,但这里展示的简单方案已被证明与软件替代方案相比表现良好[DCW+11],甚至与使用硬件辅助的算法相比也表现出色[DLM+10]。然而,我们能期望这种方案的最佳性能是2倍的可扩展性,因为最多只有两个线程可以同时持有队列的锁。这一限制同样适用于基于非阻塞同步的算法,例如迈克尔提出的基于比较和交换的队列算法[ Mic 03]。
事实上,正如Dice等人[DLM+10]所指出的,一个不同步的单线程双向队列显著优于他们研究的所有并行实现。因此,关键在于无论采用何种实现方式,向共享队列中排队或出队都可能产生显著的开销。鉴于这些队列严格遵循先进先出(FIFO)的原则,这一点并不令人意外。
此外,这些严格的FIFO队列仅在线性化点上严格遵循FIFO[HW90]6 这些对调用者来说是不可见的,在事实上,这些线性化点被埋藏在基于锁的关键段中。这些队列对于(比如说)各个操作开始的时间而言,并不是严格遵循先进先出的原则[HKLP12]。这表明,在并发程序中,严格的先进先出属性并非那么有价值,实际上,Kirsch等人提出了较为宽松的队列,提供了更好的性能和可扩展性[KLP12]。7 尽管如此,如果你在推动所有
通过单个队列使用并发程序的数据,您确实需要重新考虑您的整体设计。
第6.1.1节快速测验答案中给出的就餐哲学家问题的最佳解决方案 是一个“横向并行”或“数据并行”的绝佳例子。在这种情况下,同步开销几乎(甚至完全)为零。相比之下,双端队列实现则是“纵向并行”或“流水线”的例子,因为数据从一个线程移动到另一个线程。流水线所需的更紧密协调反过来又需要更大的工作单元来达到一定的效率水平。
这两个例子说明了划分在设计par allel算法方面是多么强大。第6.3.5节简要介绍第三个例子,即矩阵乘法。然而,这三个例子都要求并行程序有更多更好的设计准则,这一主题将在下一节中讨论。
6.2 设计准则
学习一磅需要十磅常识来应用它。
波斯谚语
获得最佳性能和可扩展性的方法之一是不断优化,直到找到最佳的并行程序。然而,如果你的程序不是微乎其微,那么可能的并行程序的数量将如此庞大,以至于在宇宙的寿命内无法保证收敛。此外,“最佳的并行程序”到底是什么意思?毕竟,第2.2节中列出了至少三个并行编程的目标:性能、生产力和通用性,而最佳的性能可能会以牺牲生产力和通用性为代价。显然,我们需要在设计时能够做出更高层次的选择,以便在该程序过时之前,能够达到一个令人满意的并行程序。
然而,要实际产生一个现实世界的设计,需要更详细的设计标准,这一任务将在本节中进行。由于这是现实世界,这些标准往往在不同程度上相互冲突,要求设计师仔细权衡由此产生的折衷。
因此,这些标准可以被看作是作用于设计的“力量”,而这些力量之间特别好的权衡被称为“设计模式”[Ale79,GHJV 95]。
实现三个并行编程目标的设计标准是:加速、竞争、开销、读写比和复杂度:
加速比:如第2.2节所述,提高性能是进行并行化所需付出全部时间和努力的主要原因。加速比定义为运行顺序版本程序所需时间与运行并行版本程序所需时间之比。
争论:如果一个并行程序使用的CPU多于该程序能够保持忙碌的CPU数量,那么多余的CPU将因争用而无法执行有用的工作。这可能是锁争用、内存争用或许多其他性能杀手。
工作与同步比率:单处理器、单线程、不可抢占且不可中断的8 给定并行程序的某个版本不需要任何同步原语。因此,这些原语所消耗的时间(包括通信缓存未命中、消息延迟、锁定原语、原子指令和内存屏障)都是间接的开销,不会直接贡献于程序预期完成的有效工作。需要注意的是,关键在于同步开销与临界区代码开销之间的关系,较大的临界区能够容忍更大的同步开销。工作与同步的比例与同步效率的概念有关。
读写比:一种很少更新的数据结构通常会被复制而不是分区,而且可能通过不对称同步原语进行保护,这些原语以牺牲写入者的同步开销为代价减少读取者的同步开销,从而降低整体同步开销。对于频繁更新的数据结构,相应的优化也是可行的,如第五章所述。
复杂性:并行程序比等效的顺序程序更复杂,因为并行程序的状态空间远大于顺序程序。尽管具有规律结构的大状态空间在某些情况下可以容易理解,但并行程序员必须考虑同步原语、消息传递、锁定设计、临界区识别和死锁等问题,在这个更大的状态空间背景下进行处理。
这种更高的复杂性通常会转化为更高的开发和维护成本。因此,预算限制可能会减少对现有程序进行修改的数量和类型,因为给定程度的加速只能带来有限的时间和麻烦。更糟糕的是,增加的复杂性实际上会降低性能和可扩展性。
因此,超过某个点后,可能存在比并行化更便宜且更有效的顺序优化。如第2.2.1节所述,并行化只是众多性能优化之一,而且更易于应用于基于CPU的瓶颈问题。
这些标准将共同作用以强制执行最大加速。前三个标准是相互关联的,因此本节其余部分分析了这些相互关系。
请注意,这些标准也可能作为需求规范的一部分出现,并且它们是总结并发算法质量问题的一个解决方案,见第113页。 例如,加速可以作为相对需求(“越快越好”)或工作负载的绝对要求(“系统必须支持每秒至少1,000,000次网络访问”)。经典的设计模式语言将相对需求描述为力,而将绝对要求描述为上下文。
了解这些设计标准之间的关系,对于确定并行程序的适当设计权衡非常有帮助。
1.程序在独占锁关键部分花费的时间越少,潜在的加速就越大。这是Amdahl定律的结果[Amd 67],因为在给定的时间内,只有一个CPU可以在一个给定的独占锁关键部分执行。
更具体地说,对于无界线性扩展而言,程序在给定的独占临界区中花费的时间比例必须随着CPU数量的增加而减少。例如,除非一个程序在最严格的独占锁临界区中的时间少于十分之一,否则它无法扩展到10个CPU。
2.当实际加速比小于可用CPU数量时,争用效应会消耗多余的CPU时间和/或时间。CPU数量与实际加速比之间的差距越大,CPU使用效率就越低。同样地,期望的效率越高,可实现的加速比就越小。
3.如果可用的同步原语相对于它们保护的关键部分具有较高的开销,提高速度的最佳方法是减少这些原语被调用的次数。这可以通过批量处理关键部分、使用数据所有权(见第8章)、使用非对称原语(见第9章)或采用粗粒度设计如代码锁定来实现。
4.如果关键部分的开销比保护它们的基本操作高,那么提高加速的最佳方法是通过移动到读/写锁定、数据锁定、不对称或数据所有权来增加并行性。
5.如果临界区的开销相对于保护它们的原语来说较高,并且被保护的数据结构被读取的次数远多于修改的次数,则提高并行性的最佳方法是转向读/写锁定或不对称原语。
6.许多改进SMP性能的改变,例如减少锁保留,也改善了实时延迟[McK05c]。
值得重申的是,竞争包括各种形式,包括锁竞争、内存竞争、缓存溢出、热限制等。本章主要探讨锁竞争和内存竞争。
6.3 同步粒度
做好小事是做好大事的一步。
哈里·F·班克斯
图6.10 给出了不同同步粒度级别的图示视图,每个级别都在以下各节中进行了描述。这些章节主要关注锁定,但所有形式的同步都存在类似的粒度问题。
如果程序在单个处理器上运行足够快,并且不与其他进程、线程或中断处理程序交互,你应该移除同步原语,以节省它们带来的开销和复杂性。几年前,有人认为摩尔定律最终会迫使所有程序都属于这一类。然而,如图6.11所示,事实并非如此。 在2003年左右,单线程性能的指数级增长停止了。因此,提高性能将越来越需要并行性。10 考虑到2006年保罗在一台双核笔记本电脑上首次使用了这个句子的第一个版本,再加上2020年添加的许多图表是在每个插槽有56个硬件线程的系统上生成的,可以看出并行计算已经真正普及。同样重要的是,以太网带宽仍在持续增长,如图6.12所示。 这种增长将继续激励多线程服务器来处理通信负载。
请注意,这并不意味着您应该以多线程方式编写每个程序。同样,如果一个程序在一个单处理器上运行得足够快,那么请不要浪费自己在SMP同步定时器上所花费的开销和复杂性。
清单6.4:顺序程序哈希表搜索 |
1 structhash_table 2{ 3 longnbuckets; 4个结构节点**桶;5} 6 7类型定义结构节点{ 8个未签名的长密钥; 9结构节点*next;10}node_t; 11 12 int hash_search(structhash_table *h,长密钥)13{ 14结构节点*cur;15 16 cur=h->buckets[key%h->nbuckets]; 17 while(cur!=NULL){ 18如果(cur->key>=key){ 19返回(cur->key ==key);20} 21 cur=cur->next; 22} 23 return0; 24} |
清单6.4中的哈希表查找代码的简洁性 强调这一点. 11 一个关键点是,由于并行性而产生的加速通常仅限于CPU的数量。相比之下,由于顺序优化而产生的加速,例如精心选择的数据结构,可以任意大。 |
快速测试6.15:如何验证哈希表? |
另一方面,如果你没有处于这种幸福的状态,请继续阅读! 由于代码锁定仅使用全局锁,因此它非常简单。12 特别容易将现有程序改造以使用代码锁定,以便在多处理器上运行。如果程序只有一个共享资源,代码锁定甚至可以提供最优性能。然而,许多更大更复杂的程序需要大部分执行发生在关键部分,这反过来又导致代码锁定严重限制了它们的可扩展性。 因此,对于那些执行时间中只有很小一部分用于关键部分或只需要适度扩展的程序,你应该使用代码锁定。此外,主要采用后续章节中描述的可扩展方法的程序,通常会使用代码锁定来处理罕见的错误情况或重要的状态转换。在这种情况下,代码锁定将提供一个相对简单的程序,其与顺序版本非常相似,如清单6.5所示。 但是请注意,在清单6.4的hash_ search()中,比较的简单返回 由于需要在返回之前释放锁,现在变成了三个语句。 请注意,hash_loc k获取和释放语句位于第19行, 24 ,和29 在希望并发访问哈希表的CPU之间进行哈希表所有权的调解 |
1spinlock_thash_lock; 2
3 structhash_table 4{
5 longnbuckets;
6个结构节点**桶;7};
8
9类型定义结构节点{
10个未签名的长密钥;
11结构节点*next;12}node_t;
13
14 int hash_search(structhash_table *h,长键)15{
16个结构节点*cur;
17 intretval; 18
20 cur=h->buckets[key%h->nbuckets];
21 while(cur!=NULL){
22如果(cur->key>=key){
24spin_unlock(&hash_lock);
25 returnretval; 26}
27 cur=cur->next; 28}
30 return0;
31}
访问哈希表。另一种理解方式是,hash_lock分配了时间分区,因此每个请求的CPU在拥有该哈希表期间都有自己的时间分区。此外,在设计良好的算法中,应该有足够多的时间分区,在这些时间里没有CPU拥有该哈希表。
不幸的是,代码锁定特别容易发生“锁竞争”,即多个CPU需要同时获取锁。那些照顾过小孩(或像孩子一样行事的老年人)的SMP程序员会立即意识到只有某样东西的一个副本存在的危险,如图6.13所示。
下面的章节中介绍了一种名为“数据锁定”的解决方案。
许多数据结构可以被分区,每个分区都有自己的锁。这样,数据结构的每个部分的关键段可以并行执行,尽管在给定时间只能有一个实例执行某个部分的关键段。当需要减少竞争且同步开销不会限制加速时,应使用数据锁定。数据锁定通过将过大的关键段实例分布在多个数据结构中来减少竞争,例如,在哈希表中为每个哈希桶维护关键段,如清单6.6所示。 可扩展性的增加再次导致复杂性略有增加,形式为额外的数据结构,即结构桶。 与图6.13所示的争议情况相反
如图6.14所示,数据锁定有助于促进和谐。 在并行程序中,这一点几乎是正确的
1 structhash_table 2{ 3 longnbuckets; 4个结构桶**个桶;5个; 6 7个结构桶bucket{ 8spinlock_tbucket_lock; 9node_t*list_head;10}; 11 12类型定义结构节点{ 13个未签名的长密钥; 14结构节点*next;15}node_t; 16 17 int hash_search(structhash_table *h,长键)18{ 19个结构桶*bp; 20个结构节点*cur; 21 intretval; 22 23 bp = h->桶[key%h->nbuckets]; 24spin_lock(&bp->bucket_lock); 25 cur=bp->list_head; 26 while(cur!=NULL){ 27如果(cur->key>=key){ 28 retval=(当前->key==键); 29spin_unlock(&bp->bucket_lock); 30 returnretval; 31} 32 cur=cur->next; 33} 34spin_unlock(&bp->bucket_lock); 35 return0; 36} |
图6.14:数据锁定
总是转化为性能和可扩展性的增加。因此,Sequentin在其内核中大量使用了数据锁定[ BK85,In m85,Gar90,Dov90,MD92,MG 92,MS 93]。
另一种看待这个问题的方式是将每个->桶锁视为仅管理与该->桶锁对应的桶的所有权,而不是像代码锁定那样管理整个哈希表的所有权。每个锁仍然划分时间,但每桶锁定技术还划分了地址空间,因此整个技术可以被认为是在划分时空。如果桶的数量足够多,这种空间划分应该能够以高概率允许特定CPU间接访问某个哈希桶。
然而,正如照顾过小孩的人可以再次证明的那样,即使提供足够的资源也无法保证平静的生活。类似的情况也可能出现在SMP程序中。例如,Linux内核维护了一个文件和目录缓存(称为“dcache”)。缓存中的每个条目都有自己的锁,但与根目录及其子目录对应的条目比那些较为冷门的条目更可能被访问。这可能导致许多CPU争夺这些热门条目的锁,从而导致类似图6.15所示的情况。
在许多情况下,可以设计算法来减少数据偏斜的实例,在某些情况下甚至完全消除它(例如,在Linux内核的dcache[MSS04,Cor10a,Bro15a,Bro15b,Bro15c]中)。数据锁定常用于可分区的数据结构,如哈希表,以及多个实体各自由给定数据结构的一个实例表示的情况。Linux内核的任务列表就是一个例子,每个任务结构都有自己的alloc_锁和pi_锁。
在动态分配的结构上使用数据锁定的一个关键挑战是确保在获取锁时结构仍然存在[GKAS99]。清单6.6中的代码 通过将锁放置在静态分配的哈希桶中来解决这一挑战,这些哈希桶永远不会被释放。然而,如果哈希表是可重置的,那么这个技巧就不起作用了,因为此时锁现在是动态分配的。在这种情况下,
图6.15:数据锁定和偏斜
需要有一些方法来防止在获取锁期间释放哈希桶。
数据所有权将给定的数据结构分配到各个线程或CPU上,使得每个线程/CPU可以独立访问其子集的数据结构,而无需任何同步开销。然而,如果一个线程希望访问其他线程的数据,则第一个线程无法直接访问。相反,第一个线程必须与第二个线程通信,让第二个线程代表第一个线程执行操作,或者将数据迁移到第一个线程。
数据所有权可能看起来很晦涩,但它被经常使用:
1.仅由一个CPU或线程可访问的变量(例如Cand C++中的自动变量)属于该CPU或进程。
2.用户界面的实例拥有相应用户的上下文。应用程序与并行数据库引擎交互时,通常被编写得像完全顺序程序一样。这些应用程序拥有用户界面及其当前操作。因此,显式的并行性仅限于数据库引擎本身。
3.参数化模拟通常通过授予每个线程对参数空间的特定区域的所有权而简单地并行化。也有为这类问题设计的计算框架[Uni08a]。
如果存在大量共享,线程或CPU之间的通信可能会导致显著的复杂性和开销。此外,如果最常使用的数据恰好由单个CPU拥有,该CPU将成为“热点”,有时结果类似于图6.15所示。 然而,在不需要共享的情况下,数据所有权可以实现理想的性能,并且使用代码
可以像清单6.4中所示的顺序程序那样简单。 这种情况通常被称为“令人尴尬的平行”,在最好的情况下,类似于之前图6.14中所示的情况。
另一个重要的数据所有权实例发生在数据被读取时,在这种情况下,所有线程都可以通过复制“拥有”它。
数据锁定分区了地址空间(每个分区有一个哈希桶)和时间(使用每个桶的锁),而数据所有权仅分区了地址空间。数据所有权无需分区时间的原因是,给定的线程或CPU被分配了特定地址空间分区的永久所有权。
数据所有权将在第8章中详细说明。
本节从数学同步效率的角度探讨锁定粒度和性能。对数学不感兴趣读者可以选择跳过本节。
该方法是使用一个粗糙的排队模型来评估基于单个共享全局变量的同步机制的效率,该模型基于M/M/1 queue.M/M/1排队模型,即“到达率”λ和“服务率”μ呈指数分布。到达率λ可以理解为系统在同步自由时每秒处理的平均同步操作次数,换句话说,λ是非同步单位工作开销的倒数。例如,如果每个工作单位是一个事务,且每个事务处理需要一毫秒的时间,不包括同步开销,则λ为每秒1000个事务。
服务率μ的定义类似,但它是系统在每个事务开销为零的情况下每秒处理的平均同步操作次数,忽略CPU之间必须等待以完成其同步操作这一事实。换句话说,μ可以大致理解为在没有竞争的情况下同步开销。例如,假设每个事务的同步操作涉及一个原子增量指令,并且计算机系统能够在每个CPU上每5纳秒执行一次私有变量的原子增量(见图5.1 )。13 因此,μ的值约为每秒200,000,000个原子增量。
当然,随着越来越多的CPU增加一个共享变量,ofλ值就会增加,因为每个CPU都能够独立地处理事务(再次忽略同步):
λ = nλ 0 (6.1)
这里,n是CPU的数量,λ0是单个CPU的事务处理能力。请注意,在没有竞争的情况下,单个CPU执行单个事务的预期时间为1/λ0。
因为CPU必须“等待”在彼此后面才能获得增加单个共享变量的机会,我们可以使用M/M/1排队模型来表达预期的总等待时间:
图6.16 绘制了同步效率e与CPU/线程数n的关系图,针对几个开销比f的值。例如,在使用5纳秒原子增量时,f = 10的线表示每个CPU每50纳秒尝试一次原子增量,而f= 100的线则表示每个CPU每500纳秒尝试一次原子增量,这进而
对应着数百(或许数千)条指令。鉴于每条跟踪随着CPU或线程数量的增加急剧减少,我们可以得出结论,在当前的商用硬件上大量使用基于单个全局共享变量原子操作的同步机制将无法良好扩展。这是第五章讨论的并行计数算法背后力量的抽象数学描绘。实际应用中可能会有所不同。
然而,效率的概念仍然有用,即使在几乎没有或完全没有正式同步的情况下也是如此。以矩阵乘法为例,其中一个矩阵的列与另一个矩阵的行相乘(通过“点积”),结果是第三个矩阵的一个元素。由于这些操作之间没有冲突,可以将第一个矩阵的列分配给一组线程,每个线程计算结果矩阵中对应的列。因此,线程可以完全独立运行,没有任何同步开销,正如在matmul .c中所做的那样。因此,可以预期完美的效率为1.0。
然而,图6.17 讲述了一个不同的故事,特别是对于64乘64矩阵乘法,即使在单线程运行时,其效率也从未超过0.3,而且随着更多线程的加入而急剧下降。14 128×128矩阵表现更好,但增加线程后性能提升不大。256×256矩阵的扩展性尚可,但仅限于少数几颗CPU。512×512矩阵的乘法效率在10个线程时明显低于1.0,而1024×1024矩阵乘法在几十个线程时也明显偏离完美状态。尽管如此,这一数据清楚地展示了批量处理带来的性能和可扩展性优势:如果必须承担同步开销,不妨物有所值,这正是第113页提出的关于同步粒度选择问题的解决方案。
鉴于这些低效性,值得研究一些可内扩展的方法,例如第6.3.3节中描述的数据锁定或者采用下一节中讨论的并行快速路径方法。
6.4 并行快速路径
面对困难有两种方法:改变困难,或者改变自己去面对困难。
菲莉丝·博托姆
细粒度(因而通常性能更高)的设计通常比粗粒度设计更复杂。在许多情况下,大部分开销是由一小部分代码造成的[Knu73]。那么为什么不把精力集中在那小部分上呢?
这是并行快速路径设计模式背后的理念,即在不增加整个算法复杂度的情况下,积极地并行化常见情况下的代码路径。你不仅需要了解希望并行化的具体算法,还要考虑该算法将面临的负载。构建一个并行快速路径通常需要极大的创造力和设计努力。
并行快速路径结合了不同的模式(一个用于快速路径,另一个用于其他地方),因此是一个模板模式。以下并行快速路径实例经常出现
足以证明它们自身的模式,如图6.1 8所示:
1.读写锁定(下文第6.4.1节中描述) .
清单6.7:读写锁定哈希表搜索 |
1rwlock_thash_lock; 2 3 structhash_table 4{ 5 longnbuckets; 6个结构节点**桶;7} 8 9类型定义结构节点{ 10个未签名的长密钥; 11结构节点*next;12}node_t; 13 14 int hash_search(structhash_table *h,长密钥)15{ 16个结构节点*cur; 17 intretval; 18 19read_lock(&hash_lock); 20 cur=h->buckets[key%h->nbuckets]; 21 while(cur!=NULL){ 22如果(cur->key>=key){ 23 retval=(cur->key == key); 24read_unlock(&hash_lock); 25 returnretval; 26} 27 cur=cur->next; 28} 29read_unlock(&hash_lock); 30 return0; 31} |
2.读取副本更新(RCU)可以在第9.5节中作为高性能的读写锁定替代方案。其他替代方案包括危险指针(第9.3节)和序列锁定(第9.4节)。本章不再进一步讨论这些替代方案。
3.层次锁定([ McK96a]),在第6.4.2节中提及。
4.资源分配器缓存([ McK96a,MS93])。请参见第6.4.3节 更多详细信息。
如果同步开销可以忽略不计(例如,程序使用粗粒度并行处理且临界区较大),并且只有少量的临界区会修改数据,那么允许多个读取器并行执行可以显著提高可扩展性。写入器排除了其他所有读取器。有许多读写锁的实现方式,包括第4.2.4节中描述的POSIX实现。清单6.7 展示了如何使用读写锁定实现哈希搜索。
读写锁定是一种简单的非对称锁定实例。Snaman[ST87]描述了一种更复杂的六模式非对称锁定设计,它被用于几个集群系统中。一般而言,以及特别地,读写迭代锁定在第7章中进行了详尽的描述。
层次锁定背后的理念是拥有一个粗粒度的锁,它只持有足够长的时间来决定获取哪个细粒度的锁。清单6.8 如何显示
我们的哈希表搜索可能被调整为进行层次锁定,但也显示了这种方法的巨大弱点:我们已经支付了获取第二个锁的开销,但我们只持有它很短的时间。在这种情况下,数据锁定方法会更简单,而且很可能表现得更好。
本节介绍了一个并行固定块大小内存分配器的简化示意图。更详细的描述可以在文献[MG92,MS93,BA 01,MSK 01,Eva 11,Ken 20]或Linux内核[Tor 03]中找到。
6.4.3.1并行资源分配问题
并行内存分配器面临的基本问题是,在一般情况下,需要提供极快的内存分配和释放,同时需要在不利的分配和释放模式下有效地分配内存。
要了解这种紧张关系,可以考虑将数据所有权直接应用于这个问题——简单地划分内存,让每个CPU拥有自己的份额。例如,假设一个有12个CPU的系统有64g内存,那么
我正在使用的笔记本电脑。我们可以简单地为每个CPU分配5吉字节的内存区域,并允许每个CPU从自己的区域内分配内存,而无需锁定及其复杂性和开销。不幸的是,当CPU 0仅分配内存而CPU 1仅释放内存时,这种方案就会失败,这在简单的生产者-消费者工作负载中经常发生。
另一种极端,即代码锁定,由于锁竞争过多和开销过大而受到困扰[MS93]。
常用的解决方案采用并行快速路径,每个CPU拥有少量缓存块,并且有一个大型代码锁定共享池用于额外的块。为了防止任何给定的CPU独占内存块,我们限制了每个CPU缓存中可以包含的块数量。在双CPU系统中,内存块的流动如图6.19所示。 :当某个CPU试图释放一个块时,如果其池已满,它会将块发送到全局池;同样地,当该CPU试图分配一个块时,如果其池为空,它会从全局池中提取块。
6.4.3.3 数据结构
“玩具”实现的分配器缓存的实际数据结构如清单6.9所示 (“smpalloc.c”)。图6.19中的“全局池”。 由全局内存globalmem实现,类型为struct globalmempool,两个CPU池则由每线程变量perthreadme m实现,类型为struct perthreadmempool。这两种数据结构在其池字段中都包含指向块的指针数组,这些指针从索引零开始填充。因此,如果全局内存pool[3]为空,则从索引4开始的其余数组也必须为空。cur字段包含池数组中最高编号的完整元素的索引,或在所有元素均为空时为-1。所有
清单6.9:分配器-缓存数据结构 |
1 #define TARGET_POOL_SIZE 3 2 #define GLOBAL_POOL_SIZE 40 3 4个全局结构mempool{ 5spinlock_tmutex; 6 intcur; 7个结构memblock * pool[ GLOBAL_ POOL_ SIZE];8}全局mem; 9 每个线程10个结构mempool{ 11 intcur; 12个结构memblock * pool[2 * TARGET_ POOL_ SIZE];13}; 14 15DEFINE_PER_THREAD(每个线程的结构内存池,每个线程的内存); |
globalmem.pool[0]到globalmem.pool[globalmem.cur]中的元素必须全部满,其余的必须全部为空。15
池数据结构的操作如图6.20所示,六个方框代表构成池字段的指针数组,前面的数字表示cur字段。阴影方框表示非空指针,而空白方框表示空指针。尽管这一数据结构可能令人困惑,但一个重要的不变量是,cur字段总是比非空指针的数量少一个。
6.4.3.4功能分配
分配函数memblock_alloc()可以在清单6.10中看到。 第7行 获取当前线程的每个线程池,以及行8 检查是否为空。
如果是,第9行 – 16 尝试从在线9处获得的自旋锁下的全局池中重新填充它 并于第16行发布。 第10行 – 14 将块从全局池移动到线程池,直到本地池达到目标大小(半满)或全局池耗尽,第15行 将每个线程池的计数设置为正确的值。
1空2{ 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17} | memblock_free(结构体memblock *p) inti; 结构体perthreadmempool *pcpp; pcpp=&__get_thread_var(perthreadmem); 如果(pcpp-> cur >= 2 * TARGET_ POOL_ SIZE -1){spin_lock(&globalmem.mutex); 对于(i = pcpp-> cur;i >= TARGET_ POOL_ SIZE;i--){ globalmem.pool[++globalmem.cur]= pcpp->pool[i];pcpp->pool[i] =NULL; } spin_unlock(&globalmem.mutex);} |
在任何情况下,第18行 检查每个线程池是否仍然为空,如果不是,则行19 – 21移除一个模块并重新旋转它。否则,请参见第23行 讲述了记忆耗尽的悲惨故事。
6.4.3.5免费功能
清单6.11 显示内存块空闲功能。第6行 获取此线程的池指针,以及第7行 检查此线程池是否已满。
如果是,则第8行 – 15将每个线程池的一半空出,放入全局线程池中,第8行 和14 获取和释放自旋锁。第9行 – 12将循环移动块从本地移到全局池,并行13 将每个线程池的计数设置为正确的值。
在任何情况下,第16行 然后将新释放的块放入线程池中。
6.4.3.6 表演
粗略性能结果16 如图6.21所示 在双核英特尔x86架构上运行,主频为1 GHz(每个CPU 4300 MIPS),每个CPU的缓存最多允许六个块。在这个微基准测试中,每个线程反复分配一组块,然后释放该组中的所有块,组内块的数量即为x轴上显示的“分配运行长度”。y轴显示每微秒成功分配/释放对的数量——失败的分配不计入。图中的“X”表示双线程运行的结果,而“+”则表示单线程运行的结果。
请注意,长度不超过六的运行线性扩展并表现出色,而超过六的运行则表现不佳,几乎总是呈现负增长。因此,确保TARGET_POOL_SIZE足够大非常重要,幸运的是,在实际操作中这通常很容易实现[MSK01],尤其是在当今的大容量内存环境下。例如,在大多数系统中,将TARGET_POOL_SIZE设置为100是相当合理的,在这种情况下,分配和释放可以保证至少99 %的时间限制在每个线程池内。
从图中可以看出,在适用通用数据所有权的情况下(运行长度不超过六个),性能比必须获取锁的情况有了很大的提高。避免通用情况下的同步将是本书的一个反复出现的主题。
快速测验6.25:在两线程测试中,当运行长度达到或超过19时观察到了分配失败。考虑到全局池大小为40,每线程的目标池大小为三个,线程数n等于两个,并假设每个线程的池最初为空且没有使用任何内存,那么最小的分配运行长度m是多少,导致了失败。
可能发生什么?(回想一下,每个线程会反复分配m块内存,然后释放这m块内存。)或者,给定n个线程,每个线程的堆大小为s,且每个线程首先反复分配m块内存,然后释放这些m块内存,全局池的大小需要多大?注意:要获得正确答案,你需要检查smpalloc .c源代码,并且很可能需要逐行执行。你已经被警告了!
6.4.3.7验证
验证这个简单的分配器会生成指定数量的线程,每个线程反复分配指定数量的内存块,然后释放它们。这种简单的方案足以锻炼每个线程的缓存和全局池,如图6.21所示。
对于要在生产环境中使用的内存分配器,需要进行更积极的验证。tcalloc[Ken20]和jemalloc[Eva11]的测试套件很有指导意义,Linux内核内存分配器的测试也是如此。
6.4.3.8 真实世界设计
玩具并行资源分配器非常简单,但现实世界的设计以无数种方式扩展了这种方法。
首先,实际的分配器需要处理广泛的分配大小,而不仅仅是这个玩具示例中显示的一个单一大小。一种流行的方法是提供一组固定的大小,这些大小的间隔是为了平衡外部和内部碎片化,例如20世纪80年代末的BSD内存分配器[MK88]。这样做意味着“globalmem”变量需要按每个大小复制一次,相关的锁也需要相应地复制,从而导致数据锁定而不是玩具程序代码的锁定。
其次,生产级系统必须能够重新利用内存,这意味着它们必须能够将块合并成更大的结构,如页面[MS93]。这种合并还需要通过锁来保护,而锁也可以按大小复制。
第三,合并后的内存必须返回到底层内存系统,同时还需要从底层内存系统分配内存页面。这一级别的锁定需求取决于底层内存系统的锁定需求,但很可能是代码锁定。在这一级别,代码锁定通常是可以接受的,因为在设计良好的系统中,这种情况很少发生[MSK01]。
并发用户空间分配器面临类似的挑战[Ken20,Eva11]。
尽管这种现实世界的设计更复杂,但其基本思想是相同的——如表6.1所示的并行快速路径的重复应用。
“并行快速路径”是第113页提出的不可分区应用程序问题的解决方案之一。
表6.1:Real-Worl d并行分配器示意图
等级 | 目的 | |
按线程池 | 数据所有权 | 高速分配 |
全局块池 | 数据锁定 | 在各个线程之间分配块 |
正在合并 | 数据锁定 | 将区块组合成页面 |
系统内存 | 密码锁定 | 从系统中读取/写入内存 |
6.5 超越党派分歧
如果你有足够的弹药,那么高目标是没问题的。
霍利·R·埃弗哈特
本章讨论了如何利用数据分区来设计简单的线性可扩展并行程序。第6.3.4节 暗示了数据复制的可能性,这将在第9.5节中得到很好的应用。
应用分区和复制的主要目标是实现线性加速比,换句话说,就是确保随着CPU或线程数量的增加,所需总工作量不会显著增加。通过分区和/或复制可以解决的问题之一是令人尴尬的并行问题,这会导致接近的加速比。但我们能否做得更好?
为了回答这个问题,让我们来探讨迷宫和迷阵的求解方法。当然,迷宫和迷阵作为研究对象已有数千年历史[维基12],因此使用计算机生成和解决它们也就不足为奇了,包括生物计算机[阿达11]、通用专用GPU[埃里08],甚至离散硬件[KFC11]。并行求解迷阵有时被用作大学课程项目[苏黎世11,尤尼10],以及展示并行编程框架优势的工具[Fos10]。
常见的建议是使用并行工作队列算法(PWQ)[ ETH11,Fos10]。本节通过将PWQ与顺序算法(SEQ)以及另一种并行算法进行比较来评估这一建议,所有情况下均解决随机生成的方形迷宫。第6.5.1节 讨论PWQ,第6.5.2节 讨论了另一种并行算法,第6.5.4节 分析其异常性能,第6.5.5节 从第6.5.6节的交替并行算法中推导出改进的顺序算法 进行进一步性能比较,最后见第6.5.7节 介绍了未来的发展方向和总结。
PWQ基于SEQ,如清单6.12所示 (maze_seq.c的伪代码)。迷宫由一个二维的细胞阵列和一个基于非线性阵列的工作队列命名->visited表示。
第7行访问初始单元,循环的每次迭代跨越第8行 – 21t raverses passages headed by one cell. The loop spanning lines 9 – 13 扫描scans-the->visited[]数组,寻找具有未访问邻居的已访问单元格,并在循环中跨越行14 – 19 穿过由该邻居领导的次级子网的一个分支。行20 初始化以供下次通过外循环。
maze_ try_ visit_ cell()的伪代码如第1行所示 – 12见清单6.13 (maze.c)第4行 检查c和t单元格是否相邻且相连,而第5行 检查是否还没有访问过单元t。celladdr()函数返回指定单元的地址。如果任一检查失败,行6 返回失败。第7行 表示下一个单元格,第8行 在-> visited[]数组的下一个槽中记录这个单元格,第9行 表示此插槽现已满,以及行10 将此单元格标记为已访问,并记录从该单元格的起始位置的距离。第11行 然后返回成功。
maze_ find_ any_ next_ cell()的伪代码显示在第14行 – 28 见清单6.13(maze.c)第17行 取当前单元格距离+1,而行19, 21 , 23 , 和25 检查每个方向上的电池,以及第20行, 22 , 24 , 和26 如果对应的单元格是候选下一个单元格,则返回true。prevcol()、nextcol()、prevrow()和nextrow()分别执行指定的数组-索引转换操作。如果没有任何单元格是候选单元格,则行27 返回false。
路径是通过从星点开始,计算出迷宫中单元格的数量来记录的,如图6.22所示 ,其中起始单元格位于左上角,结束单元格位于右下角。从结束单元格开始,按照连续递减的单元格编号遍历解。
清单6.13:SEQ Helper伪代码 | ||
1 int | maze_try_visit_cell(struct maze*mp,cell c,cell | t, |
2 | cell*n, int d) | |
3{ 4 | ||
5 | (*celladdr(mp,t)& VISITED)) | |
6 | ||
7 | *n=t; | |
8 | mp->visited[mp->vi]=t; | |
9 10 | mp-> vi++; | |
11 | return1; | |
12} | ||
13 | ||
14 int | ||
15 | cell*n) | |
16{ | ||
17 18 | int d=(*celladdr(mp,c)& DISTANCE)+ 1; | |
19 | 如果(maze_try_visit_c ell(mp,c,prevcol(c),n, | |
20 | return1; | |
21 | 如果(maze_try_visit_cell(mp,c,nextcol(c),n, | d)) |
22 | return1; | |
23 | 如果(maze_try_visit_ce ll(mp,c,prevrow(c),n, | |
24 | return1; | |
25 | 如果(maze_try_visit_cell(mp,c,nextrow(c),n, | |
26 | return1; | |
27 | return0; | |
28} |
并行工作队列求解器是清单6.12中所示算法的直接并行化 和6.13。 第10行 见清单6.12 必须使用fetch-and-add,局部变量vi必须在各个线程之间共享。第5行 和10 见清单6.13 必须组合成一个CAS环,CAS故障表示迷宫中存在环路。第8行 – 9 此列表中的所有元素都必须使用fetch-and-add来仲裁并发尝试在-> visited[]数组中记录单元格。
如图6.23所示,这种方法确实为运行在2.53 GHz的双CPU Lenovo W500提供了显著的加速。 显示了基于500个不同随机生成的500×500迷宫的解决方案,两种算法的累积分布函数(CDF)的累积分布函数(CDF)基于500个不同的500×500迷宫的解决方案。CDF在x轴上的投影的大量重叠将在第6.5.4节中讨论。
有趣的是,序列解路径追踪器对并行算法同样适用。然而,这揭示了并行算法的一个显著弱点:任何时候最多只有一个线程在沿解路径前进。这一弱点将在下一节中讨论。
年轻的迷宫求解者常被建议从两端开始,这一建议最近在自动迷宫求解的背景下再次被提及[Uni10]。这些建议实际上涉及分区,这种策略在并行编程中作为强大的并行化手段,不仅适用于操作系统内核[BK85,Inm85],也适用于应用程序[Pat10]。本节将采用这一策略,使用两个子线程分别从解决方案路径的两端开始,并简要探讨其性能和可扩展性的影响。
分区并行算法(PART),如清单6.14所示 (maze_part.c),与SEQ类似,但有几个重要的区别。首先,每个子线程都有自己的访问数组,由父线程通过第1行所示的passe d传递, 必须初始化为all[-1,-1]。第7行 将指向此数组的指针存储到线程变量myvisited中,以便辅助函数访问,并同样存储指向本地访问的指针
清单6.15:分区并行辅助程序伪代码 | |||
1 int | maze_try_visit_cell(s truct maze*mp, | int c, | int t, |
2 | int*n, int d) | ||
3{ | |||
4 | cell_tt; | ||
5 | cell_t*tp; | ||
6 | intvi; | ||
7 | |||
8 | 如果(!maze_cells_connected(mp,c, | t)) | |
9 | return0; | ||
10 | |||
11 | 执行{ | ||
12 | |||
13 | 如果(t&访问过){ | ||
14 | 如果((t& TID)!= | mytid) | |
15 | mp->已完成 | ||
16 | return0; | ||
17 | } | ||
18 | }而(! CAS(tp,t,t| VISITED | | myid | |d)); |
19 | *n=t; | ||
20 | vi=(*myvi)++; | ||
21 | |||
22 | return1; | ||
23} |
索引。第二,父访问每个子的第一个单元格,子在第8行检索。 第三,当一个孩子找到另一个孩子已经访问过的单元格时,迷宫即被解决。当maze_try_visit_cell()检测到这一点时,它会在迷宫结构中设置一个->done字段。第四,因此每个孩子必须定期检查->done字段,如第13行所示, 18 , 和23。 TheREAD_ONCE()p rimitive必须禁用任何可能合并连续加载或重新加载值的编译器优化。C++1x可松弛的易失性加载就足够了[Smi19]。最后,maze_ find_ any_ next_ cell的()函数必须使用比较和交换来标记一个单元已访问,但除了线程创建和连接提供的顺序约束外,无需其他顺序限制。
maze_ find_ any_ next_ cell()的伪代码与清单6.13中所示的完全相同。 ,但是maze_try_visit_cell()的伪代码不同,如清单6.15所示。 第8行 – 9检查是否连接了这些单元,如果没有,则返回失败。循环跨越线路11 – 18 标记新访问的单元格的尝试。第13行 检查是否已经访问过,如果是,则行16 返回失败,但仅在第14行之后 检查我们是否遇到了另一个线程,如果是,则行15 表示已找到解决方案。行19 对新单元格的更新,第20行 和21 更新此线程的访问数组,并行22 返回成功。
性能测试显示了一个令人惊讶的异常,如图6.24所示。 PART (17毫秒条件)的中位解算时间比SEQ (79毫秒)快4倍以上,尽管它只运行在两个线程上。
对于这种戏剧性的性能异常,首先反应是检查错误,这表明需要进行严格的验证。这是下一节的主题。
大部分验证工作包括一致性检查,可以通过在codeSamples/SMPdesign/maze/*.c中搜索ABORT()来找到这些检查。示例检查包括:
1.迷宫解决方案步骤最终位于迷宫之外。
2.突然出现的行数或列数为零或更少的迷宫。
3.新创建的迷宫,其中包含不可到达的单元格。
4.没有解决方案的迷宫。
5.不连续的母胶溶液。
6.尝试在迷宫外部启动迷宫求解程序。
7.迷宫,其解路径比迷宫中的单元格数量更长。
8.不同线程的子解决方案相互交叉。
9.内存分配失败。
10.系统调用失败。
Paul的妻子对解决谜题非常感兴趣,因此她还进行了额外的手动验证。
然而,如果这个迷宫软件要在生产中使用,无论这意味着什么,明智的做法是构建一个独立的迷宫破解程序。尽管如此,这些迷宫和解法都被证明是相当有效的。因此,下一节将更深入地分析第6.5.2节中提到的可扩展性异常。
尽管算法实际上是在寻找有效迷宫的有效解,但图6.2中的CDF曲线图4 假设数据点是独立的。但事实并非如此:性能测试随机生成一个迷宫,然后在该迷宫上运行所有求解器。因此,绘制每个生成的迷宫的求解时间比值的累积分布函数是有意义的,如图6.25所示。 大大减少了CDFs的重叠。该图显示,对于某些迷宫,PART的速度比SEQ快四十多倍。相比之下,PWQ的速度从未超过SEQ的两倍。二十线程上的四十倍加速需要解释。毕竟,这不仅仅是令人尴尬的并行性,其中可划分性意味着增加线程不会提高整体计算成本。相反,这是屈辱性的并行性:增加线程显著降低了整体计算成本,从而实现了巨大的算法超线性加速。
进一步的研究表明,PART有时访问迷宫的细胞少于2%,而SEQ和PWQ从未访问少于9%。这种差异的原因如图6.26所示。 如果从左上角穿过溶液的线到达圆圈,另一条线就无法到达迷宫的右上部分。同样地,如果另一条线到达正方形,第一条线就无法到达迷宫的左下部分。因此,PART很可能只访问一小部分非解路径细胞。简而言之,超线性加速是由于各条线相互干扰所致。这与几十年来并行编程的经验形成了鲜明对比,在这些经验中,程序员们一直在努力避免线之间的干扰。
图6.27 确认了所有三种方法中访问的细胞与溶液时间之间存在强烈的相关性。PART的散点图斜率小于SEQ,表明PART的双线程访问给定比例的迷宫速度比canSEQ的单线程更快。PART的散点图还偏向于较小的访问百分比,证实了PART总的工作量较少,因此观察到了令人尴尬的并行性。这种令人尴尬的并行性还提供了超过两倍的速度提升,如第113页所述。
PWQ访问的细胞比例与SEQ相似,而且即使在相同的访问比例下,PWQ的解的时间也比PART长。原因如图6.28所示。 每个拥有超过两个邻居的单元格都有一个红色圆圈。由于每个这样的单元格可能导致PWQ中的竞争,因为一个线程可以进入但只有两个线程可以退出,这会损害性能,正如本章前面所提到的。相比之下,PART可以在找到解决方案时发生一次这样的竞争。当然,SEQ从不发生竞争。
尽管PART的加速效果令人印象深刻,但我们不应忽视顺序优化。图6.29 表明,当使用-O3编译SEQ时,其速度大约是未优化PWQ的两倍,接近未优化PART的性能。用-O3编译所有三种算法的结果与图6.25所示的结果相似(尽管比图6.25所示的结果快)。 ,除了PWQ提供的加速几乎与SEQ相比没有区别,这符合Amdahl定律[Amd67]。然而,如果目标是将性能提高到未优化SEQ的两倍,而不是达到最优性,编译器优化就非常有吸引力。
缓存对齐和填充通常通过减少虚假共享来提高性能。然而,对于这些迷宫解决方案算法,对迷宫单元数组进行对齐和填充会导致性能下降,对于1000x1000的迷宫,性能可下降高达42 %。缓存局部性比避免虚假共享更重要,特别是对于大型迷宫。对于较小的20x20
或者50×50迷宫,对齐和填充可以为PART带来高达40%的性能改进,但对于这些小尺寸,SEQ无论如何都表现更好,因为PART没有足够的时间来弥补线程创建和销毁的开销。
简而言之,分区并行迷宫求解器是一种有趣的算法超线性加速的实例。如果“算法超线性加速”引起认知失调,请继续阅读下一节。
算法的超线性加速表明,可以采用并行处理方法,例如在清单6.14的main do-while循环中,通过手动在各个线程之间切换上下文。 这种上下文切换是直接的,因为上下文仅由变量c和vi组成:在实现该效果的众多方法中,这是上下文切换开销与访问百分比之间的一个很好的折衷。如图6.30所示 ,这个协程算法(COPART)非常有效,在一个线程上的性能大约是两个线程(maze_2seq.c)的PA RT的30 %。
图6.31 和6.32 展示了不同迷宫大小的影响,比较了PWQ和PART在两个线程上分别与SEQ或COPART的性能,误差范围为90%置信区间。PART在100×100及更大迷宫中表现出优于线性的扩展性,而对COPART的扩展性则较为温和。PART在大约200×200迷宫规模时,能量效率超过了COPART,因为高频情况下功率消耗大致与频率的平方成正比[Mud01],因此在两个线程上1.4倍的扩展可以消耗与单个线程相同能量且解速度相等。相比之下,除非未优化,否则PWQ对SEQ和COPART的扩展性较差:图6.31 和6.32 使用-O3生成。
图6.33 展示了PWQ和PART相对于COPART的性能。对于PART运行超过两个线程的情况,额外的线程均匀分布在连接起始和结束单元格的对角线上。简化了链路状态路由[BG87]用于检测PART运行超过两个线程时的提前终止(当一个线程同时连接到起始和结束时,该解决方案会被标记)。PWQ表现较差,但PART在两个线程时达到盈亏平衡,在五个线程时再次达到盈亏平衡,并且超过五个线程后实现了适度的加速。理论上的能量效率盈亏平衡在七个和八个线程的情况下处于90%的置信区间内。
两线程峰值的原因是(1)两线程情况下终止检测的复杂度较低,以及(2)第三及后续线程向前推进的概率较低:只有前两条线程能保证从解线上开始。这与图6.32中的结果相比,表现较差。 这是由于运行在2.66 GHz的更大、更老的Xeon系统中可用的硬件集成度较低。
未来的工作还有很多。首先,本节仅应用了人类迷宫求解者使用的一种技术。其他方法包括跟随墙壁以排除迷宫的部分区域,以及根据先前路径的位置选择内部起点。其次,不同的起点和终点选择可能有利于不同的算法。第三,尽管PART算法的前两个线程放置较为简单,但剩余线程的放置方案却多种多样。最优放置可能取决于起点和终点。第四,研究无法解决的迷宫和循环迷宫可能会产生有趣的结果。第五,轻量级C++11原子操作可能会提高性能。第六,比较三维迷宫(或更高阶迷宫)的速度提升会很有趣。最后,对于迷宫而言,令人尴尬的并行性表明使用协程可以实现更高效的顺序执行。令人尴尬的并行算法是否总是导致更高效的顺序实现,还是存在本质上并行的算法,其中协程上下文切换开销超过了速度提升?
本文对迷宫解算法的并行化进行了演示和分析。传统的基于工作队列的算法只有在禁用编译器优化时才能得到很好的效果,这表明使用高级语言/开销语言获得的一些先前结果将被优化技术的进步所否定。
本节给出了一个清晰的例子,说明将并行性作为一级优化技术而非顺序算法的派生,为改进顺序算法铺平了道路。并行性的高级设计时应用很可能是研究的一个富有成果的领域。本节讨论了解决迷宫问题,从轻微可扩展到令人尴尬的并行化,再回到原点。希望这次经历能够激发人们将并行化作为一级设计时的整体应用优化技术来研究,而不是将其视为事后微优化,仅用于现有程序的修补。
6.6 分区、并行和优化
知识没有价值,除非你把它付诸实践。
安东·契诃夫
最重要的是,尽管本章已经证明了在设计层面应用并行性可以取得优异的结果,但这一节表明这还不够。对于迷宫解决方案等搜索问题,本节显示搜索策略比并行设计更为重要。是的,对于这种特定类型的迷宫,智能地应用并行性确实找到了更优的搜索策略,但这类幸运并不能替代对搜索策略本身的明确关注。
正如在第2 .2节中所指出的,并行性只是众多优化方法中的一种。一个成功的系统设计需要关注最重要的优化。尽管我可能希望声称不然,但这种优化可能是并行性的,也可能不是。
但是,对于许多情况,如果并行处理是正确的优化方法,下一节将介绍同步工作马——锁定。
Chapter 7 Locking
锁定是最糟糕的通用方法
同步机制,但不包括所有这些
其他机制也时不时地被尝试过。
向温斯顿·丘吉尔的遗体和他所引用的任何人致歉
在最近的并发研究中,锁定常常扮演着反派的角色。锁定被指责引发死锁、队列问题、饥饿、不公平、数据竞争以及各种其他并发罪行。有趣的是,在生产级共享内存并行软件中,锁定同样扮演着生产工具的角色。本章将探讨这种反派与英雄之间的二元对立,如图7.1所示。和7.2。
这种两面性背后有很多原因:
1.锁定的许多缺点都有实用的设计解决方案,在大多数情况下都行得通,例如:
(a)使用锁层次结构来避免死锁。
(b)死锁检测工具,例如Linux内核的lockdep[Cor06a]。
(c)适合锁定的数据结构,如数组、哈希表和基数树,将在第10章中介绍。
2.锁定的一些罪过只是在高度竞争的情况下才会出现的问题,而这种竞争只有在设计糟糕的程序中才会达到。
3.通过与其他同步机制结合使用锁定,可以避免一些锁定的缺点。这些其他机制包括统计计数器(见第5章)、引用计数器(见第9.2节)、危险指针(见第9.3节)、序列锁定读取器(见第9 .4节)、RCU(见第9.5节)和简化非阻塞数据结构(见第14.2节)。
4.直到最近,几乎所有大型共享内存并行程序都是秘密开发的,因此很难了解到这些实用的解决方案。
5.锁定对于某些软件组件非常有效,而对于其他组件则效果极差。开发人员在处理那些锁定效果良好的组件时,通常会对锁定持有更积极的看法;而那些处理过锁定效果不佳的组件的开发人员,则可能持相反态度,这将在第7.5节中讨论。
6.所有好的故事都需要一个反派,而锁具有着悠久而光荣的历史,作为研究论文的替罪羊。
本章将概述避免锁定更严重错误的几种方法。
7.1 活命
我努力活下去。
贝蒂·戴维斯
鉴于锁定机制被指责为死锁和饥饿,共享内存并行开发人员的一个重要关注点就是保持生存。因此,以下各节将介绍死锁、活锁、饥饿、不公平和低效性。
死锁发生在一组线程中的每个成员都持有至少一个锁,同时又等待该组中某个成员持有的锁时。即使在一个包含单个线程的组中,当该线程尝试获取它已经持有的非递归锁时也会发生这种情况。因此,即使只有一个线程和一个锁,也可能发生死锁!
如果没有某种外部干预,死锁将永远存在。任何线程都无法获取它正在等待的锁,直到持有该锁的线程释放它,但持有该锁的线程不能释放它,直到它自己也获得了它正在等待的锁。
我们可以创建一个死锁场景的有向图表示,其中节点s代表线程和锁,如图7.3所示。 从线程到锁的箭头表示该线程持有锁,例如,线程B持有锁2和锁4。从线程到锁的箭头表示该线程正在等待锁,例如,线程B正在等待锁3。
死锁场景总是包含至少一个死锁循环。在图7.3中, 此循环为Thread B、Lock 3、Thread C、Lock 4,然后返回到Thread B。
尽管有些软件环境,如数据库系统,可以从现有的死锁中恢复,但这种方法要求其中一个线程被杀死或者锁被强行从其中一个线程中窃取。这种杀死和强行窃取对于事务来说是很好的,但对于内核和应用程序级别的锁使用来说却常常有问题:处理由此产生的部分更新结构可能极其复杂、危险且容易出错。
因此,内核和应用程序应避免死锁。避免死锁的策略包括锁定层次结构(第7.1.1.1节 ),局部锁定层次结构(第7.1.1.2节 ),分层锁定体系结构(第7.1.1.3节 ),时间锁定层次结构(第7.1.1.4节 ),处理包含锁指针的API的策略(第7.1.1.5节 ),条件锁定(第7.1.1.6节 ),首先获取所有需要的锁(第7.1.1. 7节 ),一次只锁一个设计(第7.1.1.8节 ),以及信号/中断处理程序的策略(第7.1.1.9节) 虽然没有一种避免死锁的策略适用于所有情况,但是有很好的工具可供选择。
锁定层次结构对锁进行排序,并禁止无序地获取锁。在图7.3中 我们可能会按数字顺序排列锁,从而禁止一个线程在持有相同或更高编号的锁时获取给定的锁。线程B违反了这一层次结构,因为它试图在持有4号锁的情况下获取3号锁。这种违规行为导致了死锁的发生。
再次,应用锁定层次结构时,应按顺序排列锁并禁止乱序获取锁。对于不同类型的锁,从一种类型到另一种类型建立一个经过深思熟虑的层次结构是有帮助的。例如,在搜索树中每个节点上的锁,传统方法是按照要获取的锁地址顺序进行锁的获取。无论哪种方式,在大型程序中,明智的做法是使用如Linux内核lockdep[ Cor06a]这样的工具来强制执行锁定层次结构。
然而,锁定层次结构的全局性使得它们难以应用于库函数。毕竟,当使用给定库函数的程序尚未编写时,糟糕的库函数实现者怎么可能遵循待定义的锁定层次结构呢?
一种特殊(但常见)的情况是,库函数不调用调用者的任何代码。在这种情况下,在持有库的任何锁的同时,调用者永远不会获得任何锁,因此不会出现包含库和调用者锁的死锁循环。
但是假设库函数确实调用了调用者的代码。例如,qsort()调用了调用者提供的比较函数。通常情况下,这个比较函数将作用于不变的局部数据,因此它不需要获取锁,如图7.4所示。 但是,也许有人足够疯狂,能够对一个键正在改变的集合进行排序,从而要求比较函数获取锁,这可能会导致死锁,如图7.5所示。 图书馆如何避免这种僵局?
在这种情况下,黄金法则是在调用unknowncode之前释放所有锁。要遵循这一规则,qsort()函数必须在调用比较函数之前释放所有锁。因此,在比较函数获取调用者的任何锁之前,qsort()不会持有其任何锁,从而避免死锁。
要了解局部锁定层次结构的好处,请比较图7.5 和7.6。 在这两个图中,应用程序函数foo()和bar()分别在持有锁A和锁B的情况下调用qsort()。由于这是qsort()的并行实现,因此它会获取锁C。函数foo()将函数cmp()传递给qsort(),而cmp()则获取锁B。函数bar()将一个简单的整数比较函数(未显示)传递给qsort(),这个简单的函数不会获取任何锁。
现在,如果qsort()在调用cmp()时持有锁C,违反了上面的gol den-release-all-locks规则,如图7.5所示, 可能会发生死锁。要了解这一点,请假设一个线程调用foo(),而另一个线程同时调用bar()。第一个线程将获取Lock A,第二个线程将获取Lock B。
如果第一个线程调用qsort()时获取了锁C,那么它在调用cmp()时将无法获取锁B。但由于第一个线程持有锁C,第二个线程调用qsort()时也无法获取锁C,因此无法释放锁B,从而导致死锁。
相反,如果qsort()在调用比较函数之前释放锁C,而这个函数在qsort()看来是未知代码,那么就可以避免死锁,如图7.6所示。
如果每个模块在调用未知代码之前释放所有锁,则如果每个模块单独地消除死锁,则可以避免死锁。因此,这条规则极大地简化了死锁分析,并极大地提高了模块化。
然而,这条黄金法则附带了一个警告。当你释放这些锁时,它们保护的任何状态都可能受到任意更改的影响,而这些更改对于函数调用者来说太容易被遗忘,从而导致微妙且难以重现的错误。由于qsort()比较函数很少获取锁,让我们切换到另一个例子。
考虑清单7.1中的递归树迭代器 (rec_ tree_ itr .c)这个迭代器会遍历树中的每个节点,调用用户的回调函数。在调用前释放树锁,在返回后重新获取。这段代码假设了以下几点:(1)当前节点的子节点数量没有变化,(2)递归过程中存储在栈上的祖先节点仍然存在,(3)已访问的节点本身没有被移除和释放。如果一个线程调用tree_add(),而另一个线程释放树锁以运行回调函数,可能会遇到这些风险。
一种策略是确保在释放锁的同时保持状态,例如,通过获取节点上的引用来防止其被释放。或者,可以在回调函数返回后重新获取锁并重新初始化状态。
1结构节点{ 2 intdata; 3 intnchildren; 4个结构节点**子节点;5}; 6 7结构树{ 8spinlock_ts; 9个结构节点*root;10}; 11 12 voidtree_for_each_rec(structtree *tr,struct node*nd, 13void(*callback)(struct node*)) 14{ 15结构节点**itr;16 17spin_unlock(&tr->s); 18回叫(nd); 19spin_lock(和tr->s);20 21 itr=nd->children; 22 for(int i=0;i<nd->nchildren;i++){ 23tree_for_each_rec(tr、*itr、回调); 24itr++; 25} 26} 27 28 voidtree_for_each(结构树*tr, 29void(*callback)(struct node*)) 30{ 31spin_lock(&tr->s); 32tree_for_each_rec(tr、tr->root、回调); 33spin_unlock(&tr->s);34} 35 36 voidtree_add(s truct tree*tr,struct node*parent, 37结构节点new_ch ild) 38{ 39spin_lock(&tr->s); 40父母->子女++; 41parent->children=realloc(parent->children, 42 sizeof(struct node*)* 43parent->nchildren); 44parent->children[parent->nchildre n-1]=new_child; 45spin_unlock(&tr->s);46} |
不幸的是,一方面可能无法保留状态,另一方面也可能无法重新初始化,因此无法在调用未知代码之前释放所有锁,从而排除了局部锁定层次结构。然而,我们可以构建一个分层的锁定层次结构,如图7.7所示。 在这里,cmp()函数使用了一个新的锁D,该锁是在获取所有锁A、B和C后获得的,从而避免了死锁。因此,全局死锁层次结构有三层,第一层包含锁A和B,第二层包含锁C,第三层包含锁D。
请注意,通常无法通过机械方式将cmp()更改为使用新的Lock D。恰恰相反,往往需要进行深入的设计层面修改。然而,为了防止死锁,这种修改所需的努力通常是值得的。更重要的是,在设计阶段,即在生成任何代码之前,最好能检测到这种潜在的死锁!
另一个在调用未知代码之前释放所有锁是不切实际的例子是想象一个链表的迭代器,如清单7.2所示 (locked_list.c)。list_start()函数获取列表的锁并返回第一个元素(如果有),而list_next()会返回指向列表中下一个元素的指针,或者在到达列表末尾时释放锁并返回NULL。
清单7.3 显示此列表的用途。第1行 – 4定义包含单个整数的list_ints元素,以及第6行 – 17 显示如何遍历列表。第11行 锁定列表并获取第一个元素的指针,第13行 提供了指向我们enclosing list_ ints结构的指针,第14行 打印相应的整数,以及第15行 移动到下一个元素。这很简单,隐藏了所有的锁定。
也就是说,只要代码处理每个列表元素时没有获得跨其他调用tolist_start()或tolist_start()的锁,锁定就会保持隐藏状态。
1结构2 3 4}; 5 6结构7{ 8 9 10} 11 12结构13 14{ 15 16 17 18 19 20 21 22 23} | 锁定列表{spinlock_ts; structcds_list_headh; cds_list_head*list_start(structlocked_list*lp) spin_lock(&lp->s); returnlist_next(lp、&lp->h); cds_list_head*list_next(structlocked_list*lp, structcds_list_head np)structcds_list_head ret; ret=np->next; 如果(ret==&lp->h){ spin_unlock(&lp->s);ret=NULL; } returnret; |
1 structlist_ints{ 2 structcds_list_headn; 3 inta; 4}; 5 6 voidlist_print(structlocked_list*lp)7{ 8 structcds_list_head np; 9 structlist_ints*ip; 10 12 while(np!= NULL){ 13 ip=cds_list_entry(np,structlist_ints,n); 17} |
list_ next(),这会导致死锁。我们可以通过分层锁定层次结构来避免死锁,以考虑列表迭代器锁定。
这种分层方法可以扩展到任意多的层次,但每一层的增加都会提高锁定设计的复杂性。对于某些面向对象的设计而言,这种复杂性的增加尤为不便,因为这些设计中控制流会在大量对象之间无序地来回传递。 面向对象设计习惯与避免死锁需求之间的这种不匹配是并行编程被一些人认为是如此困难的一个重要原因。
第9章介绍了高度分层锁定层次结构的一些替代方案。
避免死锁的一种方法是推迟获取其中一个冲突锁。这种方法在Linux内核的RCU中使用,其call_rcu()函数由Linux内核调度器在持有锁时调用。这意味着call_rcu_()无法总是安全地调用调度器来唤醒线程,例如,为了唤醒一个RCU的kthread,以启动call_rcu()队列回调所需的新宽限期。
然而,宽限期持续时间很长,通常只需再等待一毫秒即可开始新的宽限期,因此这通常不是问题。因此,ifcall_rcu()会检测调度器中可能存在的死锁k,并安排稍后启动新的宽限期,具体是在定时器处理程序还是调度器时钟中断处理程序中,取决于配置。由于两个处理程序之间没有持有调度锁,因此成功避免了死锁。
因此,总体方法是遵循锁定层次结构,通过将锁定获取推迟到没有锁定的环境来实现。
尽管有一些例外,但包含锁指针的外部API通常是一个设计不当的API。毕竟,将内部锁交给其他软件组件是信息隐藏的反面,而信息隐藏正是关键的设计原则。
一个例外是那些将某些实体交给其他实体处理的功能,在这种情况下,调用者的锁必须一直保持到移交完成,但函数返回前必须释放锁。例如,POSIXpthread_cond_wait()函数就是一个这样的例子,通过传递一个指向pthread_mutex_t的指针可以防止因唤醒丢失而导致的挂起。
清单7.4:协议分层和死锁 | |
1spin_lock(&lock2); | |
2 | layer_2_processing(包); |
3 | |
4 | spin_lock(&nextlayer->lock1); |
5 | spin_unlock(&lock2); |
6 | layer_1_processing(包); |
7 | spin_unlock(&nextlayer->lock1); |
在一个重要的条件锁定特例中,在执行任何处理之前,会先获取所有需要的锁,这些锁可能通过哈希涉及的数据结构地址来识别。在这种情况下,处理不必是幂等的:如果发现无法在不释放已获取的锁的情况下获取某个锁,只需释放所有锁并重试。只有当所有需要的锁都已持有时,才会进行任何处理。
一种相关的方法,两阶段锁定[BHG87],在事务数据库系统中长期使用。在两阶段锁定事务的第一阶段,获取锁但不释放。一旦所有需要的锁都被获取,事务进入第二阶段,在此阶段释放锁但不再获取。这种锁定方法允许数据库为其事务提供序列化保证,换句话说,确保事务所见和产生的所有值都与所有事务的某种全局顺序一致。许多这样的系统依赖于事务中止的能力,尽管可以通过避免在获取所有必要锁之前对共享数据进行任何更改来简化这一过程。活锁和死锁是这类系统中的问题,但在许多数据库教科书中可以找到实际解决方案。
在某些情况下,可以避免嵌套锁,从而避免死锁。例如,如果一个问题完全可分区,每个分区可以分配一个锁。那么,在给定分区工作的线程只需获取相应的锁即可。由于任何时候都不会有线程同时持有多个锁,因此死锁是不可能的。
但是,必须存在某种机制来确保在没有锁定的情况下保持所需的数据结构。第7.4节讨论了这种机制 其他一些内容在第9章中介绍。
涉及信号处理程序的死锁通常可以通过指出在信号处理程序中调用pthread_ mutex_ lock()是不合法的来迅速解决[Ope 97]。然而,从信号处理程序中调用锁定原语是可能的(尽管往往不明智)。此外,几乎所有的操作系统内核都允许在中断处理程序中获取锁,这些中断处理程序类似于信号处理程序。
诀窍是在获取可能在信号(或中断)处理程序中获得的任何锁时阻塞信号(或禁用中断)。此外,如果持有此类锁,则在信号处理程序之外尝试获取任何已获取的锁而不阻塞信号是非法的。
如果锁被处理程序为多个信号获取,那么在获取该锁时,必须阻止每一个信号,即使该锁是在信号处理程序中获取的。
不幸的是,在一些操作系统中,包括Linux在内,锁定和解锁信号可能代价高昂,因此性能问题通常意味着在信号处理器中获得的锁只能在信号处理器中获得,而且使用无锁同步机制在应用程序代码和信号处理器之间进行通信。
或者,除了处理致命错误之外,完全避免使用信号处理器。
7.1.1.10讨论
共享内存并行程序员可以使用大量避免死锁的策略,但有些顺序程序却无法用这些方法解决。这就是为什么专家程序员的工具箱中不止一种工具的原因之一:锁定是一种强大的并发工具,但有些任务更适合用其他工具来处理。
尽管如此,本节中描述的策略在许多情况下都证明是很有用的。
虽然条件锁定可以是一种有效的死锁避免机制,但它也可能被滥用。例如,考虑清单7.6中所示的对称性很好的例子。 此示例的美观性隐藏了一个丑陋的活锁。要查看这一点,请考虑以下事件序列:
1.线程1在第4行获取锁1, 然后调用do_ one_ thing()。
2.线程2在行18上获取锁2, 然后调用do_a_第三件事()。
3.线程1试图在第6行获取loc k2, 但失败了,因为线程2持有它。
4.线程2尝试在行20上获取锁1, 但失败了,因为线程1持有它。
4spin_lock(&lock1);
7spin_unlock(&lock1);
8 gotoretry; 9}
10do_another_thing();
11spin_unlock(&lock2);
12spin_unlock(&lock1);13}
14
18spin_lock(&lock2);
21spin_unlock(&lock2);
22 gotoretry; 23}
24 do afourth_thing();
25spin_unlock(&lock1);
26spin_unlock(&lock2);27}
5.线程1在第7行释放锁1 然后跳转到第3行重试。
6.线程2在第21行释放锁2 ,然后跳到第17行重试。
7.活锁舞从头开始重复。
活锁可以被看作是一种极端的饥饿形式,其中一组线程饿死了,而不是其中只有一个。3
活锁和饥饿是软件事务内存实现中的严重问题,因此引入了争用管理器的概念来封装这些问题。对于锁定而言,简单的指数退避通常可以解决活锁和饥饿问题。其思路是在每次重试前引入指数级递增的延迟,如清单7.7所示。
为了获得更好的结果,退避应该被限定,通过排队锁定获得更好的高竞争结果[And90],这将在第7.3.2节中讨论更多。 当然,最好的方法是使用一个良好的并行设计,通过保持低锁竞争来避免这些问题。
不公平可以被认为是一种不那么严重的饥饿形式,其中一部分人
争夺给定锁的线程被授予了大部分的获取。这
1 void thread1(void)2{ 3个未签名的整数wait= 1; 4重试: 5spin_lock(&lock1); 6do_one_thing(); 7if(!spin_trylock(&lock2){ 8spin_unlock(&lock1); 9睡眠(等待); 10等待=等待<< 1; 11 gotoretry; 12} 13do_another_thing(); 14spin_unlock(&lock2); 15spin_unlock(&lock1);16} 17 18 void thread2(void)19{ 20个未签名的整数wait= 1; 21次重试: 22spin_lock(&lock2); 23 do athird_thing(); 24if(!spin_trylock(&lock1){ 25spin_unlock(&lock2); 26睡眠(等待); 27 wait= wait<< 1; 28 gotoretry; 29} 30 do afourth_thing(); 31spin_unlock(&lock1); 32spin_unlock(&lock2);33} |
可以在具有共享缓存或NUMA特性的机器上发生,例如,如图7.8所示。 如果CPU 0释放了一个其他所有CPU都在尝试获取的锁,那么CPU 0和1之间的互连共享意味着CPU 1将比CPU 2到7更有优势。因此,CPU 1很可能会获取该锁。如果CPU 1持有锁的时间足够长,以至于CPU 0在CPU 1释放锁时请求锁,反之亦然,那么锁可以在CPU 0和1之间切换,绕过CPU 2到7。
锁是通过原子指令和内存屏障实现的,通常涉及缓存未命中。正如我们在第三章中所见,这些指令相当昂贵,其开销大约比简单指令高出两个数量级。这可能成为锁定的一个严重问题:如果你用锁保护一条指令,开销会增加一百倍。即使假设完全可扩展性,也需要一百个CPU才能跟上单个CPU执行相同代码而不使用锁的情况。
这种情况不仅限于锁定。图7.9 展示了这一原理如何应用于古老的锯木活动。如图所示,锯一块木板会将木板的一小部分(锯片的宽度)转化为锯末。当然,锁是隔开时间而不是锯木,4 但就像锯木头一样,使用锁来分割时间会浪费一些时间,因为锁头和(更糟糕的是)锁竞争。一个重要的区别是,如果有人把一块木板锯成太小的碎片,大部分木板转化为锯末的情况会立即显现。相比之下,某个特定的锁获取是否浪费了过多时间则不总是显而易见。
这种情况强调了第6.3节讨论的同步粒度权衡的重要性,特别是图6.16:粒度过粗会限制可扩展性,而粒度过细会导致过多的同步开销。
获取锁可能很昂贵,但一旦获得,CPU的缓存就成为了一个有效的性能提升器,至少对于大型关键部分是这样。此外,一旦获得锁,该锁保护的数据就可以被锁持有者访问,而不会受到其他线程的干扰。
Rust编程语言通过允许开发者在锁和它保护的数据之间建立编译器可见的关联,进一步推进了锁/数据关联[JJKD21]。一旦建立了这种关联,尝试在没有相应锁的情况下访问数据将导致编译时诊断。希望这能大大减少这类错误的发生频率。当然,这种方法并不适用于数据分布在某些数据结构节点中的情况,也不适用于锁定对象纯粹抽象的情况,例如,当给定锁需要保护的状态机转换的小部分子集时。因此,Rust允许锁与类型关联,而不是数据项,甚至可以不关联任何东西。最后一种选择使Rust能够模拟传统的锁定用例,但在Rust开发者中并不流行。也许Rust社区会提出其他机制来适应其他锁定用例。
7.2 锁的类型
生活中只有你认为你知道的才是锁,但你并不知道。接受你的无知,尝试一些新的东西。
丹尼斯·维克斯
锁的种类多得令人惊讶,短小的章节不可能详尽地介绍所有种类。以下各节将讨论专用锁(第7.2.1节 ),读写锁(第7.2.2节 ),多角色锁(第7.2.3节 ),以及范围锁定(第7.2.4节) .
独占锁就是他们所说的:一次只有一个线程可以持有锁。因此,持有此类锁的线程对受该锁保护的所有数据都有独占访问权,因此得名。
当然,这一切都假设了锁被跨所有对据称受该锁保护的数据的访问持有。尽管有一些工具可以帮助(例如参见第12.3.1节),但确保在需要时始终获取锁的责任最终在于开发人员。
需要注意的是,无条件获取独占锁有两个效果:(1)等待所有先前持有该锁的线程释放它;(2)阻止任何其他获取尝试,直到该锁被释放。因此,在获取锁时,任何并发的获取操作都必须分为先前持有者和后续持有者的部分。不同类型的独占锁使用不同的分区策略[Bra11,GGL+19],例如:
1.严格的FIFO,获取锁的顺序是先获取的先开始。
3.按优先级顺序,具有较高优先级的线程比任何试图在同一时间获取锁的较低优先级线程更早地获得锁,但对具有相同优先级的线程来说,某些FIFO排序适用。
4.随机,即从所有尝试获取的线程中随机选择新的锁持有者,而不考虑时间。
5.不公平,使得某个给定的获取可能永远无法获得锁(参见第7.1.3节) .
不幸的是,具有更强保证的锁定实现通常会产生更高的开销,这促使了生产环境中各种各样的锁定实现。例如,实时系统通常需要在优先级级别内实现一定程度的FIFO排序,以及其他许多需求(见第14.3.5.1节),而非实时系统在面临高并发时可能只需要足够的排序来避免饥饿现象,最后,旨在避免并发的非实时系统可能根本不需要公平性。
读写锁[ CHP71]允许任意数量的读取器同时持有锁,或单个写入器单独持有锁。理论上,读写锁应能为频繁读取而很少写入的数据提供出色的可扩展性。实际上,其可扩展性取决于读写锁的具体实现方式。
经典的读写锁实现涉及一组原子操作的计数器和标志。这种实现方式与互斥锁在短临界区段中遇到的问题相同:获取和释放锁的开销大约是简单指令开销的两倍。当然,如果临界区足够长,获取和释放锁的开销就会变得微不足道。然而,由于每次只能有一个线程操作锁,所需的临界区大小会随着CPU数量的增加而增大。
可以通过使用线程独占锁来设计一个对读取者更为有利的读写锁[HW92]。读取时,线程仅获取自己的锁;写入时,线程获取所有锁。在没有写入者的情况下,每个读取者只会产生原子指令和内存屏障开销,而不会发生缓存未命中,这对于锁定原语来说是非常好的。不幸的是,写入者也会产生缓存未命中以及原子指令和内存屏障开销——这些开销会随着线程数量的增加而成倍增长。
简而言之,读写锁在许多情况下都非常有用,但每种实现方式都有其缺点。读写锁的经典用例涉及非常长的读侧关键部分,最好以数百微秒甚至毫秒为单位来衡量。
与独占锁一样,读写锁的获取无法完成,直到所有先前持有该锁的冲突者都释放了它。如果锁被读取持有,那么读取获取可以立即完成,但写入获取必须等待,直到不再有任何读者持有该锁。如果锁被写入持有,则所有获取都必须等待,直到写入者释放该锁。同样,不同的读写锁实现为读者提供不同程度的先进先出顺序,而对写入者则提供不同的顺序。
但假设大量读者持有锁,而作者正等待获取锁。是否应该允许读者继续获取锁,可能会导致作者被饿死?同样地,假设作者持有锁,且大量读者和作者都在等待获取锁。当前作者释放锁时,应将其交给读者还是其他作者?如果交给读者,那么在下一个作者被允许获取锁之前,应该允许多少读者获取锁?
对于这些查询问题,有许多可能的答案,它们具有不同的复杂度、开销和公平性。不同的实现方式可能会产生不同的成本,例如,某些类型的读写锁在从读持有者模式切换到写持有者模式时会产生极大的延迟。以下是一些可能的方法:
1.读者偏好实施无条件地优先考虑读者而非作者,可能允许无限期地阻止写入收购。
2.批处理公平实现确保当读取器和写入器同时获取锁时,两者都可通过批处理获得合理的访问权限。例如,锁可能允许每个CPU有五个读取器,然后是两个写入器,接着是另外五个读取器,依此类推。
3.作家偏好实现无条件地偏爱作家而不是读者,可能允许无限期地阻止阅读获取。
当然,这些区别只有在高锁竞争条件下才重要。
请牢记锁的等待/阻塞双重性质。这将在第9章讨论可伸缩高性能专用锁定替代方案时再次提及。
读写锁和独占锁在访问策略上有所不同:独占锁最多允许一个持有者,而读写锁则允许多个读持有者(但只有一个写持有者)。存在大量的可能访问策略,其中之一是VAX/VMS分布式锁管理器的策略。
(DLM)[ ST87],如表7.1所示。 空白单元格表示兼容模式,而含有“X”的单元格表示不兼容模式。
VAX/VMS DLM使用6种模式。为了进行比较,独占锁使用2种模式(未持有和已持有),而读写锁使用3种模式(未持有、读已持有和写已持有)。
第一个模式为null,或者未持有。该模式与所有其他模式兼容,这是可以预料的:如果一个线程没有持有锁,则它不应该阻止任何其他线程获取该锁。
第二种模式是并发读取,它与除独占模式以外的所有其他模式兼容。并发读取模式可用于累积数据结构的近似统计信息,同时允许更新过程并发进行。
第三种模式是并发写入,它与空值、并发读取和并发写入兼容。并发写入模式可用于更新近似统计信息,同时仍允许读取和并发更新并行进行。
第四种模式是受保护的读取,它与空、并发读取和受保护的读取兼容。受保护的读取模式可用于获取数据结构的一致快照,同时允许并发地进行读取但不允许更新。
第五种模式是受保护的写入模式,它与空值和并发读取兼容。受保护的写入模式可用于对可能干扰受保护的读取器但可被并发读取器容忍的数据结构执行更新。
第六种也是最后一种模式是“排他”模式,它只与“空”兼容。当需要排除所有其他访问时,使用“排他”模式。
有趣的是,VAX/VMS DLM可以模拟独占锁和读写锁。独占锁只使用空模式和独占模式,而读写锁可能使用空模式、受保护读模式和受保护写模式。
尽管VAX/VMS DLM策略在分布式数据库中得到了广泛应用,但在共享内存应用中似乎并未得到大量使用。其中一个可能的原因是,分布式数据库较高的通信开销可能会掩盖VAX/VMS更复杂的准入策略带来的更高开销。
尽管如此,VAX/VMS DLM是一个有趣的例子,说明了锁定背后的概念可以多么灵活。它还作为现代数据库管理系统所使用的锁定方案的一个非常简单的介绍,这些方案可以有三十多种锁定模式,而VAX/VMS仅有六种。
到目前为止讨论的锁定原语需要显式的获取和释放原语,例如,分别为spin_lock()和andspin_unlock()。另一种方法是使用面向对象的方法 资源获取初始化(RAII)模式[ES90]。5 这种模式常用于C++等语言中的自动变量,在对象进入作用域时调用相应的构造函数,在离开该作用域时调用相应的析构函数。通过让构造函数获取锁并让析构函数释放锁,也可以应用于锁定机制。
这种方法非常有用,事实上在1990年我确信这是唯一需要的锁定类型。6 RAII锁定的一个非常好的特性是,您不需要仔细释放每个退出该范围的代码路径上的锁,这一特性可以消除一系列麻烦的错误。
然而,RAII锁定也有其阴暗面。RAII使得封装锁的获取和释放变得相当困难,例如,在迭代器中。在许多迭代器实现中,你希望在迭代器的“开始”函数中获取锁,在“停止”函数中释放锁。而RAII锁定则要求锁的获取和释放必须在同一作用域级别进行,这使得这种封装变得困难甚至不可能。
严格的RAII锁定还禁止重叠的关键区段,因为作用域必须嵌套。这一禁令使得表达许多有用构造变得困难甚至不可能,例如,在多个并发尝试断言事件之间进行中介的锁定树。在任意大的并发尝试组中,只需一个成功,其余尝试的最佳策略是尽快且无痛地失败。否则,在大型系统(如数百个CPU)上,锁竞争会变得病态。因此,C++17[Smi19]在其unique_lock类中提供了严格RAII的逃逸机制,允许关键区段的作用域控制程度大致与显式获取和释放锁原语所能达到的程度相当。
图7.10展示了来自Linux内核RCU的严格-RAII-不友好数据结构示例。 在这里,每个CPU都被分配了一个leafrcu_node结构,而eachrcu_node结构则有一个指向其父结构(奇怪的是,这个父结构被命名为->parent)的指针,一直指向根rcu_node结构,该根结构的->parent指针为空。每个父结构中的childrcu_node结构数量可以不同,但通常为32或64个。eachrcu_node结构还包含一个名为->fqslock的锁。
清单7.8:条件锁定以减少争用 | |
1空2{ 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22} | force_quiescent_state(structrcu_node*rnp_leaf) intret; structrcu_node*rnp=rnp_leaf;structrcu_node *rnp_o ld=NULL; for(;rnp!=NULL;rnp=rnp->parent){ ret =(READ_ ONCE(gp_ flags))|| !raw_spin_trylock(&rn-pn>fqslock);如果(rnp_old!=NULL) raw_spin_unlock(&rnp_old->fqslock);if(ret) 返回 一次写入( gp_标志,1); 一次写入( gp_标志,0); |
通用方法是“先获取后释放”,即某个CPU有条件地获取其叶rcu_node结构的->fqslock,如果成功,则尝试获取父节点的->fqslock,然后释放子节点的->fqslock。此外,在每一层,CPU检查一个globalgp_flags变量,如果该变量表明其他CPU已触发事件,则第一个CPU退出竞争。这种先获取后释放的过程会持续进行,直到gp_flags变量显示有人赢得了比赛,某个尝试获取->fqslock失败,或者根rcu_node结构的->fqslock已被获取。如果根rcu_节点结构的->fqslock已被获取,则调用名为do_force_quiet状态()的函数。
实现此功能的简化代码如清单7.8所示。 此函数的作用是在CPU之间进行协调,这些CPU同时检测到需要调用thedo_force_quiescent_state()函数。在任何时候,只有一个do_ force_ quiescent_ state()实例处于活跃状态才有意义,因此如果有多个并发调用者,我们最多只需要其中一个真正调用do_ force_ quiescent_ state(),其余的则需要(尽可能快速且轻松地)放弃并离开。
为此,通过跨越线路7的环路进行每次通过 – 15尝试在rcu_node层次结构中向上移动一级。如果gp_flags变量已经设置(第8行 )或者如果尝试获取当前rcu_node结构的->fqslock失败(第9行 ),然后将局部变量ret设置为1。如果行10 看到局部变量rnp_old不为NULL,这意味着我们持有rnp_old的-> fqs_锁,第11行 释放此锁(但仅在尝试获取父rcu_node结构的>fqslock之后)。如果第12行 看到第8行 或9 看到了放弃的理由,第13行 返回给调用者。否则,我们必须获取当前rcu_node结构的>fqslock,所以第14行 将指向此结构的指针保存在局部变量rnp_ old中,以便为下一次循环遍历做好准备。
如果控制到达第16行 ,我们赢得了比赛,现在持有根rcu_node结构的-> fqslock。如果第16行 仍然看到全局变量gp_标志为零,第17行 将gp_flags设置为1,第18行 调用do_force_quiescent_state(),以及第19行 将gp_flags重置为零。无论如何,第21行 释放根rcu_node结构的-> fqslock。
1个类型定义intxchglock_t;
2#定义DEFINE_ XCHG_ LOCK (n) xchglock_ t n = 0 3
4空xchg_lock(xchglock_t *x p)5{
10} 11
12空xchg_unlock(xchglock_t * xp)13{
15}
此函数说明了不常见的层次锁定模式。使用严格的RAII锁定很难实现这种模式,7 就像前面提到的迭代器封装一样,所以在可预见的未来,将需要显式锁/解锁原语(或C++17-styleunique_lock转义)。
7.3 锁定实施问题
当你把梦变成现实时,它永远不是完整的实现。做梦比做梦容易。
沙伊·阿加西
开发人员几乎总是最好使用系统提供的任何锁定原语,例如POSIX pthread互斥锁[Ope97,But97]。然而,研究示例实现是有帮助的,考虑极端工作负载和环境带来的挑战也是有帮助的。
本节将回顾清单7.9中所示的实现。 此锁的数据结构只是一个int,如第1行所示 ,但可以是任何整型。此锁的初始值为零,表示“未锁定”,如第2行所示。
通过第4行所示的xchg_锁()功能进行锁定获取 – 10 . 此函数使用嵌套循环,外层循环反复原子地交换锁的值和值为1(表示“锁定”)。如果旧值已经是值为1(换句话说,其他人已经持有锁),那么内层循环(第7行 – 8)旋转,直到锁可用,此时外部循环尝试再次获取锁。
锁定释放由第12行所示的xchg_unlock()功能执行 – 15 . 第14行 将值零(“解锁”)原子交换到锁中,从而将其标记为已被释放。
这个锁是test-and-setlock[SR84]的一个简单示例,但非常相似的机制已经被广泛用作纯自旋锁在生产中。
有许多其他基于原子指令的锁定实现方法,其中许多在Mellor-Crummey和Scott的经典论文[ MCS91]中进行了综述。这些实现代表了多维设计权衡的不同点[ GGL+ 19,Gui18,McK96b]。例如,前一节介绍的基于原子交换的测试-设置锁在竞争较低时表现良好,并且具有较小的内存占用优势。它避免了将锁提供给无法使用它的线程,但因此在高竞争水平下可能会遭受不公平或甚至饿死现象。
相比之下,票锁[MCS91]曾经在Linux内核中使用过,在高竞争级别下避免了不公平。然而,由于其严格的先进先出规则,它可能会将锁授予当前无法使用它的线程,这可能是由于该线程被抢占或中断所致。另一方面,重要的是不要过分担心抢占和中断的可能性。毕竟,在许多情况下,这种抢占和中断可能恰好发生在获取锁之后。8
所有锁定实现中,当等待者在一个内存位置上轮转时,包括测试-设置锁和票锁,在高竞争水平下都会遇到性能问题。问题在于释放锁的线程必须更新相应内存位置的值。在低竞争情况下,这不是问题:对应的缓存行很可能仍然由持有锁的线程访问和写入。相反,在高竞争水平下,每个尝试获取锁的线程都会有一个只读的缓存行副本,持有锁的线程需要先使所有这些副本失效,才能执行释放锁的更新操作。一般来说,CPU和线程越多,在高竞争条件下释放锁时产生的开销就越大。
这种负可扩展性促使了多种不同的排队-局部-
想法[和90,GT 90,MCS 91,WKS 94,Cra 93,MLH 94,TS 93],其中一些 在最近版本的Linux内核中被使用[Cor14b]。队列锁通过为每个线程分配一个队列元素来避免高缓存失效开销。这些队列元素链接在一起形成一个队列,控制着锁将如何授予等待线程。关键在于每个线程在其自己的队列元素上进行轮转,因此锁持有者只需使下一个线程CPU的缓存中的第一个元素失效即可。这种安排大大减少了在高并发情况下锁传递的开销。
最近的排队锁实现还考虑了系统的架构,优先在本地授予锁,同时采取措施避免饥饿[SSVM02,RH03,RH02,JMRR02,MCM02]。这些方法可以类比于传统上用于调度磁盘I/O的电梯算法。
不幸的是,提高高并发下队列锁效率的调度逻辑,在低并发下也会增加其开销。因此,林本洪和阿南特·阿加瓦尔将简单的测试-设置锁与队列锁结合使用,在低并发时使用测试-设置锁,在高并发时切换到队列锁[LA94],从而在低并发时获得低开销,在高并发时实现公平性和高吞吐量。布朗宁等人采用了类似的方法,但避免使用单独的标志位,使得测试-设置快速路径使用与简单测试-设置锁相同的指令序列[BMMM05]。这种方法已在生产中得到应用。
在高竞争级别下,另一个问题出现在锁持有者被延迟时,特别是当这种延迟是由于抢占引起的,这可能导致优先级反转。在这种情况下,低优先级线程持有锁,但被中等优先级的CPU密集型线程抢占,导致高优先级进程在尝试获取锁时被阻塞。结果是,CPU密集型的中等优先级进程阻止了高优先级进程运行。一种解决方案是优先级继承[LR 80],尽管对此做法仍有一些争议[Yod 04a,Loc02],但它已被广泛用于实时计算[SRL90,Cor06b]。
另一种避免优先级反转的方法是在持有锁时防止抢占。由于在持有锁的同时防止抢占还能提高吞吐量,大多数专有的UNIX内核都提供了一种调度器意识同步机制[KWS97],这主要归功于某个大型数据库供应商的努力。这些机制通常以提示的形式出现,即在给定代码区域中应避免抢占,这种提示通常存储在一个机器寄存器中。这些提示通常表现为特定机器寄存器中的一个位,使得这些机制的每个锁获取开销极低。相比之下,L inux避开了这些提示。相反,L inux内核社区对调度器意识同步请求的回应是一种称为future-exes[FRK02,Mol06,Ros06,Dre11]的机制。
有趣的是,原子指令并不是实现锁所必需的[Dij65,Lam74]。关于基于简单加载和存储的锁实现问题的精彩阐述,可以在赫尔利希和沙维特的教科书中找到[HS08,HSLS20]。这里的主要观点是,尽管仔细研究这些实现既有趣又启发人,但目前它们的实际应用很少。然而,除了下面描述的一个例外,这种研究留作读者的练习。
Gamsa等人[GKAS99,第5.3节]描述了一种基于令牌的机制,在该机制中,令牌在各个CPU之间循环。当令牌到达某个CPU时,它具有独占性
访问受该令牌保护的任何内容。可以使用多种方案来实现基于令牌的机制,例如:
1.维护per-CPU标志,该标志最初对所有CPU都是零。当某个CPU的标志非零时,它持有令牌。当它用完令牌后,它将它的标志设为零,并将下一个CPU的标志设置为1(或任何其他非零值)。
2.维护每个CPU的计数器,初始设置为对应CPU的编号,我们假设该编号范围从零到N−1,其中N是系统中CPU的数量。当一个CPU的计数器大于下一个CPU的计数器(考虑计数器溢出的情况)时,第一个CPU持有令牌。完成令牌操作后,它将下一个CPU的计数器设置为其自身计数器值加一。
这种锁的独特之处在于,即使当前没有其他CPU使用它,某个CPU也不能立即获取它。相反,该CPU必须等待令牌轮到自己。这在CPU需要定期访问临界区但不能容忍令牌循环速率差异的情况下非常有用。Gamsa等人[GKAS99]用它实现了一种读取-复制更新的变体(见第9.5节),但它也可以用于保护周期性的CPU级操作,例如内存分配器使用的CPU级缓存刷新[MS 93]、垃圾收集的CPU级数据结构,或向共享存储(甚至大容量存储)刷新CPU级数据。
Linux内核现在使用队列自旋锁[C或14b],但由于实现复杂性导致性能在不同竞争级别下表现不佳,这一过程并非一帆风顺[Mar18,Dea18]。随着越来越多的人熟悉并行硬件,并且越来越多的代码被并行化,我们可以期待更多专用锁定原语的出现,例如Gueraro等人[GGL+19,Gui18]。然而,您应该仔细考虑这一重要的安全提示:尽可能使用标准同步原语。标准同步原语相比自定义实现的一大优势在于,它们通常更少出错。9
7.4 基于锁的存在性保证
存在先于并支配本质。
让-保罗·萨特
并行编程中的一个关键挑战是提供存在性保证[GKAS99],以便尝试访问给定对象时可以依赖于该对象在整个访问尝试期间都存在。
清单7.10:没有存在性保证的元素锁定(有缺陷!) | |
1 int 2{ 3 4 5 6 7 8 9 10 11 12 13 14 15} | 删除(键入密钥) intb; 结构元素*p; b=哈希函数(键);p=hashtable[b]; 如果(p== NULL||p-> key!= key) spin_lock(&p->lock);hashtable[b]=NULL; spin_unlock(&p->lock); |
1.只要应用程序正在运行,基础模块中的全局变量和静态局部变量就会存在。
2.只要加载模块仍然处于加载状态,加载模块中的全局变量和静态局部变量就会一直存在。
3.只要模块的至少一个功能有活动实例,该模块就会一直加载。
4.给定函数实例的栈上变量将一直存在,直到该实例返回。
5.如果在某个函数中执行或已被该函数调用(直接或间接),那么该函数具有一个活动实例。
这些隐式存在性保证是直接的,尽管涉及隐式存在性保证的错误确实会发生。
但更有趣且麻烦的保证涉及堆内存:一个动态分配的数据结构会一直存在,直到被释放。需要解决的问题是同步释放该结构与同时对该结构进行并发访问。一种方法是使用显式保证,例如锁定。如果某个结构只有在持有特定锁的情况下才能被释放,那么持有该锁就保证了该结构的存在。
但这种保证依赖于锁本身的存在。一种直接的方法是将锁放在全局变量中以确保其存在,但全局锁定的缺点在于限制了可扩展性。一种随着数据结构规模增加而改进的可扩展性方法是在结构的每个元素中放置一个锁。不幸的是,在数据元素本身中放置要保护的数据元素的锁会受到微妙的竞争条件的影响,如清单7.10所示。
要了解这些条件中的一个,请考虑以下事件序列:
1.线程0调用delete(0),并到达第10行 上市,获取锁。
2.线程1同时调用delete(0),到达第10行 ,但锁旋转是因为线程0持有它。
清单7.11:基于锁的存在性保证的逐元素锁定 | |
1 int 2{ 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19} | 删除(键入密钥) intb; 结构元素*p;spinlock_t*sp; b=哈希函数(密钥);sp=&locktable[b]; p=hashtable[b]; 如果(p== NULL||p-> key!= key){ sp解锁(sp); return0;} hashtable[b]=NULL;spin_unlock(sp); kfree (p); return1; |
3.线程0执行第11行 – 14,从哈希表中删除元素,释放锁,然后释放元素。 | |
4. Thread 0继续执行,并分配s内存,获取它刚刚释放的精确内存块。 | |
5. Thread 0然后将此内存块初始化为其他类型的结构。 | |
6.Thread1的spin_lock()操作失败,因为它认为的p->锁不再是自旋锁。 由于没有存在保证,数据元素的身份可能在线程尝试获取该元素的锁时发生变化 ! 修复此示例的一种方法是使用一组哈希全局锁,这样每个哈希桶都有自己的锁,如清单7.11所示。 这种方法允许获得正确的锁(在线9 )在获得指向数据元素的指针之前(在第10行) 尽管这种方法对于包含在单一可分区数据结构中的元素非常有效,例如列表中所示的哈希表,但如果某个数据元素可以是多个哈希表或更复杂的数据结构如树或图的成员时,就会出现问题。这些问题不仅可以通过解决方案解决,而且这些方案也是基于锁的软件事务内存实现的基础[ST95,DSS06]。然而,第9章描述了更简单且更快的方法来提供存在性保证。 | |
7.5 锁定:英雄还是反派?
你要么死得像个英雄,要么活得够久,看到自己变成一个恶棍。
亚伦·埃克哈特饰演Harv ey Dent
正如现实生活中常有的情况,锁定可以是英雄也可以是反派,这取决于其使用方式以及所面临的问题。根据我的经验,编写整个应用程序的人对锁定感到满意,而编写并行库的人则不太满意,那些将现有顺序库并行化的人更是极其不满。以下部分将讨论这些观点差异的一些原因。
在编写整个应用程序(或整个内核)时,开发人员对设计拥有完全的控制权,包括同步设计。假设设计充分利用了分区技术,如第6章所述,锁定可以成为一种极其有效的同步机制,这一点通过生产级并行软件中大量使用锁定得到了证明。
然而,尽管这类软件通常基于锁定设计其大部分同步机制,但几乎总是会使用其他同步机制,包括特殊的计数算法(第5章)、数据所有权(第8章)、引用计数(第9.2节)、危险指针(第9.3节)、序列锁定(第9.4节)和读取复制更新(第9.5节)。此外,实践者还使用死锁检测工具[Cor06a]、锁获取/释放平衡工具[Cor0 4b]、缓存未命中分析工具[The11]、基于硬件计数器的性能分析工具[EGMDB11,Th12b]等。
如果设计得当,使用良好的同步机制组合和良好的工具,锁定对于应用程序和内核来说是相当有效的。
与应用程序和内核不同,库的设计者无法预知库将要交互的代码的锁定设计。事实上,这些代码可能多年后才会编写出来。因此,库设计者对同步设计的控制较少,必须更加谨慎。
死锁当然是特别令人关注的问题,第7.1.1节讨论的技术 需要应用。因此,一种流行的避免死锁的策略是确保库的锁是包围程序锁定层次结构的独立子树。然而,这可能比看起来要困难。
第7.1.1.2节讨论了其中一个并发症 ,即当库函数调用应用程序代码时,qsort()的比较函数参数就是一个例子。另一个复杂之处在于信号处理程序的交互。如果库函数在接收到信号后调用应用程序的信号处理程序,死锁就可能像库函数直接调用信号处理程序一样发生。最后一种复杂情况出现在那些可以在fork()/ exec()对之间使用的库函数中,例如由于系统()函数的使用。在这种情况下,如果你的库函数在fork()时持有锁,那么子进程将带着这个锁开始运行。因为释放锁的线程在父进程中运行而不是子进程中,如果子进程调用你的库函数,死锁就会发生。
以下策略可用于避免这些情况下的死锁问题:
1.不要使用回调或信号。
2.不要在回调函数或信号处理程序中获取锁。
3.让呼叫者控制同步。
4.参数化库API,将锁定委托给调用者。
5.明确避免回调死锁。
6.明确避免信号处理程序死锁。
7.避免调用fork()。
以下各节将讨论每种策略。
如果库函数避免回调,而整个应用程序避免信号,则该库函数获得的任何锁都是锁定层次结构树的叶子节点。这种安排避免了死锁,如第7.1.1.1节所述。 虽然该策略在应用中非常有效,但有些应用必须使用信号处理程序,有些库函数(如第7.1.1.2节讨论的qsort()函数 )需要回拨电话。
下一节中描述的策略通常可用于这些情况。
7.5.2.2避免锁定回调和信号处理程序
如果回调函数和信号处理程序都不获取锁,那么它们就不会陷入死锁循环,这使得简单的锁定层次结构可以再次将库函数视为锁定层次树上的叶子节点。这种策略对于大多数qsort的使用非常有效,因为其回调函数通常只是比较传入的两个值。同样,对于许多信号处理程序来说,这一策略也非常适用,尤其是在信号处理程序内部获取锁通常是不被鼓励的情况下[Gro01],10 但如果应用程序需要从信号处理程序操纵复杂的数据结构,则可能会失败。
即使必须操作复杂的数据结构,以下一些方法可以避免在信号处理器中获取锁:
1.使用基于非阻塞同步的简单数据结构,将在第14.2.1节中讨论。
2.如果数据结构过于复杂,无法合理使用非阻塞同步,请创建一个队列来允许非阻塞的入队操作。在信号处理程序中,不要直接操作复杂的数据结构,而是在队列中添加一个元素来描述所需的变化。然后,可以有一个单独的线程从队列中移除元素,并使用常规锁定执行所需的变化。有许多现成的并发队列实现[KLP12,Des09b,MS96]。
应强制执行此策略,并定期对回调和信号处理程序进行检查(最好能实现自动化)。在执行这些检查时,要警惕那些聪明的程序员,他们可能(不明智地)用原子操作创建了自制锁。
7.5.2.3呼叫者控制同步化
让调用者控制同步在库函数对数据结构的独立调用可见实例进行操作时效果非常好,每个实例都可以单独同步。例如,如果库函数对搜索树进行操作,并且应用程序需要大量独立的搜索树, 然后应用程序可以为每棵树关联一个锁。应用程序根据需要获取和释放锁,因此库无需了解并行性。相反,应用程序控制并行性,使得锁定能够非常有效地工作,正如第7.5.1节所讨论的那样。
但是,如果库实现了一个需要内部并发的数据结构,例如,哈希表或并行排序,则此策略将失败。在这种情况下,库必须绝对控制自己的同步。
7.5.2.4 参数化Library同步
这里的想法是在库的API中添加参数,以指定要获取哪些锁、如何获取和释放这些锁。这种策略允许应用程序通过指定要获取的锁(通过传递指向特定锁的指针)以及如何获取这些锁(绕过锁获取和释放函数的指针),来承担避免死锁的全局任务。同时,它还允许给定的库函数通过决定在哪里获取和释放锁来控制自身的并发性。
特别是,这种策略允许锁的获取和释放功能根据需要阻塞信号,而无需库代码关注哪些信号需要被哪个锁阻塞。这种策略所采用的关注点分离可以非常有效,但在某些情况下,后续章节中介绍的策略可能更为适用。
也就是说,必须非常谨慎地考虑将锁的显式指针传递给外部API,如第7.1.1.5节中所讨论的那样。 虽然这种做法有时是正确的,但你应该先看看其他的设计,这样对你自己有好处。
7.5.2.5明确避免回调死锁
本策略的基本规则在第7.1.1.2节中进行了讨论 :“在调用未知代码之前释放所有锁。”这通常是最佳方法,因为它允许应用程序忽略库的锁定层次结构:库仍然是应用程序整体锁定层次结构的一个叶节点或隔离子树。
在无法在调用未知代码之前释放所有锁的情况下,可以使用第7.1.1 .3节中描述的分层锁定层次结构。 可以很好地工作。例如,如果未知代码是一个信号处理程序,这意味着库函数块会在所有锁获取过程中发出信号,这可能既复杂又缓慢。因此,在信号处理程序(可能是不明智地)获取锁的情况下,下一节中的策略可能会有所帮助。
7.5.2.6明确避免信号处理器死锁
假设某个库函数已知会获取锁,但不会阻塞信号。进一步假设需要在信号处理程序内外调用该函数,并且不允许修改此库函数。当然,如果不采取特别措施,那么当信号到达而该库函数持有锁时,信号处理程序调用同一库函数时可能会发生死锁,因为该函数试图重新获取同一锁。
以下方法可以避免死锁:
1.如果应用程序在信号处理程序中调用库函数,则每次从信号处理程序外部调用库函数时,必须阻塞该信号。
2.如果应用程序在信号处理程序中获取的锁上调用库函数,则每次库函数在信号处理程序之外调用时都必须阻塞该信号。
这些规则可以通过使用类似于Linux内核的lockdep锁依赖检查器[Cor06a]的工具来强制执行。lockdep的一个优点是它不会被人类的直觉所欺骗[Ros11]。
7.5.2.7 fork()和exec()之间使用的库函数
如前所述,如果正在执行库函数的线程在其他线程调用fork()时持有锁,则父进程的内存将被复制以创建
子进程意味着这个锁将在子进程的上下文中被持有。释放该锁的线程运行在父进程中,但不在子进程中,这意味着虽然父进程中的锁副本会被释放,但子进程中的锁副本永远不会被释放。因此,子进程尝试调用同一库函数(从而获取同一锁)时,会导致死锁。
解决这个问题的一个实用且直接的方法是在进程仍为单线程时,通过fork()创建一个子进程,并让这个子进程保持单线程状态。随后,可以向这个初始子进程发送创建更多子进程的请求,它可以在其多线程父进程中安全地执行所需的fork()和exec()系统调用。
解决此问题的另一种不太实用和直接的方法是让库函数检查锁的所有者是否仍在运行,如果不是,则通过重新初始化并获取锁来“破坏”它。然而,这种方法有几个漏洞:
1.受该锁保护的数据结构可能处于某种中间状态,因此简单地破坏该锁可能会导致任意内存损坏。
2.如果子进程创建了其他线程,那么两个线程可能会同时破坏锁,结果是这两个线程都认为自己拥有锁。这可能会再次导致任意内存损坏。
pthread_atfork()函数旨在帮助处理这些情况。其核心思想是注册一个三元函数组,其中一个在fork()之前由父进程调用,另一个在fork()之后由父进程调用,最后一个则在fork()之后由子进程调用。这样可以在这三个节点进行适当的清理操作。
请注意,pthread_在fork()处理程序中的编码通常相当微妙。pthread_ atfork()最有效的情况是数据结构可以由子进程重新初始化。这可能是POSIX标准禁止在fork()和exec()之间使用任何非异步信号安全函数的原因之一,该规则排除了在此期间获取锁的可能性。
fork()/exec()includeposix_spawn()andio_uring_spawn()[Tri 22,Edg 22]的其他替代方案。
7.5.2.8并行库:讨论
无论使用何种策略,库的API描述都必须包括对该策略的清晰描述以及调用者应如何与该策略交互。简而言之,
使用锁定构建并行库是可能的,但是并不像构建一个并行应用程序那么容易。
随着低成本多核系统的普及,一个常见的任务是将原本仅设计用于单线程使用的现有库并行化。这种对并行性的忽视非常普遍,可能导致库API在并行编程方面存在严重缺陷。潜在的缺陷包括:
1.隐式禁止分区。
2.需要锁定的回调函数。
3.面向对象的意大利面代码。
以下章节讨论了这些缺陷以及锁定的后果。
7.5.3.1 禁止分区
假设您正在编写一个单线程的哈希表实现。很容易且快速地维护哈希表中项目总数的确切计数,也很容易且快速地在每次添加和删除操作时返回这个确切计数。那么为什么不这样做呢?
一个原因是精确计数器在多核系统上执行或扩展性能不佳,如第5章中所见。因此,哈希表的并行化实现将无法执行或扩展良好。
那么,对此可以做些什么呢?一种方法是使用第5章中的算法返回一个近似计数。另一种方法是完全放弃元素计数。
不管怎样,都需要检查哈希表的使用情况,以了解为什么添加和删除操作需要精确计数。以下是一些可能性:
1.确定何时调整哈希表大小。在这种情况下,近似计数应该相当有效。从最长链的长度触发调整操作也可能很有用,因为可以以非常分区的方式计算和维护最长链。
2.估算遍历整个哈希表所需的时间。在这种情况下,近似计数也很有效。
3.例如,为了诊断目的,检查在向哈希表中添加和从哈希表中移除项目时是否有丢失。这显然需要精确计数。然而,鉴于这种使用方式具有诊断性质,可能只需维护哈希链的长度,然后在锁定增删操作的同时偶尔汇总这些长度即可。
事实证明,现在有一些性能和可伸缩性对并行库API施加的约束已经有了坚实的理论基础[AGH+11a,AGH+ 11b,McK 11b]。任何设计并行库的人都需要密切关注这些约束。
虽然很容易将锁定归咎于并发不友好的API所造成的问题,但这样做并无帮助。另一方面,人们很少
选择同情那位在(比如说)1985年做出这一选择的不幸开发者。当时能够预见并行需求的开发者实属罕见且勇敢,而要真正开发出一个良好的并行友好型API,则需要更加罕见的才华与幸运的结合。
时代变迁,代码亦须随之更新。然而,如果某个流行库拥有大量用户,那么对API进行不兼容的改动就显得十分愚蠢。通常,增加一个并行友好型API来补充现有的主要使用的顺序API是最佳选择。
然而,人性就是这样,我们可以预期我们的不幸开发者更可能抱怨锁定,而不是抱怨他或她自己的糟糕(尽管可以理解的)API设计选择。
7.5.3.2容易发生死锁的回调
第7.1.1.2节, 7.1.1.3 ,和7.5.2 描述了不当使用回调可能导致锁定问题。这些部分还介绍了如何设计库函数以避免这些问题,但期望一个没有并行编程经验的1990年代程序员能够遵循这样的设计是不现实的。因此,尝试将现有单线程、依赖大量回调的库并行化的人员很可能会多次诅咒锁定的罪恶。
如果一个库的回调密集型使用量非常大,明智的做法是再次向该库添加一个并行友好的API,以便现有用户可以逐步转换他们的代码。或者,在这些情况下提倡使用事务内存。尽管关于事务内存的研究尚未定论,第17.2节讨论了其优缺点。需要注意的是,除非硬件事务内存实现提供了前向保证(这在第17.3节中有所讨论),否则硬件事务内存无法在此处发挥作用,而这种情况很少见。其他看似相当实用的替代方案(尽管宣传较少)包括第7.1.1.6节中讨论的方法。 和7.1.1.7 以及将在第8章和第9章中讨论的那些。
7.5.3.3面向对象的意大利面代码
面向对象编程在20世纪80年代或90年代成为主流,因此大量单线程的面向对象代码出现在生产环境中。尽管面向对象可以是一种有价值的软件技术,但对象的无序使用很容易导致面向对象的意大利面代码。在这样的代码中,控制权从一个对象跳到另一个对象,几乎随机地移动,使得代码难以理解,更不用说适应锁定层次结构了。
尽管许多人可能会认为无论如何都应该清理这样的代码,但说起来容易做起来难。如果你的任务是并行化这样一个庞然大物,你可以通过使用第7.1.1.6节中描述的技术来减少抱怨锁定的机会。 以及7.1.1.7, 以及将在第8章和第9章讨论的内容。这种情况似乎是启发事务内存的用例,因此也值得尝试。话虽如此,在选择同步机制时应考虑第3章中讨论的硬件习惯。毕竟,如果同步机制的开销比被保护的操作高出几个数量级,结果就不会很好了。
这引出了一个在这些情况下值得提出的问题:代码是否应该保持顺序?例如,也许应该在进程中引入并行性 级别,而不是线程级别。一般来说,如果一个任务被证明是极其困难的,那么值得花一些时间思考不仅如何完成这个特定的任务,而且如何解决手头的问题。
7.6 摘要
成就解锁。
未知的
锁定或许是使用最广泛且最有用的同步工具。然而,它在从一开始就设计到应用程序或库中时效果最佳。鉴于大量现有的单线程代码将来可能需要并行运行,因此锁定不应成为你并行编程工具箱中的唯一工具。接下来的几章将讨论其他工具,以及它们如何最好地与锁定和其他工具协同工作。
Chapter 8 Data Ownership
避免锁定带来的同步开销最简单的方法之一是将数据分配给各个线程(或在内核的情况下,分配给各个CPU),使得每条数据仅由一个线程访问和修改。有趣的是,数据所有权涵盖了“三大”并行设计技术:它在不同线程(或CPU)之间进行划分,批量处理所有本地操作,并且将同步操作的消除推向了逻辑上的极致。因此,数据所有权被广泛使用也就不足为奇了:即使是初学者也会几乎本能地使用它。事实上,它的使用如此频繁,以至于本章不会介绍任何新的例子,而是会回顾前几章的内容。
数据所有权有许多方法。第8.1节 在数据所有权方面,它代表了逻辑上的极端,每个进程都有自己的私有地址空间。第8.2节 着眼于另一极端,数据被共享,但不同的线程拥有不同的访问权限。第8.3节 描述了函数式传输,这是一种允许其他线程直接访问特定线程所拥有的数据的方法。第8.4节 描述指定线程如何可以被指定拥有指定函数和相关数据。第8.5节 讨论了通过将共享数据的算法转换为使用数据所有权来提高性能。最后,第8.6节 列出了几个将数据所有权作为一级公民的软件环境。
8.1 多个进程
阿曼的家就是他的城堡
英国古代法律
第4.1节介绍了以下示例:
这个例子并行运行了两个thecompute_it程序实例,作为不共享内存的独立进程。因此,给定进程中所有的数据都归该进程所有,几乎整个示例中的数据都是如此。这种方法几乎完全消除了同步开销。由此产生的极端简洁性和最优性能的结合显然非常吸引人。
同样,也可以用C语言和sh语言编写这种模式,如清单4.1和4.2所示。
值得重申的是,这些简单的并行形式绝不是为了逃避责任或偷懒,而是让代码运行得更快的简单而优雅的方法。它速度快,扩展性好,编程容易,维护方便,而且能完成任务。此外,采用这种方法(在适用的情况下)可以让开发人员有更多时间专注于其他事情,无论是将复杂的单线程优化应用于计算部分,还是将复杂的并行编程模式应用于不适用此方法的代码部分。有什么不喜欢的呢?
下一节讨论共享内存并行程序中数据所有权的使用。
8.2 部分数据所有权和pthre ads
多想你所拥有的,少想你所没有的。
马库斯·奥勒留·安提努斯
并发计数(参见第5章)大量使用数据所有权,但增加了一个转折。线程不允许修改其他线程拥有的数据,但是可以读取它。简而言之,共享内存的使用允许更细致的所有权和访问权限概念。
例如,考虑第83页清单5.4中所示的每线程统计计数器实现。这里,inc_ count()更新仅对应线程的计数器实例,而read_count()访问但不修改所有线程的计数器实例。
部分数据所有权在Linux内核中也很常见。例如,某个CPU可能只有在中断禁用的情况下才能读取其自身的某些变量集;而另一个CPU则可能只有在持有相应CPU锁时才能读取第一个CPU的同一组变量。然后,如果该CPU既禁用了中断又持有其CPU锁,它就可以更新自己的这组变量。这种安排可以视为一种读写锁,允许每个CPU以极低开销访问其自身的变量集。这一主题有许多变体。
就其本身而言,纯数据所有权也是常见且有用的,例如第141页第6.4.3节开始讨论的每线程内存分配器缓存。在这个算法中,每个线程的缓存完全属于该线程。
8.3 产品运输
如果山不来到穆罕默德,那么穆罕默德必须去山。
弗朗西斯·培根的论文
上一节介绍了一种数据所有者功能的弱形式,其中线程可以访问其他线程的数据。这可以理解为将数据带到需要它的函数中。另一种方法是将函数发送给数据。
第5.4.3节从第101页开始说明了这种方法,特别是第103页清单5.18中的theflush_local_count_sig()和flush_local_count()函数。
flush_ local_ count_ sig()函数是一个信号处理函数,充当已发送的函数。Thepthread_kill()函数inflush_local_count()发送信号——即发送该函数——然后等待已发送的函数执行。此已发送的函数没有额外的复杂性,即需要与任何同时执行的add_ count()或sub_ count()函数交互(参见第104页的列表5.19和第1 05页的列表5.20)。
8.4 指定螺纹
让一个人从事他最熟悉的职业。
西塞罗
前面的部分描述了让每个线程保留自己的数据副本或部分数据的方法。相比之下,本节介绍了一种功能分解的方法,即指定一个特殊线程拥有完成其任务所需的数据的权利。第5. 2.4节中描述的最终一致计数器实现提供了一个示例。该实现有一个指定的线程运行最终()函数,如清单5.5第17至32行所示。这个最终()线程会定期将每个线程的计数拉入全局计数器,因此,正如名称所言,对全局计数器的访问最终会收敛到实际值。
8.5 私有化
当然, 一个人和另一个人之间的区别
抓住了他和他 真正拥有。
赛珍珠
提高共享内存并行程序性能和可扩展性的一种方法是将其转换为由特定线程拥有的私有数据,从而将共享数据转换为私有数据。
一个极好的例子是第6.1.1节中快速问答题答案中的展示n,该方法通过私有化来解决“哲学家就餐”问题,其性能和可扩展性远超标准教科书的解决方案。原问题设定为五位哲学家围坐在桌旁,每对相邻的哲学家之间有一把叉子,最多允许两位哲学家同时进食。
我们可以通过提供额外的5个叉来轻而易举地将这个问题私有化,这样每个哲学家都有自己的私人叉对。这使得所有五个哲学家可以同时吃饭,并且也大大减少了某些类型疾病的传播。
在其他情况下,私有化会带来成本。例如,考虑第90页清单5.7中所示的简单限位计数器。这是一个算法的例子,在该算法中,线程可以读取彼此的数据,但只能更新自己的数据。快速回顾算法可以发现,唯一的跨线程访问是在read_ count()中的求和循环中进行的。如果消除这个循环,我们将转向更高效的纯数据所有权,但这会导致结果fromread_count()的准确性降低。
部分私有化也是可行的,但需要一些同步要求,不过比完全共享的情况要少。第4.3.4.4节探讨了一些部分私有化的可能性。第9章将通过提供安全地将公共数据结构私有化的方法,引入数据所有权的时间组件。
简而言之,私有化是并行程序员工具箱中的一个强大工具,但必须谨慎使用。就像其他同步原语一样,它有可能增加复杂性,同时降低性能和可扩展性。
8.6 数据所有权的其他用途
如果我们有能力接受,一切属于我们的东西都会来到我们身边。
泰戈尔
数据所有权在能够对数据进行分区的情况下工作最好,这样就几乎不需要跨线程访问或更新。幸运的是,这种情况相当常见,并且在各种并行编程环境中都存在。
数据所有权的示例包括:
1.所有消息传递环境,如MPI[ MPI08]和BOINC[ Uni08a]。
2. Map-reduce[ Jac08]。
3.客户端-服务器系统,包括RPC、web服务和几乎所有具有后端数据库服务器的系统。
4.无共享数据库系统。
5.具有独立进程地址空间的叉接系统。
6.基于过程的并行主义,如Erlang语言。
7.私有变量,例如,在线程环境中使用的C语言栈上的自动变量。
8.许多并行线性代数算法,特别是那些适合于GPGPU的算法。
9.适用于网络操作的系统内核,每个连接(也称为流[DKS89,Zha89,McK90])都被分配给一个特定的线程。最近的一个例子是IX操作系统[BPP+16]。IX确实有一些共享的数据结构,这些数据结构使用同步机制,具体描述见第9.5节。
数据所有权或许是现存最被低估的同步机制。若使用得当,它能带来无与伦比的简洁性、性能和可扩展性。或许正是这种简洁性让它失去了应有的尊重。希望对数据所有权的微妙之处及其强大功能有更多认识,能够赢得更多的尊重,更不用说带来更高的性能和可扩展性,同时降低复杂度。
Chapter 9 Deferred Processing
苍天不负有心人。
紫罗兰
推迟工作的策略可以追溯到有记录的历史出现之前。它偶尔会被嘲笑为拖延症,甚至纯粹是懒惰。然而,在过去的几十年里,工人们已经认识到这种策略在简化和简化并行算法方面的价值[KL80,Mas92]。信不信由你,并行编程中的“懒惰”往往优于和优于勤奋!这些性能和可伸缩性的好处源于延迟工作可以削弱同步原语,从而减少同步开销。
那些愿意并能够阅读和理解这一章的人将会揭开许多谜团,包括:
2.一个并发引用计数器,它不仅避免了这种陷阱,而且还避免了昂贵的原子读-修改-写访问,此外还避免了对被遍历的数据结构的任何类型的写入。
4.一种同步原语,允许使用完全相同的机器指令序列对并发更新的链接数据结构进行遍历,这些机器指令序列可能用于遍历相同数据结构的顺序实现。
6.如何在各种延迟处理原语中进行选择。
工作延迟的一般方法包括参考计数(第9.2节)、危险指针(第9.3节)、序列锁定(第9.4节)和RCU(第9.5节)。最后,第9.6节描述了如何在本章中涵盖的工作延迟计划中进行选择,第9.7节讨论了更新。但首先,第9.1节将介绍一个示例算法,用于比较和对比这些方法。
9.1 运行示例
一盎司的应用程序值得大量的抽象。
布克T.华盛顿
本章将使用一个简化的数据包路由算法来演示这些方法的价值,并允许对它们进行比较。在操作系统内核中使用路由算法,将每个传出的TCP/IP数据包传递到适当的网络接口。这个特殊的算法是在BSD UNIX [Jac88]中使用的20世纪80年代经典的数据包序列优化算法的简化版本,由一个简单的链表组成。现代路由算法使用更复杂的数据结构,然而,一个简单的算法将帮助在一个简单的设置中突出并行性特定的问题。
我们进一步简化了算法,将搜索键从由源和目标IP地址和端口组成的四倍简化到一个简单的整数。查找和返回的值也将是一个简单的整数,因此数据结构如图9.1所示,它将地址为42的数据包定向到接口1,地址为56到接口3,地址为17到接口7。这个列表通常会被频繁地搜索,并且很少被更新。在第三章中,我们了解到,逃避不方便的物理定律,如有限光速和物质的原子性质的最好方法是要么分割数据,要么依赖阅读——主要是共享。本章将读共享技术应用于BSD包路由。
清单9.1(route_seq.c)显示了一个简单的单线程实现,它对应于图9.1。第1-5行定义route_entry结构,第6行定义route_list标题。第8-20行定义路由查找(),它按顺序搜索loste(),返回相应的->iface,如果没有路由条目,则返回ULONG_MAX。第22-33行定义了route_add(),它分配一个routh_entry结构,初始化它,并将其添加到列表中,在内存分配失败时返回-ENOMEM。最后,第35-47行定义了route_del(),如果指定的routh_entry结构存在,则删除并释放它,否则返回-ENOENT。
这个单线程实现可以作为本章中各种并发实现的原型,也可以作为对理想的可伸缩性和性能的估计。
清单9.1:BSD前路由表 |
1结构路由_entry{ 2 3 6 CDS_LIST_HEAD(route_list);7 10 11 13 14 15 16 18 19 21 22introute_add(无符号长addr,无符号长接口)23{ 24 26 27 28 29 30 31 32 34 35 int route_del(无符号长addr)36 { 37 39 40 41 42 43 45 47 } |
9.2 参考计数
我永远也不会让你走!
未知的
引用计数会跟踪对给定对象的引用数,以防止过早地释放该对象。因此,它有着悠久而光荣的使用历史,至少可以追溯到20世纪60年代早期的魏曾鲍姆的一篇论文[魏63]。魏森鲍姆讨论参考计数,就好像它已经众所周知了,所以它可能可以追溯到20世纪50年代,甚至40年代。也许更进一步,鉴于人们修理大型危险机器长期以来一直使用通过挂锁实现的机械参考计数技术。在进入机器之前,每个工人都将一个挂锁锁在机器的开/关开关上,从而防止工人在机器内部时通电。因此,参考计数是并发实现预bsd路由的一个极好的历史悠久的候选对象。
为此,清单9.2显示了数据结构和route_查找()函数,清单9.3显示了route_add()和route_del()函数(均在route_refcnt.c上)。由于这些算法与清单9.1中所示的顺序算法非常相似,所以我们将只讨论这些差异。
从清单9.2开始,第2行添加实际的引用计数器,第6行添加->re_freed使用后检查字段,第9行添加用于同步并发更新的路由锁,第11-15行添加re_free(),设置->re_freed,使route_>re_freed()检查使用后错误。在route_ lookup()本身中,第29-30行释放先前元素的参考计数,如果计数为零则释放它,行34-42获得新元素的引用,第35和36行执行自由使用后检查。
在清单9.3中,第11、15、24、32和39行引入了锁定以同步并发更新。第13行初始化->re_freed自由检查后使用字段,最后,如果引用计数的新值为零,则第33-34行调用re_free()。
图9.2显示了只读工作负载上的引用计数的性能和可伸缩性,其中10元素列表运行在一个8个28核超线程2.1 GHz x86系统上,共有448个硬件线程(hps .2019。12.02a/lscpu.hps).“理想”跟踪是通过运行清单9.1中所示的顺序代码生成的,其工作原理仅仅是因为这是一个只读工作负载。引用计数的性能非常糟糕,其可伸缩性甚至更糟糕,因为“refcnt”跟踪与x轴难以区分。考虑到第3章,这并不奇怪:参考计数的获取和发布将频繁的共享内存写入添加到其他只读工作负载中,从而招致了严重的惩罚
清单9.2:参考计数的预bsd路由表查找(BUGGY!!!) |
1结构路由_entry{ 2 3 4 6 8结构route_entry route_list; 9 DEFINE_SPINLOCK(线路锁);10 11静态空白re_free(结构route_entry *rep)12 { 13 14 16 17无符号长route_查找(无符号长addr)18 { 19 20 21 22 23 25重试: 26 27 28 30 31 32 34 35 36 37 38 39 40 42 43 45 46 47 48 |
但情况会变得更糟。
运行多个更新线程重复调用route_add()和del(),将会很快遇到清单9.2第36行的中止()语句,该语句表示空闲后使用错误。这反过来又意味着引用计数不仅严重降低了可伸缩性和性能,而且还未能提供所需的保护。
导致无后使用错误的事件序列如下,图9.1所示:
1.线程A查找地址42,到达清单9.2中的route_roockup()的第32行。换句话说,线程A有一个指向第一个元素的指针,但尚未获得对它的引用。
2.线程B调用清单9.3中的route_del()来删除地址42的路由条目。它成功完成,因为这个条目的->re_refcnt字段等于值1,所以它调用re_free()来设置->re_freed字段并释放该条目。
3.线程A继续执行rouch_查找()。它的代表指针是非null的,但是第35行看到它的->re_freed字段是非零的,所以第36行调用中止()。
问题是引用计数位于要保护的对象中,但这意味着在获取引用计数本身的瞬间没有保护!这是Gamsa等人指出的锁定问题的参考计数对应物[GKAS99]。我们可以想象使用全局锁或引用计数来保护每个路由条目的引用计数获取,但这将导致严重的争用问题。尽管存在的算法允许在并发环境中进行安全的引用计数获取[Val95],但它们不仅非常复杂和容易出错[MS95],而且还提供了糟糕的性能和可伸缩性[HMBW07]。
简而言之,并发性绝对降低了参考计数的有效性!当然,与其他同步原语一样,引用计数也有众所周知的易用性缺点。这一方面可能导致内存泄漏,另一方面也可能导致内存过早释放。
这是一个引用计数陷阱,等待着粗心的并发代码开发人员,在第201页提到。
为了成功地解决问题,以完全不同的方式来看待问题是有帮助的。为此,下一节将描述什么可以被认为是一个由内到外的引用计数,它提供了良好的性能和可伸缩性。
9.3 危险指针
如果有疑问,就把它翻过来。
Zara木匠
避免并发引用计数问题的一种方法是由内到外实现引用计数器,也就是说,不是增加存储在数据元素中的整数,而是在每个cpu(或每个线程)列表中存储指向该数据元素的指针。这些列表中的每个元素都称为危险指针[Mic04a]。2然后,通过计算引用该元素的危险指针的数量,可以获得给定数据元素的“虚拟引用计数器”的值。因此,如果该元素无法访问,并且不再有任何引用它的危险指针,则可以安全地释放该元素。
当然,这意味着必须非常小心地进行危险指针获取,以避免并发删除的破坏性竞争。清单9.4显示了一个实现,它显示了第1-16行的hp_try_record(),第18-27行的hp_record(),以及第29-33行的hp_clear()(hazptr.h)。
第16行上的hp_try_record()宏只是ht的一个铸造包装器
通过一个更新,它将返回一个特殊的HAZPTR_POISON令牌。
第6行读取指向要被保护的对象的指针。如果第8行发现此指针是NULL或特殊的HAZPTR_POISON已删除对象标记,它将返回指针的值以通知调用者失败。否则,第9行将指针存储在指定的危险指针中,第10行通过在第1行11中重新加载原始指针强制该存储的完全排序。(有关内存排序的更多信息,请参见第15章。)如果原始指针的值没有改变,那么危险指针保护指向对象,在这种情况下,第12行返回一个指向该对象的指针,这也向调用者指示成功。否则,如果指针在两个READ_ONCE()调用之间发生更改,则第13行表示失败。
hp_record()函数非常简单:它反复调用hp_try_记录(),直到返回值不是HAZPTR_POISON。
hp_clear()函数更简单,smp_mb()强制调用者使用受危险指针保护的对象和设置为NULL之间进行完全排序。
一旦将受危险指针保护的对象从其链接的数据结构中删除,因此未来的危险指针阅读器现在无法访问它,它将被传递给
清单9.5:危险指针扫描和释放 |
比较(无效,无效) 3 5 8 9 10 11 12 14 16 17 20 21 22 23 25 26 29 31 33 35 37 38 39 41 45 47 48 void hazptr_free_later(hazptr_head_t *n)49 { 50 52 53 56 } |
清单9.6:BSD前路由表查找 |
1结构路由_entry{ 2 3 4 6 8结构route_entry route_list; 9 DEFINE_SPINLOCK(路由器); 10危险_指针__线程*my_hazptr;11 12无符号长route_查找(无符号长addr)13 { 14 15 16 18重试: 19 20 22 23 24 25 26 29 30 |
hazptr_free_later(),显示在清单9.5(hazptr_free_later().c)的第48-56行。第50行和第51行将对象排在每个线程列表rlist上,第52行以rcount对对象进行计数。如果第53行看到现在有足够多的对象排队,第54行调用hazptr_scan()试图释放其中一些。
hazptr_scan()函数显示在列表的第6-46行。该函数依赖于固定的最大线程数(NR_THREADS)和固定的最大危险指针数(K),这允许使用固定大小的危险指针数组。因为任何线程都可能需要扫描危险指针,所以每个线程都维护自己的数组,该数组由每个线程的变量gplist引用。如果第14行确定该线程尚未分配其gplist,则第15-18行执行该分配。第20行上的存储屏障确保所有线程在第22-28行扫描所有危险指针之前看到该线程删除的所有对象,将非空指针累积到plist数组并以psize计数它们。第29行上的存储屏障确保在释放任何对象之前发生。第30行,然后对这个数组进行排序,以便在下面使用二进制搜索。
第31行和第32行将从该线程的待释放对象列表中删除所有元素,将它们放在本地tmplist上,第33行将将计数归零。每个对象都通过循环跨越线34-45来处理每个要被释放的对象。第35和36行从tmtist中移除第一个对象,如果第37和38行确定存在保护该对象的危险指针,第39-41行将其放回rlist上。否则,第43行将释放该对象。
Pre-BSD路由示例可以使用清单9.6中所示的数据结构和路由查找(),清单9.7中的危险指针可以使用路由_add()和route_del()(route_hazptr.c)。与参考计数一样,危险指针
实现与第203页上的清单9.1中所示的顺序算法非常相似,所以我们将只讨论差异。
从清单9.6开始,第2行显示了用于使用危险指针队列的->hh字段,第6行显示了用于检测使用后错误的->re_freed字段,第21行调用hp_try_record()试图获取危险指针。如果返回值为NULL,则第23行向调用者返回一个未找到的指示。如果对hp_try_record()的调用与删除赛跑,第25行分支回到第18行的重试,从一开始就重新遍历列表。当找到所需的元素时,do-while循环将失效,但如果该元素已经被释放,则第29行终止程序。否则,元素的->iface字段将返回给调用者。
请注意,第21行调用了hp_try_record(),而不是更容易使用的hp_记录(),即在hp_try_record()失败时重新启动完整的搜索。而这样的重新启动是正确性所绝对必需的。要了解这一点,请考虑一个包含元素A、B和C的受危险、指针保护的链表,它受到以下事件序列的影响:
1.线程0存储指向元素B的危险指针(可能已从元素A遍历到元素B)。
2.线程1从列表中删除元素B,它将从元素B到元素C的指针设置为特殊的HAZPTR_POISON值,以标记删除。因为线程0有一个指向元素B的危险指针,所以还无法释放它。
3.线程1将从列表中删除元素C。因为没有引用元素C的危险指针,所以会立即释放它。
4.线程0试图获取一个指向现在已删除的元素B的后继者的危险指针,但是hp_try_record()返回HAZPTR_POISON值,迫使调用者从列表的开始重新启动其遍历。
这是一件非常好的事情,因为B的后继任者是现在释放的元素C,这意味着线程0的后续访问可能导致任意可怕的内存损坏,特别是如果元素C的内存已经被重新分配到其他目的。因此,危险指针读取器通常必须在面对并发删除时重新启动完整的遍历。通常,重新启动必须返回到某个全局(因此是不朽的)指针,但如果该位置保证仍然活动,有时可以在某些中间位置重新启动,例如,由于当前线程持有一个锁、一个引用计数等。
因为算法使用危险指针可能在任何步骤的遍历链接数据结构,这样的算法通常必须注意避免做任何更改数据结构,直到他们获得所有危险指针所需的更新问题。
这些危险指针限制给阅读器带来了巨大的好处,这是因为危险指针存储在每个CPU或线程的本地,这反过来允许在没有任何遍历而不写入被遍历的数据结构的情况下执行遍历。参见第112页上的图5.8,危险指针使CPU缓存能够实现
清单9.7:BSD前路由表添加/删除 | ||
1 | int | route_add(无符号长addr,无符号长接口) |
2 | { | |
3 | 结构路由entry*rep; | |
4 | ||
5 | 代表=malloc(代表); | |
6 | 如果(!代表) | |
7 | 返回-ENOMEM; | |
8 | rep->addr = addr; | |
9 | rep->iface =接口; | |
10 | 代表->re_freed=0; | |
11 | spin_lock(&路由器锁); | |
12 | rep->re_next=routh_list.re_next; | |
13 | route_list .re_next =代表; | |
14 | spin_oluch(&路由器锁); | |
15 | 返回0; | |
16 | } | |
17 | ||
18 | int | route_del(无符号长addr) |
19 | { | |
20 | 结构路由entry*rep; | |
21 | 结构路径输入repp; | |
22 | ||
23 | spin_lock(&路由器锁); | |
24 | repp = &route_list .re_next; | |
25 | 为(;;){ | |
26 | rep = *repp; | |
27 | 如果(代表== NULL) | |
28 | 破碎 | |
29 | 如果(rep->addr == addr){ | |
30 | *repp =代表->re_next; | |
31 | ||
32 | ||
33 | hazptr_free_later(&rep->hh); | |
34 | 返回0; | |
35 | } | |
36 | 爬行动物= &rep->re_next; | |
37 | } | |
38 | spin_oluch(&路由器锁); | |
39 | 返回-ENOENT; | |
40 | } |
执行资源复制,这反过来又允许削弱并行访问-控制机制,从而提高性能和可伸缩性。
重新启动危险指针遍历的另一个优点是减少最小内存占用:任何当前未被危险指针引用的对象都可以立即释放。相比之下,第9.5节将讨论一种机制,即避免读端重试(并最小化读端开销),但这可能会导致更大的内存占用。
route_add()和route_del()函数如清单9.7所示。第10行初始化->re_freed,第31行毒害新删除的对象的->re_next字段,第33行将该对象传递给hazptr_free_later()函数,一旦安全完成,hazptr_free_later()函数将释放该对象。自旋锁的工作原理与清单9.3中的相同。
与其他机制的实际性能比较可以在第10章和其他出版物中找到[HMBW07,McK13,Mic04a]。
而危险指针是在第201页中提到的并发参考计数器。下一节试图通过使用序列锁来改进危险指针,这就避免了读侧写和每个对象的内存障碍。
9.4 顺序锁
这就像重新开始一样。
约翰列侬
已发布的序列锁定记录[Eas71,Lam77]可以追溯到阅读-写入器锁定,但序列锁定仍然是相对模糊的。序列锁在Linux内核中用于读取——主要是读取器必须查看的处于一致状态的数据。然而,与读者-作者锁定不同,读者并不排除作者。相反,与危险指针一样,序列锁迫使读取器如果从并发写入器检测到活动,则重试操作。从图9.4中可以看出,使用序列锁来设计代码是很重要的,这样读者就很少需要重试了。
序列锁定的关键组成部分是序列号,在没有更新的情况下,它是偶数,在有更新的情况下,它是奇数。然后,读者可以在每次访问前后快照该值。如果其中一个快照有
1做{ | |
2 | seq = read_seqbegin(&test_seqlock); |
3 | /*读取端访问。*/ |
4}而 | (read_seqretry(&test_seqlock,seq)); |
1 write_seqlock (& test_seqlock ); | |
2 | /*更新*/ |
3 | write_sequnlock (& test_seqlock ); |
一个奇数值,或者如果两个快照不同,则有一个并发更新,读卡器必须丢弃访问的结果,然后重试它。因此,当访问受序列锁保护的数据时,读者会使用清单9.8中所示的read_seqbegin()和read_seqretry()函数。写入器必须在每次更新之前和之后增加该值,并且在给定的时间内只允许增加一个写入器。因此,作者在更新受序列锁保护的数据时,会使用清单9.9中所示的write_seqlock()和write_sequnlock()函数。
因此,受序列锁保护的数据可以有任意大量的并发阅读器,但一次只有一个写入器。序列锁定用于Linux内核中以保护用于计时的校准量。它还用于路径名遍历,以检测并发重命名操作。
在清单9.10(seqlock.h)中显示了一个序列锁的简单实现。seqlock_t数据结构显示在第1-4行,其中包含序列号和一个用于序列化写入器的锁。第6-10行显示了seqlock_init(),顾名思义,它初始化了一个seqlock_t。
第12-19行显示了read_seqbegin(),它开始了一个序列锁读取侧临界部分。第16行获取序列计数器的快照,第17行在调用者的关键部分之前命令此快照操作。最后,第18行返回快照的值(清除最小的位),调用者将其传递给稍后对read_seqretry()的调用。
清单9.10:序列锁定实现 |
1类型def结构{ 2 5 6静态内联void seqlock_init(seqlock_t *slp)7 { 8 11 12个静态内联无符号长read_seqbegin(seqlock_t *slp)13 { 14 17 20 21个静态内联int read_seqretry (seqlock_t *slp, 22 24 27 30 31静态内联空隙write_seqlock(seqlock_t *slp)32 { 33 37 38静态内联空隙write_sequnlock(seqlock_t *slp)39 { |
清单9.11:序列锁定的bsd前路由表查找(BUGGY!!!) |
1结构路由_entry{ 2 3 4 5 8 DEFINE_SEQ_LOCK(sl);9 10无符号长route_查找(无符号长addr)11 { 12 13 14 15 17重试: 19 20 21 22 24 25 27 30 31 33 34 |
第21-29行显示read_seqretry(),如果自对应调用read_seqbegin()以来至少有一个作者,则返回true。第26行在第27行获取序列计数器的新快照之前,命令调用者之前的临界部分。第28行检查序列计数器是否已经发生了变化,换句话说,是否已经至少有一个写入器,如果有,则返回true。
第31-36行显示了write_seqlock(),它只是获得锁,增加序列号,并执行一个内存障碍,以确保该增量在调用者的关键部分之前排序。第38-43行显示了write_sequnlock(),它执行一个内存障碍,以确保呼叫者的临界部分在第41行上的序列号增加之前被排序,然后释放锁。
清单9.12:序列锁定的bsd前路由表添加/删除(BUGGY!!!) | ||
1 | int | route_add(无符号长addr,无符号长接口) |
2 | { | |
3 | 结构路由entry*rep; | |
4 | ||
5 | 代表=malloc(代表); | |
6 | 如果(!代表) | |
7 | 返回-ENOMEM; | |
8 | rep->addr = addr; | |
9 | rep->iface =接口; | |
10 | ||
11 | write_seqlock(&sl); | |
12 | rep->re_next=routh_list.re_next; | |
13 | route_list .re_next =代表; | |
14 | write_sequnlock(&sl); | |
15 | 返回0; | |
16 | } | |
17 | ||
18 | int | route_del(无符号长addr) |
19 | { | |
20 | 结构路由entry*rep; | |
21 | 结构路径输入repp; | |
22 | ||
23 | ||
24 | repp = &route_list .re_next; | |
25 | 为(;;){ | |
26 | rep = *repp; | |
27 | 如果(代表== NULL) | |
28 | 破碎 | |
29 | 如果(rep->addr == addr){ | |
30 | *repp =代表->re_next; | |
31 | write_sequnlock(&sl); | |
32 | ||
33 | 代表->re_freed=1; | |
34 | 免费(代表); | |
35 | 返回0; | |
36 | } | |
37 | 爬行动物= &rep->re_next; | |
38 | } | |
39 | ||
40 | 返回-ENOENT; | |
41 | } |
那么,当序列锁定应用到预bsd的路由表时,会发生什么呢?清单9.11显示了数据结构和route_查找(),而Listing9.12显示了route_add()和route_del()(route_seqlock.c)。这个实现再次类似于前面部分中的对应实现,因此只会突出显示差异。
在清单9.11中,第5行添加了->re_freed,在第29行检查。第8行添加了一个序列锁,rouke()在第18、23和32行使用,第24和33行分支回第17行的重试标签。其效果是重试与更新同时运行的任何查找。
在清单9.12中,第11、14、23、31和39行获取并释放序列锁,而第10和33行handes->re_freed。因此,这个实现非常简单。
它在只读工作负载上也表现得更好,如图9.5所示,尽管它的性能仍然远非理想。更糟糕的是,它遭受了免费使用后使用的失败。问题是,在read_seqretry()有机会警告并发更新之前,读者可能会因为访问已经释放的结构而遇到分段冲突。
如第201页所暗示的,序列锁的读侧和写侧临界部分都可以被视为事务,因此序列锁定可以被视为事务内存的有限形式,这将在第17.2节中讨论。序列锁定的限制是:(1)序列锁定限制更新,(2)序列锁定不允许遍历指向更新器可能释放的对象的指针。这些限制当然可以通过事务性内存来克服,但也可以通过结合其他同步原语与序列锁定来克服。
序列锁允许作者推迟阅读时间,但反之亦然。这可能会导致不公平,甚至缺乏作者繁重的工作量。3另一方面,在没有作者的情况下,序列锁阅读器相当快,规模线性。只有人类想要两个世界的最好一面:快速阅读而没有阅读端失败的可能性,更不用说饥饿了。此外,用指针来克服序列锁定的限制也会很好。下一节将介绍一个完全具有这些属性的同步机制。
9.5 读取复制更新(RCU)
“免费”是一个非常好的价格!
汤姆彼得森
前面几节中讨论的所有机制都使用了许多方法中的一种来推迟特定的行动,直到它们能够安全地执行。第9.2节中讨论的引用计数器使用显式计数器来推迟可能干扰读取器的操作,从而导致读取端争用,从而导致较差的可伸缩性。第9.3节所涵盖的危险指针以每个线程列表的形式使用隐式计数器的指针。这避免了读取端争用,但要求读取器执行存储和条件分支,以及读取端原语中的全内存障碍,或者更新端原语中实时不友好的处理器间中断。4第9.4节中提出的序列锁也避免了读端争用,但不保护指针遍历,并且像危险指针一样,需要读端原语中的全内存障碍,或者需要更新端原语中的处理器间中断。这些计划的缺点提出了这样一个问题:是否有可能做得更好。
本节介绍读取副本更新(RCU),它提供了一个API,允许阅读器与源代码中的区域相关联,而不是对频繁更新的共享数据进行昂贵的更新。本节的其余部分将从许多不同的角度来研究RCU。第9.5.1provides节RCU的经典介绍,第9.5.2covers节基本RCU概念,第9.5.3presents节Linux内核API,第9.5.4introduces节一些常见的RCU用例,最后第9.5.5covers节最近与RCU相关的工作。
虽然RCU以微妙和困难而闻名,但如果使用得当,它是相当简单的。事实上,正如巴特勒·兰普森一样的权威将其归类为容易并发[AH22,第3章]。
前面几节中讨论的方法为预bsdd的路由表提供了良好的可伸缩性,但显然不是理想的性能。因此,在精神“只有那些走得太远知道你能走多远”,5我们将一路,研究算法的并发读者可能执行完全相同的汇编语言指令序列单线程查找,尽管并发更新的存在。当然,这个值得称赞的目标可能会引发严重的可实现性问题,但如果我们不尝试,我们甚至不可能成功!
9.5.1.1最小限度的插入和删除
为了最小化对可实现性的关注,我们关注一个最小的数据结构,它由一个为NULL或引用单个结构的单一全局指针组成。虽然这种数据结构可能很小,但在生产中被大量使用[RH18]。图9.6显示了一种经典的插入方法,它显示了随着时间从上到下推进的四种状态。第一行显示了初始状态,gptr等于NULL。在第二行中,我们分配了一个未初始化的结构,如问号所示。在第三行中,我们已经初始化了该结构。最后,在第四行也是最后一行中,我们更新了gptr,以引用新分配和初始化的元素。
我们可能希望这个对gptr的赋值可以使用一个简单的c语言赋值语句。不幸的是,Section4.3.4.1dashes抱有这些希望。因此,更新程序不能使用简单的c语言赋值,而是必须使用如图所示的smp_store_release(),或者,如我们可以看到的,使用rcu_assign_pointer()。
类似地,人们可能希望读者可以使用一个c语言赋值来获取gptr的值,并保证获得NULL的旧值或获取新安装的指针,但无论如何都能看到有效的结果。不幸的是,第4.3.4.1节也粉碎了这些希望。为了获得这个保证,读者必须使用READ_ONCE(),或者,正如我们将看到的,rcu_dereference()。然而,在大多数现代计算机系统上,每个读侧原语都可以用一个加载指令来实现,这正是通常在单线程代码中使用的指令。
从读者的角度回顾图9.6,在前三个状态下,所有读者都会看到gptr的值为NULL。在进入第四个状态时,一些读者可能会看到gptr的值仍然为NULL,而另一些读者可能会看到它引用了新插入的元素,但在一段时间后,所有读者都会看到这个新元素。在任何时候,所有的读者都会看到gptr包含一个有效的指针。因此,确实可以在链接的数据结构中添加新的数据,同时允许并发读取器执行通常在单线程代码中使用的相同的机器指令序列。这种免费的并发读取方法提供了出色的性能和可伸缩性,而且也非常适合实时使用。
插入当然非常有用,但迟早也需要删除数据。从图9.7中可以看出,第一步很简单。再次吸取了第4.3.4.1节的经验教训,smp_store_release()被用来记空指针,从而从图中的第一行移动到第二行。在这一点上,现有的读者看到了->addr为42和->iface为1的旧结构,但新的读者将看到一个
NULL指针,即并发阅读器可能对状态有分歧,如图中的“两个版本”所示。
我们只需等待所有已存在的阅读器完成,就可以回到一个单一的版本,如第3行所示。在这一点上,所有已存在的阅读器都完成了,以后的阅读器没有指向旧数据项的路径,因此不能再有任何阅读器引用它。因此,它可以被安全地释放,如第4行所示。
因此,如果有一种等待已有读取器完成的方法,就可以向链接数据结构和链接数据结构中删除数据,尽管读取器执行适合单线程执行的相同机器指令序列。所以也许一路走并不是太远!
但是我们怎样才能知道所有的读者实际上都完成了呢?这个问题是第9.5.1.3节的主题。但是,首先,下一节定义了RCU的核心API。
9.5.1.2核心RCU API
完整的linux内核API非常广泛,有超过100个API成员。但是,本节将限制为6个核心RCU API成员,这就足够了
表9.1:核心RCU API
原始的 | 目的: | |
读者 | | |
rcu_read_unlock() | 结束RCU读侧临界部分。 | |
rcu_dereference() | 安全地加载一个受RCU保护的指针。 | |
更新程序 | synchronize_rcu() | 等待所有已存在的RCU读取端关键部分完成。 |
call_rcu() | 在所有已存在的RCU读取侧临界部分完成后,调用指定的函数。 | |
rcu_assign_pointer() | 安全地更新一个受RCU保护的指针。 |
接下来的部分将介绍RCU并介绍其基本原理。完整的API内容见第9.5.3节。
读者使用了核心api中的三个成员。rcu_read_lock()和rcu_read_unlock()函数划分了RCU读侧临界部分。这些可能是嵌套的,这样一个rcu_read_lock()-rcu_read_unlock()对就可以被封闭在另一对中。在这种情况下,RCU读侧关键部分的嵌套集作为一个很大的关键部分,覆盖了嵌套集的全部范围。第三个读端API成员rcu_dereference()获取一个受RCU保护的指针。从概念上讲,rcu_dereference()只是从内存加载,但我们将在9.5.2.1节中看到,rcu_dereference()必须阻止编译器和(在一种情况下)CPU使用稍后引用此指针的内存操作重新排序其负载。
核心api的其他三个成员被更新者使用。syaan_rcu()函数实现了图9.7中的“等待阅读器”操作。call_rcu()函数是synchronize_rcu()的异步对应物,通过在所有已存在的RCU阅读器完成后调用指定的函数。最后,rcu_assign_pointer()宏用于更新一个受RCU保护的指针。从概念上讲,这只是一个赋值语句,但我们将在第9.5.2.1that节中看到,rcu_assign_pointer()必须防止编译器和CPU重新排序这个赋值,以便在用于初始化指向结构的任何先前赋值之前。
表9.1总结了核心RCU API,便于参考。因此,我们准备继续介绍RCU的关键RCU操作,等待读者。
将synchronize_rcu()和call_rcu()的阅读器等待功能建立在由rcu_read_lock()和rcu_read_解锁()更新的引用计数器上是很诱人的,但是Figure5.1in第5章显示,并发引用计数会导致极大的开销。这种极端的开销在具体情况下得到了证实
在第205页上的图9.2中的参考计数器。危险指针极大地降低了开销,但是,正如我们在213页的图9.3中看到的,不是零。然而,许多RCU实现使用具有精心控制的缓存位置的计数器。
第二种方法观察到内存同步是昂贵的,因此使用寄存器,即每个CPU或线程的程序计数器(PC),因此对读取器没有开销,至少在没有并发更新的情况下是这样。更新器轮询每个相关的PC,如果该PC不在读侧代码中,那么相应的CPU或线程处于静止状态,反过来,信号显示有可能访问新删除的数据元素的任何读取器的完成。一旦所有CPU或线程的pc被发现在任何阅读器之外,宽限期就完成了。请注意,这种方法带来了一些严重的挑战,包括内存排序、有时从读者那里调用的函数,以及不断令人兴奋的代码运动优化。然而,这种方法据说被用于生产。
第三种方法是简单地等待一段固定的时间,该时间足以舒适地超过任何合理的读者的寿命[Jac93,Joh95]。这在实时系统中很有效[RLPB18],但在不那么奇特的环境中,墨菲说,即使为不合理的长寿读者做好准备也是至关重要的。要看到这一点,请考虑失败这样做的后果:当不合理的阅读器仍在引用它时,数据项将被释放,该项很可能立即重新分配,甚至可能作为其他类型的数据项。不合理的读者和不知情的重新分配器就会试图将相同的内存用于两个非常不同的目的。随后出现的混乱将非常难以调试。
第四种方法是永远等待,并且知道这样做甚至可以容纳最不合理的读者。这种方法也被称为“内存泄漏”,由于内存泄漏经常需要过早和不方便的重新启动,因此名声不好。然而,当更新速率和正常运行时间都有明显的限制时,这是一个可行的策略。例如,这种方法可以很好地在一个高可用性的集群中工作,其中系统会定期崩溃,以确保集群确实保持高可用性。6在有垃圾收集器的环境中,泄漏内存也是一种可行的策略,在这种情况下,垃圾收集器可以被认为是堵塞了泄漏[KL80]。但是,如果您的环境缺少垃圾收集器,请继续阅读!
第五种方法避免了周期崩溃,而是周期性地“停止世界”,例如传统的停止世界的垃圾收集器。这种方法在无处不在的连接之前的几十年里也被广泛使用,当时在每个工作日结束时关闭电力系统是一种常见的做法。然而,在当今这个始终相连的世界中,停止这个世界会严重降低响应时间,这是开发并发垃圾收集器的动机之一[BCR03]。此外,虽然我们需要所有已有的读者来完成,但我们不需要它们全部同时完成。
这个观察结果导致了第六种方法,即一次停止一个CPU或线程。这种方法的优点是根本不会降低读者的响应时间,更不用说严重降低了。此外,许多应用程序已经有了状态(称为静止状态),只有在所有已存在的阅读器都完成之后才能达到。在事务处理系统中,一对连续事务之间的时间可能会
处于静止状态。在反应性系统中,一对连续事件之间的状态可能是静止状态。在非抢占式操作系统内核中,上下文开关可以是静止状态[MS98a]。无论如何,一旦所有的cpu和/或线程都通过了一个静止状态,系统就被说已经完成了一个宽限期,在这个宽限期时,所有在这个宽限期开始时存在的阅读器都保证已经完成。因此,释放在该宽限期开始之前被删除的任何被删除的数据项也被保证是安全的。
在非先发制人的操作系统内核中,要使上下文切换成为有效的静止状态,读取器在引用通过图9.6和9.7所示的gptr指针获得的给定实例数据结构时,必须禁止阻塞。这种无阻塞约束与在纯自旋锁上的类似约束是一致的,即CPU在持有自旋锁时被禁止阻塞。如果没有这个约束,所有的cpu都可能被旋转线程试图获得被阻塞线程持有的旋锁。旋转线在获得锁之前不会放弃它们的CPU,但是持有锁的线不可能释放它,直到其中一个旋转线放弃了一个CPU。这是一种典型的死锁情况,通过禁止阻塞,同时持有自旋锁来避免这种死锁。
同样,对解引用gptr的读取器线程也施加了同样的约束:这样的线程在使用指向数据项完成之前不允许阻塞。返回到图9.7的第二行,其中更新程序刚刚完成执行smp_store_release(),假设CPU 0执行一个上下文切换。因为读取器不允许在遍历链表时进行阻塞,所以我们保证可能已经在CPU 0上运行的所有之前的读取器都已完成。将这一推理扩展到其他CPU,一旦观察到每个CPU执行上下文切换,我们就保证所有之前的读取器都已经完成,并且不再有任何引用新删除的数据元素的读取器线程。然后,更新器可以安全地释放该数据元素,从而产生图9.7底部所示的状态。
这种方法被称为基于静止状态的回收(QSBR)[HMB06]。QSBR示意图如图9.8所示,随着时间从图的顶部推进到底部。青色的方框描述了RCU读侧临界部分,每个部分以rcu_read_lock()开始,以rcu_read_unlock()结束。CPU 1执行了WRITE_ONCE(),它删除了当前的数据项(大概之前已经读取了指针值并利用了适当的同步),然后等待读取器。这个等待操作会导致一个即时的上下文切换,这是一个静止状态(用粉红色的圆圈表示),这反过来意味着在CPU 1上的所有先前的读取都已经完成。接下来,CPU 2进行上下文切换,这样CPU1和2上的所有读取器都已完成。最后,CPU 3进行了一个上下文切换。此时,已知整个系统中的所有读取器都已完成,因此宽限期结束,允许synchronize_rcu()返回到其调用者那里,进而允许CPU 1释放旧的数据项。
虽然生产质量的QSBR实现可能相当复杂,但一个玩具的非抢占式的linux内核实现却相当简单:
空白synchronize_rcu(空白){ int cpu; for_each_online_cpu(cpu) sched_setaffinity(当前->pid,cpumask_of(cpu));} |
for_each_online_cpu()原语遍历所有CPU,sched_()函数导致当前线程在指定的CPU上执行,这迫使目标CPU执行上下文切换。因此,一旦for_each_online_cpu()完成,每个CPU都执行了一个上下文切换,这反过来又保证了所有预先存在的读取器线程都已经完成。
请注意,这种方法不是生产质量的。对许多角情况的正确处理和对一些强大优化的需要意味着生产质量的实现是相当复杂的。此外,针对可抢占环境的RCU实现要求读者实际做一些事情,在非实时linux内核环境中,可以简单地将rcu_read_锁()和rcu_read_unlock()定义为先禁用()和先启用(),
并证明了即使面对并发更新,也以零成本提供读端同步是可能的。实际上,清单9.13显示了如何实现读取(access_route())、图9.6的插入(ins_route())和图9.7的删除(del_route())。(第9.5.4.1节显示的路由表。)
快速测试9.26:清单9.13中的rcu_read_lock()和rcu_read_unlock()的意义是什么?为什么不让静止的状态为自己说话呢?
请参考清单9.13,请注意,route_lock用于在调用ins_route()和del_route()的并发更新器之间进行同步。但是,这个锁不是由调用access_route()的阅读器获取的:阅读器反而受到第9.5.1.3节中描述的QSBR技术的保护。
请注意,ins_route()只是返回gptr的旧值,图9.6假定该值始终为NULL。这意味着调用者有责任确定如何处理非null值,这项任务由于读者可能仍在一段不确定的时间内引用它而变得复杂。呼叫者可以使用以下方法之一:
1.使用synchronize_rcu()来安全地释放这个指向性的结构。尽管从RCU的角度来看,这种方法是正确的,但可以说它存在软件工程泄漏-api问题。
2.如果返回的指针是非空的,则触发一个断言。
3.将返回的指针传递给稍后调用的ins_route(),以恢复之前的值。
相比之下,del_route()使用synchronize_rcu()和免费的()来安全地释放新删除的数据项。
这个示例显示了读取和更新受RCU保护的数据结构的一种通用方法,但是,有相当多的用例,其中一些将在第9.5.4节中介绍。
总之,实际上可以创建并发链接的数据结构,而执行与单线程读取器将执行的相同的机器指令序列的读取器可以遍历这些数据结构。下一节总结了RCU的高级属性。
9.5.1.5 RCU属性
一个关键的RCU属性是,读取不需要等待更新。该属性使RCU实现能够提供低成本甚至无成本的读取器,从而实现低开销和优秀的可伸缩性。这个属性还允许RCU读取器和更新器进行有用的并发向前进程。相比之下,传统的同步原语必须使用昂贵的指令强制执行严格的互斥,从而增加了开销并降低了可伸缩性,但通常也禁止读取器和更新者进行有用的并发向前进程。
如前所述,RCU划定读者与rcu_read_lock()和rcu_read_解锁(),并确保每个读者有一个一致的视图(见图9.7)通过维护多个版本的对象和使用更新端原语如synchronize_rcu()确保对象不释放,直到完成所有读者可能使用它们。RCU使用rcu_assign_pointer()和rcu_dereference()分别为发布和读取对象的新版本提供了高效和可伸缩的机制。这些机制将工作分配在读取路径和更新路径之间,从而使读取路径非常快,以类似于危险指针的方式使用复制和弱化优化,但不需要读取侧重试。在某些情况下,包括CONFIG_PREEMPT=n Linux内核,RCU的读端原语没有任何开销。
但是这些属性在实践中真的有用吗?这个问题将在下一节中进行讨论。
RCU自2002年10月以来就已经在Linux内核中使用了[Tor02]。从那时起,RCU API的使用已经大幅增加,如图9.9所示。RCU在被Linux内核中接受之前和在Linux内核之后都被大量使用,如第9.5.5节所述。总之,RCU具有广泛的实际适用性。
本节中讨论的最小示例是对RCU的一个很好的介绍。然而,RCU的有效使用通常要求您对您的问题有不同的思考。因此,检查RCU的基本原理是很有用的,这是下一节所讨论的任务。
本节重新检查上一节中所涵盖的地面,但独立于任何特定的示例或用例。喜欢生活得非常接近实际代码的人可能希望跳过本节中介绍的基本基本原理。
RCU由三种基本机制组成,第一种用于插入,第二种用于删除,第三种用于允许读取器容忍并发的插入和删除。第9.5.2.1节描述了用于插入的发布-订阅机制,第9.5.2.2describes节如何等待已有的RCU阅读器支持删除,以及第9.5.2.3discusses节如何维护最近更新的对象的多个版本允许并发插入和删除。最后,第9.5.2.4节总结了RCU的基本原理。
9.5.2.1发布-订阅机制
因为RCU阅读器不被RCU更新器排除在外,所以当阅读器访问它时,受RCU保护的数据结构可能会发生改变。可以移动、删除或替换已访问的数据项。因为数据结构对读者来说不会“保持静止”,所以每个读者的访问都可以被认为是订阅了当前版本的
受RCU保护的数据项。对于更新者来说,他们可以被认为是发布新版本。
不幸的是,正如在第4.3.4.1节中所述和在第9.5.1.1节中所重申的那样,对这些发布和订阅操作使用普通访问是不明智的。相反,有必要通知编译器和CPU需要注意,如图9.10所示,它说明了清单9.13中并发执行ins_route()(及其调用者)和access_route()之间的交互。
图9.10中的ins_route()列显示了ins_route()调用者分配一个新的路由结构,然后包含初始化预的垃圾。然后,调用者初始化新分配的结构,然后调用ins_route()来发布一个指向新路由结构的指针。发布不影响结构的内容,因此该结构在发布后仍然有效。
同一图中的access_route()列显示了被订阅和取消引用的指针。此解除引用操作绝对必须看到有效的路由结构,而不是初始化之前的垃圾,因为引用垃圾可能会导致内存损坏、崩溃和挂起。如前所述,避免这种垃圾意味着发布和订阅操作必须同时通知编译器和CPU需要维护所需的顺序。
发布由rcu_assign_pointer()执行,它确保ins_路由()的调用者的初始化在实际发布操作的指针存储之前被排序。此外,rcu_assign_pointer()必须是原子性的,因为并发读取器可以看到指针的旧值或指针的新值,但不能看到这两个值的某些混搭。C11存储发布操作满足了这些要求,事实上,在Linux内核中,rcu_assign_pointer()是根据smp_store_release()来定义的,这类似于C11存储发布。
请注意,如果需要并发更新,则将需要某种同步机制来调解同一指针上的多个并发rcu_assign_pointer()调用。在Linux内核中,锁定是可选择的机制,但是
几乎可以使用任何同步机制。一个特别轻量级的同步机制的例子是第8章的数据所有权:如果每个指针都属于一个特定的线程,那么该线程可以在该指针上执行rcu_assign_pointer(),而不需要额外的同步开销。
订阅由rcu_dereference()执行,它命令在取消引用之前从指针加载订阅操作。类似于rcu_asist_指针(),rcu_dereference()必须是原子的,因为加载的值必须是从单个存储加载的,例如,编译器不能破坏负载。9不幸的是,对rcu_dereference()的编译器的支持最多是一个正在进行中的工作[MWB+ 17,MRP+ 17,BM18]。与此同时,Linux内核依赖于易失性负载、各种CPU架构的细节、编码限制[McK14e],以及在DEC Alpha [Cor02]上的内存障碍指令。然而,在其他架构上,rcu_dereference()通常会发出单个加载指令,就像等效的单线程代码一样。编码限制在第15.3.2节中有更详细的描述,但是,现场选择的常见情况(“->”)工作得相当好。不需要最终读取端性能的软件可以使用C11获取负载,从而提供所需的订购和更多,尽管是有代价的。希望对rcu_dereference()的轻级编译器支持将在适当的时候出现。
简而言之,使用rcu_assign_pointer()来发布指针和使用rcu_取消引用()来订阅它们,成功地避免了图9.10中所示的“不确定”的垃圾负载。因此,这两个原语可以用于向链接结构添加新数据,而不中断并发读取器。
在不中断读取器的情况下将数据添加到链接结构是一件好事,与单线程读取器相比,这可以在不增加读取端成本的情况下实现。但是,在大多数情况下,还需要删除数据,这是下一节的主题。
在其最基本的形式中,RCU是一种等待事情完成的方式。当然,还有很多其他的方式来等待事情完成,包括参考计数、读者-作者锁、事件等等。RCU的最大优点是,它可以等待(例如)20,000个不同事物中的每一个,而不必明确地跟踪每一个,也不必担心性能下降、可伸缩性限制、复杂的死锁场景和内存泄漏危险
使用显式跟踪的方案。
在RCU的例子中,每个等待的东西都被称为RCU读侧关键部分。如表9.1所示,RCU读取侧临界部分以rcu_read_lock()原语开始,并以相应的rcu_read_unlock()原语结束。RCU读侧关键部分可以嵌套,并且可以包含几乎任何代码,只要该代码不包含静止状态。例如,在Linux内核中,在RCU读取侧临界部分中休眠是非法的,因为上下文切换是一种静止状态。10如果您遵守这些约定,您可以使用RCU来等待任何预先存在的RCU读取侧关键部分完成,而synchronize_rcu()则使用间接的方法来完成实际的等待[DMS+ 12,McK13]。
RCU读侧临界段与以后的RCU宽限期之间的关系是一种如果-则关系,如图9.11所示。如果一个给定的临界部分的任何部分先于一个给定的宽限期的开始,那么RCU保证所有的该临界部分都将先于该宽限期的结束。在图中,P0()对x的访问先于P1()对这个相同变量的访问,因此也先于P1()对synchronize_rcu()的调用所产生的宽限期。因此,可以保证P0()对y的访问将先于P1()的访问。在这种情况下,如果r1的最终值为0,则r2的最终值也保证为0。
RCU读侧临界段与早期RCU宽限期之间的关系也是一种if-then关系,如图9.12所示。如果给定临界部分的任何部分在给定宽限期结束之后,那么RCU保证所有临界部分都在该宽限期的开始之后。在
图中,P0()对y的访问遵循P1()对相同变量的访问,因此遵循P1()对synchronize_rcu()的调用产生的宽限期。因此,可以保证P0()对x的访问将遵循P1()的访问。在这种情况下,如果r2的最终值为1,那么r1的最终值也保证为1。
最后,如图9.13所示,RCU读取侧临界部分可以通过RCU宽限期完全重叠。在这种情况下,r1的最终值为1,r2的最终值为0。
但是,r1的最终值不是0,r2的最终值不是1。这将意味着RCU的读取侧临界部分完全重叠了一个宽限期,这是被禁止的(或者至少在RCU中构成了一个错误)。因此,RCU的等待阅读保证有两部分: (1)如果给定RCU阅读侧临界部分的任何部分先于给定宽限期的开始,那么该临界部分先于该宽限期的结束。(2)如果一个给定的RCU读侧临界部分的任何部分会在一个给定的宽限期的结束之后,那么整个临界部分就会在该宽限期的开始之后。这个定义对于几乎所有基于RCU的算法都是足够的,但是对于那些想要更多的算法,简单的RCU可执行形式模型可以作为Linux内核v4.17及更高版本的一部分获得,如第12.3.2节所讨论的。此外,RCU的排序属性在第15.4.3节中更详细的内容进行了更详细的研究。
尽管RCU的等待阅读器功能有时确实用于为如图9.11–9.13所示的变量排序赋值,但它更经常用于安全地释放从链接结构中删除的数据元素,正如在第9.5.1节中所做的那样。一般的过程可以用以下的伪代码来说明:
1.进行更改,例如,从链接列表中删除一个元素。
2.等待所有已存在的RCU读取侧关键部分完全完成(例如,通过使用synchronize_rcu())。
3.例如,例如,释放上面替换的元素。
这个更抽象的过程需要一个比Figures9.11–9.13更抽象的图表,这是特定于一个特定的试金石测试。毕竟,无论RCU更新的形式和RCU读侧关键部分的形式如何,RCU实现都必须正确工作。图9.14满足了这一需求,显示了四种可能的场景,在每个场景中,时间从上到下向前推进。在每个场景中,RCU阅读器由左边的方框堆栈表示,RCU更新器由右边的堆栈表示。
在第一个场景中,读卡器在更新器开始删除之前开始执行,因此该读卡器可能对已删除的数据元素有一个引用。因此,在阅读器完成之前,更新器不能释放此元素。在第二种场景中,读卡器在删除完成后才会开始执行。读取器无法获得对已删除的数据元素的引用,因此可以在读取器完成之前释放此元素。第三种场景与第二种场景类似,但说明了即使读者无法获得对一个元素的引用,仍然允许推迟对该元素的释放,直到读者完成之后。在第四个也是最后一个场景中,读取器在更新器开始删除数据元素之前开始执行,但这个元素在读取器完成之前(错误地)被释放。一个正确的RCU实现将不允许这第四种情况发生。这个图表因此说明了RCU的等待读者的功能:给定一个宽限期,每个读者在这个宽限期结束之前结束,在这个宽限期开始之后开始,或者两者都开始,在这种情况下,它完全包含在这个宽限期内。
因为RCU的读者可以在更新过程中取得进展,不同的读者可能对数据结构的状态存在分歧,这是下一节将讨论的主题。
本节讨论RCU如何通过包含多个版本的数据来容纳无同步读取器。因为这些无同步的读取器提供了非常弱的时间同步,所以RCU用户通过空间同步进行补偿。空间同步在第6章中讨论了,并在实践中大量使用以获得良好的性能和可伸缩性。在本节中,空间同步将用于实现一种较弱的(但有用的)形式的正确性,以及优秀的性能和可伸缩性。
第9.5.1.1节中的图9.7显示了空间同步的一个简单变体,在其中,与del_route()同时运行的不同阅读器(参见清单9.13)可能会看到旧的路由结构或空列表,但无论什么方式都会得到有效的结果。当然,仔细看图9.6就会发现,调用ins_route()也会导致并发读取器看到不同的版本:无论是初始的空列表还是新插入的路由结构。请注意,引用计数(第9.2节)和风险指针(第9.3节)也会导致并发读取器看到不同的版本,但RCU的轻量级读取器更有可能发生这种情况。
然而,维护多个弱一致的版本可能会带来一些惊喜。例如,考虑图9.15,其中读取器正在遍历同时更新的链表。11在图的第一行,阅读器引用数据项A,在第二行,一直看到B和B。在第三行,更新器删除元素A,在第四行,更新器将元素E添加到列表的末尾。在第五行也是最后一行,读者完成它的遍历,看到元素A到E。
除了没有时间有这样的名单存在。这种情况可能比图9.7中显示的更令人惊讶,图9.7中不同的并发读者看到不同的版本。相比之下,在图9.15中,读者看到了一个从未真正存在过的版本!
解决这种奇怪情况的一种方法是通过较弱的语义学。读取器遍历必须遇到在完整遍历过程中出现的任何数据项(B、C和D)中的任何数据项,并且可能遇到也可能不遇到仅在部分遍历(A和E)中出现的数据项。因此,在这种特殊的情况下,读者遍历遇到所有五个元素是完全合理的。如果这个结果有问题,解决此情况的另一种方法是使用更强的同步机制,如阅读-写入器锁定,或聪明地使用时间戳和版本控制,如第9.5.4.11节所述。当然,更强大的机制将会更昂贵,但工程寿命的关键是选择和权衡。
虽然这种情况看起来很奇怪,但它与现实世界完全一致。正如我们在第3.2节中看到的,有限的光速在计算机系统中不能被忽略,在这个系统之外也不能被忽略。这反过来又意味着,系统内表示系统外现实世界状态的任何数据总是永远过时,因此与现实世界不一致。因此,序列{A、B、C、D、E}很可能发生在现实世界中,但由于光速延迟,从未出现在计算机系统的内存中表示。在这种情况下,读者令人惊讶的遍历将正确地反映现实。
因此,在真实世界的数据上运行的算法必须考虑到不一致的数据,要么通过容忍不一致,要么通过采取步骤来排除或拒绝它们。在许多情况下,这些算法也完全能够处理系统内的不一致性。
第9.1节中介绍的前bsd包路由例子就是一个很好的例子。路由列表的内容是由路由协议设置的,这些协议具有显著的延迟(秒甚至分钟),以避免路由不稳定性。因此,一旦路由更新到达一个给定的系统,它很可能已经以错误的方式发送数据包。在更新运行的几微秒内以错误的方式发送一些数据包显然不是问题,因为处理延迟路由更新的高级协议操作也会处理内部不一致。
互联网路由也不是唯一能容忍不一致的情况。重复,任何算法,在系统内数据跟踪系统外的状态必须容忍不一致性,包括安全策略(通常由委员会的人类),存储配置,和WiFi接入点,更不用说可移动硬件如麦克风、耳机、相机、鼠标、打印机和其他。此外,图9.9所示的大量Linux内核RCU API的使用,以及Linux内核对引用计数的大量使用和危害的增加其他项目中的指针表明,对这种不一致性的容忍比人们想象的更普遍。
这种常见情况的不一致性容忍的一个根本原因是,单项查找在实践中比全数据结构遍历要常见得多。毕竟,全数据结构的遍历比单项查找要昂贵得多,因此开发人员有动机避免这种遍历。并发更新不仅比完整的遍历更不可能影响单项查找,而且孤立的单项查找也无法检测到这种不一致性。因此,在常见的情况下,这种不一致不仅是可以容忍的,它们实际上是看不见的。
在这种情况下,RCU阅读器可以被认为是完全有更新器,尽管这些阅读器可能执行与单线程程序执行的机器指令序列完全相同,正如201页所暗示的那样。例如,参考第226页上的清单9.13,假设每个读取器线程在其生命周期中只调用access_route()一次,并且读取器和更新线程之间没有其他通信。然后,每次调用访问_路由()都可以在ins_route()调用之后进行排序,该ins_route()调用产生了由访问_路由()中列表的第11行访问的路由结构,并在任何后续的ins_route()或del_route()调用之前进行排序。
总之,维护多个版本正是实现RCU阅读器的极低开销的原因,正如前面所述,许多算法都不被多个版本所困扰。然而,也有一些算法绝对不能处理多个版本。有一些技术可以将这种算法应用于RCU [McK04],例如,使用第13.4.2节中描述的序列锁定。
这些示例假设在整个更新操作中保持互斥锁,这意味着在给定时间最多可能有两个版本的列表。
本节描述了基于RCU的算法的三个基本组件:
1.一种发布-订阅机制,用于添加新数据,包括用于更新端发布的rcu_assign_指针()和用于读端订阅的rcu_dereference(),
2.一种等待现有的RCU阅读器完成的方式,一方面是基于由rcu_read_lock()和rcu_read_unlock()分隔的阅读器,另一方面是通过synchronize_rcu()或call_rcu()等待的更新器(正式描述见第15.4.3节),以及
3.一种维护多个版本以允许更改在不伤害或过度延迟的情况下的并发RCU阅读器的原则。
这三个RCU组件允许在面对并发读取器时更新数据,这些并发读取器可能正在执行与单线程实现中的读取器将使用的相同的机器指令序列。这些RCU组件可以以不同的方式组合,以实现令人惊讶的不同类型的基于RCU的算法,其中一些在9.5.4节中介绍。然而,通常在更高的抽象级别上工作会更好。为此,下一节将描述linux内核API,它包括简单的数据结构,如列表。
本节从RCU的linux-内核API的角度来看它。12第9.5.3.2节介绍了RCU的等待完成api,第9.5.3.3presents RCU的发布-订阅和版本维护api,第9.5.3.4presents RCU的列表处理api,第9.5.3.5presents RCU的诊断api,以及第9.5.3.6节描述了RCU在哪个上下文中可以使用的各种api。最后,9.5.3.7presents节总结语。
对内核内部不感兴趣的读者可能希望跳过到第251页的第9.5.4节,但最好是在回顾了下一节关于软件工程的考虑之后。
9.5.3.1 RCU API和软件工程
已经浏览了表9.2、9.3、9.4和9.5的读者可能会注意到,linux内核api的完整列表有超过100个成员。这与表9.1中显示的仅有的6个API成员形成了鲜明的(也许是令人沮丧的)对比。这种情况清楚地提出了这样一个问题:“为什么会有这么多人?”??"
这个问题将在下面的部分中得到更彻底的回答,但同时本节的其余部分总结了动机。
有一句明智的老话,大意是“错是人”。这意味着RCU API的很大一部分的目的是提供诊断,最显著的是表9.5,但在其他地方也是如此。
造成人类错误的重要原因是人类大脑的局限性,例如,短期记忆的有限能力。在这本书中展示的玩具的例子并没有强调这些限制。这是出于必要:许多读者在学习新材料的同时推动他们的认知极限,所以例子需要保持简单。
因此,这些示例将rcu_dereference()调用与封闭的rcu_read_lock()和rcu_read_unlock()调用相同的函数中。相比之下,现实世界的软件必须经常从不同的函数,甚至从不同的翻译单元调用这些API成员。因此,Linux内核RCU API已经扩展以适应锁定程序,这允许rcu_dereference()和朋友在不受rcu_read_lock()保护时抱怨。Linux内核RCU还检查一些双自由错误,RCU读侧临界部分中的无限循环,并尝试在RCU读侧临界部分中调用静止状态。
现实世界的软件适应人类认知极限的另一种方式是通过抽象。因此,linux内核API包含了对其进行操作的成员除了表9.1中的面向指针的核心API之外。Linux内核本身还提供了受RCU保护的哈希表和搜索树。
像Linux这样的操作系统内核在图2.3中所示的软件堆栈的“铁三角形”的底部附近运行,在那里性能至关重要。因此有专门的变异的RCU api用于快速路径,例如,在9.5.3.3节中讨论,RCU_INIT_POINTER()可以代替rcu_assign_pointer()在RCU保护指针被分配给空或当指针尚未被读者访问。使用RCU_INIT_POINTER()允许编译器在选择指令和执行优化方面有更多的回旋余地,从而提高了性能。
另一方面,当使用错误的RCU_INIT_POINTER()会导致静默内存损坏,所以请小心!是的,在某些情况下,内核可以检查给定内核上下文中RCU API成员的不适当使用,但是RCU_INIT_POINTER()使用的约束还无法检查。
最后,在Linux内核中,上述人类认知的限制因在Linux上运行的工作负载的多样性和严重性而加剧。在v5.16中,这产生了不少于五种类型的RCU,每一种都设计为RCU读者和作者提供不同的性能、可伸缩性、响应时间和能源效率权衡。这些RCU口味将是下一节的主题。
对“什么是RCU”最直接的答案是,RCU是一个API。例如,在Linux内核中使用的RCU实现由表9.2总结,其中分别显示了RCU、“可睡眠”RCU(SRCU)、任务RCU和通用API的等待阅读器部分,表9.3显示了API的发布-订阅部分[McK19b].13]
如果您是RCU的新手,您可以考虑只关注表9.2中的一个列,每一个列都总结了Linux内核的RCU API家族中的一个成员。例如,如果您主要对理解在Linux内核中如何使用RCU感兴趣,那么“RCU”将是开始的地方,因为它是最常用的。另一方面,如果您想理解RCU本身,“Tasks RCU”有最简单的API。您以后总是可以回来查看其他列。
“RCU”列对应于三个linux内核RCU实现的整合[McK19c,McK19a],其中RCU读侧关键部分以rcu_read_lock()、rcu_read_lock_bh()或rcu_read_lock_sched()开始,分别以rcu_read_unlock()、rcu_read_unlock_bh()或rcu_read_unlock_ sched()结束。任何禁用下半部分、中断或抢占的代码区域也充当RCU读侧关键部分。RCU读侧裂缝切片可以嵌套。相应的同步更新端原语synchronize_rcu()和synchronize_rcu_expedited(),以及它们的同步缩写syyan_net(),等待当前执行的RCU读取端的任何类型的关键部分完成。这种等待的长度被称为“宽限期”,而synchronize_rcu_expedited()旨在减少宽限期延迟
表9.2: RCU等待完成的api
RCU:原始 | SRCU:睡眠读者 | 任务RCU:免费追踪蹦床 | 任务RCU粗鲁:自由的空闲任务跟踪蹦床 | 任务RCU跟踪:保护可睡眠的BPF程序 | |
初始化和清理 | define_srcu() DEFINE_STATIC_SRCU() init_srcu_struct() cleanup_srcu_struct() | ||||
读取端 临界截面标记 | rcu_read_lock() ! rcu_read_unlock() ! rcu_read_lock_bh() rcu_read_unlock_bh() rcu_read_lock_sched() rcu_read_unlock_sched() (再加上任何贬低的底部 一半,优先购买权,或中断。) | srcu_read_lock() srcu_read_unlock() | 自愿的上下文切换 | 自愿的上下文切换和 代码的优先启用区域 | rcu_read_lock_trace() rcu_read_unlock_trace() |
更新端原语(同步) | synchronize_rcu()同步_net() synchronize_rcu_expedited() | synchronize_srcu() synchronize_srcu_expedited() | synchronize_rcu_tasks() | synchronize_rcu_tasks_rude() | synchronize_rcu_tasks_trace() |
更新端原语 (异步/回调) | call_rcu() ! | call_srcu() | call_rcu_tasks() | call_rcu_tasks_rude() | call_rcu_tasks_trace() |
更新端原语(等待回调) | rcu_barrier() | srcu_barrier() | rcu_barrier_tasks() | rcu_barrier_tasks_rude() | rcu_barrier_tasks_trace() |
更新端原语(启动/等待) | get_state_synchronize_rcu() cond_synchronize_rcu() | ||||
更新侧原语(空闲内存) | kfree_rcu() | ||||
类型安全存储器 | |||||
读取侧约束 | 无阻塞(仅限优先购买权) | 没有具有相同srcu结构的synchronize_srcu() | 没有自愿的上下文切换 | 既不阻塞也不优先购买 | 没有RCU任务跟踪的宽限期 |
读取侧开销 | 简单的指令,记忆障碍 | 免费的 | CPU本地访问(优先=免费) | CPU本地访问 | |
异步 更新端开销 | 亚微秒 | 亚微秒 | 亚微秒 | 亚微秒 | 亚微秒 |
恩典期延迟 | 10秒 | 毫秒 | 助手 | 毫秒 | 10秒 |
加速的 宽限期 | 10秒微秒 | 微秒 | N/A | N/A | N/A |
增加的CPU开销和ipi的费用。异步更新端原语call_rcu()在后续宽限期之后调用具有指定参数的指定函数。例如,call_rcu(p,f);将导致在随后的宽限期之后调用“RCU回调”f (p)。在某些情况下,例如在卸载使用call_rcu()的linux内核模块时,需要等待所有未完成的RCU回调来完成[McK07e]。rcu_barrier()原语将执行这项工作。
最后,RCU可用于提供类型安全的内存[GC96],如第9.5.4.5节所述。在RCU的上下文中,类型安全内存保证了给定的数据元素在访问它的任何RCU读侧关键部分期间都不会更改类型。要使用基于RCU的类型安全内存,请将SLAB_TYPESAFE_BY_RCU传递给kmem_cache_create()。
Table9.2displays中的“SRCU”列是一个专门的RCU API,允许在由srcu_read_lock()和srcu_read_unlock()分隔的srcuread侧临界部分[McK06]中进行一般睡眠。但是,与RCU不同的是,SRCU的srcu_read_lock()返回一个必须传递到相应srcu_read_unlock()的值。这种差异是由于SRCU用户为每个不同的SRCU使用分配了一个srcu_struct,因此没有方便的地方来存储每个任务的读取器嵌套计数。(请记住,尽管Linux内核提供了动态分配的每个cpu的存储,但目前还没有动态分配的每个任务的存储。)
如果一个给定的srcu_struct结构必须在多个翻译单元中使用,则可以使用DEFINE_ SRCU()定义为一个全局变量,否则也可以定义为DEFINE_ STATIC_SRCU()。例如,DEFINE_SRCU(my_srcu)将创建一个名为my_srcu的全局变量,程序中的任何文件都可以使用该变量。或者,srcu_struct结构可以是堆栈上的变量或动态分配的内存区域。在这两种非全局变量的情况下,内存必须在第一次使用前使用init_srcu_struct()进行初始化,并在最后一次使用后(但在底层存储消失之前)使用cleanup_srcu_struct()进行清理。
然而它们被创建,这些不同的srcu_struct结构阻止SRCU读侧临界部分阻断不相关的synchronize_srcu()和synchronize_srcu_expedited()调用。当然,在SRCU读侧临界部分中使用synchronize_srcu()或synchronize_srcu_expedited()都会导致自死锁,因此应该避免。与RCU一样,SRCU的synchronize_srcu_expedited()与synchronize_srcu()相比减少了宽限期延迟,但以增加CPU开销为代价。
与普通的RCU类似,使用异步调用_srcu()函数可以避免自死锁。但是,在使用call_srcu()时必须特别小心因为单个任务可以很快地注册SRCU回调。鉴于SRCU允许读者阻塞任意的时间段,这可能会消耗任意大量的内存。相反,给定同步synchronize_srcu()接口,给定任务必须完成等待给定宽限期,才能开始等待下一个宽限期。
同样类似于RCU,还有一个srcu_barrier()函数,它等待调用所有之前的call_srcu()回调。
换句话说,SRCU通过允许开发人员限制其范围来弥补其极其薄弱的前进进度保证。
表9.2中的“Tasks RCU”列显示了一个专门的RCU API,它调节了在linux-内核跟踪中使用的蹦床的释放。这些蹦床用于将控制从被跟踪的代码中的一个点转移到进行实际跟踪的代码中。当然,有必要确保在一个给定的蹦床中执行的所有代码在释放该蹦床之前都已经完成。
对被跟踪的代码的更改通常仅限于单个跳转或调用指令,因此不能适应实现rcu_read_lock()和rcu_read_unlock()所需的代码序列。蹦床也不能包含这些对rcu_read_lock()和rcu_read_unlock()的呼叫。要看到这一点,请考虑一个即将开始执行给定蹦床的CPU。因为它还没有执行rcu_read_lock(),这个蹦床可以在任何时候被释放,这对这个CPU来说是一个致命的惊喜。因此,蹦床不能被在跟踪代码或蹦床本身中执行的同步原语所保护。这确实提出了一个关于究竟如何保护蹦床的问题。
回答这个问题的关键是要注意,蹦床代码从来不包含直接或间接执行自愿上下文切换的代码。这段代码可能会被抢占,但它永远不会直接或间接地调用调度()。这表明RCU的一种变体具有自愿的上下文切换和空闲执行作为其唯一的静止状态。这个变体是taskes RCU。
任务RCU没有读侧标记功能,这很好,因为它的主要用例没有地方放置这样的标记。相反,调用调度()直接作为静止状态。更新可以使用synchronize_rcu_tasks()来等待所有已存在的蹦床执行完成,或者它们可以使用它的异步对应项,call_rcu_tasks()。还有一个rcu_barrier_tasks(),等待完成对应于所有call_rcu_任务()调用的回调。没有synchronize_rcu_tasks_expedited(),因为还没有对它的请求,尽管实现一个有用的变体也不会没有挑战。
“任务RCU Rude”列提供了在第9.5.1.4节中介绍的玩具集成功能的一个更有效的变体。这种变体会导致每个CPU执行一个上下文切换,因此任何自愿的上下文切换或代码的任何可抢占区域都可以作为一个静止状态。Tasks RCU Rude变体使用linux内核工作队列工具来强制并发上下文切换,而不是玩具实现所采用的串行cpu-cpu方法。该API反映了Tasks RCU的特性,包括缺乏显式的读侧标记。
表9.3: RCU发布-订阅和版本维护API
因此,对于Tasks RCU家族具有明确的读侧标记是不寻常的!
rcu_pointer_handoff()原语只是返回它的唯一参数,但对于工具检查从RCU读侧关键部分泄漏的指针很有用。rcu_pointer_handoff()的使用表明,对于这些工具,有关结构的保护已经从RCU转移到一些其他一些机制,如锁定或参考计数。
RCU_INIT_POINTER()宏可以用于初始化尚未公开给阅读器的受RCU保护的指针,或者,也可以将受RCU保护的指针设置为NULL。在这些受限的情况下,不需要由rcu_assign_pointer()提供的内存屏障指令。类似地,RCU_POINTER_INITIALIZER()提供了一个gcc风格的结构初始化器,以允许在结构中轻松地初始化受RCU保护的指针。
第二类订阅指向数据项的指针,或者,安全地遍历受RCU保护的指针。同样,简单地使用c语言访问加载这些指针可能会导致在被指向的数据中看到预初始化前的垃圾。类似地,在RCU读侧临界部分之外以任何方式加载这些指针都可能导致随时释放指向对象。但是,如果指针只是要测试而不要取消引用,那么释放指向的对象并不一定是一个问题。在这种情况下,可以使用rcu_access_pointer()。但是,通常情况下,RCU读侧保护是必需的,因此rcu_dereference()原语使用Linux内核的锁锁工具[Cor06a]来验证此rcu_dereference()调用是否受rcu_read_lock()、srcu_read_lock()或其他RCU读侧标记的保护。相比之下,rcu_access_pointer()原语不涉及锁锁,因此当在RCU读侧临界部分之外使用时,不会引发锁锁投诉。
另一种不需要保护的情况是,更新端代码在保持更新端锁的同时访问受RCU保护的指针。针对这种情况,提供了受rcu_dereference_保护的()API成员。它的第一个参数是RCU保护的指针,第二个参数采用一个锁表达式,描述为了访问安全必须持有哪些锁。从读取器和更新器调用的代码都可以使用rcu_dereference_check(),它也采用锁锁表达式,但也可以从不持有锁的读取端代码调用。在某些情况下,锁程序表达式可能非常复杂,例如,在使用细粒度锁定时,可能持有大量锁中的任何一个,而且可能很难找出哪些适用。在这些(希望是罕见的)情况下,rcu_dereference_raw()提供了保护,但不检查是否在读取器中被调用,或是否持有任何特定的锁。rcu_dereference_raw_不跟踪()API成员的行为类似,但无法跟踪,因此可以通过跟踪代码安全地使用。
尽管几乎任何链接结构都可以通过操作指针来访问,但高级结构可以非常有帮助。因此,下一节将介绍Linux内核所使用的各种受RCU保护的链接表。
虽然rcu_assign_pointer()和rcu_dereference()在理论上可以用于构建任何可能的受RCU保护的数据结构,但在实践中,通常最好使用更高级层次的构造。因此,rcu_assign_pointer()和
rcu_dereference()原语已经嵌入到Linux的列表操作API的特殊RCU变体中。Linux有四种双链表的变体,环状结构列表_head和线性结构hlist_head/结构hlist_node,结构hlist_nulls_head/结构hlist_nulls_node,和结构hlist_bl_head/结构hlist_bl_node对。前者如图-ure9.16所示,其中绿色(最左边的)框表示列表标题,蓝色(最右边的三个)框表示列表中的元素。这种表示法很麻烦,因此将缩写为如图9.17所示,它只显示非头(蓝色)元素。
Linux的hlist15是一个线性列表,这意味着它只需要一个指针作为标头,而不是循环列表所需的两个指针,如图9.18所示。因此,使用hlist可以使大型哈希表的哈希-桶数组的内存消耗减半。与前面一样,这种表示法很麻烦,因此hlist结构将以与list_head样式列表相同的方式缩写,如Figure9.17中所示。
Linux的hlist的一个变体,名为hlist_nulls,提供了多个不同的NULL指针,但在其他方面使用如图9.18所示的相同布局。在这个变体中,具有零低阶位的->下一个指针被认为是一个指针。但是,如果低阶位设置为1,上层位识别NULL指针的类型。此类型的列表用于允许无锁读取器检测节点何时从一个列表移动到另一个列表。例如,哈希表的每个桶都可能使用其索引来标记它的空指针。如果读取器遇到一个与它开始的桶的索引不匹配的空指针,该读取器知道它正在遍历的元素在遍历期间被移动到其他桶,并随身携带该读取器。读者可以
使用null()函数(如果传递了hlist_nulls NULL指针则返回true)来确定它何时到达列表的结尾,使用get_nulls_value()函数(返回其参数的NULL指针标识符)来获取NULL的类型
建议当get_nulls_value()返回一个意外的值时,阅读器可以采取纠正措施,例如,从一开始就重新启动其遍历。
关于hlist_nulls的更多信息可以在Linux-内核源代码树中找到,并在rculist_nulls.rst文件(旧内核中的rculist_nulls.txt)中提供了有用的示例代码。
Linux的hlist的另一个变体包含了位锁定,名为hlist_bl。这个变体使用如图9.18所示的相同布局,但是保留了头指针的低阶位(图中的“第一”)来锁定列表。这种方法还减少了内存的使用,因为它允许使用指针本身来存储一个单独的自旋锁。
表9.4总结了这些链表变体的API成员。更多信息可在Linux-内核源代码树的文档/RCU目录和Linux每周新闻[McK19b]中获得。
但是,本节的其余部分扩展了list_replace_rcu()的使用,因为这个API成员为RCU提供了它的名称。这个API成员用于执行更复杂的更新一个元素的列表有多个字段原子更新,这样给定的读者看到旧的值或新的值,但不是两组的混合物。例如,链接列表中的每个节点可能都有整数字段->a、->b和->c,并且可能需要将给定节点的字段分别从5、6和7更新到5、2和3。
下面的讨论使用图9.19来说明状态更改。每个元素中的三元组分别表示字段->a、->b和->c的值。红色阴影的元素可能会被阅读器引用,而且由于阅读器不直接与更新器同步,因此阅读器可能会与整个替换过程同时运行。请注意,为了清晰起见,省指针了从尾部到头部的链接。
该列表的初始状态,包括指针p,与删除示例相同,如图的第一行所示。
下面的文本描述了如何将5、2、3替换5、6、7元素,这样任何给定的读者都能看到这两个值中的一个。
第15行分配一个替换元素,导致图9.19第二行所示的状态。此时,读取器无法保存对新分配元素的引用(如绿色阴影所示),并且它未初始化(如问号所示)。
第16行将旧元素复制到新元素,从而得到如图9.19的第三行所示的状态。新分配的元素仍然不能被阅读器引用,但它现在已被初始化。
表9.4:受RCU保护的列表API
列表:循环的双链接列表 | 线性双链列表 | hlist_nulls:线性双链表,最多31位标记 | hlist_bl:具有位锁定的线性双链表 | |||||
结构列表head | hlist_head | 结构 | hlist_nulls_head | 结构 | hlist_bl_head | |||
结构 | hlist_node | 结构 | hlist_nulls_node | 结构 | hlist_bl_node | |||
初始化 | ||||||||
init_list_head_rcu() | ||||||||
全遍历 | ||||||||
list_for_each_entry_rcu() | hlist_for_each_entry_rcu() | hlist_nulls_for_each_entry_rcu() | ||||||
列表_for_每个条目_无锁() | hlist_for_each_entry_rcu_bh() hlist_for_each_entry_rcu_notrace() | hlist_nulls_for_each_entry_safe() | ||||||
恢复遍历 | ||||||||
list_for_each_entry_continue_rcu() | hlist_for_each_entry_continue_rcu() | |||||||
list_for_each_entry_from_rcu() | hlist_for_each_entry_continue_rcu_bh() hlist_for_each_entry_from_rcu() | |||||||
逐级遍历 | ||||||||
list_entry_rcu() list_entry_无锁() list_first_or_null_rcu() list_next_rcu() list_next_or_null_rcu() | hlist_first_rcu() hlist_next_rcu() hlist_pprev_rcu() | hlist_nulls_first_rcu() hlist_nulls_next_rcu() | hlist_bl_first_rcu() | |||||
添加 | ||||||||
list_add_rcu() list_add_tail_rcu() | hlist_add_before_rcu() hlist_add_behind_rcu() hlist_add_head_rcu() hlist_add_tail_rcu() | hlist_nulls_add_head_rcu() | ||||||
删除 | ||||||||
list_del_rcu() | hlist_del_rcu() | hlist_nulls_del_rcu() | hlist_bl_del_rcu() | |||||
hlist_del_init_rcu() | hlist_nulls_del_init_rcu() | hlist_bl_del_init_rcu() | ||||||
更换 | ||||||||
list_replace_rcu() | ||||||||
绞接 | ||||||||
list_splice_init_rcu() | list_splice_tail_init_rcu() |
表9.5: RCU诊断api
类别 | 原语 |
标记RCU指针 | rcu __ |
调试对象支持 | init_rcu_head() destroy_rcu_head() init_rcu_head_on_stack() destroy_rcu_head_on_stack() |
失速报警控制 | rcu_cpu_stall_reset() |
回调检查 | rcu_head_init() rcu_head_after_call_rcu() |
锁止装置支架 | rcu_read_lock_held() rcu_read_lock_bh_held() rcu_read_lock_sched_held() srcu_read_lock_held() rcu_is_watching() rcu_lockdep_warn() rcu_nonidle() rcu_sleep_check() |
第17行将q->b更新为值“2”,第18行将q->c更新为值“3”,如图9.19的第四行所示。请注意,读者仍然无法访问新分配的结构。
现在,第19行进行替换,这样新元素最终对读者可以看到,因此被阴影显示为红色,如图9.19的第五行所示。此时,如下图所示,我们有两个版本的列表。已有的读者可能会看到5、6、7元素(因此现在是黄色的),但新的读者将会看到5、2、3元素。但是任何给定的读者都可以保证看到一组值或另一组值,而不是两者的混合。
在第20行上的synchronize_rcu()返回之后,将经过一个宽限期,因此在list_replace_rcu()之前开始的所有读取都将完成。特别是,任何可能持有引用5,6,7元素的读者都保证已经退出他们的RCU阅读侧关键部分,因此被禁止继续持有引用。因此,不再有任何阅读器引用旧元素,如图9.19第六行的绿色阴影所示。就读者而言,我们又回到了列表的单一版本,但是用新元素代替旧元素。
在第21行上的kfree()完成后,该列表将出现在图9.19的最后一行中所示。
尽管RCU是以替换案例命名的,但Linux内核中的绝大多数RCU使用都依赖于简单的独立插入和删除,如第9.5.2.3节中的图9.15所示。
下一节将介绍帮助开发人员调试使用RCU的代码的api。
表9.5显示了RCU的诊断性api。
调试对象支持对于作为从Linux内核的内存分配器获得的结构的一部分的任何rcu_head结构都是自动的,但是那些构建自己的特殊目的内存分配器的rcu_head结构可以在分配和空闲时间分别使用init_rcu_head()和destroy_rcu_ head()。那些使用函数调用堆栈上的rcu_head结构(它会发生!)可以在第一次使用前使用init_rcu_head_on_stack(),在最后一次使用后使用destroy_rcu_head_on_stack(),但在从函数返回之前使用。调试对象支持允许检测涉及将相同的rcu_head结构快速连续传递给call_rcu()和朋友的错误,这是call_rcu()中臭名昭著的双无内存分配错误类的call_rcu()对应物。
停止警告控制由rcu_cpu_stall_reset()提供,它允许调用者在当前宽限期的剩余时间内抑制RCU CPU停止警告。RCU CPU停止警告有助于查明RCU读侧临界部分运行时间过长的情况,这对于内核调试器能够抑制它们很有用,例如,当遇到断点时。
回调检查由rcu_head_init()和rcu_head_after_call_ rcu()提供。前者在传递给调用_rcu()之前在rcu_head结构上调用,然后rcu_head_after_call_rcu()将检查查看是否用指定的函数调用了回调。
对锁锁[Cor06a]的支持包括rcu_read_lock_held()、rcu_read_lock_ bh_held()、rcu_read_lock_sched_held()和srcu_read_lock_held(),如果在相应类型的RCU读端关键部分中调用,它们都返回true。
因为rcu_read_lock()不能从空闲循环中使用,而且由于能源效率的考虑导致空闲循环变得相当华丽,如果在使用rcu_read_lock()合法的上下文中调用,rcu_is_观察()返回true。请注意,srcu_read_lock()可能从空闲甚至离线cpu中使用,这意味着rcu_is_watching()不适用于SRCU。
如果锁定程序已启用且其参数计算结果为true,RCU_LOCKDEP_WARN()将发出警告。例如,如果在RCU读端临界部分之外调用RCU_LOCKDEP_WARN(!rcu_read_lock_held())将发出警告。
RCU_NONIDLE()可用于强制RCU在执行作为唯一参数传入的语句时进行监视。例如,RCU_NONIDLE(WARN_ON(!rcu_is_watich()))永远不会发出警告。然而,2020-2021年时间框架的变化将RCU扩展到空闲循环,这应该会大大减少甚至消除对RCU_NONIDLE()的需求。
最后,如果在RCU、RCU-bh或RCU-sched读侧关键部分中调用,rcu_sleep_check()将发出警告。
9.5.3.6RCU在哪里可以使用的api?
图9.20显示了在哪个内核环境中可以使用哪些api。RCU读侧原语可以在任何环境中使用,包括NMI,RCU突变和异步宽限期原语可以在NMI以外的任何环境中使用,最后,RCU同步宽限期原语只能在进程上下文中使用。RCU列表遍历原语包括list_ for_each_entry_rcu()、hlist_for_each_entry_rcu()等。类似地,RCU列表突变原语包括list_add_rcu()、hlist_del_rcu()等。
请注意,来自其他RCU家族的原语可以被替换,例如,srcu_read_lock()可以在任何可以使用rcu_read_lock()的上下文中使用。
在其核心上,RCU只不过是一个API,它支持发布和订阅插入,等待所有RCU阅读器完成,并维护多个版本。也就是说,可以在RCU之上构建更高层次的结构,包括Section9.5.4中列出的阅读-写入器锁定、引用计数和存在保证结构。此外,我毫不怀疑Linux社区将继续为RCU找到有趣的新用途,就像它们对整个内核中许多同步原语的作用一样。
当然,一个更完整的RCU视图还将包括您可以使用这些api做的所有事情。
然而,对于许多人来说,一个RCU的完整视图必须包括示例RCU实现。附录Bentury展示了一系列不断增加复杂性和能力的“玩具”RCU实现,尽管其他人可能更喜欢经典的“读取-复制更新的用户级实现”[DMS+ 12]。对于其他人,下一节将给出一些RCU用例的概述。
本节回答了这个问题:“什么是RCU?”从RCU可以使用的角度来看。因为RCU最常用于替换一些现有的机制,所以我们主要从它与这些机制的关系的角度来看待它,比如
表9.6:RCU使用情况
机制RCU替换 | 页 |
用于BSD前路由的RCU | 252 |
等待已存在的事情完成 | 254 |
分阶段状态更改 | 257 |
仅添加的列表(发布/订阅) | 259 |
类型安全存储器 | 259 |
存在保证 | 260 |
轻质垃圾收集器 | 261 |
仅删除列表 | 262 |
准读写锁 | 262 |
准参考计数 | 272 |
准多版本并发性控制(MVCC) | 274 |
详见表9.6,如图9.23所示。在本表中列出的部分之后,第9.5.4.12provides节是一个摘要。
与后面的部分相比,本节主要关注一个非常特定的用例,以便与其他机制进行比较。
清单9.14和9.15显示了受RCU保护的预BSD路由表(route_rcu.c)的代码。前者显示数据结构和route_lookup(),后者显示route_add()和route_del()。
在清单9.14中,第2行添加了RCU回收使用的->rh字段,第6行添加了->re_freed使用后检查字段,第16行、22行和26行添加了RCU读侧保护,第20行和21行添加了“使用后检查”。在清单9.15中,第11、13、30、34和39行添加了更新侧锁定,第12和33行添加了RCU更新侧保护,第35行导致在宽限期经过之后调用route_cb(),第17-24行定义了route_cb()。这是一个工作并发实现的最小添加代码。
清单9.14: RCU bsd前路由表查找 |
1结构路由_entry{ 2 3 4 6 8 CDS_LIST_HEAD(route_list); 9 DEFINE_SPINLOCK(线路锁);10 11无符号长route_查找(无符号长addr)12 { 13 14 16 17 18 20 22 23 25 27 |
清单9.15: RCU bsd前路由表的添加/删除 |
1introute_add(无符号长addr,无符号长接口)2{ 3 5 6 7 8 9 11 12 13 14 16 17静态空隙route_cb(结构体rcu_head *rhp)18 { 19 21 22 23 25 26 int route_del(无符号长addr)27 { 28 31 32 33 35 36 38 40 41 } |
图9.21显示了在只读工作负载上的性能。RCU的规模相当好,并提供了近乎理想的性能。然而,这些数据是使用用户空间RCU的RCU_SIGNAL风格的[Des09b,MDJ13f]生成的,为此rcu_read_锁定()和rcu_read_unlock()生成少量的代码。RCU的QSBR味道会发生什么,它不会为rcu_read_lock()和rcu_read_unlock()生成任何代码?(关于RCU QSBR的讨论见Section9.5.1,特别是图9.8。)
这个问题的答案如图9.22所示,这表明RCU QSBR的运行性能和可伸缩性实际上超过了理想的无同步工作负载。
尽管Pre-BSD路由是一个很好的RCU用例,但值得看看图9.23中所示的更广泛的用例之间的关系。此任务将由以下各部分执行。
在阅读这些部分时,请询问自己其中哪一个用例最能描述bsd前路由。
如第9.5.2节所述,RCU的一个重要组件是等待RCU阅读器完成的一种方式。RCU的一个伟大的力量是,它允许你等待成千上万的不同的事情完成而无需显式跟踪每一个,而不导致性能退化,可伸缩性限制,复杂的死锁场景,和内存泄漏危险固有的方案使用显式跟踪。
在本节中,我们将展示同步_sched()的读端对应项(包括任何禁用抢占的东西,以及硬件操作和禁用中断的原语)如何允许您与不可屏蔽中断(NMI)处理程序交互,而使用锁定是相当困难的。这种方法被称为“纯RCU”[McK04],在Linux内核中的几个地方使用了它。
清单9.16:使用RCU等待NMIs完成 |
1结构配置文件缓冲区{ 2 3 7 void nmi_profile(无符号长pc值)8{ 11 12 14 15 17 20 23 24 26 |
这种“纯RCU”设计的基本形式如下: 1.例如,更改操作系统对NMI的反应方式。 |
2.等待所有已存在的读侧临界部分完全完成(例如,通过使用同步_sched()primitive).16,这里的关键观察是,后续的RCU读侧临界部分可以确保看到所做的任何更改。 |
3.清理,例如,返回状态,表示更改已成功进行。 本节的其余部分将介绍改编自Linux内核的示例代码。在本例中,现已不存在的o配置文件设施中的nmi_stop()功能使用同步_sched()来确保在释放相关资源之前,所有机上的NMI通知都已完成。此代码的简化版本如清单9.16所示。 第1-4行定义了一个fofifel_缓冲区结构,包含一个大小和一个不确定的条目数组。第5行定义了一个指向配置文件缓冲区的指针,它可能在其他地方初始化,以指向动态分配的内存区域。 第7-16行定义了nmi_profile()函数,该函数从NMI处理程序中调用。因此,它不能被抢占,也不能被一个正常的中断处理程序中断,但是,它仍然会由于缓存丢失、ECC错误和同一内核内的其他硬件线程窃取循环而发生延迟。第9行使用rcu_dereference()原语获得指向配置文件缓冲区的本地指针,以确保DEC Alpha上的内存排序,如果当前没有分配配置文件缓冲区,第11和第12行退出该函数,而第13和第14行退出该函数 |
16在Linux内核v5.1及更高版本中,同步_sched()已被包含到synchronize_rcu()中。
参数超出了定义范围。否则,第15行将增加由pcvalue参数索引的配置文件缓冲区条目。请注意,使用缓冲区存储大小可以保证范围检查与缓冲区相匹配,即使一个较大的缓冲区突然被一个较小的缓冲区所取代。
第18-27行定义了nmi_stop()函数,其中调用者负责互斥(例如,保持正确的锁)。第20行获取指向轮廓缓冲器的指针,如果没有缓冲器,则第22行和第23行退出该功能。否则,第24行NULL将退出配置文件缓冲区指针(使用rcu_assign_pointer()原语来维护在弱有序机器上的内存排序),第25行等待RCU缓冲宽限期过去,特别是等待代码的所有不可抢占区域,包括NMI处理程序,来完成。一旦在第26行继续执行,我们就可以保证任何获得指向旧缓冲区的指针的nmi_profile()实例都已经返回。因此,可以释放缓冲区,在这种情况下,使用kfree()原语是安全的。
简而言之,RCU可以方便地在配置文件缓冲区之间动态切换(您只需尝试通过原子操作有效地实现这一点,或者完全通过锁定!)。这是罕见的RCU的罕见使用。RCU通常用于更高的抽象级别,如下面的章节所示。
图9.24显示了一个有效处理维护操作的阶段状态更改的时间轴。如果没有正在进行的维护操作,常见情况操作必须快速进行,例如,不获取读写器锁。但是,如果正在进行维护操作,则必须仔细进行常见情况操作,并考虑到由于它们与维护操作同时运行而增加的复杂性。这意味着常见情况下的操作在维护操作期间会产生更高的开销,这也是维护操作通常计划在低负载期间进行的原因之一。
在图中,这些明显冲突的要求通过在维护操作之前有一个准备阶段和之后有一个清理阶段来解决,在此期间,常见情况操作可以快速或谨慎地进行。
此分阶段状态更改的伪代码示例如清单9.17所示。共情况操作由cco()在从第5行到第10行的RCU读侧临界段内执行。这里,第6行检查全局be_wecel标志,调用cco_carefully()或cco_quickly(),如所示。
这允许主()函数在第15行上设置be_ceal标志,并在第16行上等待RCU宽限期。当控制到达第17行时,所有看到be_cerel值的错误(因此可能调用cco_quickly()函数)的cco()函数都将完成它们的操作,因此所有当前执行的cco()函数都将调用cco_carefully()。这意味着要调用do_maint()函数是安全的。然后第18行等待可能与do_maint()并发运行的所有cco()函数完成,最后第19行将be_weel标志设置为false。
清单9.17:维护操作的分阶段状态更改 |
小心,2 3 void计算机控制中心(void)4 { 6 7 8 9 11 } 12 空隙管道(无效) 16 19 |
分阶段状态更改允许频繁操作使用轻量级检查,而不需要昂贵的锁获取或原子读-修改-写操作,并且在Linux内核中以rcu_sync [NZ13]的幌子使用,以实现具有轻量级读取器的读-写入器信号量的变体。分阶段状态更改只向等待完成的用例添加了一个已检查的状态变量(第9.5.4.2节),因此也处于相当低的抽象级别。
仅添加数据结构,以仅添加列表为例,可以用于一组令人惊讶的常见用例,也许最常见的是更改的日志记录。仅添加的数据结构是对RCU的底层发布/订阅机制的纯粹使用。
一个bsd前路由表的仅添加的变体可以从清单9.14和9.15中派生出来。因为没有删除,route_del()和route_cb()函数可以免除,连同->rh和->re_freed rothe_entry结构的字段,rcu_read_lock(),rcu_read_unlock()调用routhe_查找()函数,和所有使用->re_freed在所有剩余功能字段。
当然,如果route_add()函数有许多并发调用,那么在路由锁上就会有严重的争用,如果使用无锁技术,那么在路由列表上就会有严重的内存争用。避免这种争用的通常方法是使用并发友好的数据结构,如哈希表(参见第10章)。或者,每个cpu的数据结构可以定期合并到单个全局数据结构中。
另一方面,如果没有任何删除,具有许多route_add()并发调用的延长时间段最终将消耗所有可用内存。因此,大多数受RCU保护的数据结构也实现了删除。
许多无锁算法并不要求给定的数据元素通过给定的RCU读端关键部分引用它来保持相同的标识——但只有在该数据元素保留相同类型的情况下。换句话说,这些无锁算法可以容忍在给定的数据元素引用它时被释放并重新分配为相同类型的结构,但必须禁止类型上的更改。这种保证在学术文献[GC96]中被称为“类型安全内存”,它比第9.5.4.6节中讨论的存在保证要弱,因此相当难处理。Linux内核中的类型安全内存算法使用板缓存,特别是用SLAB_TYPESAFE_BY_RCU标记这些缓存,以便在返回释放的板到系统内存时使用RCU。RCU的使用保证了在任何已存在的RCU读取侧关键部分期间,该板的任何正在使用的元素都将保留在该板中,从而保留其类型。
需要注意的是,SLAB_TYPESAFE_BY_RCU绝对不会阻止kmem_ cache_alloc()立即重新分配刚刚通过kmem_cache_free()释放的内存!事实上,rcu_dereference()刚刚返回的受SLAB_TYPESAFE_BY_RCU保护的数据结构可能会被释放并进行任意大量的重新分配,即使是在rcu_read_lock()的保护下。相反,SLAB_TYPESAFE_BY_RCU的操作方法是防止kmem_cache_free()将一个完全释放的数据结构板返回给系统,直到一个RCU宽限期过去之后。简而言之,尽管给定的RCU读侧临界部分可能会看到给定的SLAB_TYPESAFE_BY_RCU数据元素被任意地释放和重新分配,但元素的类型保证在该临界部分完成之前不会更改。
因此,这些算法通常使用一个验证步骤,通过检查以确保新引用的数据结构确实是被请求的数据结构[LS86,第2.5节]。这些验证检查要求数据结构的某些部分不受自由重新分配过程的影响。这样的验证检查通常很难正确进行,并且可以隐藏微妙和困难的错误。
因此,尽管基于类型安全的无锁算法在极少数困难的情况下可能非常有用,但您应该在可能的情况下使用存在性保证。毕竟,更简单的事情几乎总是更好的事情!另一方面,基于类型安全的无锁算法可以提供改进的缓存局部性,从而提高性能。这种改进的缓存局部性是由于这样的算法可以立即重新分配一个新释放的内存块。相比之下,基于存在保证的算法必须等待所有已存在的读取器才能重新分配内存,此时内存可能已经从CPU缓存中弹出。
从图9.23中可以看到,RCU的类型-安全内存用例结合了等待完成和发布-订阅组件,但是在Linux内核中还包含了由SLAB_TYPESAFE_BY_RCU标志指定的板分配器的延迟回收。
9.5.4.6存在保证
Gamsa等人[GKAS99]讨论了存在保证,并描述了如何使用类似于RCU的机制来提供这些存在保证(见PDF第7页的第5节),第7.4节讨论了如何通过锁定来保证存在,以及这样做的后续缺点。其效果是,如果在RCU读侧临界段中访问了任何受RCU保护的数据元素,则该数据元素保证在该RCU读侧临界段期间仍然存在。
清单9.18演示了基于RCU的存在保证如何通过一个从哈希表中删除元素的函数来启用每个元素的锁定。第6行计算一个哈希函数,第7行进入一个RCU读侧临界部分。如果第9行发现哈希表的对应桶为空,或者存在的元素不是我们希望删除的元素,那么第10行退出RCU读侧临界部分,第11行表示失败。
清单9.18:存在性保证启用每个元素的锁定 | |
1 int 2 { 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 } | 删除(int键) 结构元素; b=哈函数(键);rcu_read_lock(); 如果(p==NULL||p->键!=键){ 返回0;} spin_lock(&p->锁); rcu_read_unlock() ; rcu_assign_pointer(散列式[b],为空); spin_olook(&p->锁);synchronize_rcu(); 返回1;} spin_olook(&p->锁);rcu_read_unlock(); 返回0; |
否则,第13行获得更新侧自旋锁,然后第14行检查该元素是否仍然是我们想要的元素。如果是这样,第15行离开RCU读侧临界部分,第16行将其从表中移除,第17行释放锁,第18行等待所有预先存在的RCU读侧临界部分完成,第19行释放新移除的元素,第20行表示成功。如果元素不再是我们想要的元素,第22行释放锁,第23行离开RCU读侧临界部分,第24行表示未能删除指定的键。
警报阅读器将认识到这只是对原始等待完成主题(第9.5.4.2节)的轻微变化,添加发布/订阅、链接结构、堆分配器(通常)和延迟回收,如图9.23所示。他们还可能注意到比第7.4节中讨论的基于锁的存在保证具有死锁豁免的优势。
人们第一次了解RCU的感叹是“RCU有点像垃圾收集器!”这个感叹号有很大的真实性,但也可能是误导。
也许考虑RCU和自动垃圾收集器(GC)之间关系的最好方法是RCU类似于GC,因为收集的时间是自动确定的,但是RCU与GC的不同之处在于: (1)程序员
必须手动指示给定的数据结构何时有资格被收集,并且(2)程序员必须手动标记可能保存引用的RCU读侧关键部分。
尽管有这些差异,但相似之处确实很深。事实上,我所知道的第一个类似于RCU的机制使用基于引用计数的垃圾收集器来处理宽限期[KL80],RCU和垃圾收集之间的联系最近被注意到[SWS16]。
轻量级的垃圾收集器用例与保证存在的用例非常相似,只向混合算法中添加所需的非阻塞算法。这个轻量级的垃圾收集器用例也可以与下一节中描述的存在性保证一起使用。
仅删除列表是9.5.4.4节中介绍的仅添加列表,可以认为是存在保证用例,但没有发布/订阅组件,如图9.23所示。当初始化时知道列表中的可能成员,并且可以删除成员时,可以使用仅删除列表。例如,列表中的元素可能表示系统中的硬件元素,但如果不重新启动就无法修复或替换。
一个bsd前路由表的仅删除变体可以从清单9.14和9.15中派生出来。因为没有添加,所以可以取消route_add()函数,或者,它的使用可能被限制为初始化时间。理论上,route_查找()函数可以使用非RCU迭代器,尽管在Linux内核中,这将导致来自调试代码的投诉。此外,一个RCU迭代器的增量成本通常可以忽略不计。
因此,仅进行删除的情况通常使用为添加和删除而设计的算法和数据结构。
也许Linux内核中最常用的RCU是在读密集型情况下替代读写器锁定。然而,从一开始,我并没有立即看到RCU的使用。事实上,早在20世纪90年代早期实现通用RCU实现之前,我就选择了一个轻量级读写器锁[HW92]17。我所设想的轻量级读写器锁的每一个使用都是使用RCU实现的。事实上,轻量级的阅读器-写入器锁在三年多后才首次被使用。天啊,我觉得傻了!
RCU和读写器锁定之间的关键相似之处在于,两者都有可以同时执行的读侧关键部分。事实上,在某些情况下,可以机械地将RCU API成员替换为相应的读写器锁定API成员。但首先,为什么要麻烦呢?
RCU的优点包括性能、死锁豁免权和实时延迟。当然,RCU也有局限性,包括读取器和更新器同时运行,低优先级RCU读取器可以阻止等待宽限期过去的高优先级线程,宽限期延迟可以延长许多毫秒。下面的段落将讨论这些优点和限制。
linux内核RCU相对于读写器锁定的读端性能优势如图9.25所示,它是在一个448-CPU 2.10 GHz Intel x86系统上生成的。
请注意,在单个CPU上,读写器锁定比RCU慢一个数量级以上,而在192个CPU上,RCU锁定比RCU慢四个数量级以上。相比之下,RCU的规模相当好。在这两种情况下,误差条覆盖了30次运行的全部测量范围,其中线是中位数。
更温和的视图可以从CONFIG_PREEMPT内核,尽管RCU仍然击败阅读器锁定之间的七倍一个CPU和192个CPU的三个数量级,如图9.26所示,这是生成在相同的448-CPU 2.10 GHz x86系统。请注意,在大量cpu上的读写器锁定的高可变性。误差条跨越了整个数据范围。
当然,图9.25和9.26中的读取器-写器锁定的低性能被不现实的零长度临界部分夸大了。RCU的性能优势随着临界截面开销的增加而降低,如Figure9.27所示,它在与之前的图相同的系统上运行。这里,y轴表示读取侧原语的开销和临界区开销的和,x轴表示临界区开销,以纳秒为单位。但请注意对数尺度y轴,这意味着轨迹之间的小间隔仍然代表着显著的差异。这个图显示了不可抢占的RCU,但是考虑到可抢占的RCU的读取端开销只有大约3纳秒,它的图将与图9.27几乎相同。
读写器锁定有三个跟踪,上面的跟踪是100个CPU,下一个跟踪是10个CPU,最低的跟踪是1个CPU。cpu的数量越多,临界段越短,RCU的性能优势就越大。100-CPU系统不再罕见,而且许多系统调用(因此它们包含的任何RCU读取端关键部分)在微秒内完成,这一事实强调了这些性能优势。
此外,正如下一段所讨论的,RCU读侧原语几乎完全没有死锁。
僵局免疫尽管RCU提供了显著的性能优势——主要是工作负载,这是创建RCU的主要原因之一
事实上是它对读侧僵局的免疫力。这种豁免源于这样一个事实,即RCU读侧原语不阻塞、旋转甚至不向后分支,因此它们的执行时间是确定性的。因此,它们不可能参与一个死锁循环。
RCU的读侧死锁豁免的一个有趣的结果是,可以无条件地将RCU读取器升级到RCU更新器。尝试使用读写器锁定进行这样的升级会导致死锁。下面是执行RCU读到更新升级的示例代码片段:
rcu_read_lock() ; list_for_each_entry_rcu(p,&head,list_field){使用(p); 如果(需要更新(p)) { spin_lock(我的锁); do_更新(p); spin_解锁(&my_lock); } } rcu_read_unlock() ; |
注意,do_udate()是在锁保护和RCU读侧保护下执行的。
RCU的死锁豁免的另一个有趣的结果是它对大量优先级倒置问题的豁免。例如,低优先级RCU读取器不能阻止高优先级RCU更新程序获取更新侧锁。类似地,低优先级的RCU更新器不能阻止高优先级的RCU阅读器进入RCU读取端关键部分。
实时延迟因为RCU读侧原语既不旋转也不阻塞,所以它们提供了极好的实时延迟。此外,如前所述,这意味着它们不受涉及RCU读取侧原语和锁的优先级反转的影响。
然而,RCU很容易受到更微妙的优先级反转场景的影响,例如,等待RCU宽限期过去的高优先级进程可以被-rt内核中的低优先级RCU读取器阻止。这可以通过使用RCU优先级提升来解决[McK07d,GMTW08]。
然而,使用RCU优先级提升要求rcu_read_unlock()进行降提升,这需要获取调度器锁。因此,在调度器和RCU中需要进行一些注意,以避免死锁,而在v5.15的Linux内核中,RCU需要避免在持有任何RCU的锁时调用调度器。
这反过来又意味着,当启用了RCU优先级提升时,rcu_read_unlock()并不总是无锁的。然而,如果rcu_read_unlock()的关键部分没有得到优先级提升,它仍然是无锁的。此外,关键部分将不会被优先级提升,除非它们被优先化,或者,在-rt内核中,它们获得非原始的自旋锁。这意味着从在任何给定CPU上运行的最高优先级任务的角度来看,rcu_read_unlock()通常将是无锁的。
RCU读取器和更新器同时运行,因为RCU阅读器从不旋转或阻塞,而且因为更新器不受任何形式的回滚或中止语义,RCU阅读器和更新器确实可以同时运行。这意味着RCU读取器可能访问陈旧的数据,甚至可能看到不一致,其中任何一个都可以使从读写器锁定到RCU的转换非常重要。
然而,在非常大量的情况下,不一致和陈旧的数据并不是问题。最典型的例子是网络路由表。因为路由更新可能需要相当长的时间才能到达给定的系统(几秒甚至几分钟),所以当更新到达时,系统将以错误的方式发送数据包。继续以错误的方式发送更新并持续几毫秒通常不是问题。此外,由于RCU更新器可以在不等待RCU阅读器完成的情况下进行更改,RCU阅读器可能比批公平阅读-写入锁定阅读器更快地看到变化,如图9.28所示。
一旦收到更新,rwlock写入器在最后一个读取器完成之前不能继续,后续的读取器在写入器完成之前不能继续。但是,这些后续的读者可以保证看到新的值,如最右边的方框的绿色阴影所示。相比之下,RCU阅读器和更新者不会相互阻止,这使得RCU阅读器可以更快地看到更新后的值。当然,因为它们的执行与RCU更新器重叠,所有的RCU阅读器都很可能看到更新的值,包括在更新前开始的三个阅读器。然而,只有最右边的绿色阴影的RCU阅读器才能保证看到更新后的值。
读写器锁定和RCU只是提供了不同的保证。通过读写锁定,任何在作者开始后开始的读者都保证看到新的值,而任何在作者旋转时试图开始的读者可能会看到也可能不会看到新的值,这取决于rwlock实现的读者/作者偏好。相比之下,使用RCU,任何在更新器完成后开始的读取器都保证会看到新值,而任何在更新器开始后完成的读取器可能会看到也可能不会看到新值,这取决于时间。
这里的关键是,尽管读写器锁定确实保证一致性在计算机系统的范围,在某些情况下,这种一致性的代价增加与外部世界,由有限的光速度和非零大小的原子。换句话说,读-写器锁定以对外部世界的陈旧数据为代价获得内部一致性。
注意,如果在读取读写器锁定时计算一个值,然后在该锁释放后使用该值,那么这个读写器锁定用例使用的是陈旧的数据。毕竟,这个值所基于的数量在该锁被释放后的任何时候都可能发生变化。这种阅读器-写入器锁定的用例通常很容易转换为RCU,如列表9.19,9.20、9.21以及附带的文本所示。
低优先级RCU读取器可以在实时RCU [GMTW08]或SRCU [McK06]中阻止高优先级回收器,抢占读取器将阻止一个宽限期完成,即使一个高优先级任务被阻止等待该宽限期完成。实时RCU可以通过用call_rcu()代替synchronize_rcu()或使用RCU优先级提升[McK07d,GMTW08]来避免这个问题。也许有一天有必要通过提高优先级来增强SRCU和RCU任务跟踪,但在一个明确的现实世界需求被证明之前。
RCU宽限期延长了许多毫秒,除了用户空间RCU [Des09b,MDJ13f],加速宽限期,以及附录B中描述的几个“玩具”RCU实现,RCU宽限期延长了毫秒。尽管有许多技术可以使如此长的延迟无害,包括使用异步接口(call_rcu()和call_rcu_bh())或轮询接口(get_state_synchronize_rcu()、start_poll_synchronize_rcu()和poll_state_synchronize_rcu()),但这种情况是RCU在读取主要情况下使用的主要原因。
如第9.5.3节所述,在Linux内核中,可以通过加速宽限期获得更短的宽限期,例如,通过调用synchronize_rcu_加速()而不是synchronize_rcu()。加快的宽限期可以将延迟减少到几十微秒,尽管以牺牲更高的CPU利用率和IPIs为代价。添加的IPIs在某些实时工作负载中可能特别不受欢迎。
代码:读写器锁定vs。RCU在最好的情况下,从读写器锁定到RCU的转换非常简单,如列表9.19,9.20和9.21所示,所有这些都取自维基百科[MPA+06]。
然而,转换并不总是如此简单。这是因为清单9.21中的spin_lock()和synchronize_rcu()都不排除清单9.20中的读取器。首先,spin_lock()不会以任何方式与rcu_read_锁()和rcu_read_unlock()交互,因此不排除它们。其次,尽管write_lock()和synchronize_rcu()都在等待预先存在的读取器,但只有write_lock()才能阻止后续读取器的开始。18.因此,synchronize_rcu()
1 | 结构el { | 1 | 结构el { |
2 | 结构列表_headlp; | 2 | 结构列表_headlp; |
3 | 长键; | 3 | 长键; |
4 | spinlock_t互斥因子; | 4 | spinlock_t互斥因子; |
5 | int数据; | 5 | int数据; |
6 | /*其他数据字段*/ | 6 | /*其他数据字段*/ |
7 | }; | 7 | }; |
8 | DEFINE_RWLOCK(李斯特姆); | 8 | DEFINE_SPINLOCK(李斯特姆); |
9 | LIST_HEAD(头); | 9 | LIST_HEAD(头); |
1 | 输入搜索(长键,输入结果) | 1 | 输入搜索(长键,输入结果) |
2 | { | 2 | { |
3 | 结构el *p; | 3 | 结构el *p; |
4 | 4 | ||
5 | read_lock(&listmutex); | 5 | rcu_read_lock() ; |
6 | 每个输入列表(页和头,页) | { 6 | list_for_each_entry_rcu(p,和头,lp){ |
7 | 如果(p->键==键){ | 7 | 如果(p->键==键){ |
8 | *result = p->data; | 8 | *result = p->data; |
9 | 读取解锁(&listmutex); | 9 | rcu_read_unlock() ; |
10 | 返回1; | 10 | 返回1; |
11 | } | 11 | } |
12 | } | 12 | } |
13 | 读取解锁(&listmutex); | 13 | rcu_read_unlock() ; |
14 | 返回0; | 14 | 返回0; |
15 | } | 15 | } |
1 | int删除(长键) | 1 | int删除(长键) |
2 | { | 2 | { |
3 | 结构el *p; | 3 | 结构el *p; |
4 | 4 | ||
5 | 写_lock(&listmutex); | 5 | spin_lock(&listmutex); |
6 | 每个条目(p,&head, | lp) { 6 | 每个条目的列表(p,和头部,lp) { |
7 | 如果(p->键==键){ | 7 | 如果(p->键==键){ |
8 | list_del(&p->lp); | 8 | list_del_rcu(&p->lp); |
9 | write_解锁(&listmutex); | 9 10 | spin_uloke(&列表互斥);synchronize_rcu(); |
10 | kfree (p); | 11 | kfree (p); |
11 | 返回1; | 12 | 返回1; |
12 | } | 13 | } |
13 | } | 14 | } |
14 | write_解锁(&listmutex); | 15 | spin_解锁(&listmutex); |
15 | 返回0; | 16 | 返回0; |
16 | } | 17 | } |
不能排除读者。然而,许多使用读写器锁定的情况都可以转换为RCU。
用RCU替换阅读器-写入器锁定的更详细的案例可以在其他地方找到[Bro15a,Bro15b]。
语义学:读者作者锁定vs。RCU在上一节的扩展中,读取器-写器锁定语义可以粗略和非正式地总结为以下三个时间约束:
1.写端获取将等待任何读取持有者释放锁定。
2.写入器端获取等待任何写入保持器释放锁。
3.读端获取等待任何写保持器释放锁定。
RCU完全免除了约束#3,并削弱了其他两个如下:
1.编写者会先等待任何预先存在的读取持有器,然后再进入其更新的破坏性阶段(通常是释放内存)。
2.作者可以根据需要相互同步。
当然,正是这种削弱允许RCU实现获得优秀的性能和可伸缩性。它还允许RCU实现上述无条件的读到写升级,这在读写器锁定中非常吸引人,也非常容易出现死锁。使用RCU的代码可以以惊人的大量方式来弥补这种削弱,但最常见的是通过施加空间约束:
1.新的数据被放置在新分配的内存中。
2.旧数据将被释放,但仅需在:
(a)该数据已经被取消链接,以便以后的读者无法访问,而(b) A随后的RCU宽限期已经过去。
当然,对于一些读写锁定的用例,RCU削弱的语义是不合适的,但是Linux内核的经验表明,超过80%的读写锁实际上可以被RCU取代。例如,一个通用的读取器-写入器锁定用例在保持锁时计算一些值,然后在释放该锁后使用该值。这个用例会导致陈旧的数据,因此经常会适应RCU较弱的语义。
如图9.6和9.7所示的RCU单例数据结构说明了这种时间和空间约束的相互作用。该结构在清单9.22的第1-4行中定义,并包含两个整数字段,->a和->b(单例例.c)。该结构的当前实例由在第4行中定义的曲线配置指针引用。
当前结构的字段通过cur_a和cur_b参数传递回在第6-20行上定义的get_config()函数。这两个字段可能有点过时,但它们绝对必须相互一致。get_config()函数在RCU从第10行开始到第18行结束的临界部分中提供了这种一致性,这提供了所需的时间同步。第11行获取指向当前我的配置结构的指针。这个结构将被使用,无论任何并发的更改由于
清单9.22: RCU单例子获得 |
1结构myconfig { 2 5 6intget_配置(int *cur_a,int *cur_b)7 { 8 11 12 14 |
清单9.23: RCU单例例集 | |
1空白2{ 3 4 5 6 7 8 9 10 11 12 13 14 } | set_config(int cur_a,int cur_b) 结构我的配置*mcp; mcp=malloc(规模(*mcp));BUG_ON(!mcp); mcp->a = cur_a;mcp->b = cur_b; mcp = xchg(&curconfig, mcp); 如果(mcp){ } |
调用set_config()函数,从而提供所需的空间同步。如果第12行确定曲线配置指针为NULL,则第14行返回失败。否则,第16行和第17行复制出->a和->b字段,第19行返回成功。这些->a和->b字段来自相同的肌配置结构,而RCU读侧临界部分阻止了该结构被释放,从而保证了这两个字段彼此一致。
该结构由清单9.23中所示的set_config()函数进行了更新。第5-8行分配并初始化一个新的肌配置结构。第9行原子交换指针这个新结构的指针旧结构,同时也提供完整的内存顺序xchg()操作前后,从而提供所需的更新/阅读器空间同步和所需的更新/更新器同步。如果第10行确定指向旧结构的指针实际上是非NULL,则第11行等待宽限期(从而提供所需的阅读器/更新器时间同步),第12行释放旧结构,知道不再有仍然引用它的阅读器是安全的。
包含“5,25”),每个绿色虚线箭头表示与新结构的交互作用(在右边,包含“9,81”)。
黑色虚线箭头表示左右两侧的RCU阅读器之间的时间关系,中间是RCU宽限期,每个箭头指向从旧事件到新事件。对synchronize_rcu()的调用遵循最左边的rcu_read_lock(),因此该synchronize_rcu()调用必须在相应的rcu_read_unlock()之后才能返回。相反,对synchronize_rcu()的调用出现在最右边的rcu_read_lock()之前,这允许来自同一synchronize_rcu()的返回忽略相应的rcu_read_解锁()。这些时间关系阻止了我配置结构在RCU阅读器仍然访问它们时被释放。
这两条水平的灰色虚线表示不同的读者得到不同结果的时间段,然而,每个读者只会看到这两个对象中的一个。在第一条水平线之前结束的所有读取器都将看到最左边的我的配置结构,而在第二条水平线之后开始的所有读取器都将看到最右边的结构。在这两行之间,也就是说,在宽限期期间,不同的读者可能会看到不同的对象,但只要每个阅读器只加载曲线配置指针一次,每个阅读器将看到它的我的配置结构的一致视图。
简而言之,当在合适的链接数据结构上操作时,RCU结合时间和空间同步,以近似读-写器锁定,RCU读侧关键部分作为读-写器锁定读取器,如图9.23和9.29所示。RCU的时间同步由读取侧标记提供,例如rcu_read_lock()和rcu_read_unlock(),以及更新侧宽限期原语,例如synchronize_rcu()或call_ rcu()。空间同步由读端rcu_dereference()族提供,每个族都订阅rcu_assign_发布的版本
pointer().19 RCU的时间和空间同步组合与第6.3.2,6.3.3节和第7.1.4节中提出的方案形成了对比,其中时间和空间同步分别通过锁定和静态数据结构布局分别提供。
因为在RCU读侧关键部分正在进行时,不允许完成宽限期,所以RCU读侧原语可以用作一种受限制的引用计数机制。例如,请考虑以下代码片段:
rcu_read_lock()和rcu_dereference()原语的组合可以被认为是获得了对p的引用,因为在rcu_dereference()分配给p之后开始的宽限期不可能在我们达到匹配的rcu_read_unlock()之后结束。这种参考计数方案受到了限制
禁止在RCU读侧临界部分内等待RCU宽限期,也禁止将RCU读侧临界部分的引用从一个任务传递到另一个任务。
不管这些限制如何,以下代码都可以安全地删除p:
spin_lock(&mylock);p=头; rcu_assign_pointer(头,空);spin_olook(&mylock); /*等待释放所有引用。*/ synchronize_rcu() ; kfree (p); |
对head的分配防止未来对p的任何引用被获取,synchronize_rcu()等待以前获得的任何引用被释放。
当然,RCU也可以与传统的参考计数结合起来,如第13.2节所述。
但为什么要费心呢?同样,部分答案是性能,如图9.30和9.31所示,再次显示了448-CPU 2.1 GHz Intel x86系统中不可抢占和可抢占的linux内核RCU的数据。与参考计数相比,不可抢占的RCU的优势范围从一个CPU的超过一个数量级到192个CPU的大约四个数量级。可抢占的RCU的优势范围从一个CPU的3倍到192个CPU的3个数量级。
然而,与读写器锁定一样,RCU的性能优势在短时间临界部分和大量cpu中最为显著,如图9.32所示。此外,与读写器锁定一样,许多系统调用(因此它们包含的任何RCU读侧关键部分)都在几微秒内完成。
虽然传统的引用计数器通常与特定的数据结构或特定的数据结构组相关联,但这种方法确实有一些缺点。例如,为各种数据结构维护单个全局引用计数器通常会导致弹跳包含引用计数的缓存行。正如我们在图9.30–9.32中看到的,这种缓存线跳跃会严重降低性能。
相比之下,RCU的轻量级rcu_read_lock()、rcu_dereference()和rcu_read_ulockey()读侧原语允许非常频繁的读侧使用,而性能下降可以忽略不计。除了对rcu_dereference()的调用并没有做任何特定的操作来获取对指向对象的引用。繁重的工作是由rcu_read_lock()和rcu_read_unlock()原语以及它们与RCU宽限期的交互来完成的。
忽略这些对rcu_dereference()的调用允许RCU被认为是一种“批量引用计数”机制,其中每个对rcu_read_lock()的调用都获得对每个受RCU保护的对象的引用,并且很少或没有开销。然而,RCU附带的限制可能会相当繁重。例如,在许多情况下,linux内核禁止在RCU读侧关键部分中睡觉将会破坏整个目的。第9.3节中描述的危险指针机制可以更好地满足这种情况。在代码很少休眠的情况下,通过使用RCU在普通非休眠情况下作为引用计数,并在需要休眠时桥接到显式引用计数器来处理。
或者,如果引用必须由一个代码部分的单个任务持有,则可能包含睡眠RCU(SRCU)[McK06]。这并不能涵盖引用从一个任务“传递”到另一个任务的罕见情况,例如,当启动I/O时获取引用并在相应的完成中断处理程序中释放时。同样,通过显式引用计数器或危险指针可以更好地处理这种情况。
当然,SRCU本身也带来了一些限制,即将srcu_read_lock()的返回值传递到相应的srcu_read_unlock()中,并且不会从硬件中断处理程序或不可屏蔽中断(NMI)处理程序中调用SRCU原语。关于这一限制提出了多少问题,以及如何最好地
处理它,目前尚没有定论。
然而,在引用保存在单个CPU或任务范围内的常见情况下,RCU可以用作高性能和高度可伸缩的引用计数机制。
如图9.23所示,准引用计数将RCU读取器作为单独或批量引用计数添加,也可能在角落情况下连接到引用计数器。
RCU也可以被认为是一种具有弱一致性标准的简化的多版本并发控制(MVCC)机制。第9.5.2.3节涉及了多版本的方面。但是,在其本机形式中,RCU只在给定的受RCU保护的数据元素中提供版本一致性。
然而,在某些情况下,跨多个数据元素需要一致性和新的数据。幸运的是,有许多方法可以避免不一致和陈旧的数据,包括以下内容:
1.将RCU读取器封闭在序列锁定读取器中,如果发生更新,将强制重试RCU读取器,如Section13.4.2and Section13.4.3中所述。
2.将必须一致的数据放到链接数据结构的单个元素中,并避免在RCU阅读器可见的任何元素中更新这些字段。RCU的读者获得对任何此类元素的参考,然后保证看到一致的值。其他详细信息见第13.5.4节。
3.使用每个元素锁来保护“删除”标志,允许RCU阅读器拒绝过时的数据[McK04,ACMS03]。
4.提供一个存在标志,所有数据元素引用的更新[McK14d、McK14a、McK15b、McK16b、McK16a]。
5.使用多种基于计数器的方法之一[McK08a、McK10、MW11、McK14b、MSFM15、KMK+19]。在这些方法中,更新者维护一个版本号,并维护到给定数据片段的旧版本的链接。读者可以获取当前版本号的快照,并在必要时遍历这些链接以找到与该快照一致的版本。
简而言之,当使用RCU来近似多版本并发控制时,您只为实际需要的一致性级别付费。
如Figure9.23所示,准多版本并发控制是基于存在保证,添加读侧快照操作和对读取器和写器的约束,约束的确切形式由一致性要求决定,如上所述。
在其核心,RCU只不过是一个API:
1.一种用于添加新数据的发布-订阅机制,
2.一种等待已存在的RCU阅读器完成的方式,以及
3.一种维护多个版本以允许更改在不伤害或过度延迟的情况下的并发RCU阅读器的原则。
也就是说,可以在RCU之上构建更高层次的结构,包括前面几节中描述的各种用例。此外,我毫不怀疑,对于RCU以及任何其他一些同步原例,还将继续找到新的用例。因此,RCU的用例在概念上比RCU本身更为复杂,正如在第201页中所暗示的那样。
同时,图9.33显示了RCU最有用的一些粗略的经验法则。
如图右上角的蓝色框所示,如果您读取了数据,RCU的工作效果最好——主要是允许陈旧和不一致的数据的数据(但关于陈旧和不一致数据的更多信息)。Linux内核中的典型示例是路由表。因为路由更新可能需要好几秒钟甚至几分钟才能在互联网上传播,所以系统一直在以错误的方式发送数据包。在几毫秒内继续以错误的方式发送其中一些信息的小可能性几乎从来都不是问题。
如果您有一个需要一致数据的工作负载,RCU工作良好,如绿色的“读主要需要一致数据”框所示。本例中的一个例子是Linux内核从用户级System-V信号量id到相应的内核内数据结构的映射。信号量的使用频率远远超过它们被创建和销毁的频率,所以这个映射主要是读取的。但是,对已经被删除的信号量执行信号量操作将是错误的。这种对一致性的需求是通过使用内核内信号量数据结构中的锁,以及在删除信号量时设置的“已删除”标志来处理的。如果用户ID映射到带有“删除”标记集的内核数据结构,则数据结构被忽略,因此用户ID被标记为无效。
虽然这要求读取器为代表信号量本身的数据结构获得一个锁,但它允许他们免除对映射数据结构的锁定。因此,阅读器锁定地遍历用于从ID映射到数据结构的树,这反过来大大提高了性能、可伸缩性和实时响应。
正如黄色的“读写”框所示,RCU对于需要一致数据的读写工作负载也很有用,尽管通常与许多其他同步原语一起使用。例如,最近的Linux内核中的目录项缓存将RCU与序列锁、每个cpu锁和每个数据结构锁一起使用,以在常见情况下允许无锁遍历路径名。尽管RCU在这种读写情况下非常有益,但相应的代码通常比读取的代码在大多数情况下更复杂。
最后,如图左下角的红色方框所示,更新——主要是需要一致数据的工作负载很少是使用RCU的好地方,尽管也有一些例外[DMS+ 12]。例如,如第9.5.4.5节所述,在内部
在Linux内核中,SLAB_TYPESAFE_BY_RCU板分配器标志为RCU阅读器提供了类型安全的内存,这可以大大简化非阻塞同步和其他无锁算法。此外,如果罕见的读取器在实时系统上的关键代码路径上,为这些读取器使用RCU可能会提供实时响应的好处,超过弥补增加的更新端开销,如14.3.6.5节中讨论的。
简而言之,RCU是一个API,它包括一个用于添加新数据的发布-订阅机制,一种等待现有的RCU阅读器完成的方式,以及一个维护多个版本的原则,以允许更新以避免伤害或过度延迟并发的RCU阅读器。这个RCU API最适合读取-大多数情况,特别是当应用程序可以容忍陈旧和不一致的数据时。
已知的第一次提到类似RCU的东西是来自唐纳德·克努斯[Knu73,基本算法的第413页]的错误报告,反对约瑟夫·魏森鲍姆的滑移列表处理设施[魏63]。Knuth报告这个漏洞是正当的,因为SLIP没有任何形式的宽限期保证。
第一个已知的关于任何类似RCU的非漏洞报告出现在Kung和雷曼的里程碑式论文[KL80]中。在学术界有一些额外的应用[ML82,ML84,Lis88,Pug90和91,PAB+95,CAK+96,RSB+97,GKAS99],但该领域的大部分工作是由从业者[RTY+ 87,HOS89,Jac93,Joh95,SM95,SM97,SM98,MS98a]。
到2000年,该倡议已经传递给开源项目,最著名的是Linux内核社区[Rus00a,Rus00b,MS01,MAK+01,MSA+02,ACMS03].20 RCU在2002年底被接受到Linux内核中,随后有许多在可伸缩性、健壮性、实时响应、能源效率和专门用例方面的改进。截至2023年,linux内核RCU仍在积极开发中。
然而,在2010年代中期,许多社区和机构的RCU研发出现了一个可喜的热潮[Kaa15]。第9.5.5.1节描述了RCU的使用,第9.5.5.2describes节的RCU实现(以及创建和使用实现的工作),最后,第9.5.5.3节描述了RCU及其使用的验证和验证。
9.5.5.1 RCU使用
波特兰州立大学(PSU)的菲尔·霍华德和乔恩·沃波尔已经将RCU应用于红黑树[How12,HW11],并结合使用软件事务内存同步的更新。乔什·特里普利特和乔恩·沃波尔(同样是PSU的成员)将RCU应用于可调整大小的哈希表[Tri12、TMW11、Cor14c、Cor14d]。其他受RCU保护的可调整大小的散列表是由赫伯特·徐[徐10]和马修·德斯诺埃尔斯[MDJ13c]创建的。
麻省理工学院的奥斯汀·克莱门茨、弗兰斯·卡肖克和尼科莱·泽尔多维奇创建了一个RCU优化的平衡二叉树(盆景)[CKZ12],并将此树应用到Linux内核的虚拟机子系统中,以减少Linux内核的mmap_sem上的读端争用。这项工作导致了数量级的加速和可扩展性,至少可以达到80个cpu。这与PeterZijlstra[之前的14]开发的补丁类似,但两者都受到了限制,因为当时,文件系统数据结构对RCU阅读器并不安全。Clents等人通过优化匿名页面的页面故障路径,避免了这一限制。最近,文件系统数据结构已经安全RCU读者(Cor10,Cor11],所以也许这个工作可以实现所有页面类型,不只是匿名页面——彼得·齐吉尔斯特拉,事实上,最近原型,和劳伦特米福尔莱斯皮纳斯继续沿着这些线工作。马修·威尔科克斯和利亚姆·豪莱特正在努力使用RCU,以实现对其他内存管理数据结构的细粒度锁定和无锁访问。
麻省理工学院的延东·毛和罗伯特·莫里斯以及哈佛大学的埃迪·科勒创造了另一种RCU保护树马斯特特里[MKM12],它结合了B+树和尝试的想法。尽管这个树比受RCU保护的哈希表慢约2.5倍,但与哈希表不同,它支持对键范围的操作。此外,Masstree支持具有长共享密钥前缀的对象的高效存储,此外,通过日志记录到大量存储提供持久性。
该论文指出,马斯特里的性能与缓存相当,即使马斯特里持续存储更新,而缓存却没有。本文还将Masstree的性能与持久数据存储MongoDB、VoltDB和Redis进行了比较,报告了Masstree具有显著的性能优势,在某些情况下超过两个数量级。另一篇论文[TZK+ 13],由屠史蒂芬、郑文汀、芭芭拉·利斯科夫、麻省理工学院和科勒的塞缪尔·马登撰写,将马斯特里应用于一个名为Silo的内存数据库,在一个著名的交易处理基准上,实现了每秒700K笔交易(每分钟4200万笔交易)。有趣的是,筒仓保证了线性化性,而不会在保持锁时产生宽限期的开销。
玛雅·阿贝尔和哈吉特·阿蒂亚采取了更严格的方法[AA14]到RCU保护的搜索树,像马斯特里一样,允许并发更新。本文包括了一个正确的证明,包括证明这棵树上的所有操作都是线性化的。不幸的是,该实现通过在保持锁时引起宽限期等待的全部延迟来实现线性化性,这降低了仅进行更新的工作负载的可伸缩性。解决这个问题的一种方法是放弃线性化[HKLP12,McK14d],然而,Arbel和Attiya创建了一个RCU变体,以减少低端宽限期延迟。当然,没有什么是免费的,而且这个RCU变体似乎达到了大约32个cpu的可伸缩性限制。尽管对于降低线性化性,从而获得性能和可伸缩性还有许多可扩展性要说,但看到学者们试验替代RCU实现是非常好的。
蒂莫西·哈里斯创建了一个基于时间的用户空间RCU [Har01],它对雅各布森[Jac93]和Joh95]之前创建的RCU进行了改进。之前的两种基于时间的方法都假设读者持续时间有一个明显的上限,这可以在硬实时系统中正确工作。在非实时系统中,这种类型的方法
当读者被中断、抢占或以其他延迟时,容易失败。然而,这种容易失败的实现将被独立发明两次,这一事实表明了对类似RCU的机制的需求的深度。Timothy Harris对之前的两个努力进行了改进,要求每个读者在开始读侧遍历之前对全局时间基进行快照。然后推迟释放读取器可见对象,直到所有进程的读取器快照表明删除该对象之后的时间。然而,全局时间基础在某些系统上可能是昂贵且不准确的。
Keir Fraser创建了一个名为EBR的用户空间RCU,用于
非阻塞同步计时和软件事务性内存[Fra03,Fra04,FH07]。这项工作改进了蒂莫西·哈里斯的工作,用一个软件计数器取代了全球时钟,从而消除了大部分的费用和与当时的商品系统全球时钟相关的所有不准确性。有趣的是,这项工作一方面引用了Linux内核RCU,但同时也启发了最初的不可优先考虑的Linux内核RCU实现的QSBR这个名称。
马修·德斯诺耶斯创建了一个用户空间RCU,用于跟踪[Des09b、Des09a、DMS+ 12、MDJ13f、MDJ13c、MDJ13b、MDJ13d、MDJ13e、MDJ13h、MDJT13b、MDJ13g、MDJ13a、MDJT13a],已在许多项目中使用[BD13]。
布拉格查尔斯大学的研究人员也一直在致力于RCU的实现,包括安德烈·波齐米克[Pod10]和亚当·哈拉斯卡[Hra13]的论文。
刘玉洁(利哈伊大学)、维克多卢昌科(甲骨文实验室)和迈克尔斯皮尔(也叫利哈伊)按下可扩展非零指标(SNZI)[ELLLM07]作为宽限期机制。预期的用途是实现软件事务性内存(见第17.2节),它施加了线性化的要求,这反过来似乎限制了可伸缩性。
类似于RCU的机制也在寻找进入Java的方法。[SZJ12]使用类似RCU的机制来消除与Java的垃圾收集器交互时需要的读取障碍,从而导致显著的性能改进。
上海交通大学的刘润、张恒和陈海波研究了一种特殊的RCU,他们用于优化的“被动阅读-作者锁”[LZC14],类似于高萨姆·谢诺伊[她06]和斯里瓦萨·巴特[Bha14]创建的RCU。Liu等人的论文从一些专家方面都很有趣[McK14g]。
Mike Ash发布了一篇对苹果目标c运行时中类似RCU的原语的描述。这种方法通过指定的代码范围来识别读侧关键部分,从而成为实现零读侧开销的另一种方法,尽管这种方法给跨多个函数的大型读侧关键部分带来了一些有趣的实际挑战。
佩德罗·拉马尔赫特和安德烈亚·科雷亚[RC15]制作了《穷人的RCU》,尽管使用了一对读者-作者锁,但它设法为读者提供了无锁的前进保证[MP15a]。
玛雅·Ar贝尔和亚当·莫里森[AM15]制作了“谓词RCU”,它努力减少宽限期时间,以有效地支持在宽限期保持更新侧锁的算法。这导致减少了更新到宽限期的批处理,并减少了可伸缩性,但确实成功地提供了较短的宽限期。
亚历山大·马特维耶夫(麻省理工学院)、尼尔·沙维特(麻省理工学院和特拉维夫大学)、帕斯卡·费尔伯(纳沙泰尔大学)和帕特里克·马里耶(也是纳沙泰尔大学)[MSFM15]产生了一种类似RCU的机制,可以被认为是明确标记只读事务的软件事务内存。它们的用例需要在不同的宽限期内保持锁,这限制了可伸缩性[MP15a,MP15b]。这似乎是第一个充分利用RCU酷刑测试套件的与RCU相关的学术工作,也是第一个向linux内核RCU提交性能改进的工作,它被v4.4所接受。
亚历山大·马特维耶夫的RLU随后由Jaeho Kim等人的MV-RLU跟进[KMK+ 19]。这项工作通过允许多个并发更新,避免跨宽限期保持锁,以及使用渐同步宽限期,例如call_rcu()而不是synchronize_rcu(),提高了RLU的可扩展性。本文还做出了一些有趣的性能评估选择,并在第594页的第17.2.3.3节中进一步讨论。
Adam Belay等人创建了一个RCU实现,该实现保护其IX操作系统中TCP/IP的地址解析协议(ARP)使用的数据结构[BPP+ 16]。
迪米特里奥斯西亚等应用HTM和RCU搜索树[SNGK17,SBN+ 20],克里斯蒂娜等使用HTM和RCU颜色图[GGK18],和在公园等使用HTM和RCU优化NUMA系统上的高竞争锁定。
Alex Kogan等人将RCU应用于可伸缩地址空间的范围锁定[KDI20]。
9.5.5.3 RCU验证
在2017年初,人们普遍认为几乎任何bug都是一个潜在的安全漏洞,因此验证和验证是首要的问题。
石溪大学的研究人员已经生产了一个具有RCU感知功能的数据竞赛探测器[Dug10,Sey12,SRK+ 11]。IMDEA的阿列克谢·戈特斯曼、特拉维夫大学的诺姆·里内茨基和牛津大学的杨宏修克发表了一篇论文[GRY12],用分离逻辑表达了RCU的形式语义,并继续使用并发性的其他方面。
约瑟夫·塔萨罗蒂(卡内基-梅隆大学)、德里克·德雷尔(马克斯·普朗克软件系统研究所)和维克多·瓦菲亚迪斯(也叫MPI-SWS)[TDV15]制作了一个手动正式证明用户空间RCU的静止状态回收(QSBR)变体的正确性[Des09b,DMS+ 12]。梁(牛津大学)、保罗·麦肯尼(IBM)、丹尼尔·克罗宁和汤姆·梅勒姆(都包括牛津)[LMKM16]使用C有界模型检查器(CBMC)[CKL04]生成了Linux核树RCU相当一部分的正确性的机械证明。Lance Roy [Roy17]使用CBMC为大量linux内核可睡眠RCU(SRCU)提供了类似的正确性证明[McK06]。最后,米卡利斯和康斯坦丁诺斯·萨格纳斯(雅典国立技术大学)[KS17a,KS19]使用Nighugg工具[LSLK14]对Linux核树RCU的正确性进行了机械证明。
除了注入到RCU中专门用于测试验证工具的bug之外,这些工作都没有找到任何bug。相比之下,亚历克斯·格罗斯(俄勒冈州立大学)、伊夫特哈尔·艾哈迈德、卡洛斯·延森(两人都是俄亥俄州立大学)和保罗·e·麦肯尼(IBM)[GAJM15]
财产 | 参考计数 | 危险指针 | 序列锁定 | RCU |
| | | | |
内存开销 | 每个对象的计数器 | 每个指针 每个对象的读取器 | 无保护 | 没有一个 |
保护持续时间 | 可以是长 | 可以是长 | 无保护 | 用户必须绑定持续时间 |
需要遍历重试 | 如果对象已删除 | 如果对象已删除 | 如果有任何更新 | 从不 |
自动突变linux内核RCU的源代码,以测试rcu酷刑测试套件的覆盖范围。这项工作在这个套件的覆盖范围中发现了几个漏洞,其中一个是在Tiny RCU中隐藏了一个真正的漏洞(已经修复)。 如果幸运的话,所有这些验证工作最终将产生更多更好的并发代码验证工具。 |
9.6 选择哪个?
总是选择看起来最好的方式,不管它多么粗糙;习惯很快就会使它变得容易和愉快。
毕达哥拉斯
第9.6.1provides节是高级概述,第9.6.2provides节是本章中介绍的延迟处理技术之间的差异。本讨论假设一个链接的数据结构足够大,因此读者不会保存从一个遍历到另一个遍历的引用,并且可以在任何位置和任何时间添加和从结构中删除元素。第9.6.3节然后指出了一些危险指针、序列锁定和RCU的生产用途。这个讨论应该可以帮助您在这些技术之间做出明智的选择。
表9.7显示了一些区分延迟回收技术的高级属性。
“阅读器”行总结了图9.22中所示的结果,它显示了除了引用计数外,所有的结果都享有相当快速和可伸缩的阅读器。
“内存开销”行评估每种技术对存储阅读器保护的外部存储的需求。RCU依赖于静止状态,因此不需要存储来表示阅读器,无论是在对象的内部还是外部。引用计数可以在结构中的每个对象中使用一个整数,并且不需要额外的存储空间。危险指针需要提供外部到对象的指针,并且每个CPU或线程都有足够的指针来跟踪在任何给定时间被引用的所有对象。考虑到大多数基于危险指针的遍历只需要少数的危险指针,这在实践中通常不是一个问题。当然,
“保护持续时间”描述了用户可以保护给定对象的一段时间内的约束(如果有的话)。引用计数和危险指针都可以保护对象延长时间,不会产生副作用,但是保持对一个对象的RCU引用可以防止所有其他RCU被释放。因此,RCU读取器必须相对较短,以避免系统的内存不足,SRCU、Tasks RCU和Trace RCU等特殊目的实现是此规则的例外。同样,序列锁不提供指针遍历保护,这就是为什么它通常用于静态数据。
“需要遍历重试”行告诉对给定对象的新引用是否会无条件获得,就像使用RCU一样,或者引用获取是否会失败,导致重试操作,这是引用计数、危险指针和序列锁的情况。在引用计数和危险指针的情况下,只有在删除给定对象时试图获取对该对象的引用时,才需要重试,下一节将详细介绍这个主题。当然,如果它与任何更新同时运行,序列锁定必须重试其关键部分。
当然,不同的行在不同的情况下会有不同的重要性级别。例如,如果您当前的代码存在危险指针的读侧可伸缩性问题,那么危险指针可能需要重试引用获取并不重要,因为您当前的代码已经处理了这个问题。类似地,如果响应时间考虑已经限制了读取器遍历的持续时间,就像在内核和底层应用程序中经常发生的那样,那么RCU有持续时间限制要求并不重要,因为您的代码已经满足了它们。同样地,如果读者必须已经写入了他们正在遍历的对象,那么引用计数器的读取侧开销可能就不那么重要了。当然,如果要保护的数据是在静态分配的变量中,那么序列锁定无法保护指针就无关紧要了。
最后,基于延迟动态采样在危险指针和RCU之间的动态切换工作[BGHZ16]。这将将危险指针和RCU之间的选择推迟到运行时,并将决策的责任委托给软件。
然而,当在这些技术之间进行选择时,这个表应该有很大的帮助。但是那些希望有更多细节的人应该继续讲到下一节。
表9.8提供了更详细的经验法则,可以帮助您在本章中介绍的四种延迟处理技术中进行选择。
如“存在保证”行所示,如果需要对链接数据元素的存在保证,则必须使用引用计数、风险指针或RCU。序列锁不提供存在保证,而是提供更新检测,重试任何遇到更新的读侧关键部分。
财产 | 参考计数 | 危险指针 | 序列锁定 | RCU |
| | | | |
更新和阅读器同时取得进展 | 是 | 是 | 不 | 是 |
读者之间的争论 | 高 | 没有一个 | 没有一个 | 没有一个 |
每个关键部分的读者们的开销 | N/A | N/A | 二 smp_mb() | 范围从无到两个smp_mb() |
每个对象的读取器遍历开销 | 读修改-写原子操作、内存障碍指令和缓存 小姐 | smp_mb()* | 没有,但不安全 | 无(易失性访问) |
读者前进进度保证 | 无锁 | 无锁 | 阻塞 | 无限制等待 |
阅读器参考获取 | 可能失败(条件) | 可能失败 有前提的 | 不安全 | 不能失败 无条件的 |
内存足迹 | 有界的 | 有界的 | 有界的 | 无界的 |
复垦前进进度 | 无锁 | 无锁 | N/A | 阻塞 |
自动回收 | 是 | 用例 | N/A | 用例 |
代码行 | 94 | 79 | 79 | 73 |
*这个smp_mb()可以通过使用linux内核成员屏障()系统调用将其降级为编译器屏障()。
当然,正如“更新和读取器并发进展”行所示,这种更新检测意味着序列锁定不允许更新器和读取器同时进行向前进展。毕竟,阻止这种前进的进程是首先使用序列锁定的全部要点!这种情况指出了将序列锁定与参考计数、危险指针或RCU结合使用的方法,以提供存在性保证和更新检测。实际上,Linux内核在路径名查找期间以这种方式结合了RCU和序列锁定。
“读者之间的争论”、“读者每评论部分开销”和“读者每对象遍历开销”行粗略了这些技术的阅读侧开销。引用计数的开销可能相当大,阅读器之间存在争议,并且对所遍历的每个对象都需要一个完全有序的读-修改-写原子操作。危险指针会为遍历的每个数据元素和序列锁产生内存障碍的开销
“阅读器前进保证”行显示,只有RCU有一个有限的无等待前进保证,这意味着它可以通过执行有限数量的指令来执行有限的遍历。
“阅读器参考获取”行表示只有RCU能够无条件地获取参考。序列锁的条目是“不安全的”,因为,同样,序列锁检测更新,而不是获取引用。如果给定的获取失败,引用计数和危险指针都要求从一开始就重新启动遍历。要查看这一点,请考虑一个按该顺序包含对象a、B、C和D的链表,以及以下一系列事件:
1.阅读器获得对对象B的引用。
2.更新器删除对象B,但避免释放它,因为阅读器持有一个引用。该列表现在包含对象A、C和D,并且对象B的->的下一个指针被设置为HAZPTR_POISON。
3.更新程序删除对象C,以便列表现在包含对象A和d。因为没有对对象C的引用,所以它立即被释放。
4.读取器试图在现在已被删除的对象B之后前进到对象的后继者,但是中毒的>下一个指针阻止了这一点。这是一件好事,因为对象B的->下一个指针会指向自由职业者。
5.因此,读者必须从列表的头部重新启动其遍历。
因此,当无法获取引用时,危险指针或引用计数器遍历必须从一开始就重新启动该遍历。在嵌套的链接数据结构的情况下,例如,一个包含链接列表的树,必须从最外层的数据结构重新启动遍历。这种情况使RCU具有显著的易用性优势。
然而,RCU的易用性优势并不是免费提供的,这可以在“内存足迹”行中看到。RCU对无条件引用获取的支持意味着它必须避免释放给定的RCU阅读器可访问的任何对象,直到该读取器完成。因此,RCU具有无限制的内存占用,至少除非更新被限制。相反,引用计数和风险指针只需要保留并发读取器实际引用的那些数据元素。
内存占用和获取失败之间的这种紧张关系有时在Linux内核中通过结合使用RCU和引用计数器来解决。RCU用于短命引用,这意味着RCU读侧临界部分可以很短。这些较短的RCU读取侧临界部分反过来意味着相应的RCU宽限期也可以很短,这限制了内存占用。对于需要更长寿命引用的少数数据元素,将使用引用计数。这意味着参考获取失败的复杂性只需要处理这些少数数据元素:大部分参考获取是无条件的,由RCU提供。有关将引用计数与其他同步机制相结合的更多信息,请参见第13.2节。
“回收前进进度”行显示,危险指针可以提供非阻塞的更新[Mic04a,HLM02]。引用计数可能会,也可能不会,这取决于实现。然而,序列锁定不能提供非阻塞的更新,因为它的更新侧锁。RCU更新者必须等待阅读器,这也排除了完全非阻塞的更新。然而,也有一些情况下
唯一的阻塞操作是等待释放内存,这导致的情况,在许多目的上,与非阻塞一样好[DMS+ 12]。
如“自动回收”行所示,只有引用计数才能自动释放内存,即使只有非循环数据结构。危险指针和RCU的某些用例可以使用链接计数提供自动回收,这可以被认为是参考计数,但仅适用于来自数据结构的其他部分的传入链接[Mic18]。
最后,“代码行”行显示了在bsd之前的路由表实现的大小,给出了一个相对易用性的粗略概念。也就是说,需要注意的是要注意,引用计数和序列锁定实现是有问题的,而且正确的引用计数实现要复杂得多[Val95,MS95]。就其本身而言,一个正确的序列锁定实现需要添加一些其他的同步机制,例如,危险指针或RCU,以便序列锁定检测并发更新,而其他机制提供了安全的引用获取。
随着使用这些技术获得更多的经验,无论是单独的还是在组合中,本节中提出的经验规则将需要细化。然而,这节确实反映了当前的技术水平。
本节指出了一些对危险指针、序列锁定和RCU的公开可见的生产用途。参考文献计数被省略了,不是因为它不重要,而是因为它不仅被广泛使用,而且在半个世纪前的教科书中有大量的文献记载。列出这些其他技术的生产用途的人们希望得到的好处之一是提供例子来研究,或者发现错误,视情况而定。21
9.6.3.1生产使用危险指针
2010年,基思·博斯蒂克增加了一个危险指针。2015年发布的MongoDB 3.0包含了“连线老虎”,因此也包含了危险指针。
2011年,Samy Al Bahra向并发工具包库添加了危险指针[Bah11b]。
2014年,马克西姆·基津斯基增加了危险指针。
2015年,David Gwynne向OpenBSD [Gwy15]引入了共享参考指针,一种危险指针的形式。
2017-2018年,铁锈语言弧交换[Van18]和conc[切割17]箱推出了自己的危险指针实现。
2018年,法师迈克尔向脸书的愚蠢图书馆[Mic18]添加了危险指针,在那里它被大量使用。
9.6.3.2生产中使用的序列锁定
Linux内核在2003年为v2.5.60添加了序列锁定[Cor03],这是通过x86实现每日()系统调用中使用的一种特殊技术来实现的。
2011年,Samy Al Bahra在并发工具包库中增加了序列锁定[Bah11c]。
保罗·邦齐尼在2013年在QEMU模拟器中添加了一个简单的序列锁定[Bon13]。
Alexis Menard在2016年在铬中抽象了一个序列锁实现[Men16]。
2018年,在宝石()中添加了一个简单的序列锁定实现[Gol18a]。特征库还有一个特殊目的的队列,由类似于序列锁定的机制管理。
9.6.3.3生产使用的RCU
IBM的VM/XA采用了被动序列化机制,这类似于20世纪80年代的RCU[HOS89]。
DYNIX/ptx于1993年采用了RCU[MS98a,SM95]。
Linux内核采用了迪潘卡尔Sarma的RCUin 2002的实现[Tor02]。用户空间RCU项目始于2009年[Des09b]。
Knot DNS项目从2010年开始使用用户空间RCU库[Slo10]。同年,OSv内核增加了一个RCU实现[Kiv13],后来又添加了一个受RCU保护的链接列表[Kiv14b]和一个受RCU保护的哈希表[Kiv14a]。
2011年,Samy Al Bahra在并发工具包库[Bah11a]中添加了epochs(RCU的一种形式[Fra04,FH07])。
NetBSD在2012年开始使用上述的v6.0的被动序列化[The12a]。除此之外,在NetBSD数据包过滤器(NPF)中使用了被动序列化[Ras14]。
2015年,Paolo Bonzini在2015年通过用户空间RCU库的友好分叉为QEMU模拟器添加了RCU支持[BD13,Bon15]。
2015年,马克西姆·基津斯基将RCU加入利比亚银行[Khi15]。
我们在2016年实现了libqsbr,即基于QSBRand时代的回收(EBR)[Ras16],这两种方法都是RCU的实现类型。
Sheth等人[SWS16]演示了利用Go的垃圾收集器提供类似RCU的功能的价值,并且Go编程语言提供了可以提供此功能的值类型。22
马特·克莱因描述了一种用于特使代理的RCU类机制[Kle17]。
2018年,本田在数据平面开发工具包(DPDK)中增加了一个RCU库[Nag18]。
Stjepan Glavina将基于时代的RCU实现合并到锈语言[Gla18]的并发支持“板条箱”中。
杰森·多南菲尔德制作了一个RCU实现,作为他的连线端口到窗口NT内核的一部分[Don21]。
最后,任何被垃圾收集的并发语言(而不仅仅是Go!)以零增量成本获得RCU实现的更新部分。
也许当序列锁定、危险指针和RCU都像引用计数器一样被广泛使用和众所周知的时候就会到来。在此之前,这些机制的生产使用应该有助于指导机制的选择
以及展示如何最好地应用它们中的每一个。这样,我们就揭开了在第201页提出的最后一个谜团。
下一节将讨论更新,这是本章中描述的许多阅读机制的一个棘手的问题。
9.7 更新怎么样?
生活中唯一的东西就是改变。
弗朗索瓦·德拉罗切福科德
本章中提到的延迟处理技术最直接适用于阅读——主要是一些情况,这就引出了这样一个问题:“但是更新怎么办呢?”毕竟,提高读者的性能和可伸缩性是很好的,但很自然地也想要良好的性能和可伸缩性。
我们已经看到了一种具有高性能和可伸缩性的情况,即第五章中调查的计数算法。这些算法以部分分区的数据结构为特色,因此更新可以在本地操作,而更昂贵的读取必须跨越整个数据结构。塞拉斯·博伊德-维克希泽已经将这个概念推广到生成OpLog,他已经将其应用于linux内核路径名查找、VM反向映射和stat()系统调用[BW14]。
另一种方法被称为“干扰者”,是为处理大容量输入数据流的应用程序设计的。该方法是依赖于单生产者-单消费者的FIFO队列,从而最大限度地减少对同步的需要[Sut13]。对于Java应用程序,破坏器还具有最小化对垃圾收集器的使用的优点。
当然,在可行的情况下,完全分区或“共享”系统提供了优秀的性能和可伸缩性,如第6章所述。
下一章将介绍几种类型的数据结构的上下文中的更新。