进步的艺术是在变化中保持秩序,在秩序中保持变化。
怀德海
因果关系和排序是非常直观的,黑客通常对这些概念有很强的把握。这些直觉在编写、分析和调试顺序代码时不仅非常有用,而且在使用诸如锁定等标准互斥机制的并行代码时也非常有用。不幸的是,这些直觉在代码中完全崩溃,而是使用弱有序的原子操作和内存障碍。这类代码的一个示例实现了标准互斥机制,而另一个示例实现了使用较弱同步的快速路径。尽管侮辱了直觉,但有些人认为弱点是一种美德。美德或缺点,这一章将帮助您理解内存顺序,通过实践,这将足以实现同步原语和性能关键的快速路径。
第15.1节将演示真实的计算机系统可以重新排序内存参考,给出它们这样做的一些原因,并提供一些关于如何防止不希望的重新排序的信息。第15.2节和第15.3节将分别涵盖硬件和编译器可能给粗心的并行程序员带来的痛苦类型。第15.4节概述了在更高的抽象级别上建模内存排序的好处。第15.5节随后将详细介绍一些具有代表性的硬件平台。最后,第15.6节提供了一些可靠的直觉和有用的经验法则。
除非人们控制它,没有什么是有序的。创造中的一切都是松散的。
亨利沃德比彻,更新
内存排序的一个动机可以在清单15.1(C-SB+o-o+o-o.litmus)中看似简单的试金石中看到,乍一看似乎可以保证
纯粹主义者会坚持存在条款永远不会被满足,但我们在这里使用“触发”来类比断言。
2,即线程P0()的局部变量r2的实例等于零。试金石命名法的文件见第12.2.1节。
3请注意,结果对确切的硬件配置、系统加载的程度以及其他许多方面都很敏感。所以为什么不在你自己的系统上尝试一下呢?
但是为什么记忆排序首先会发生错误呢?难道cpu就不能自己跟踪订购情况吗?这难道不是我们最初就有电脑来记录事情的原因吗?
许多人确实希望他们的电脑能跟踪事情,但也有许多人坚持认为他们要快速跟踪事情。事实上,对性能的关注是如此强烈,以至于现代cpu非常复杂,这从图15.1中的简化方框图中可以看出。那些需要从他们的系统中挤出最后几个百分点的性能的人,反过来,在调整他们的软件时,也需要密切关注这个数字的细节。除了这种对细节的密切关注意味着当一个给定的CPU随着年龄的增长而退化时,软件将不再在它上快速运行。例如,如果最左边的ALU失败,经过调优以充分利用所有ALU的软件可能比未调优的软件运行得更慢。解决这个问题的一个方案是,一旦系统的任何cpu开始退化,就停止服务。
另一种选择是回顾第3章的经验教训,特别是对于许多重要的工作负载,主内存无法跟上现代cpu,而现代cpu可以在从内存中获取单个变量所需的时间内执行数百个指令。对于这样的工作负载,CPU的详细内部结构是无关的,
CPU可以用图15.2中标记的CPU、存储缓冲区和缓存来近似。
因为这些数据密集型工作负载,CPU运动越来越大的缓存,如图3.11,这意味着尽管第一个加载由给定的CPU从一个给定的变量将导致一个昂贵的缓存错过3.1.6节中讨论,随后重复加载变量,CPU可能很快执行,因为初始缓存错过将变量加载到CPU的缓存。
但是,也需要容纳从多个cpu到一组共享变量的频繁并发存储。在缓存相干系统中,如果缓存包含给定变量的多个副本,则该变量的所有副本必须具有相同的值。这对于并发加载工作得非常好,但对于并发存储却不那么好:每个存储必须对旧值的所有副本做一些事情(另一个缓存丢失!),考虑到有限的光速和物质的原子性质,这将比急躁的软件黑客所希望的要慢。而这些存储字符串则是在图15.2中使用蓝色块标记的存储缓冲区的原因。
从图15.2中删除内部CPU复杂度,添加第二个CPU,并在图15.3中显示主内存结果。当给定的CPU存储到该CPU缓存中不存在的变量时,那么新值将被放置在该CPU的存储缓冲区中。然后,CPU可以立即继续操作,而不必等待存储区对位于其他CPU缓存中的该变量的所有旧值进行处理。
尽管存储缓冲区可以极大地提高性能,但它们可能会导致指令和内存引用的执行异常,从而导致严重的混乱,如图15.4所示。
特别是,存储缓冲区会导致如清单15.1所示的内存排序错误。
表15.1显示了导致这种错误排序的步骤。第1行显示了初始状态,其中CPU 0在缓存中有x1,CPU1在缓存中有x0,这两个变量的值都为零。第2行显示了由于每个CPU的存储区而引起的状态变化(清单15.1中的第9行和第17行)。因为两个CPU在缓存中都没有存储到变量,所以两个CPU都在各自的存储缓冲区中记录它们的存储。
第3行显示了两个加载项(清单15.1中的第10行和第18行)。因为每个CPU加载的变量在该CPU的缓存中,所以每个加载立即返回缓存值,在这两种情况下都为零。
但是cpu还没有完成:它们迟早必须清空存储缓冲区。
因为缓存移动数据在相对较大的块称为数据线,因为每个数据线可以持有几个变量,每个CPU必须得到数据线到自己的缓存,这样它可以更新的部分数据线对应的变量的存储缓冲区,但不干扰任何数据线的其他部分。每个CPU还必须确保弹轴线不存在于任何其他CPU的缓存中,为此使用读取无效操作。如第4行所示,在两个读取无效操作完成后,两个CPU交换了粗线,因此CPU0的缓存现在包含x0,而CPU1的缓存现在包含x1。一旦这两个变量进入了它们的新家,每个CPU就可以将其存储缓冲区刷新到相应的缓存行中,并保留每个变量的最终值,如第5行所示。
总之,需要存储缓冲区来允许cpu有效地处理存储指令,但它们可能会导致违反直觉的内存排序错误。
但是如果你的算法真的需要它的内存引用,你会怎么做呢?例如,假设您正在使用一对标志与一个驱动程序进行通信,一个标志表示驱动程序是否在运行,另一个标志表示是否在运行
1 C C-SB+o-mb-o+o-mb-o 2 3 {} 4 5 P0(int *x0, int *x1) 6 { 7 int r2; 8 11 r2 = READ_ONCE(*x1); 12 } 13 14 P1(int *x0, int *x1) 15 { 16 int r2; 17 20 r2 = READ_ONCE(*x0); 21 } 22 23已存在(1:r2=0 /\ 0:r2=0) |
对该驱动程序有一个未决的请求。请求者需要设置请求-挂起的标志,然后检查驱动程序正在运行的标志,如果为false,则唤醒驱动程序。一旦驱动程序服务了它知道的所有挂起请求,它需要清除驱动程序运行标志,然后检查请求挂起标志以查看ifit需要重新启动。这种非常合理的方法不能工作,除非有一些方法来确保硬件处理存储和加载的顺序。这是下一节的主题。
事实证明,有一些编译器指令和同步原语(如锁定和RCU)负责通过使用内存障碍(例如,Linux内核中的smp_mb())来维护排序的错觉。这些记忆障碍可以是显式指令,因为它们在手臂、权力、安定和阿尔法上,或者它们可以被其他指令暗示,因为它们通常在x86上。由于这些标准的同步原语保留了排序的错觉,因此您的阻力最小的路径是简单地使用这些原语,从而允许您停止阅读本节。
但是,如果您需要实现同步原语本身,或者如果您只是对了解内存顺序的工作原理感兴趣,请继续阅读吧!这个旅程的第一站是清单15.2(C-SB+o-mb-o+o-mb-o.litmus),它在P0()和P1()中的存储和加载之间放置了一个smp_mb()linux内核的全内存障碍,但在其他方面与清单15.1相同。这些障碍阻止了在我的x86笔记本电脑上的1亿次试验中发生反直觉的结果。有趣的是,由于这些障碍而增加的开销导致法律结果,两个负载返回值2超过80万次,而清单15.1中的无障碍代码只有167次。
这些障碍对排序有深刻的影响,如表15.2所示。虽然前两行与表15.1中相同,尽管smp_mb()
第3行上的说明本身不会改变状态,它们确实会导致存储在加载(第6行)之前完成(第4行和第5行),这排除了表15.1中所示的反直觉的结果。注意,变量x0和x1仍然有大于
表15.2:内存排序:事件的存储-缓冲顺序
然而,第2行的一个值,正如前面承诺的那样,smp_mb()调用最终会解决问题。
尽管像smp_mb()这样的完全障碍具有非常强的排序保证,但它们的优势在放弃的硬件和编译器优化方面具有很高的价格。许多情况可以用更弱的排序来处理,保证使用更便宜的内存排序指令,或者,在某些情况下,根本没有内存排序指令。
表15.3提供了Linux内核的排序原语及其保证的廉价表。每一行对应于一个可能提供或不提供排序的原语或类别,标记为“先前排序操作”和“后续排序操作”的列是可能(或可能不)排序的操作。包含“Y”的单元格表示无条件地提供排序,而其他字符表示只部分或有条件地提供排序。空白单元格表示没有提供订单。
“存储”行还涵盖了原子RMW操作的存储部分。此外,“负载”行覆盖了一个成功的值返回的_放松的()RMW原子操作的负载组件,尽管组合的“_放松的()RMW操作”行在值返回的情况下提供了一个方便的组合引用。执行不成功的值返回RMW操作原子的CPU必须使所有其他CPU缓存中的相应变量无效。因此,不成功的值返回原子RMW操作具有存储的许多属性,这意味着“_放松的()RMW操作”行也适用于不成功的值返回原子RMW操作。
*_获取行覆盖smp_load_acquire(),cmpxchg_acquire(),xchg_获取(),等等;*_释放行覆盖smp_store_release(),rcu_分配指针(),cmpxchg_release(),xchg_release(),等;而“成功的全强度非无效RMW”行包括原子_添加_返回(),原子_添加_,除非(),atomic_dec_and_test(),cmpxchg(),xchg(),等等。“成功”限定符适用于原子_add_,除非()、cmpxchg_acquire()和cmpxchg_release(),当它们指示失败时,它们对内存或排序都没有影响,如前面的“_放松()RMW操作”行所示。
列“C”表示累积量和传播量,如第15.2.7.1节和第15.2.7.2节所述。同时,当涉及最多有两个线程时,通常可以忽略此列。
需要注意的是,这个表只是一个备忘表,因此绝对不能替代对内存顺序的良好理解。为了开始建立这样的理解,下一节将介绍一些基本的经验法则。
本节介绍了一些“好且足够”的基本经验规则。实际上,您可以编写大量具有出色性能和可伸缩性的并发代码,而不需要任何这些经验规则。更复杂的经验规则将在第15.6节中介绍。
一个给定的线程会按顺序查看它自己的访问权限。此规则假设从/到共享变量的加载和存储分别使用READ_ONCE()和WRITE_ONCE()。否则,编译器可能会深刻地打乱您的代码,有时CPU也会做一些打乱,如第15.5.4节中所讨论的。
中断处理程序和信号处理程序是线程的一部分。中断处理程序和信号处理程序都发生在一个线程中的一对相邻指令之间。这意味着给定的处理程序似乎从中断线程的角度进行原子执行,至少在汇编语言级别上是这样。但是,C和C++语言并没有定义共享普通变量的处理程序和中断线程的结果。相反,这些共享变量必须是sig_atomic_t、无锁原子或易失性原子。
另一方面,由于处理程序在中断的线程的上下文中执行,因此用于同步处理程序和线程之间通信的内存顺序可能非常轻量级。例如,获取负载的对应是READ_ONCE(),后面是()编译器指令,发布存储的对应是屏障(),后面是WRITE_ONCE()。一个完整的内存障碍的对应物是障碍()。最后,在线程内禁用中断或信号(视情况而定)不包括处理程序。
排序具有有条件的if-then语义。图15.5illustrates,这是内存障碍。假设两个存储障碍都足够强,如果CPU 1,s访问Y1发生在CPU 0,s访问Y0之后,那么CPU 1,s访问X1保证发生在CPU 0,s访问X0之后。当你怀疑哪些记忆障碍足够强大时,smp_mb()总是会做这项工作,尽管要付出代价。
清单15.2就是一个很恰当的例子。第10和19行上的smp_mb()作为屏障,第9行的x0存储为X0,第11行的x1的负载为Y0,第18行的x1存储为Y1,第20行的x0的负载为X1。逐步应用if-then规则,我们知道如果P10()的局部变量r2被设置为值0,则在第11行从x1到x1加载之后发生。if-then规则将声明从第20行的x0的加载发生在存储到第9行的x0之后。换句话说,只有当P0()的局部变量r2以值0结尾时,P1()的局部变量r2才保证以值2结尾。这强调了内存排序保证是有条件的,而不是绝对的。
虽然图15.5特别提到了内存障碍,但同样的if-then规则也适用于Linux内核的其他排序操作。
订购操作必须进行配对。如果您在一个线程中仔细地排序操作,但在另一个线程中没有这样做,那么就没有排序。这两个线程都必须为应用if-then规则提供排序。
订购操作几乎永远不会加快运行速度。如果您发现自己试图添加一个内存障碍,试图迫使之前的存储更快地刷新到内存,请抵制!增加订购量通常会减慢工作速度。当然,在某些情况下,添加指令会加速运行,如图254页上的图9.22所示,但在这种情况下,需要进行仔细的基准测试。即便如此,很有可能虽然你在系统上加快了一些速度,但你很可能在用户的系统上大大放慢了速度。或者关于你未来的系统。
订购操作并不神奇。当您的程序由于某些竞争条件而失败时,通常很容易加入一些内存排序操作,试图阻止您的bug不存在。一个更好的反应是以一种精心设计的方式使用更高级级的原语。在并发编程中,设计不存在的错误几乎总是比将它们压缩到更低的概率更好。
这些都只是粗略的经验法则。尽管这些经验法则涵盖了在实际实践中看到的绝大多数情况,就像任何一套经验法则一样,它们确实有它们的局限性。下一节将通过引入试金石测试来演示这些限制,这些测试旨在侮辱你的直觉,同时增加你的理解。这些试金石测试还将阐明表15.3中所示的linux内核内存排序备忘单所代表的许多概念,并可以在适当的工具下自动分析[AMM+ 18]。第15.6节将回到这个小抄,根据所有干预的干预技巧和陷阱,展示一套更复杂的经验规则。
清单15.3:软件逻辑分析器 |
1个状态。变量= mycpu; |
2 lasttb = oldtb = firsttb = gettb(); |
3而(状态变量== mycpu){ |
4 lasttb = oldtb; |
5 oldtb = gettb(); |
6 如果(首先第一个> 1000) |
7 破碎 |
8 } |
知道陷阱在哪里,这是逃避它的第一步。 |
莱托·阿特雷德斯公爵,沙丘,弗兰克·赫伯特
现在您知道硬件可以重新排序内存访问,并且可以阻止它这样做,下一步就是让您承认您的直觉有问题。第15.2.1节介绍了这个痛苦的任务,第15.2.1节,该节展示了一些代码,表明标量变量可以同时接受多个值,第15.2.2到15.2.7节展示了一系列直观正确的代码片段,这些代码片段在实际硬件上严重失败。一旦你的直觉通过了悲伤的过程,后面的部分将总结记忆排序所遵循的基本规则。
但是首先,让我们快速看看单个变量在单个时间点上可能有多少个值。
很自然地认为一个变量是按照定义良好的全局顺序接受定义良好的值序列。不幸的是,旅程中的下一站会对这个安慰人的小说说“再见”。希望您已经开始对表15.1和表15.2的第2行说“再见”,如果是这样的话,本节的目的是要强调这一点。
为此,请考虑清单15.3中所示的程序片段。这个代码片段由多个cpu并行执行。第1行为当前CPU的ID设置一个共享变量,第2行初始化gettb()函数中的几个变量,该函数提供一个细粒度硬件“时间基”计数器的值,在所有CPU之间同步(不幸的是,不是所有CPU架构都可用),从第3-8行开始的循环记录了变量保留这个CPU分配给它的值的时间长度。当然,其中一个cpu将“赢”,因此如果没有因为第6-7行的检查,就永远不会退出循环。
在退出循环,firsttb将持有一个时间戳后不久分配和lasttb将持有一个时间戳之前的最后采样共享变量仍然保留分配值,或值等于firsttb如果共享变量已经改变之前进入循环。这使得我们可以在532纳秒的时间段内绘制每个CPU的状态值的视图,如图15.6所示。该数据是在2006年在1.5 GHz POWER5系统上收集的
有8个核,每个核包含一对硬件线程。CPu1、2、3、4记录这些值,CPU 0控制测试。时间基计数器周期约为5.32 ns,足够细粒度,允许观察中间缓存状态。
每个水平条表示给定CPU随时间变化的观察结果,左边的灰色区域表示相应CPU第一次测量之前的时间。在前5 ns中,只有CPU 3对该变量的值有一个意见。在接下来的10 ns中,cpu2和3对变量的值存在不一致,但随后同意该值是“2”,这实际上是最终商定的值。然而,CPU 1认为近300 ns的值是“1”,而CPU 4认为近500 ns的值是“4”。
如果您认为有四个cpu的情况很有趣,那么考虑图15.7,它显示了相同的情况,但是在时间t = 0时,每个都有15个cpu将它们的数量分配给单个共享变量。图中的两个图的绘制方式与图15.6相同。唯一的区别是,横轴的单位是时间基蜱,每个蜱持续约5.3纳秒。因此,整个序列比图15.6中记录的事件要长一些,这与cpu数量的增加相一致。上面的图表显示了整体图片,而下面的图表放大了前50个时间基。同样,CPU 0协调测试,因此不记录任何值。
所有cpu最终对最终值9达成一致,但在值15和12提前领先之前。请注意,对于下图中垂直线所示的时间21时变量的值有14种不同的观点。还要注意,所有cpu看到的序列的顺序与图15.8中所示的有向图一致。然而,这些数字强调了正确使用内存排序操作的重要性。
一个变量在单个时间点上可以承担多少个值?在系统中,每个存储缓冲区多达一个!因此,我们进入了一种制度,在那里我们必须告别关于变量值和时间流逝的舒适直觉。这是需要进行内存排序操作的机制。
但是请记住第三章和第六章的经验教训。将所有cpu并发存储到同一个变量是设计并行程序的方法,至少如果性能和可伸缩性对您很重要的话是这样的。
不幸的是,内存排序有许多其他方式可以侮辱你的直觉,而且并不是所有这些方式都与性能和可伸缩性相冲突。下一节将详细介绍对不相关的内存引用的重新排序。
1 C C-MP+o-wmb-o+o-o 2 3 {} 4 5 P0(int* x0, int* x1) { 6 WRITE_ONCE(*x0,2); 7 smp_wmb(); 8 WRITE_ONCE(*x1,2); 9 } 10 11 P1(int* x0, int* x1) { 12 int r2; 13 int r3; 14 15 r2 = READ_ONCE(*x1); 16 r3 = READ_ONCE(*x0); 17 } 18 19个已存在(1:r2=2 /\ 1:r3=0) |
第15.1.1节显示,即使是像x86这样相对强排序的系统,也可以在以后的加载中重新排序之前的存储,至少当存储和加载是针对不同的变量时是这样。本节建立在该结果的基础上,我们将查看负载和存储的其他组合。
清单15.4(C-MP+o-wmb-o+o-o.litmus)显示了经典的消息传递试金石,其中x0是消息,x1是指示消息是否可用的标志。在此测试中,smp_wmb()强制订购P0()存储,但没有为负载指定排序。相对强排序的架构,如x86,确实会强制排序。然而,弱有序的体系结构通常不是[AMP+ 11]。因此,清单的第19行上的存在子句可以触发。
清单15.5:执行消息传递试金石的顺序 |
1 C C-MP+o-wmb-o+o-rmb-o 2 3 {} 4 5 P0(int* x0, int* x1) { 6 WRITE_ONCE(*x0,2); 7 smp_wmb(); 8 WRITE_ONCE(*x1,2); 9 } 10 11 P1(int* x0, int* x1) { 12 int r2; 13 int r3; 14 16 smp_rmb(); 17 r3 = READ_ONCE(*x0); 18 } 19 20已存在(1:r2=2 /\ 1:r3=0) |
1 C C-LB+o-o+o-o 2 3 {} 4 5 P0(int *x0, int *x1) 6 { 7 int r2; 8 9 r2 = READ_ONCE(*x1); 10 WRITE_ONCE(*x0,2); 11 } 12 13 P1(int *x0, int *x1) 14 { 15 int r2; 16 17 r2 = READ_ONCE(*x0); 18 WRITE_ONCE(*x1,2); 19 } 20 |
从不同位置重新排序加载的一个基本原理是,当早期的加载缺少缓存,但后期加载的值已经存在时,这样做允许继续执行。
因此,依赖于有序加载的便携式代码必须添加显式排序,例如,清单15.5(C-MP+o-wmb-o+o-rmb-o.litmus中第16行所示的smp_rmb()),这可以防止存在子句的触发。
15.2.2.2加载之后是存储
清单15.6(C-LB+o-o+o-o.litmus)显示了经典的负载缓冲试金石。尽管相对强排序的系统,如x86或IBM大型机不会与后续存储一起重新排序之前的加载,但许多弱排序的架构确实可以
1 C C-LB+o-r+a-o 2 3 {} 4 5 P0(int *x0, int *x1) 6 { 7 int r2; 8 9 r2 = READ_ONCE(*x1); 10 smp_store_release (x0, 2); 11 } 12 13 P1(int *x0, int *x1) 14 { 15 int r2; 16 17 r2 = smp_load_acquire (x0); 18 WRITE_ONCE(*x1,2); 19 } 20 |
清单15.8:消息传递试金石,无作者订购(无订购) |
1 C C-MP+o-o+o-rmb-o 2 3 {} 4 5 P0(int* x0, int* x1) { 6 WRITE_ONCE(*x0,2); 7 WRITE_ONCE(*x1,2); 8 } 9 10 P1(int* x0, int* x1) { 11 int r2; 12 int r3; 13 14 r2 = READ_ONCE(*x1); 15 smp_rmb(); 16 r3 = READ_ONCE(*x0); 17 } 18 19个已存在(1:r2=2 /\ 1:r3=0) |
允许这样的重新排序[AMP+ 11]。因此,第21行上的存在子句确实可以触发。
虽然实际硬件很少出现这种重新排序[3月17日],但需要这样做的一种情况是,当加载缺少缓存,存储缓冲区几乎已满,后续存储的轴线已经准备就绪。因此,可移植代码必须强制执行任何必需的排序,例如,如清单15.7(C-LB+o-r+a-o.litmus)所示。smp_store_release()和smp_load_acquire()保证第21行上的存在子句永远不会触发。
清单15.8(C-MP+o-o+o-rmb-o.litmus)再次显示了经典的消息传递试金石测试,smp_rmb()提供了对P1(),s加载的订购,但对P0(),s存储没有任何订购。同样,相对强有序的体系结构确实强制排序,但弱有序的体系结构并不一定这样做[AMP+ 11],这意味着存在子句可以触发。这种重新排序可能是有益的一种情况是,当存储缓冲区已满时,另一个存储已准备好执行,但最老的商店所需的粗线还不可用。在这种情况下,
1 C C-MP+o-wmb-o+ld-addr-o 2 3 { 4 y=1; 5 x1=y; 6 } 7 8 P0(int* x0, int** x1) { 9 WRITE_ONCE(*x0,2); 10 smp_wmb(); 11 WRITE_ONCE(*x1,x0); 12 } 13 14 P1(int** x1) { 15 int *r2; 16 int r3; 17 19 r3 = READ_ONCE(*r2); 20 } 21 22已存在(1:r2=x0 /\ 1:r3=1) |
7请注意,在v4.15及更高版本上不需要无锁的_去引用(),因此在这些以后的Linux内核中不可用。在包含这个脚注的这本书的版本中也不需要它。
清单15.12:负载缓冲数据相关的试金石 |
1CC-LB+-r+数据-o2 3 {} 4 5 P0(int *x0, int *x1) 6 { 7 int r2; 8 9 r2 = READ_ONCE(*x1); 10 smp_store_release (x0, 2); 11 } 12 13 P1(int *x0, int *x1) 14 { 15 int r2; 16 18 WRITE_ONCE(*x1,r2); 19 } 20 21个已存在(1:r2=2 /\ 0:r2=2) |
然而,需要注意的是,地址依赖关系可能是脆弱的,并且很容易被编译器优化破坏,如第15.3.2节中所讨论的。
当加载指令返回的值用于计算稍后存储指令存储的数据时,就会发生数据依赖关系。请注意上面的“数据”:如果负载返回的值被用来计算以后的存储指令使用的地址,这将是一个地址依赖项,这在第15.2.3节中涉及。然而,数据依赖关系的存在意味着,在单线程代码中用于更新链接数据结构的完全相同的指令序列在并发代码中提供了较弱但非常有用的排序。
清单15.12(C-LB+o-r+o-data-o.litmus)与清单15.7类似,只是P1()在第17行和第18行之间的排序不是通过获取加载强制执行的,而是通过数据依赖关系强制执行的:第17行加载的值是第18行存储的值。此数据依赖项提供的顺序足以防止存在子句的触发。
与地址依赖一样,数据依赖是脆弱的,很容易通过编译器优化破坏,如第15.3.2节中讨论的。事实上,数据依赖可能比地址依赖更加脆弱。其原因是,地址依赖关系通常涉及到指针值。相比之下,如清单15.12所示,很容易通过积分值携带数据依赖关系,编译器有更多的自由将其优化为不存在。只有一个例子,如果加载的整数乘以常数零,编译器就会
1CC-LB+o-r+-ctrl-o2 3 {} 4 5 P0(int *x0, int *x1) 6 { 7 int r2; 8 9 r2 = READ_ONCE(*x1); 10 smp_store_release (x0, 2); 11 } 12 13 P1(int *x0, int *x1) 14 { 15 int r2; 16 19 WRITE_ONCE(*x1,2); 20 } 21 22已存在(1:r2=2 /\ 0:r2=2) |
知道结果是零,因此可以用常数零替换加载的值,从而破坏依赖关系。
简而言之,只有在防止编译器破坏数据时,才能依赖数据依赖关系。
当测试负载指令返回的值以确定是否执行以后的存储指令时,就会发生控制依赖关系。换句话说,一个简单的条件分支或条件移动指令可以作为一个弱但低开销的内存障碍指令。但是,请注意“稍后的存储指令”:尽管所有平台都尊重负载到存储的依赖关系,但许多平台并不尊重负载到负载的控制依赖关系。
清单15.13(C-LB+o-r+o-ctrl-o.litmus)显示了另一个负载缓冲检查,这次使用控制依赖项(第18行)来排序第17行加载和第19行存储。排序足以防止存在的触发。
然而,控制依赖比数据依赖更容易被优化,而第15.3.3describes节是为了防止编译器破坏控制依赖而必须遵循的一些规则。
值得重申的是,控制依赖关系只提供从负载到存储的排序。因此,在Listing15.14(C-MP+o-r+o-ctrl-o.litmus的第14-16行)上显示的负载到控制依赖关系不提供排序,因此不阻止存在子句的触发。
总之,控制依赖项可能是很有用的,但它们是高维护项。
因此,只有在性能考虑因素不允许使用其他解决方案时,您才应该使用它们。
清单15.14:消息传递控制相关的试金石(不订购) |
1 C C-MP+o-r+o-ctrl-o 2 3 {} 4 5 P0(int* x0, int* x1) { 6 WRITE_ONCE(*x0,2); 7 smp_store_release (x1, 2); 8 } 9 10 P1(int* x0, int* x1) { 11 int r2; 12 int r3 = 0; 13 15 如果(r2 >= 0) 17 } 18 19个已存在(1:r2=2 /\ 1:r3=0) |
在缓存一致性平台上,所有cpu都对给定变量的负载和存储顺序达成一致。幸运的是,当使用READ_ONCE()和WRITE_ONCE()时,几乎所有的平台都是缓存一致的,如表15.3所示的备忘单中的“SV”列所示。不幸的是,这个属性是如此流行,以至于它已经被多次命名,“单变量SC”,8“单拷贝原子”[SF95],而只是简单的“相干性”[AMP+ 11]已经看到使用。这本书并没有通过为这个概念发明另一个术语来进一步加剧混淆,而是交替使用了“缓存一致性”和“一致性”。
清单15.15(C-CCIRIW+o+o+o-o+o-o.litmus)显示了一个测试缓存一致性的快速测试,其中“IRIW”代表“独立写数的独立读取”。因为这个石试测试只使用一个变量,P2()和P3()必须与P0()9和P1()9存储的顺序一致。换句话说,如果P2()认为P0()9商店是先位的,那么P3()最好不要相信P1()9商店是先位。事实上,如果出现这种情况,第33行上的存在子句就会触发。
我们很容易推测,不同大小的重叠负载和存储到单个内存区域(可能使用c语言联合关键字设置)将提供类似的排序保证。然而,Flur等人[FSP+ 17]发现了一些令人惊讶的简单金石测试,证明在实际硬件上可以违反这种保证。因此,有必要将代码限制在对给定变量的相同大小的对齐访问上,至少在考虑可移植性时是这样。
添加更多的变量和线程会增加重新排序和其他反直觉行为的范围,如下一节所讨论的。
运行在完全多拷贝原子[SF95]平台上的线程保证与存储的顺序一致,即使是对不同的变量。这种系统的一个有用的心理模型是图15.9所示的单总线体系结构。如果每个商店在公交车上都有一条信息,如果公交车一次只能容纳一个商店,那么任何一对cpu都会同意他们观察到的所有商店的顺序。不幸的是,构建一个如图所示的计算机系统,如果没有存储缓冲区,甚至是缓存,将会导致非常缓慢的计算。因此,大多数对提供多拷贝原子性感兴趣的CPU供应商反而提供了稍弱的其他多拷贝原子性[ARM17,第B2.3节],它将执行给定存储的CPU排除在
P0() | P0() & P1() | P1() | P2() | |||||
指令 | 存储 | 缓冲区 | 藏物处 | 指令 | 指令 | 存储缓冲区 | 藏物处 | |
| | y==0 | | | x==0 | |||
2 | x = 1; | x==1 | y==0 | x==0 | ||||
| (读取无效x) | x==1 | y==0 | r1 = x (1) | x==0 | |||
4 | x==1 | y==1 | y==0 | y = r1 | r2 = y | x==0 | ||
| x==1 | y==1 | (完成存储) | (准备就绪) | x==0 | |||
6 | (响应y) | x==1 | y==1 | (r2==1) | x==0 y==1 | |||
| x==1 | y==1 | smp_rmb() | x==0 y==1 | ||||
8 | x==1 | y==1 | r3 = x (0) | x==0 y==1 | ||||
| x==1 | x==0 y==1 | (响应x) | y==1 | ||||
10 | (完成存储) | x==1 y==1 | y==1 |
cpu2和3也一样。这意味着CPU 1可以从存储缓冲区中加载一个值,从而可能立即看到由CPU 0存储的值。相比之下,cpu2和3将不得不等待相应的缓存行将这个新值带给它们。
表15.4显示了可能导致清单15.16中存在子句触发的事件序列。这个事件序列将严重依赖于P0()和P1(),它们以图15.10所示的方式共享缓存和存储缓冲区。
第1行显示初始状态,P0()和P1()共享缓存中初始值y,P2()共享缓存中初始值x。
第2行显示了P0()在第7行执行其存储的即时效果。因为包含x的数据线不在P0()和P1()的共享缓存中,所以新值(1)存储在共享存储缓冲区中。
第3行显示了两个转换。首先,P0()发出一个读取无效操作来获取包含x的粗线轴,以便它可以将x的新值从共享存储缓冲区中冲出。第二,P1()从x加载(第14行)加载,这一操作立即完成,因为x的新值立即从共享存储缓冲区立即可用。
第4行还显示了两个转换。首先,它显示了P1()执行其存储到y(第15行)的即时效果,将新值放到共享存储缓冲区中。第二,它显示了P2()的开始(第23行)。
第五行延续了显示两个过渡的传统。首先,它显示P1()完成其存储到y,从共享存储缓冲区刷新到缓存。其次,它显示P2()请求包含y的轴线。
第6行显示P2()接收包含y的粗线,允许它完成加载到r2,其值为1。
第7行显示P2()执行它的smp_rmb()(第24行),从而保持它的两个加载顺序。
第8行显示P2()从x执行其负载,x立即从P2()的缓存中返回值为0。
第9行显示了P2()最终响应了P0()对包含x的粗毛线的请求,并重新回到了第3行。
最后,第10行显示P0()完成其存储,将其x值从共享存储缓冲区刷新到共享缓存。
请注意,第28行上的存在子句已经触发。r1和r2的值都是值1,r3的最终值都是值0。出现这个奇怪的结果是因为P0()的新值x早在通信到P2()之前就被通信到了P1()。
这种反直觉的结果,因为依赖项提供了排序,但它们只在自己线程的范围内提供排序。这个三线程示例需要更强的排序,这是第15.2.7.1至15.2.7.4节的主题。
清单15.16中所示的三线程示例需要累积排序或累积性。累积内存排序操作不仅命令它之前的任何给定访问,还命令任何线程对同一变量的早期访问。
依赖项不提供累积性,这就是为什么在第492页的表15.3中的READ_ONCE()行的“C”列为空的原因。然而,正如其“C”列中的“C”所示,释放操作确实提供了累积性。因此,清单15.17(C-WRC+o+o-r+a-o.litmus)将发布操作替换为Listing15.16的数据依赖关系。因为释放操作是累积的,它的排序不仅适用于清单15.17从P1()加载第14行,也适用于存储P0()到第7行,但只有当该加载返回存储的值,这与第27行存在子句中的1:r1=1匹配。这意味着P2()的负载获取足以迫使第24行x的负载在第7行存储之后发生,因此返回的值是1,这与2:r3=0不匹配,这反过来阻止了存在子句的触发。
这些排序约束如图15.11所示。还要注意,累积性并不局限于时间上的某一步。如果从第7行存储之前的任何线程从x或存储到x有另一个加载,那么该优先加载或存储也将在第24行加载之前订购,尽管只有当r1和r2最终都包含值1时。
1 C C-WRC+o+o-r+a-o 2 3 {} 4 5 P0(int *x) 6 { 8 } 9 10 P1(int *x, int* y) 11 { 12 int r1; 13 15 smp_store_release (y, r1); 16 } 17 18 P2(int *x, int* y) 19 { 20 int r2; 21 int r3; 22 24 r3 = READ_ONCE(*x); 25 } 26 |
图15.11:累积
清单15.18: W+RWC试金石测试与发布(无订购) |
1 C C-W+RWC+o-r+a-o+o-mb-o 2 3 {} 4 5 P0(int *x, int *y) 6 { 8 smp_store_release (y, 1); 9 } 10 11 P1(int *y, int *z) 12 { 13 int r1; 14 int r2; 15 17 r2 = READ_ONCE(*z); 18 } 19 20 P2(int *z, int *x) 21 { 22 int r3; 23 26 r3 = READ_ONCE(*x); 27 } 28 |
简而言之,在某些情况下,使用累积排序操作可以抑制非多拷贝原子行为。然而,累积性也有限制,这将在下一节中讨论。
清单15.18(C-W+RWC+o-r+a-o+o-mb-o.litmus)显示了累积量和存储释放的局限性,即使有一个完整的内存障碍。问题是,尽管第8行的smp_store_release()具有累积性,尽管累积性确实排序了第26行的P2()9s加载,但smp_store_release()9s排序不能通过P1()9s加载(第17行)和P2()9s存储(第24行)的组合来传播。这意味着第29行上的存在子句确实可以触发。
这种情况可能看起来完全违反直觉,但请记住,光速是有限的,计算机的大小是非零的。因此,P2()9s存储到z的影响需要时间才能传播到P1(),这又意味着P1()9s从z读取可能发生的时间要晚得多,但仍然可以看到
1 C C-W+RWC+o-mb-o+a-o+o-mb-o 2 3 {} 4 5 P0(int *x, int *y) 6 { 7 WRITE_ONCE(*x,1); 8 smp_mb(); 9 WRITE_ONCE(*y,1); 10 } 11 12 P1(int *y, int *z) 13 { 14 int r1; 15 int r2; 16 17 r1 = smp_load_acquire (y); 18 r2 = READ_ONCE(*z); 19 } 20 21 P2(int *z, int *x) 22 { 23 int r3; 24 25 WRITE_ONCE(*z,1); 26 smp_mb(); 27 r3 = READ_ONCE(*x); 28 } 29 30已存在(1:r1=1 /\ 1:r2=0 /\ 2:r3=0) |
旧的值为零。图15.12所示:仅仅因为加载看到了旧值并不意味着这个加载比新值存储的执行时间更早。
请注意,清单15.18还显示了内存障碍配对的限制,因为没有两个进程,而是三个进程。这些更复杂的试金石测试可以说是有循环,其中记忆障碍配对是双线程循环的特殊情况。清单15.18中的循环经过P0()(第7和8行)、P1()(第16和17行)、P2()(第24、25和26行),然后返回P0()(第7行)。存在子句描述了此循环:1:r1=1表示第16行的smp_load_acquire()返回第8行smp_store_release()存储的值,1:r2=0表示第24行的WRITE_ONCE()来得太晚,无法影响第17行READ_ONCE()返回的值,最后2:r3=0表示第7行的WRITE_ONCE()来得太晚,无法影响第26行READ_ONCE()返回的值。在这种情况下,存在子句可以触发的事实意味着循环是允许的。相反,在存在的子句不能触发的情况下,该循环被称为被禁止的。
但是,如果我们需要禁止与清单15.18第29行中的存在子句对应的循环呢?一种解决方案是用smp_mb()替换P0()的smp_store_release(),表15.3显示,smp_mb()不仅具有累积性,而且还具有传播性。其结果如清单15.19(C-W+RWC+o-mb-o+a-o+o-mb-o.litmus)所示。
为了完整性起见,图15.13显示了在相同变量的一组存储之间的“获胜”存储不一定是最后开始的存储。对于任何仔细检查497页图15.7的人来说,这不应该感到惊讶。
1+-++-+数据-数据-2 3 {} 4 5 P0(int *x0, int *x1) 6 { 7 int r2; 8 9 r2 = smp_load_acquire (x0); 10 WRITE_ONCE(*x1,2); 11 } 12 13 P1(int *x1, int *x2) 14 { 15 int r2; 16 17 r2 = READ_ONCE(*x1); 18 WRITE_ONCE(*x2,r2); 19 } 20 21 P2(int *x2, int *x0) 22 { 23 int r2; 24 25 r2 = READ_ONCE(*x2); 26 WRITE_ONCE(*x0,r2); 27 } 28 29已存在(0:r2=2 /\ 1:r2=2 /\ 2:r2=2) |
当然,仅仅流逝时间本身是不够的,就像在Listing15.6on 499页中看到的那样,它只有存储加载链接,而且因为它绝对没有排序,仍然可以触发它的存在子句。但是,只要每个线程提供最弱的顺序,存在子句就无法触发。例如,清单15.21(C-LB+a-o+o-data-o+o-data-o.litmus)显示了使用smp_load_acquire()排序的P0(),以及使用数据依赖性排序的P1()和P2()。这些顺序接近表15.3的顶部,这就足以防止现有子句的触发。
下一节将介绍对内存访问排序的重要使用。
在Listing15.7on第500页中显示了一个最小的释放-获取链,但是这些链可以要长得多,如清单15.22(C-LB+a-r+a-r+a-r+a-r.litmus)所示。释放-获取链越长,从通道中获得的排序就越多
清单15.22:长LB释放-收购链 |
1 C C-LB+a-r+a-r+a-r+a-r 2 3 {} 4 5 P0(int *x0, int *x1) 6 { 7 int r2; 8 9 r2 = smp_load_acquire (x0); 10 smp_store_release (x1, 2); 11 } 12 13 P1(int *x1, int *x2) 14 { 15 int r2; 16 17 r2 = smp_load_acquire (x1); 18 smp_store_release (x2, 2); 19 } 20 21 P2(int *x2, int *x3) 22 { 23 int r2; 24 25 r2 = smp_load_acquire (x2); 26 smp_store_release (x3, 2); 27 } 28 29 P3(int *x3, int *x0) 30 { 31 int r2; 32 33 r2 = smp_load_acquire (x3); 34 smp_store_release (x0, 2); 35 } 36 37已存在(0:r2=2 /\ 1:r2=2 /\ 2:r2=2 /\ 3:r2=2) |
因此,无论涉及多少个线程,相应的存在子句都不能触发。
尽管释放-获取链本质上是存储到加载的生物,但事实证明,它们可以容忍一个加载到存储的步骤,尽管这些步骤是反时间的,如图511页的15.12所示。例如,清单15.23(C-ISA2++-r+-r+a-o.litmus)显示了一个三步释放-获取链,但P3()的最终访问是x0的READ_ONCE(),P0()通过WRITE_ONCE()访问,形成这两个进程之间的非时间加载到存储链接。但是,由于P0()的smp_store_release()(第8行)是累积的,如果P3()的READ_ONCE()返回零,这个累积性将迫使READ_ONCE()在P0()的smp_store_发布()之前被排序。此外,释放-获取链(第8、15、16、23、24和32行)迫使P3()的READ_ONCE()在P0()‘s smp_store_release()之后订购。因为P3()的READ_ONCE()不能同时在P0()的smp_store_release()之前和之后,所以两件事中的任何一个或两个都必须是正确的:
1.P3()的READ_ONCE()来自于P0()的WRITE_ONCE()之后,所以READ_
ONCE()返回值2,因此存在子句的3:r2=0为false。
2.释放-获取链没有形成,即存在子句的1:r2=2、2:r2=2或3:r1=2中有一个或多个为false。
无论哪种方式,存在子句都不能触发,尽管这个试金石包含了P3()和P0()之间臭名昭著的加载到存储链接。但是永远不要忘记,释放-获取链只能容忍一个加载到商店的链接,如清单15.18所示。
发布-获取链也可以容忍一个单一的店间步骤,如清单15.24(C-Z6.2+o-r+a-r+a-r+a-o.litmus)所示。与前面的例子一样,smp_store_release()的累积性结合了释放-获取链的时间性质,阻止了第35行中存在的子句的触发。
但是请注意:添加第二个存储到存储的链接允许相应更新的存在子句被触发。要了解这一点,请查看清单15.26和15.27,它们具有相同的P0()和P1()进程。唯一的代码区别是,清单15.27有一个额外的P2(),它对P0()发布和P1()获取的x2变量执行一个smp_store_release()。存在子句也被调整,以排除P2()的smp_store_release()先于P0()的执行。
运行清单15.27中的试金石测试表明,添加P2()可以完全破坏来自释放-获取链的排序。因此,在构建释放-获取链时,请注意正确地构建它们。
1 C C-Z6 .2+o-r+a-r+a-r+a-o 2 3 {} 4 5 P0(int *x0, int *x1) 6 { 7 WRITE_ONCE(*x0,2); 8 smp_store_release (x1, 2); 9 } 10 11 P1(int *x1, int *x2) 12 { 13 int r2; 14 15 r2 = smp_load_acquire (x1); 16 smp_store_release (x2, 2); 17 } 18 19 P2(int *x2, int *x3) 20 { 21 int r2; 22 23 r2 = smp_load_acquire (x2); 24 smp_store_release (x3, 2); 25 } 26 27 P3(int *x3, int *x0) 28 { 29 int r2; 30 31 r2 = smp_load_acquire (x3); 32 WRITE_ONCE(*x0,3); 33 } 34 |
1 C C-Z6 .2+o-r+a-o+o-mb-o 2 3 {} 4 5 P0(int *x, int *y) 6 { 7 WRITE_ONCE(*x,1); 8 smp_store_release (y, 1); 9 } 10 11 P1(int *y, int *z) 12 { 13 int r1; 14 15 r1 = smp_load_acquire (y); 16 WRITE_ONCE(*z,1); 17 } 18 19 P2(int *z, int *x) 20 { 21 int r2; 22 23 WRITE_ONCE(*z,2); 24 smp_mb(); 25 r2 = READ_ONCE(*x); 26 } 27 28已存在(1:r1=1 /\ 2:r2=0 /\ z=2) |
1 C C-MP+o-r+a-o 2 3 {} 4 5 P0(int* x0, int* x1, int* x2) { 6 int r1; 7 8 WRITE_ONCE(*x0,2); 9 r1 = READ_ONCE(*x1); 10 smp_store_release (x2, 2); 11 } 12 13 P1(int* x0, int* x1, int* x2) { 14 int r2; 15 int r3; 16 17 r2 = smp_load_acquire (x2); 18 WRITE_ONCE(*x1,2); 19 r3 = READ_ONCE(*x0); 20 } 21 22已存在(1:r2=2 /\(1:r3=0 \/ 0:r1=2)) |
1 C C-MPO+o-r+a-o+o 2 3 {} 4 5 P0(int* x0, int* x1, int* x2) { 6 int r1; 7 8 WRITE_ONCE(*x0,2); 9 r1 = READ_ONCE(*x1); 10 smp_store_release (x2, 2); 11 } 12 13 P1(int* x0, int* x1, int* x2) { 14 int r2; 15 int r3; 16 17 r2 = smp_load_acquire (x2); 18 WRITE_ONCE(*x1,2); 19 r3 = READ_ONCE(*x0); 20 } 21 22 P2(int* x2) { 23 smp_store_release (x2, 3); 24 } 25 26已存在(1:r2=3 /\ x2=3 /\(1:r3=0 \/ 0:r1=2)) |
简而言之,正确构建的释放-获取链形成了一个和平的直觉幸福岛,被更复杂的记忆排序约束的强烈反直觉海洋所包围。
本节将重新讨论第845页的清单E.12,它在快速测验15.25的回答中提出。这个试金石只有两个线程,P0()的存储由smp_wmb()订购,P1()的访问由smp_mb()订购。尽管这个试金石规模小,顺序重,但在存在条款中显示的反直觉的结果实际上是允许的。
快速测试15.25的答案是,即从P0()到P1()的链接是商店到商店的链接,而从P1()到P0()的链接是商店到商店的链接。这两个链接都是反时间的,因此在这两个过程中都需要完整的记忆障碍。重新访问图15.13和15.14显示,这些反时态链接给了硬件相当大的自由度。
但这就提出了一个问题,即硬件将如何使用这个纬度来满足清单E.12中的存在子句。没有已知的“玩具”硬件实现可以实现这一点,所以让我们来研究PowerPC架构为实现这一点所经历的步骤序列。
本研究的第一步是将清单E.12翻译为PowerPC汇编语言试金石(第403页的第12.2.1节):
PPC R+lwsync+sync { 1:r1=2; 1:r2=y; 1:r4=x; } |
第一行标识测试的类型(PPC),并给出测试的名称。第3行和第4行分别初始化P0()和P1()的寄存器。第6-9行显示了与清单E.12中的C代码对应的PowerPC汇编语句,第一列是P0()的代码,第二列是P1()的代码。第7行显示了两列中的初始WRITE_ONCE()调用;第8行列分别显示了P0()和P1()的smp_wmb()和smp_mb();第9行列分别显示了P0()的WRITE_ONCE()和P1()的READ_ONCE();最后第10行显示了存在子句。
为了满足这一存在子句,P0()的stw到y必须先于P1()的,而P1()之后的后lwz必须先于P0()的stw到x。要了解这是如何发生的,需要粗略理解以下PowerPC术语。
指令提交:
这可以被认为是该指令的执行,而不是执行该指令的内存系统结果。
写入达到一致性点:
这可以看作是被写入到相应的缓存行中的值。
这可以被认为是系统已经计算出了所写入的一对值将被存入相应的缓存行的顺序,但很可能是在该缓存行到达之前。有些人可能会说,图15.7中的数据表明,真正的PowerPC硬件实际上使用了部分一致性提交来处理单个核心内的多个硬件线程的并发存储。
写入传播到线程:
当第二个硬件线程意识到第一个硬件线程的写入时,就会发生这种情况。写传播到给定线程的时间可能与缓存行移动没有任何关系。例如,如果一对线程共享一个存储缓冲区,它们可能会在涉及缓存行之前就看到彼此的写操作。另一方面,如果一对硬件线程被广泛地分开,那么第一个线程的写值可能在第二个线程知道该写之前就已经存储到相应的缓存行中。
屏障传播到线程:
硬件线程通过相互传播内存障碍指令,使彼此意识到所需要的这些指令。
确认同步:
PowerPC同步指令实现了Linux内核的smp_mb()全障碍。同步指令提供如此强的顺序的一个原因是,每个同步不仅被传播到其他硬件线程,而且这些其他线程还必须承认每个同步。这种双向通信允许硬件线程协同产生所需的强全局排序。
我们现在已经准备好逐步通过满足上述存在子句的PowerPC事件序列。
为了更好地理解这一点,请跟随https://www.cl.cam.ac.uk/ ~pes20/ppcmem/index.html,仔细地将上述汇编语言的试金石复制到窗格中。结果应该如图15.15所示,给出或取空格字符。点击左下角的“互动”按钮,在短暂的延迟后,应该会产生一个如图15.16所示的显示。如果“交互式”按钮拒绝做任何事情,这通常意味着存在语法错误,例如,在复制-粘贴操作过程中可能引入了一个虚假的换行字符。
这个显示器在每个显示线程状态的部分中都有一个可单击的链接,正如每个链接中的“提交”所暗示的那样,这些链接提交每个线程的第一个stw
指示如果你愿意,你可以点击屏幕底部附近的“启用转换”下列出的相应链接。请注意,稍后的一些内存-系统转换将出现在此显示的上部“存储子系统状态”部分。
以下单击顺序演示了如何满足现有子句:
1.提交P0()的第一个stw指令(到x)。
2.提交P1()的stw指令。
3.提交P0()的lwsync指令。
4.提交P0()的第二条stw指令(到y)。
5.提交P1()的同步指令。
6.此时,在显示线程状态的两个部分中都应该没有可点击的链接,但应该有不少链接处于“存储子系统状态”。以下步骤告诉您要单击哪一个。
7.部分相干性提交: c:W y=1 ->d:W y=2。这将系统提交到处理P0()的存储到y,即使两个存储都没有达到一致性点或任何其他线程。人们可能会想象,部分一致性提交发生在一个存储缓冲区中,该缓冲区由正在写入同一变量的多个硬件线程共享。
8.将写入传播到线程: d:W y=2传播到线程0。这对于允许P1()的同步指令传播到P0()是必要的。
9.屏障传播到线程:e:同步到线程0。
10.写达到相干点: a:W x=1。
11.写达到相干点: c:W y=1。
12.写达到相干点: d:W y=2。为了让P0()确认P1()9s同步指令,需要这三个操作。
13.确认同步:同步e:同步。
14.在线程P1()9s状态下返回,单击Read i:W x=0,它加载值为零,从而满足存在子句。剩下的只是清理工作,可以按任何顺序进行。
15.提交P1()9s lwz指令。
16.写入传播到线程: a:W x=1到线程1。
17.屏障传播到线程:b:Lwsync传播到线程1。
此时,您应该会看到类似于图15.17的东西。注意,满意的存在子句在底部用蓝色显示,证实了这种反直觉真的可能发生。如果你愿意,你可以点击“撤销”来探索其他选项,或点击“重置”来重新开始。为了更好地理解非多拷贝原子体系结构是如何操作的,以不同的顺序执行这些步骤是非常有用的。
虽然要想完全理解这种反直觉的结果是如何发生的,就需要超出本书范围的硬件细节,但这个练习应该是
提供一些有用的直觉。或者更准确地说,摧毁了一些适得其反的直觉。
科学既增加了我们的力量,也降低了我们的骄傲。
克劳德伯纳德
大多数语言,包括C语言,都是由很少或没有并行编程经验的人在单处理器系统上开发的。因此,除非明确说明,否则这些语言假设当前CPU是唯一读写内存的东西。这反过来意味着这些语言的“编译器”优化器已经准备好、愿意,并且能够对程序执行的内存引用的顺序、数量和大小进行戏剧性的更改。事实上,相比之下,由硬件进行的重新排序似乎相当平淡。
本节将帮助您驯服编译器,从而避免大量编译时的恐慌。Section15.3.1describes如何防止编译器对代码的内存引用进行破坏性优化,第15.3.2节描述了如何保护地址和数据依赖关系,最后,第15.3.3节描述了如何保护这些微妙的控制依赖关系。
如第4.3.4节所述,除非另有说明,编译器假定没有其他内容影响代码正在访问的变量。此外,这个假设不仅仅是一些设计错误,而是被庄严载入了各种标准中。12在准备以下章节时,值得总结本材料。
普通访问,如在普通访问的c-语言赋值语句中,如“r1=a”或“b=1”,都受到Section4.3.4.1中描述的共享变量骗局的影响。避免这些恶作剧的方法在第4.3.4.2–4.3.4.4节中描述:
1.普通访问可以撕裂,例如,编译器可以选择一次访问一个字节的8字节指针。通过使用READ_ONCE()和WRITE_ONCE(),可以防止撕裂对齐的机器大小的访问。
2.普通负载可以融合,例如,如果来自同一对象的早期加载的结果仍然在机器寄存器中,编译器可能会选择重用该寄存器中的值,而不是从内存重新加载。可以通过使用READ_ONCE()或通过使用屏障()、smp_rmb()和表15.3中所示的其他方法在两个负载之间强制排序来防止负载融合。
3.普通存储可以融合,因此如果有相同变量的后续存储,存储可以完全省略。可以通过使用WRITE_ONCE()或通过使用屏障()、smp_wmb()和表15.3中所示的其他方法在两个商店之间强制排序来防止存储融合。
4.可以通过现代优化编译器以令人惊讶的方式重新排序普通访问。这种重新排序可以通过强制执行上面所调用的排序来防止。
5.可以发明普通负载,例如,寄存器压力可能会导致编译器从其寄存器中丢弃以前加载的值,然后稍后重新加载它。可以通过使用READ_ONCE()或通过在负载和以后使用屏障()使用其值之间强制执行上述要求的顺序来防止发明的负载。
6.商店可以在普通商店之前发明,例如,通过使用存储到位置作为临时存储。这可以通过使用WRITE_ONCE()来预防。
7.存储可以转换为负载检查存储序列,这可以击败控制依赖关系。这可以通过使用smp_load_acquire()来预防。
请注意,所有这些共享内存的骗局都可以通过避免普通访问上的数据竞争来避免,如第4.3.4.4节所述。毕竟,如果没有数据竞争,那么上面提到的每一个编译器优化都是完全安全的。但是对于包含数据竞争的代码,随着编译器优化不断变得越来越激进,这个列表可能会在没有注意的情况下发生变化。
简而言之,使用READ_ONCE()、WRITE_ONCE()、屏障()、易失性和第492页表15.3中调用的其他原语是防止编译器优化并行算法消失的有价值的工具。编译器开始提供其他机制来避免加载和存储撕裂,例如,内存_order_放松原子加载和存储,但是,仍然需要工作[Cor16b]。此外,除了编译器问题之外,仍然需要挥发性来避免融合和发明的访问,包括C11原子访问。
请注意,您可以过度使用READ_ONCE()和WRITE_ONCE()。
例如,如果您阻止了给定变量的更改(可能是通过保持锁来保护该变量的所有更新),那么使用READ_ONCE()就没有意义了。类似地,如果您阻止任何其他CPU或线程读取给定变量(可能是因为您在任何其他CPU或线程访问它之前初始化该变量),那么使用WRITE_ONCE()就没有意义了。然而,根据我的经验,开发人员需要使用READ_ONCE()和WRITE_ONCE()比他们认为的更频繁,而且不必要的使用开销相当低。相比之下,在需要时不使用它们的惩罚可能相当高。
第15.2.3节和15.2.4节分别讨论的地址和数据依赖关系的低开销,使得它们的使用非常具吸引力。不幸的是,编译器既不理解地址,也不理解数据依赖关系,尽管人们正在努力教授它们,或者至少,标准化它们的教学过程[MWB+ 17,MRP+ 17]。与此同时,必须非常小心,以防止编译器破坏依赖关系。
清单15.28:与比较的可中断的依赖关系 |
1因特reserve_int; |
2 int *gp; |
3 int *p; |
4 |
7 处理储备金(p); |
8做些事!*/ |
1因特reserve_int; 2 int *gp; 3 int *p; 4 6如果(p == &reserve_int){ 7 处理_preft(&reserve_int); 9}其他{ 10 一起做些什么,好的!*/ 11 } |
15.3.2.1给你的依赖链一个好的开始
引导依赖链的负载必须使用正确的顺序,例如rcu_dereference()或READ_ONCE()。不遵守此规则可能会产生严重的副作用:
1.在DEC Alpha上,依赖负荷,如第15.5.1节所述。
2.如果依赖链的加载标题是C11非易失性内存_order_放松加载,编译器可以省略加载,例如,通过使用它过去加载的值。
3.如果依赖链的负载标题是普通负载,编译器可以省略负载,同样是使用过去加载的值。更糟糕的是,它可以加载两次而不是一次,因此代码的不同部分使用不同的值——编译器确实可以这样做,特别是在寄存器压力下。
4.由依赖链的头加载的值必须是一个指针。理论上,是的,您可以加载一个整数,也许是为了将它用作数组索引。在实践中,编译器对整数了解太多了,因此有太多的机会来打破你的依赖链[MWB+ 17]。
15.3.2.2避免了算术依赖关系的破坏
虽然对依赖链中的指针进行一些算术运算只是很好的,但您需要小心地避免给编译器提供太多的信息。毕竟,如果编译器学会了足够的知识来确定指针的精确值,那么它就可以使用这个精确的值,而不是指针本身。一旦编译器这样做,依赖关系就会被破坏,所有的顺序也会丢失。
1.虽然允许从指针中计算偏移量,但这些偏移量不能导致完全抵消。例如,给定一个字符指针cp,cp-(uintptr_ t)cp将取消,并可以允许编译器破坏您的依赖链。
另一方面,相互取消偏移值是完全安全和合法的。例如,如果a和b相等,cp+a-b是一个恒等函数,包括保留依赖关系。
2.比较可以打破依赖关系。清单15.28显示了这是如何发生的。
这里全局指针gp指向动态分配的整数,但如果内存低,它可能指向reserve_int变量。这个reserve_int案例可能需要特殊处理,如清单的第6行和第7行所示。但是编译器可以合理地将这段代码转换为Listing15.29中所示的形式,特别是在那些具有绝对地址的指令比使用寄存器中提供的地址的指令运行得更快的系统上。然而,在第5行的指针加载和第8行的解引用之间显然没有顺序。请注意,这只是一个例子:有很多其他的方法可以通过比较来打破依赖链。
请注意,当将一系列的不等式比较放在一起时,可能会为编译器提供足够的信息来确定指针的确切值,此时依赖关系将被破坏。此外,编译器可能能够将来自单一不等式比较的信息与其他信息结合起来,以学习确切的值,再次打破依赖关系。指向数组中元素的指针特别容易受到后一种依赖关系破坏的影响。
15.3.2.3依赖指针的安全比较
事实证明,有几种安全的方法来比较依赖的指针:
1.与NULL指针的比较。在这种情况下,编译器只能了解到指针是NULL,在这种情况下,无论如何都不允许对它的引用。
2.无论是在比较之前还是之后,依赖点从未被解引用。
3.将依赖指针与引用很久以前最后修改的对象的指针进行比较,其中“很久以前”的唯一无条件安全值是“在编译时”。关键是,除了地址或数据依赖关系之外,其他东西保证了排序。
4.两个指针之间的比较,每个指针都带有适当的依赖性。例如,您有一对指针,每个指针都包含一个依赖关系,每个指针都包含一个锁,并且您希望通过按地址顺序获取锁来避免死锁。
5.比较是不相等的,并且编译器没有足够的其他信息来推断携带依赖关系的指针的值。
1结构foo { 2 int a; 3 int b; 7结构为foo *gp2;8 10 { 11 结构foo *p;12 15 p->a = 42; 22 } 23 25 { 26 结构foo *p; 27 结构foo *q; 28 int r1, r2 = 0; 29 35 如果(p == q){ 37 } 39 } |
指针比较可能相当棘手,因此非常值得浏览清单15.30中所示的示例。这个示例使用了在第1-5行上显示的简单结构foo,以及两个全局指针,gp1和gp2,分别显示在第6行和第7行上。这个示例使用了两个线程,即第9-22行的更新器()和第24-39行的读取器()。
更新程序()线程在第13行分配内存,如果没有可用的内存,则在第14行痛苦地抱怨。第15-17行初始化新分配的结构,然后第18行将指针分配给gp1。第19行和第20行然后更新结构的两个字段,并在第18行使这些字段对读者可见之后这样做。请注意,读取器可见字段的不同步更新经常构成一个错误。尽管有合法的用例只是这样做,但这样的用例需要比本例中执行的更小心。
最后,第21行将指针分配给gp2。
读取器()线程首先在第30行获取gp2,用第31行和第32行检查是否为空,如果是空则返回。第33行获取字段->b,第34行获取gp1。如果第35行看到在第30行和第34行上获取的指针相等,则第36行获取p->c。注意,第36行使用在第30行读取的指针p,而不是在第34行读取的指针q。
但这种差异可能并不重要。在第35行上进行相同的比较可能会导致编译器(错误地)得出结论,认为两个指针是等价的,而实际上它们携带不同的依赖关系。这意味着编译器很可能会进行转换
将第36行改为r2 = READ_ONCE(q->c),这很可能导致加载值44,而不是期望值144。
简而言之,需要非常小心地确保源代码中的依赖链在编译器生成的汇编代码中仍然是依赖链。
第15.2.5节中描述的控制依赖关系由于其开销较低而具有吸引力,但也特别棘手,因为当前的编译器不理解它们,并且很容易破坏它们。本节中的规则和示例旨在帮助您防止编译器的无知破坏您的代码。
负载-负载控制依赖关系需要一个完整的读取内存障碍,而不仅仅是一个数据依赖关系障碍。考虑以下代码:
问= READ_ONCE (x);如果(q) { <数据依赖性障碍>q=READ_ONCE(y); } |
这不会产生预期的效果,因为没有实际的数据依赖,而是一个控制依赖,CPU可能通过尝试提前预测结果而短路,这样其他CPU看到y的负载发生在x的负载之前。在这种情况下,实际需要的是:
问= READ_ONCE (x);如果(q) { <read barrier> q = READ_ONCE (y);} |
然而,商店并没有被猜测。这意味着为负载存储控件依赖项提供了排序,如下示例所示:
q = READ_ONCE (x); 如果(q) WRITE_ONCE(y,1); |
控制依赖关系通常与其他类型的排序操作配对。也就是说,请注意,READ_ONCE()和WRITE_ONCE()都不是可选的!如果没有READ_ONCE(),编译器可能会将来自x的负载与来自x的其他负载融合。如果没有WRITE_ONCE(),编译器可能会将存储与y与其他存储与y融合,或者,更糟糕的是,读取值,比较它,并且只有条件地执行存储。其中任何一种都可能对排序产生高度反直觉的影响。
更糟糕的是,如果编译器能够证明(比如说)变量x的值总是非零的,那么它就有权通过消除以下“if”语句来优化原始示例:
q = READ_ONCE (x); WRITE_ONCE(y,1);/* BUG: CPU可以重新订购!!!*/ |
在“if”语句的两个分支上对相同的商店强制订购是很诱人的:
q = READ_ONCE (x); 如果(q) { 屏障 WRITE_ONCE(y,1);做一些事情,(); }其他{ 屏障 WRITE_ONCE(y,1); do_sote_else(); } |
不幸的是,当前的编译器将在高优化级别上转换如下:
q = READ_ONCE (x); 屏障 WRITE_ONCE(y,1);/* BUG:未订购!!!*/如果(q) { 做什么();}其他{ 做_oth_else();} |
现在,从x和存储到y的加载之间没有条件,这意味着CPU有其重新排序它们的权限:条件是绝对必需的,即使在应用了所有编译器优化之后,也必须出现在汇编代码中。因此,如果在本例中需要排序,则需要显式的内存排序操作,例如,发布存储:
问= READ_ONCE (x);如果(q) { smp_store_release(&y,1);做一些事情,(); }其他{ smp_store_release(&y,1);做一些事,(); } |
仍然需要初始的READ_ONCE(),以防止编译器猜测x的值。此外,您需要小心如何处理局部变量q,否则编译器可能能够猜测它的值,并再次删除所需的条件。例如:
问= READ_ONCE (x);如果(q % MAX){ WRITE_ONCE(y,1); 做什么();}其他{ WRITE_ONCE(y,2); 做_oth_else();} |
如果MAX被定义为1,那么编译器知道(q%MAX)等于零,在这种情况下,编译器有权将上述代码转换为以下内容:
给定这个转换,CPU不需要尊重从变量x和存储到变量y的负载之间的排序。添加一个障碍()来限制编译器是很诱人的,但这并没有帮助。条件消失了,障碍()不会把它带回来。因此,如果您依赖于此排序,您应该确保MAX大于1,可能如下:
q = READ_ONCE (x); BUILD_BUG_ON(MAX <= 1);如果(q % MAX){ WRITE_ONCE(y,1); 做什么();}其他{ WRITE_ONCE(y,2); 做_oth_else();} |
请再次注意,y的商店有所不同。如果它们是相同的,如前面所述,编译器可以将此存储拉到“if”语句之外。
您还必须避免过度依赖布尔短路计算。考虑此示例:
q = READ_ONCE (x); if (q || 1 > 0) WRITE_ONCE(y,1); |
因为第一个条件不能出错,而第二个条件总是为真的,所以编译器可以将这个示例转换为如下,从而击败了控制依赖关系:
q = READ_ONCE (x);WRITE_ONCE(y,1); |
这个示例强调了需要确保编译器不能超出猜测您的代码。永远不要忘记,尽管READ_ONCE()确实强制编译器实际发出给定负载的代码,但它不会强制编译器使用已加载的值。
此外,控制依赖项仅适用于所讨论的if语句的然后子句和else子句。特别是,它并不一定适用于if-语句后面的代码:
人们很容易认为这实际上是排序的,因为编译器不能重新排序挥发性访问,也不能用条件对y的写入重新排序。不幸的是,对于这种推理,编译器可能会将这两个写操作编译为条件移动指令,就像在这种奇特的伪汇编语言中一样:
清单15.31:具有控制相关的LB试金石 |
1 C C-LB+o-cgt-o+o-cgt-o 2 3 {} 4 5 P0(int *x, int *y) 6 { 7 int r1; 8 9 r1 = READ_ONCE(*x); 10 如果(r1 > 0) 11 WRITE_ONCE(*y,1); 12 } 13 14 P1(int *x, int *y) 15 { 16 int r2; 17 18 r2 = READ_ONCE(*y); 19 如果(r2 > 0) 20 WRITE_ONCE(*x,1); 21 } 22 23已存在(0:r1=1 /\ 1:r2=1) |
ld r1,x cmp r1,$0 cmov, ne r4,$1 cmov, eq r4,$2 st r4,y st $1,z |
一个弱排序的CPU在从x和存储到z的负载之间没有任何类型的依赖性。控制依赖项将只扩展到一对cmov指令和依赖于它们的存储区。简而言之,控制依赖项只适用于“if”的“then”和“else”中的存储(包括这两个子句调用的函数),而不一定适用于“if”之后的代码。
最后,控制依赖关系不提供累积性。这可以通过两个相关的石蕊蕊试验来证明,即清单15.31和15.32,x和y的初始值都为零。
清单15.31(C-LB+-cgt-o+-cgt-o.litmus)的双线程示例中的存在子句将永远不会触发。如果控件依赖保证了累积性(它们不保证),那么在清单15.32(C-WWC+-cgt-o+o-cgt-o+o.litmus)中向示例添加一个线程将保证相关的存在子句永远不会触发。
但是由于控制依赖不提供累积性,三线程试金石中的存在子句可以触发。如果您需要三线程示例来提供排序,那么您将需要在P0()中的加载和存储之间进行smp_mb(),也就是说,就在“if”语句之前或之后。此外,原来的双线程示例非常脆弱,应该避免使用。
以下规则列表总结了本节的经验教训:
1.编译器不理解控制依赖关系,所以您的工作是确保
编译器不能破坏您的代码。
1 C C-WWC+o-cgt-o+o-cgt-o+o 2 3 {} 4 5 P0(int *x, int *y) 6 { 7 int r1; 8 9 r1 = READ_ONCE(*x); 10 如果(r1 > 0) 11 WRITE_ONCE(*y,1); 12 } 13 14 P1(int *x, int *y) 15 { 16 int r2; 17 18 r2 = READ_ONCE(*y); 19 如果(r2 > 0) 20 WRITE_ONCE(*x,1); 21 } 22 23 P2(int *x) 24 { 25 WRITE_ONCE(*x,2); 26 } 27 28已存在(0:r1=2 /\ 1:r2=1 /\ x=2) |
2.控制依赖项可以根据以后的存储来排序预先加载。但是,它们不保证任何其他类型的订购:不保证对后期加载的优先加载,也不保证对后期加载的优先存储。如果您需要这些其他形式的订购,请使用smp_rmb()、smp_wmb(),或者,在以前的存储和以后的加载情况下,使用smp_mb()。
3.如果“if”语句的两个腿都以相同变量的相同存储开始,那么控件依赖项将不会对这些存储进行排序,如果需要排序,则在它们之前使用smp_mb()或使用smp_store_release()。请注意,在“if”语句的每一段的开头使用障碍()是不够的,因为如上面的例子所示,优化编译器可以在尊重障碍()定律的同时破坏控制依赖关系。
4.控制依赖关系要求在之前的加载和后续存储之间至少有一个运行时条件,而此条件必须涉及之前的加载。如果编译器能够优化条件删除,它也将优化删除排序。仔细使用READ_ONCE()和WRITE_ONCE()可以帮助保存所需的条件。
5.控制依赖关系要求编译器避免将依赖关系重新排序为不存在。仔细使用READ_ONCE()、brorom_read()或atomic64_ read()可以帮助保留您的控件依赖关系。
6.控件依赖项只适用于包含控件依赖项的“if”中的“then”和“else”,包括这两个子句调用的任何函数。控件依赖项不适用于包含控件依赖项的“if”语句结束后的代码。
7.控制依赖关系通常与其他类型的内存排序操作配对。
8.控制依赖关系不提供累积性。如果你需要累积量,请使用
一些提供它的东西,比如smp_store_release()或smp_mb()。
同样,许多流行语言的设计都考虑到了单线程的使用。
成功的多线程使用这些语言需要您特别注意内存引用和依赖关系。
方法会教你赢得时间。
约翰沃尔夫冈冯歌德
第12.3.1节中的一个快速小测验的答案表明,由于验证了在更高的抽象级别上建模的程序,因此实现了指数级增长。本节将探讨更高级的抽象如何提供对同步原语本身更深入的理解。15.4.1takes节是内存分配,15.4.2examines节是锁定的不同语义,15.4.3digs节更深入地了解RCU。
第6.4.3.2节涉及到内存分配,本节扩展了相关的内存排序问题。
关键的要求是,在释放该块之前在给定内存块上执行的任何访问必须在重新分配该块后执行的任何访问之前被命令。毕竟,如果一个免费之前的商店在另一个商店之后重新订购,这将是一个残酷和不寻常的内存分配错误!但是,要求开发人员使用READ_ONCE()和WRITE_ONCE()来访问动态分配的内存也是残酷和不寻常的。因此,尽管在第4.3.4.1节中提到了所有共享变量的诡计,但仍必须为普通的访问提供完整的订购。
当然,每个CPU看到自己的访问顺序,编译器总是完全考虑到CPU内部的恶作剧,偶尔会出现编译器错误。这些事实使得memblock_alloc()和memblock_自由()中的无锁快速路径可能,分别用Listings6.10和6.11显示。然而,这也是为什么开发人员在发布一个指向新分配的内存块的指针时,负责提供适当的顺序(例如,通过使用smp_store_release())。毕竟,在cpu-本地的情况下,分配器不一定提供任何跨cpu排序。
这意味着分配器在重新平衡其每个线程池时必须提供排序。这个顺序是由从memblock_alloc()和memblock_free()调用spin_lock()和spin_ulocok()提供的。对于任何从一个线程迁移到另一个线程的块,旧线程在将块放置在全局池之后执行spin_ unlock(&globalmem.mutex),而新线程在将块移动到每个线程池之前执行spin_lock(&globalmem.mutex)。这个spin_olocko()和spin_lock()确保新旧线程看到旧线程的访问发生在新线程的访问之前。
因此,传统使用的内存分配所需的排序可以仅通过非快速路径锁定来提供,从而允许快速路径保持无同步性。
锁定是一个众所周知的同步原语,并行编程社区已经有了几十年的经验。因此,锁定的语义非常简单。
也就是说,它们非常简单,直到你开始尝试对它们进行数学建模。
简单的部分是,任何持有给定锁的CPU或线程都可以保证看到CPU或线程在之前持有相同的锁时执行的任何访问。类似地,任何持有给定锁的CPU或线程都保证在随后持有同一锁时不会看到由其他CPU或线程将执行的访问。那还有什么呢?
事实证明,很多人:
1.是否允许cpu、线程或编译器将内存访问拉到给定的基于锁的关键部分?
2.持有给定锁的CPU或线程是否也能保证在CPU和线程最后一次获得同一锁之前看到它们执行的访问,反之亦然?
3.假设一个给定的CPU或线程执行一个访问(称之为“a”),释放一个锁,重新获得那个相同的锁,然后执行另一个访问(称之为“B”)。是否其他CPU或线程没有保证看到A和B?
4.如上所述,但是由其他CPU或线程执行?
5.如上所述,但是当锁的重新获取是其他的锁了吗?
6.spin_is_lock()提供了什么排序保证?
对这些问题甚至所有问题的反应可能是“为什么有人会这么做?”然而,任何完整的锁定数学定义都必须有解决所有这些问题的答案。因此,下面的部分将在Linux内核的上下文中解决这些问题。
15.4.2.1是否进入关键部分?
内存访问是否可以被重新排序为基于锁的关键部分?
在linux-内核内存模型的上下文中,简单的答案是“是的”。
这可以通过运行清单15.33和15.34(分别锁定.石蕊和锁定.石蕊)所示的石蕊试验来验证,两者都会产生有时的结果。这个结果表明,可以满足存在子句,即P0()和P1()的r1变量的最终值都可以为零。这意味着spin_lock()和spin_解锁()都不需要作为一个完整的内存屏障。
1C锁定前进入2 3 {} 4 5 P0(int *x, int *y, spinlock_t *sp) 6 { 7 int r1; 8 9 WRITE_ONCE(*x,1); 10 自旋锁(sp); 11 r1 = READ_ONCE(*y); 12 spin_解锁(sp); 13 } 14 15 P1(int *x, int *y) 16 { 17 int r1; 18 19 WRITE_ONCE(*y,1); 20 smp_mb(); 21 r1 = READ_ONCE(*x); 22 } 23 24个已存在(0:r1=0 /\ 1:r1=0) |
1C锁定后进入2 3 {} 4 5 P0(int *x, int *y, spinlock_t *sp) 6 { 7 int r1; 8 9 自旋锁(sp); 10 WRITE_ONCE(*x,1); 11 spin_解锁(sp); 12 r1 = READ_ONCE(*y); 13 } 14 15 P1(int *x, int *y) 16 { 17 int r1; 18 19 WRITE_ONCE(*y,1); 20 smp_mb(); 21 r1 = READ_ONCE(*x); 22 } 23 24个已存在(0:r1=0 /\ 1:r1=0) |
然而,其他环境可能会做出其他选择。例如,仅在x86 CPU系列上运行的锁定实现将具有锁定获取原语,它们对任何之前和任何后续访问的锁获取进行完全排序。因此,在这样的系统上,清单15.33中所示的订购都是免费的。有x86个锁发布实现是弱顺序的,因此无法提供清单15.34中所示的顺序,但是实现仍然可以选择保证这种顺序。
对于弱有序系统来说,它们很可能会选择执行保证两种排序所需的内存屏障指令,这可能简化了对锁定和无锁访问组合的高级使用的代码。但是,如前所述,LKMMM选择不提供这些额外的订单,部分原因是为了避免对更简单和更普遍的锁定用例施加性能惩罚。相反
,smp_mb__after_spinlock()和smp_mb__after_unlock_lock()原语提供于15.5节中讨论的更复杂的用例,如15.5节所述。
到目前为止,本节只讨论了硬件的重新排序。编译器是否也可以将内存引用重新排序为基于锁的关键部分?
在Linux内核中,这个问题的答案是一个响亮的“不!”
硬件重新排序优于编译器优化的原因无法解释的一个原因是,硬件将避免重新排序对基于锁的关键部分的页面错误访问。相比之下,编译器对页面故障没有任何线索,因此它会很高兴地将页面故障重新排序为一个关键部分,这可能会使内核崩溃。编译器也无法可靠地确定哪些访问将导致缓存丢失,因此编译器重新排序到关键部分也可能导致过度的锁争用。因此,Linux内核禁止编译器(而不是CPU)将访问移动到基于锁的关键部分。
关键部分以外的15.4.2.2访问?
如果一个给定的CPU或线程持有一个给定的锁,它可以保证看到在同一锁的所有之前的关键部分中执行的访问。类似地,这样的CPU或线程保证不会看到将在同一锁的所有后续关键部分中执行的访问。
但是在之前的关键部分和随后的关键部分之后的访问如何呢?
对于Linux内核,可以参考Linux内核的清单15.35(C-Lock-outside-across.litmus)来回答这个问题。运行这个试金石测试会产生永不结果,这意味着导致之前的关键部分的代码访问对持有相同锁的当前CPU或线程也是可见的。类似地,放置在后续关键部分之后的代码对于当前持有相同锁的CPU或线程是不可见的。
因此,Linux内核不能允许在整个给定的关键部分上移动访问。其他环境很可能也希望允许这样的代码运动,但请注意,这样做很可能会产生严重违反直觉的结果。
简而言之,由spin_lock()提供的顺序不仅扩展到整个临界部分,而且还无限期地超过了该临界部分的末尾。类似地,spin_ulooke()提供的顺序不仅扩展了整个临界部分,而且无限期地超出了临界部分的开始。
清单15.35:关键部分以外的访问 | ||
1 | C跨外部锁定 | |
2 | ||
3 4 | {} | |
5 | P0(int *x, int *y, spinlock_t | *sp) |
6 | { | |
7 8 | int r1; | |
9 | WRITE_ONCE(*x,1); | |
10 | 自旋锁(sp); | |
11 | r1 = READ_ONCE(*y); | |
12 | spin_解锁(sp); | |
13 14 | } | |
15 | P1(int *x, int *y, spinlock_t | *sp) |
16 | { | |
17 18 | int r1; | |
19 | 自旋锁(sp); | |
20 | WRITE_ONCE(*y,1); | |
21 | spin_解锁(sp); | |
22 | r1 = READ_ONCE(*x); | |
23 24 | } | |
25 | 存在(0:r1=0 /\ 1:r1=0) |
15.4.2.3订购非锁的支架?
一个没有持有给定锁的CPU或线程是否看到该锁的关键部分已被命令?
对于Linux内核,这个问题可以通过参考清单15.36(C-Lock-across-unlock-lock-1.litmus)来回答,其中显示了一个示例,其中P (0)将它的写和读取放在同一锁的两个不同的关键部分中。运行这个试金石表明,可以满足存在,这意味着答案是“不”,并且cpu可以跨连续的临界部分重新排序访问。换句话说,当单独考虑时,不仅自旋锁()和自旋解锁()更弱,当它们加在一起时,它们也比一个全屏障弱。
如果要观察到给定锁的临界部分的顺序,那么观察者必须一方面保持该锁,或者必须在第二次锁定获取后立即执行smp_mb__after_spinlock()或smp_mb__after_unlock_lock()。
但是,如果这两个临界部分在不同的cpu或线程上运行呢?
Linux内核引用Listing15.37(C-Lock- across-unlock-lock-2.litmus),其中第一次锁获取由P0()执行,第二次锁获取由P1()执行。请注意,P1()必须读取x才能拒绝在P0()执行之前由P1()执行的执行。运行这个试金石表明,可以满足存在,这意味着答案是“不”,CPU可以跨连续的关键部分重新排序访问,即使每个关键部分运行在不同的CPU或线程上。
如前面一样,如果要观察到给定锁的临界部分的顺序,那么观察者必须持有该锁,或者必须在P1()的锁获取之后立即执行smp_mb__after_spinlock()或smp_mb__after_unlock_lock()。
1C解锁锁1 | ||
2 | ||
3 {} 4 | ||
5 P0(int 6 { | *x, int *y, spinlock_t | *sp) |
7 8 | int r1; | |
9 | 自旋锁(sp); | |
10 11 12 | WRITE_ONCE(*x、1)、spin_解锁(sp)、spin_lock(sp); | |
13 14 15 } 16 | r1 = READ_ONCE(*y);spin_ulook(sp); | |
17 P1(int 18 { | *x, int *y, spinlock_t | *sp) |
19 20 | int r1; | |
21 22 | WRITE_ONCE(*y,1);smp_mb(); | |
23 24 } 25 | r1 = READ_ONCE(*x); | |
26存在 | (0:r1=0 /\ 1:r1=0) |
1C解锁锁2 2 3 {} 4 5 P0(int *x, spinlock_t *sp) 6 { 7 自旋锁(sp); 8 WRITE_ONCE(*x,1); 9 spin_解锁(sp); 10 } 11 12 P1(int *x, int *y, spinlock_t *sp) 13 { 14 int r1; 15 int r2; 16 17 自旋锁(sp); 18 r1 = READ_ONCE(*x); 19 r2 = READ_ONCE(*y); 20 spin_解锁(sp); 21 } 22 23 P2(int *x, int *y, spinlock_t *sp) 24 { 25 int r1; 26 27 WRITE_ONCE(*y,1); 28 smp_mb(); 29 r1 = READ_ONCE(*x); 30 } 31 32已存在(1:r1=1 /\ 1:r2=0 /\ 2:r1=0) |
如果当两个关键部分都被同一锁保护时,就不能保证排序,那么当使用不同的锁时,就不希望有任何排序保证了。然而,我们鼓励读者构建相应的试金石,并自己看看。
这种情况似乎违反直觉,但代码很少需要关心。这种方法还允许某些弱有序系统更有效地实现锁。
15.4.2.4订购为spin_is_锁定的()?
如果保留指定的锁,Linux内核的自pin_锁定()原语返回true,否则返回false。注意,当其他CPU或线程持有该锁时,spin_is_lock()返回true,而不仅仅是当当前CPU或线程持有该锁时。这就提出了一个问题,即()可能提供什么对spin_is_锁定的排序保证。
在Linux内核中,答案会随着时间的推移而变化。最初,spin_is_锁定的()是无序的,但一些有趣的用例激发了强排序。后来围绕linux内核内存模型的讨论得出结论,spin_is_锁定的()应该只用于调试。部分原因是,即使是一个完全有序的spin_is_锁定的()也可能返回true,因为其他一些CPU或线程即将释放有问题的锁。在这种情况下,可以从true的返回值中学到的东西很少,这意味着spin_is_锁定()的可靠使用非常复杂。其他方法几乎总是工作得更好,例如,使用显式共享变量或spin_trylock()原语。
这种情况导致了当前状态,即spin_is_lock()没有提供排序保证,除了如果它返回false,当前的CPU或线程不能保持相应的锁。
15.4.2.5为什么是数学模型锁定?
考虑到所有这些可能的选择,为什么模型一般会被锁定呢?为什么不简单地建模一个简单的实现呢?
原因之一是建模性能,如第825页上的表E.5所示。直接建模锁定通常比模拟甚至是一个简单的实现都要快几个数量级。这并不奇怪,考虑到现在的正式验证工具所经历的组合爆炸,即由被建模的代码所执行的内存访问数量的增加。因此,在API边界上分割建模可能会导致组合内爆。
另一个原因是,一个简单的实现可能会不必要地约束真实的实现或真实的用例。相比之下,建模一个柏拉图式的锁允许最广泛的实现,同时为锁的用户提供具体的指导。
如9.5.2节所述,RCU宽限期的基本属性是这个简单的两部分保证: (1)如果任何给定的RCU阅读的关键部分之前给定的宽限期的开始,那么整个的关键部分之前宽限期的结束。(2)如果一个给定的RCU读取侧的任何部分
当然,依赖关系会限制在RCU读取侧临界部分内重新排序访问的能力。
其中一些是由杰德·Alglave在LKMM的早期工作中介绍给保罗的,还有一些来自其他LKMM参与者[AMM+ 18]。
1 C C-LB+rl-o-o-rul+rl-o-o-rul 2 3 {} 4 5 P0(uintptr_t *x0, uintptr_t *x1) 6 { 7 rcu_read_lock() ; 8 uintptr_t r1 = READ_ONCE(*x0); 9 WRITE_ONCE(*x1,1); 10 rcu_read_unlock() ; 11 } 12 13 P1(uintptr_t *x0, uintptr_t *x1) 14 { 15 rcu_read_lock() ; 16 uintptr_t r1 = READ_ONCE(*x1); 17 WRITE_ONCE(*x0,1); 18 rcu_read_unlock() ; 19 } 20 21个已存在(0:r1=1 /\ 1:r1=1) |
清单15.42: RCU更新器提供完整的订购 |
1 C C-SB+o-rcusync-o+o-rcusync-o 2 3 {} 4 5 P0(uintptr_t *x0, uintptr_t *x1) 6 { 7 WRITE_ONCE(*x0,2); 8 synchronize_rcu() ; 9 uintptr_t r2 = READ_ONCE(*x1); 10 } 11 12 P1(uintptr_t *x0, uintptr_t *x1) 13 { 14 WRITE_ONCE(*x1,2); 15 synchronize_rcu() ; 16 uintptr_t r2 = READ_ONCE(*x0); 17 } 18 19个已存在(1:r2=0 /\ 0:r2=0) |
1 C C-SB+o-rcusync-o+o-rl-o-rul 2 3 {} 4 5 P0(uintptr_t *x0, uintptr_t *x1) 6 { 7 WRITE_ONCE(*x0,2); 8 synchronize_rcu() ; 9 uintptr_t r2 = READ_ONCE(*x1); 10 } 11 12 P1(uintptr_t *x0, uintptr_t *x1) 13 { 14 WRITE_ONCE(*x1,2); 15 rcu_read_lock() ; 16 uintptr_t r2 = READ_ONCE(*x0); 17 rcu_read_unlock() ; 18 } 19 20已存在(1:r2=0 /\ 0:r2=0) |
15.4.3.3 RCU的读者:前后的
在阅读本节之前,最好先考虑一下可用的保证和可维护软件应该依赖的保证之间的区别。请记住这一点,本节介绍了一些更奇特的RCU保证。
清单15.43(C-SB+o-rcusync-o+o-rl-o-rul.litmus)显示了一个与清单15.38类似的试金石,但是RCU阅读器的第一次访问是在RCU读取侧关键部分之前,而不是更传统的(和可维护的!)被包含在其中的方法。也许令人惊讶的是,在这个试金石测试中给出的结果与清单15.38中的相同:循环是禁止的。
为什么会是这样的情况呢?
由于P1()的两个访问都是不稳定的,如第4.3.4.2节中所讨论的,编译器不允许对它们重新排序。这意味着,为P1()的WRITE_ONCE()发出的代码将先于为P1()的READ_ONCE()发出的代码。因此,在rcu_read_lock()和rcu_read_unlock()中放置内存屏障指令的RCU实现将保持P1()的两次访问的顺序,一直保持到硬件级别。另一方面,依赖于基于中断的状态机的RCU实现也将完全保持这种相对于优雅的排序
1 C C-SB+o-rcusync-o+rl-o-rul-o 2 3 {} 4 5 P0(uintptr_t *x0, uintptr_t *x1) 6 { 7 WRITE_ONCE(*x0,2); 8 synchronize_rcu() ; 9 uintptr_t r2 = READ_ONCE(*x1); 10 } 11 12 P1(uintptr_t *x0, uintptr_t *x1) 13 { 14 rcu_read_lock() ; 15 WRITE_ONCE(*x1,2); 16 rcu_read_unlock() ; 17 uintptr_t r2 = READ_ONCE(*x0); 18 } 19 20已存在(1:r2=0 /\ 0:r2=0) |
1 C C-SB+o-rcusync-o+o-rl-rul-o 2 3 {} 4 5 P0(uintptr_t *x0, uintptr_t *x1) 6 { 7 WRITE_ONCE(*x0,2); 8 synchronize_rcu() ; 9 uintptr_t r2 = READ_ONCE(*x1); 10 } 11 12 P1(uintptr_t *x0, uintptr_t *x1) 13 { 14 WRITE_ONCE(*x1,2); 15 rcu_read_lock() ; 16 rcu_read_unlock() ; 17 uintptr_t r2 = READ_ONCE(*x0); 18 } 19 20已存在(1:r2=0 /\ 0:r2=0) |
由于中断发生在执行被中断代码时的精确位置而导致的周期。
这反过来意味着,如果WRITE_ONCE()遵循一个给定的RCU宽限期的结束,那么在该RCU读取侧临界部分内和之后的访问必须遵循相同的宽限期的开始。类似地,如果READ_ONCE()先于宽限期的开始,则在该临界部分内和之前的所有内容都必须先于同一宽限期的结束。
清单15.44(C-SB+o-rcusync-o+rl-o-rul-o.litmus)与此类似,但它会查看RCU读侧关键部分之后的访问。这个测试9s周期也被禁止了,因为可以用群体工具进行检查。其推理与清单15.43类似,并留给读者进行练习。
清单15.45(C-SB+o-rcusync-o+o-rl-rul-o.litmus)更进一步,将P1()9的WRITE_ONCE()移动到RCU读侧临界部分之前,并将P1()9的READ_ONCE()移动到它之后,导致一个空的RCU读侧临界部分。
也许令人惊讶的是,尽管临界部分很空,但RCU仍然设法阻止了这个循环。这可以再次使用群体工具进行检查。此外
清单15.46:没有RCU阅读器会发生什么? |
1 C C-SB+o-rcusync-o+o-o 2 3 {} 4 5 P0(uintptr_t *x0, uintptr_t *x1) 6 { 7 WRITE_ONCE(*x0,2); 8 synchronize_rcu() ; 9 uintptr_t r2 = READ_ONCE(*x1); 10 } 11 12 P1(uintptr_t *x0, uintptr_t *x1) 13 { 14 WRITE_ONCE(*x1,2); 15 uintptr_t r2 = READ_ONCE(*x0); 16 } 17 18个已存在(1:r2=0 /\ 0:r2=0) |
推理再次类似于清单15.43的重述,如果P1()的WRITE_ONCE()遵循给定宽限期的结束,那么P1()的RCU读侧关键部分——以及之后的一切——都必须遵循相同宽限期的开始。类似地,如果P1()的READ_ONCE()先于给定宽限期的开始,那么P1()的RCU读侧临界部分——以及它之前的所有部分——必须先于相同宽限期的结束。在这两种情况下,临界部分的空性都是无关紧要的。
这种情况导致了一个问题,即如果完全省略rcu_read_lock()和rcu_read_uloke()会发生什么,如清单15.46(+-+-+-o+o-o.litmus)所示。可以用群体来检查,这个石试试验的周期是允许的,也就是说,r2的两个实例的最终值都可以为零。
鉴于空的RCU读侧关键部分可以提供排序,这可能看起来很奇怪。的确,RCU的QSBR实现实际上会禁止这种结果,因为在P1()的函数体中的任何地方都没有静止状态,因此P1()将在隐式的RCU读侧临界部分中运行。然而,RCU也有非QSBR实现,它们没有隐含的RCU读侧关键部分,反过来,RCU也没有办法强制排序。因此,这个石蕊试金石的周期是允许的。
15.4.3.4多重RCU读取器和更新器
因为synchronize_rcu()的排序语义至少和smp_ mb()一样强,无论在SB试金石测试中有多少进程(如Listing15.42),在每个进程的访问之间放置synchronize_rcu()都禁止循环。此外,在SB测试中,一个进程使用syaan_rcu(),另一个进程使用rcu_read_lock()和rcu_read_unlock(),如图所示
1 C C-SB+o-rcusync-o+o-rcusync-o+rl-o-o-rul+rl-o-o-rul 2 3 {} 4 5 P0(uintptr_t *x0, uintptr_t *x1) 6 { 7 WRITE_ONCE(*x0,2); 8 synchronize_rcu() ; 9 uintptr_t r2 = READ_ONCE(*x1); 10 } 11 12 P1(uintptr_t *x1, uintptr_t *x2) 13 { 14 WRITE_ONCE(*x1,2); 15 synchronize_rcu() ; 16 uintptr_t r2 = READ_ONCE(*x2); 17 } 18 19 P2(uintptr_t *x2, uintptr_t *x3) 20 { 21 rcu_read_lock() ; 22 WRITE_ONCE(*x2,2); 23 uintptr_t r2 = READ_ONCE(*x3); 24 rcu_read_unlock() ; 25 } 26 27 P3(uintptr_t *x0, uintptr_t *x3) 28 { 29 rcu_read_lock() ; 30 WRITE_ONCE(*x3,2); 31 uintptr_t r2 = READ_ONCE(*x0); 32 rcu_read_unlock() ; 33 } 34 35已存在(3:r2=0 /\ 0:r2=0 /\ 1:r2=0 /\ 2:r2=0) |
P2()和P3()都有RCU阅读器。同样,cpu可以在RCU读取侧临界部分内重新排序访问,如图15.20所示。为了形成这个循环,P2()的临界部分必须在P1()的宽限期之后结束,而P3()的临界部分必须在同一宽限期开始之后结束,这也是在P0()的宽限期结束之后。因此,P3()的临界部分必须在P0()的宽限期开始后开始,这反过来意味着P3()从x0中的读取不可能先于P0()的写入。因此,禁止该循环,因为RCU读取侧临界部分不能跨越完整的RCU宽限期。
但是,仔细看看图15.20就可以清楚地看出,添加第三个阅读器将允许这个循环。这是因为这第三个读者可以在P0()的宽限期结束之前结束,因此在相同的宽限期开始之前开始。这反过来又表明了一般的规则:在这些RCU-only试金石测试中,如果至少有许多RCU宽限期作为RCU读侧临界部分,那么这个循环是被禁止的。
15.4.3.5 RCU和其他排序机制
但是,将RCU与其他排序机制结合起来的试金石测试怎么办呢?一般的规则是,它只需要一种机制来禁止一个循环。
例如,请参考清单15.40。应用前一节中的一般规则,因为这个试金石有两个RCU读侧临界部分,没有RCU宽限期,因此允许该循环。但是如果P0()的WRITE_ONCE()是呢
被smp_store_release()取代,P1()的READ_ONCE()被smp_load_acquire()取代?
RCU仍然允许这个循环,但发行-收购对将禁止它。因为只需要一个机制就可以禁止一个循环,所以释放-获取对将占上风,从而禁止这个循环。
关于另一个例子,请参考清单15.47。因为这个试金石有两个RCU阅读器,但只有一个宽限期,所以它的周期是允许的。但假设一个smp_mb()被放置在P1()的一对访问之间。在这个新的试金石测试,因为添加smp_mb(),P2()以及P1()的临界部分将超出P0()的宽限期,这反过来会防止P2()读x0前P0()写,如Figure15.21的红色虚线箭头。在这种情况下,RCU和全内存屏障共同工作来禁止循环,RCU在P0()和P1()和P2()之间保持顺序,以及与smp_mb()一起
简而言之,RCU的语义曾经是纯粹实用的,现在已经完全形式化了[MW05,DMS+ 12,GRY13,AMM+ 18]。
用高级原语来验证代码,而不是在该原语的特定实现中使用的低级内存访问,这是非常有益的。首先,这允许使用“这些原语”的代码根据这些原语的抽象表示进行验证,从而使这些代码不太容易受到实现更改的影响。其次,在API边界上划分验证会导致组合内爆,大大减少了形式验证的开销。
希望通过对高级原语的详细语义进行验证,将大大提高静态分析和模型检验的有效性。
摇滚拍纸!
德里克威廉姆斯
每个CPU家族都有其独特的内存排序方法,这可能使可移植性成为一个挑战,如表15.5所示。
事实上,一些软件环境只是禁止直接使用内存排序操作,从而将程序员限制在需要时合并它们的互斥原语中。请注意,本节并不是要成为涵盖每个CPU家族的所有(甚至是大部分)方面的参考手册,而是要成为参考手册
提供粗略比较的高级概述。有关详细信息,请参阅相关CPU的参考手册。
回到表15.5,第一组行查看内存顺序属性,第二组行查看指令属性。请注意,这些属性保持在机器指令级别。编译器可以并且可以比硬件更积极地进行排序。使用有标记的访问,如READ_ONCE()和WRITE_ONCE(),来约束编译器的优化,并防止不合理的重新排序。
前三行指示给定的CPU是否允许重新排序四种可能的加载和存储组合,如第15.2.1节和第15.2.1-15.2.2.3节中讨论的。下一行(“使用加载或存储重新排序的原子指令?”)指示给定的CPU是否允许使用原子指令重新排序加载和存储。
第五行和第六行包括重新排序和依赖关系,这在第15.2.3–15.2.5节中涉及,并在第15.5.1节中有更详细的解释。简而言之,Alpha需要阅读器的内存障碍,以及链接数据结构的更新者的内存障碍,然而,这些内存障碍是由v4.15和以后的Linux内核中特定于Alpha架构的代码提供的。
下一行,“非顺序一致”,表示CPU的正常负载和存储指令是否受到顺序一致性的限制。对性能的考虑要求没有一个现代主流系统是顺序一致的。
接下来的三行覆盖了在第15.2.7节中定义的多拷贝原子性。
第一个是完整的(和罕见的)多拷贝原子性,第二个是较弱的其他多拷贝原子性,第三个是最弱的非多拷贝原子性。
下一行,“非缓存相干”,涵盖了从多个线程到单个变量的访问,这一点在第15.2.6节中进行了讨论。
最后三行包括指令级别的选择和问题。第一行表示每个CPU如何实现负载-获取和存储-释放,第二行按原子-指令类型对CPU进行分类,第三行和最后一行表示一个给定的CPU是否有一个不连贯的指令缓存和管道。这样的cpu需要执行针对自修改代码的特殊指令。
常见的处理内存排序操作的“只是说不”方法在适用的地方可能非常合理,但也有一些环境,比如Linux内核,需要直接使用内存排序操作。因此,Linux提供了一个精心选择的最小共分母内存排序原母集,如下所示:
smp_mb()(全内存障碍),同时订购加载和存储。这意味着在内存屏障之前的加载和存储将在内存屏障之后的任何加载和存储之前提交到存储中。
仅排序为加载的smp_rmb()(读取内存障碍)。
只订购存储空间的smp_wmb()(写内存屏障)。
smp_mb__before_atomic(),强制在smp_mb__之前的访问和之后的RMW原子操作之后的访问。这是对完全排序原子RMW操作的系统的操作。
smp_mb__after_atomic(),强制对早期RMW原子操作之前的访问对对smp_mb__after_atomic()之后的访问进行排序。这也是对完全排序原子RMW操作的系统的一个建议。
smp_mb__after_spinlock(),强制锁访问之前的访问命令对smp_mb__after_spinlock()之后的访问。这也是对完全订购锁定收购的系统的一个建议。
mmiowb()强迫MMIO命令,由全球旋锁保护,
在2016年LWN关于MMIO [MDR16]的文章中进行了更详细的描述。
smp_mb()、smp_rmb()和smp_wmb()原语还迫使编译器避免任何可能产生跨越障碍重新排序内存优化效果的优化。
这些原语只在SMP内核中生成代码,但是,有几个版本有UP版本(分别为mb()、rmb()和wmb()),即使在UP内核中也会产生内存障碍。在大多数情况下都应该使用smp_版本。然而,后一个原语在编写驱动程序时很有用,因为即使在UP内核中,MMIO访问也必须保持有序。在没有内存排序操作的情况下,cpu和编译器都会愉快地重新安排这些访问,这充其量会使设备的行为很奇怪,并可能导致内核崩溃,甚至损坏硬件。
因此,大多数内核程序员不需要担心每个CPU的内存顺序特性,只要他们坚持使用这些接口和完全有序的原子操作。当然,如果您深入研究给定CPU的特定架构的代码,所有的赌注都不了。
此外,所有Linux的锁定原语(自旋锁、读写锁、内存锁、RCU、……)都包括任何需要的排序原语。因此,如果您正在使用正确使用这些原语的代码,那么您就不必担心Linux的内存排序原语。
也就是说,在调试时,深入了解每个CPU的内存一致性模型会非常有用,更不用说编写特定于架构的代码或同步原语了。
此外,他们还说,稍微掌握一点知识是一件非常危险的事情。想象一下,用很多知识会造成的伤害!对于那些希望更多地了解单个cpu的记忆一致性模型的人,下一节将介绍一些流行的和突出的cpu的cpu。虽然没有任何办法可以替代实际读取给定的CPU文档,但这些部分确实给出了一个很好的概述。
对于一个生命的终结已经结束的CPU,这似乎很奇怪,但是Alpha很有趣,因为它是唯一一个重新排序依赖负载的主流CPU,因此对并发api有巨大的影响,包括在Linux内核中。核心Linux内核代码需要适应Alpha的版本v4.15结束,并且在版本5.9中删除了smp_read_barrier_depends()和()api的所有跟踪。然而,这部分仍然保留在第三版中,因为在2023年初,仍有一些Linux内核黑客仍在开发Linux版本之前的Linux内核。此外,还将其修改到
人们可以在指针获取和解引用之间放置一个smp_rmb()原语,以迫使Alpha使用稍后的依赖负载对指针获取进行排序。然而,这对尊重读取侧数据依赖性的系统(如Arm、Itanium、PPC和SPARC)带来了不必要的开销。因此,在Linux内核中添加了一个smp_read_barrier_depends()原语,以消除这些系统上的开销,但在Linux内核的v5.9中被删除,以增强Alpha,READ_ONCE()的定义。因此,在v5.9中,核心内核代码不再需要关注DEC Alpha的这方面。但是,最好使用rcu_dereference(),如清单15.50中的第16行和第21行所示,它对于所有最近的内核版本都能安全有效地工作。
也可以实现一种软件机制,可以用来代替smp_store_release()来强制所有读取CPU查看写入CPU,s按顺序写。这个软件障碍可以通过向所有其他cpu发送处理器间中断(ipi)来实现。在收到这样的IPI后,CPU将执行内存障碍指令,实现类似于Linux内核提供的sys_membarrier()系统调用提供的系统范围内存障碍。需要额外的逻辑来避免死锁。当然,尊重数据依赖性的cpu将把这样一个障碍定义为简单的smp_store_release()。然而,Linux社区认为这种方法造成了过多的开销[McK01],就他们的观点而言,这完全不适合于具有积极的实时响应需求的系统。
Linux内存障碍原语的名称来源于Alpha指令,所以smp_mb()是mb,smp_rmb()是rmb,smp_wmb()是wmb。Alpha是唯一一个其READ_ONCE()包含一个smp_mb()的CPU。
有关Alpha的更多信息,请参阅其参考手册[Cor02]。
1 | 结构 | 电子插入(长键、长数据) |
2 | { | |
3 | 结构el *p; | |
4 | p=kmalloc(尺寸(*p),GFP_ATOMIC); | |
5 | spin_lock(&mutex); | |
6 | 下一个=头。下一个; | |
7 | p->键=键; | |
8 | p->数据=数据; | |
9 | smp_store_release(&head.next , p); | |
10 | spin_解锁(&mutex); | |
11 | } | |
12 | ||
13 | 结构 | el*搜索(长搜索键) |
14 | { | |
15 | ||
16 | p=rcu_dereference(下头); | |
17 | 而(p != &head){ | |
18 | 如果(p->键==搜索键){ | |
19 | 返回(p); | |
20 | } | |
21 | ||
22 | }; | |
23 | 返回(空); | |
24 | } |
Arm家族在深度嵌入式应用中很流行,特别是对于功率受限的微控制器。它的内存模型类似于功率模型(见第15.5.6节),但Arm使用了一组不同的内存屏障指令[ARM10]:
DMB(数据内存障碍)导致指定类型的操作在相同类型的任何后续操作之前已经完成。操作的“类型”可以是所有操作,也可以限制为仅写操作(类似于Alpha wmb和功率eieio指令)。此外,Arm允许缓存一致性具有三个范围中的一个:单处理器、处理器的一个子集(“内部”)和全局(“外部”)。
DSB(数据同步屏障)会使指定类型的操作在执行任何后续(任何类型)操作之前实际完成。操作的“类型”与DMB的操作相同。在Arm架构的早期版本中,DSB指令被称为DWB(漏极写缓冲区或数据写屏障,这是您的选择)。
ISB(指令同步屏障)会刷新CPU管道,因此只有在ISB完成之后,所有的指令才会在ISB完成后获取。例如,如果您正在编写一个自修改的程序(例如JIT),那么您应该在生成代码和执行代码之间执行一个ISB。
这些指令都没有与Linux的rmb()原语的语义完全匹配,因此它必须实现为一个完整的DMB。DMB和DSB指令对屏障之前和之后排序的访问有递归定义,其效果类似于功率的累积性,两者都比第15.2.7.1节中描述的LKMM累积性强。
Arm还实现了控制依赖关系,因此如果条件分支依赖于负载,那么在该条件分支之后执行的任何存储都将在加载之后排序。但是,跟在条件分支之后的负载将不能保证是
除非在分支和负载之间有一个ISB指令。请考虑以下示例:
ISB(); |
在这个例子中,加载-存储控制依赖排序导致从第1行x的加载在第4行y的存储之前被排序。但是,Arm不尊重负载-负载控制依赖关系,因此第1行上的负载很可能发生在第5行上的负载之后。另一方面,第2行上的条件分支和第6行上的ISB指令的组合确保了第7行上的负载发生在第1行上的负载之后。请注意,在第2行和第5行之间插入一个额外的ISB指令将在第1行和第5行之间强制排序。
Arm的Armv8 CPU系列[ARM17]包含64位功能,与Section15.5.2中描述的仅32位CPU相比。Armv8的内存模型与Armv7非常相似,但添加了负载获取(LDLARB、LDLARH和LDLAR)和存储释放(STLLRB、STLLRH和STLLR)指令。这些指令充当“半内存障碍”,因此Armv8cpu可以用以后的LDLAR指令重新排序以前的访问,但禁止用以后的访问重新排序早期的LDLAR指令,如图15.23所示。类似地,Armv8cpu可以使用后续的访问重新排序早期的STLLR指令,但禁止使用以后的STLLR指令重新排序以前的访问。正如人们所料,这意味着这些指令直接支持C11的加载-获取和存储-释放的概念。
然而,Armv8远远超出了C11内存模型,它要求存储释放和加载获取的组合在某些情况下作为一个完整的障碍
境况例如,在Armv8中,给定一个存储,之后存储发布,接着加载获取,之后加载,所有不同的变量,所有来自一个CPU,所有CPU都同意初始存储在最终加载之前。有趣的是,大多数TSO架构(包括x86和大型机)并不能保证这一点,因为这两个加载可以在两个存储之前重新订购。
Armv8是仅有的两种需要smp_mb__after_spinlock()原语成为完全障碍的架构之一,因为它在Linux内核中的锁获取实现相对较弱。
Armv8的区别还在于,它是第一个由供应商公开定义其内存顺序的CPU[ARM17]。
安腾提供了一个弱一致性模型,因此在没有显式的记忆障碍指令或依赖关系的情况下,安腾有权任意重新排序记忆引用[Int02a]。Itanium有一个名为mf的内存栅栏指令,但也有“半内存栅栏”修改器来加载、存储和处理其一些原子指令[Int02b]。acq修改器可以防止后续的内存-引用指令在acq之前被重新排序,但允许先前的内存-引用指令在acq之后被重新排序,类似于Armv8加载-获取指令。类似地,rel修改器可以防止先前的内存参考指令在rel之后被重新排序,但允许后续的内存参考指令在rel之前被重新排序。
这些半记忆围栏对临界区域是有用的,因为将操作推到临界区域是安全的,但如果让它们出血,可能是致命的。然而,作为为数不多的具有这种特性的cpu之一,Itanium曾经定义了与锁获取和发布相关的内存顺序的语义。奇怪的是,据传实际的安腾硬件将同时实现负载-获取和存储-发布指令作为全部障碍。然而,Itanium是第一个将加载获取和存储释放的概念(如果不是现实的话)引入其指令集的主流CPU。
iummf指令用于Linux内核中的smp_rmb()、smp_mb()和smp_wmb()原语。尽管一直有相反的谣言,“mf”助记符代表“记忆栅栏”。
安腾还提供了一个全球释放操作的总订单,包括mf指令。这提供了传递性的概念,如果给定的代码片段看到给定的访问已经发生了,任何以后的代码片段也会看到较早的访问已经发生了。假设,所有涉及的代码片段都正确地使用了内存障碍。
最后,Itanium是唯一支持Linux内核的架构,它可以将正常加载重新排序到相同的变量。Linux内核避免了这个问题,因为READ_ ONCE()发出一个易失性负载,它被编译为ld,acq指令,强制给定CPU对所有READ_ ONCE()调用进行排序,包括对相同变量的排序。
MIPS内存模型[Wav16,第479页]似乎类似于Arm、安定和功率,默认情况下是弱排序的,但尊重依赖关系。MIPS有各种各样的内存障碍指令,但它们与硬件考虑无关,而是与Linux内核和C++11标准[Smi19]提供的用例有关,其方式类似于Armv8的添加:
同时
除了内存引用之外,还有许多硬件操作,用于实现OCTEON系统的v4.13 Linux内核的smp_mb()。
sync_wmb
写内存障碍,它可以在OCTEON系统上使用,通过同步助记符来实现v4.13 Linux内核中的smp_wmb()原语。其他系统则使用纯同步系统。
sync_mb
全内存障碍,但仅用于内存操作。这可以用于实现C++原子_线程_栅栏(memory_order_seq_cst)。
同步获取
获取内存障碍,可用于实现C++的原子线程栅栏(内存顺序获取)。理论上,它也可以用于实现v4.13linux内核smp_load_acquire()原语,但在实际中使用同步。
同步释放
释放内存障碍,它可能用于实现C++的原子线程栅栏(内存顺序释放)。理论上,它也可以用于实现v4.13linux内核smp_store_release()原语,但在实际中使用同步。
sync_rmb
读取内存障碍,这在理论上可以用于实现Linux内核中的smp_rmb()原语,除了由v4.13 Linux内核支持的当前MIPS实现不需要显式指令来强制排序。因此,smp_rmb()只是约束编译器。
辛奇
指令-缓存同步,它与其他指令一起使用,以允许自修改代码,例如由即时(JIT)编译器生成的代码。
与MIPS架构师的非正式讨论表明,MIPS对传递性或累积性的定义类似于手臂和权力。然而,似乎不同的MIPS实现可能具有不同的内存顺序属性,因此查阅有关您正在使用的特定MIPS实现的文档是很重要的。
POWER和PowerPC CPU系列有各种各样的内存屏障指令[IBM94,LHF05]:
同步使所有上述操作在启动任何后续操作开始之前已经完成。因此,这个指令是相当昂贵的。
lwsync(轻量级同步)根据后续加载和存储订购加载,也为存储订购。但是,它不会根据后续负载订购存储。lwsync指令可用于实现负载-获取和存储-释放操作。有趣的是,lwsync指令强制执行了与x86、z系统相同的cpu内部排序,巧合的是,还有SPARC TSO。但是,将lwsync指令放置在每对内存引用指令之间并不会导致x86、z系统或SPARC TSO内存排序。在这些其他系统上,如果一对cpu独立地执行对不同变量的存储,那么所有其他cpu都将就这些存储的顺序达成一致。而在PowerPC上则不是这样,即使在每对内存引用指令之间都有一条lwsync指令,因为PowerPC是非多副本原子的。
Eieio(强制执行I/O的顺序执行)导致所有之前的可缓存存储似乎都在所有后续存储之前已经完成。但是,可缓存内存的存储是分开从存储到不可缓存存储的,这意味着eieio不会强制MMIO存储在旋锁发布之前。这条指令很可能是一个独特的五元音助记符。
isync强制所有之前的指令在任何子任务指令开始执行之前似乎已经完成。这意味着前面的指令必须进展得足够远,以致它们可能产生的任何陷阱已经发生或保证不会发生,并且这些指令的任何副作用(例如,页表更改)都可以在随后的指令中看到。但是,它并不强制对所有的内存引用进行排序,而只强制对指令本身的实际执行。因此,加载可能会返回旧的仍然缓存的值,并且isync指令不会强制将以前存储的值从存储缓冲区中刷新。
不幸的是,这些指令都没有完全符合Linux的wmb()原语,它要求排序所有存储,但不需要同步指令的其他高开销操作。rmb()原语也没有匹配的轻量级指令。但是没有选择: ppc64版本的wmb()、rmb()和mb()被定义为重量级同步指令。然而,Linux的smp_wmb()原语从未用于MMIO(毕竟,驱动程序必须仔细地在UP和SMP内核中的MMIOs),因此它被定义为较轻的eieio或lwsync指令[MDR16]。smp_mb()原语也被定义为同步指令,而smp_rmb()被定义为重量较轻的lwsync指令。
功率特征为“累积性”,可以用来获得传递性。当正确使用时,任何看到早期代码片段结果的代码也将看到这个早期代码片段本身所看到的访问。更多的细节可从麦肯尼和西尔维拉[MS09]。
功率尊重控制依赖的方式与Arm非常相同,除了功率异步指令取代了Arm ISB指令。
和Armv8一样,电源需要smp_mb__after_spinlock()成为一个完整的内存障碍。此外,电源是唯一需要smp_mb__after_uloko_lock()成为完整内存障碍的架构。在这两种情况下,这都是因为功率锁定原语的排序属性较弱,因为使用了lwsync指令来为获取和释放提供排序。
电源体系结构的许多成员都有不一致的指令缓存,因此内存的存储不一定反映在指令缓存中。值得庆幸的是,现在很少有人编写自我修改的代码,但是jit和编译器一直在这样做。此外,从CPU的角度来看,重新编译最近运行的程序就像自我修改代码。icbi指令(指令缓存块无效)会使指令缓存中的指定的缓存行无效,并且可以在这些情况下使用。
尽管Linux和Solaris都使用了SPARC的TSO(总存储顺序),但该体系结构还定义了PSO(部分存储顺序)和RMO(放松内存顺序)。任何在RMO中运行的程序也将在PSO或TSO中运行,类似地,在PSO中运行的程序也将在TSO中运行。向另一个方向移动共享内存并行程序可能需要仔细地插入内存障碍。
尽管SPARC的PSO和RMO模式最近并没有被大量使用,但它们确实产生了一个非常灵活的内存障碍指令[SPA94],允许对顺序进行细粒度控制:
在后续商店之前的订单。(此选项由Linux smp_wmb()原语使用。)
加载存储订单在加载之前的后续存储。
在后续加载之前的存储加载订单。
加载在后续加载之前的加载订单。(此选项由Linux smp_rmb()原语使用。)
在启动任何后续操作之前,同步将完全完成所有之前的操作。
MemIssue会在随后的内存操作之前完成之前的内存操作,这对于某些内存映射的I/O实例来说非常重要。
Lookaside与MemIssue相同,但只适用于之前的存储和后续加载,甚至只适用于访问相同内存位置的存储和加载。
那么,为什么需要“记忆问题”呢?因为“模条#存储加载”可以允许后续加载从存储缓冲区获取其值,如果写入MMIO寄存器会对要读取的值产生副作用,这将是灾难性的。相反,“membar#MemIssue”会等到存储缓冲区被刷新后才允许执行负载,从而确保负载实际上从MMIO寄存器获得其值。驱动程序可以使用“记忆#同步”,但在不需要更昂贵的“记忆#同步”的附加功能的情况下,更轻的“记忆#记忆问题”是首选。
“看吧”是“记忆问题”的一个较轻的版本,
当写入给定的MMIO寄存器时,影响下一个值是有用的
要从那个寄存器中读取。但是,当对给定MMIO寄存器的写入影响下次从其他MMIO寄存器读取的值时,必须使用较重的“内存问题”。
SPARC要求在指令流被修改和执行任何这些指令的时间之间使用刷新指令[SPA94]。这需要从SPARC的指令缓存中刷新该位置的任何优先值。请注意,刷新将接受一个地址,并且将只从指令缓存中刷新该地址。在SMP系统上,所有cpu的缓存都被刷新,但是没有方便的方法来确定cpu外刷新何时完成,尽管有一个实现注释。
但是,Linux内核在TSO模式下运行SPARC,所以上面所有的成员栏变体都具有严格的历史意义。特别是,smp_mb()原语只需要使用#StoreLoad,因为TSO禁止使用其他三个重新排序。
历史上,x86CPU提供了“进程排序”,以便所有CPU都同意给定CPU写入内存的顺序。这允许smp_wmb()原语没有CPU [Int04b]。当然,还需要一个编译器指令来防止跨smp_wmb()原语重新排序的优化。在古代,某些x86cpu没有对负载提供排序保证,所以smp_mb()和smp_rmb()原语扩展到锁定;addl。这种原子指令是加载和存储的障碍。
但那已经是古代了。最近,英特尔发布了一个针对x86的内存模型[Int07]。事实证明,英特尔的现代cpu比以前的规范中声称的更严格,所以这个模型只是要求了这种现代行为。甚至在最近,英特尔发布了x86更新的内存模型[Int11,8.2节],它要求商店的总全球订单,尽管个别cpu仍然被允许看到自己的商店比总全球订单显示的更早。为了允许涉及存储缓冲区的重要硬件优化,需要对总排序进行此例外。此外,x86提供了其他多副本原子性,例如,如果CPU 0看到CPU 1的存储,那么CPU 0保证看到CPU 1在其存储之前看到的所有存储。软件可能会使用原子操作来覆盖这些硬件优化,这也是原子操作往往比非原子操作更昂贵的原因之一。
同样重要的是,要注意,在给定的内存位置上操作的原子指令都应该是相同的大小[Int16,第8.1.2.2节]。例如,如果您编写一个程序,其中一个CPU的原子增量为一个字节,而另一个CPU在同一位置执行一个4字节的原子增量,那么您就是自己。
一些SSE指令是弱有序的(clflush和非时间移动指令[Int04a])。使用这些非时间移动指令的代码也可以使用mfence表示smp_mb(),lfence表示smp_rmb(),sfence表示smp_wmb()。一些旧版本的x86 CPU有一个模式位,支持无序存储,对于这些CPU,smp_wmb()也必须被定义为锁定;addl。
尽管更新的x86实现适应了没有任何特殊指令的自修改代码,为了与过去和未来潜在的x86实现完全兼容,给定的CPU必须在修改代码和执行它之间执行一个跳转指令或序列化指令(例如,cpuid)[Int11,第8.1.3节]。
z系统机器构成了IBM大型机系列,以前被称为360、370、390和zSeries [Int04c]。并行性在z系统中来得太晚了,但考虑到这些大型机在20世纪60年代中期首次发布,这并不能说明什么。“bcr15,0”指令用于Linux smp_mb()原语,但是编译器约束足以满足smp_rmb()和smp_wmb()原语。它还具有很强的内存排序语义,如表15.5所示。特别是,所有的CPU都将同意来自不同CPU的不相关存储的顺序,即z系统CPU家族是完全多副本原子的,并且是唯一具有这种特性的商用系统。
与大多数cpu一样,z系统体系结构并不能保证缓存一致性 指令流,因此,自修改代码必须在更新指令和执行指令之间执行序列化指令。也就是说,许多实际的z系统机器实际上可以适应自修改的代码,而不需要序列化指令。z系统指令集提供了大量的序列化指令集, 包括比较和交换、一些类型的分支(例如,前面提到的“bcr15,0”指令)和测试和设置。
在这些CPU家族之间存在相当大的差异,这一节只是触及了少数被大量使用或具有历史意义的家族的表面。对于那些希望有更多细节的人,请查阅参考手册。
但是Linux-内核内存模型的一个很大的好处是,在编写独立于架构的Linux-内核代码时,您可以忽略这些细节。
几乎所有的人都很聪明。这是他们所缺乏的方法。
F.W.尼科尔
本节将回顾表15.3和第15.1.3节,总结了中间的讨论,包括一些呼吁传递直觉和更复杂的经验规则。
但是首先,当使用内存作为通信介质时,有必要回顾从一个线程到另一个线程的时间和非时间性质,如第15.2.7节中详细讨论的。关键是,尽管负载和存储在概念上很简单,但在真正的多核硬件上,需要大量的时间才能对所有其他线程可见。
当一个线程加载其他线程存储的值时,就会发生简单而直观的情况。这个简单的因果关系案例显示了时间行为,因此软件可以安全地假设存储指令在加载指令开始之前就已经完成了。在现实生活中,加载指令可能在存储指令之前已经开始了一段时间,但所有现代硬件都必须小心地对软件隐藏这些情况。因此,当一个线程加载一个其他线程存储的值时,软件将看到预期的时间因果行为,如第15.2.7.3节所述。
这种时间行为为下一节的传递直觉提供了基础。
本节总结了关于单个线程或变量、锁定、释放-获取链、RCU和完全有序的代码的直觉。
一个只有一个变量或只有一个线程的程序将按顺序查看所有的访问。当在现代计算机系统上运行单线程时,有相当多的代码可以获得足够的性能,但这本书主要是关于需要多个cpu的软件。然后,再讲到下一节。
另一种传递直觉涉及到备受诟病的主力,锁定,在第15.4.2节中有更详细的描述,更不用说第7章了。本部分包含一个图形描述,后面是一个口头描述。
图形描述显示在Figure15.24中,它显示了由cpu0、1和2按该顺序获取和释放的一个锁。实心的黑色箭头表示
解锁锁定顺序。从它们到绿色箭头的虚线显示了对排序的影响。特别是:
1.CPU 0的解锁先于CPU 1的锁定,这一事实确保了CPU 0在其临界部分内或之前执行的任何访问都将被CPU 1在其临界部分内和之后执行的访问所看到。
2.CPU 0的解锁先于CPU 2的锁定,这一事实确保了CPU 0在其临界部分内或之前执行的任何访问都将被CPU 2在其临界部分内和之后执行的访问所看到。
3.CPU 1的解锁先于CPU 2的锁定这一事实确保了CPU 1在其临界段内或之前执行的任何访问将被CPU 2在其临界部分内和之后执行的访问看到。
简而言之,基于锁的排序是通过cpu0、1和2进行传递的。关键是,这种顺序超出了临界部分,因此,早期锁释放之前的所有内容都可以被后期锁获取之后的所有内容所看到。
对于那些喜欢单词而不是图表的人,持有给定锁的代码将看到同一锁的所有先前关键部分的传递访问。如果这样的代码在给定的关键部分中看到了访问,它也会在该关键部分之前看到所有CPU代码中的访问。换句话说,当CPU释放给定的锁时,该锁的后续所有关键部分将在锁释放之前看到该CPU所有代码的访问。
相反,持有给定锁的代码将被保护,不会在同一锁的任何后续关键部分看到访问,同样是传递的。如果这样的代码被保护,以防止在给定的关键部分中看到访问,那么它也将被保护,以防止看到在该关键部分之后的所有CPU代码中的访问。换句话说,当一个CPU获得一个给定的锁时,该锁之前的所有关键部分将受到保护,不会在获得锁之后看到该CPU的所有代码的访问。
但是“看到访问”是什么意思?到底看到了是什么访问?
首先,访问是加载或存储,可能作为读-修改-写操作的一部分发生。
如果一个CPU在释放给定锁之前的代码包含对给定变量的访问a,那么对于在稍后获取同一锁之后对任何CPU代码中包含的同一变量的访问B:
1.如果A和B都是负载,那么B将返回与A相同的值或稍后的一些值。
2.如果A是负载,而B是存储,那么B将覆盖A加载的值或以后的值。
3.如果A是一个存储,B是一个负载,那么B将返回A存储的值或稍后的值。
4.如果A和B都是存储区,那么B将覆盖A存储的值或以后的一些值。
在这里,“一些后期值”是“由某些介入访问存储的值”的缩写。
锁定是强烈的直觉,这也是它存活如此多尝试消除它的原因之一。这也是为什么你应该在它适用的地方使用它的原因之一。
释放-获取链也以一种过渡直观的方式运行。本节还包含一个图形描述,后面是一个口头描述。
图形描述如图15.25所示,它显示了一个通过cpu0、1和2扩展的释放-获取链。黑色箭头描述了释放-获取的顺序。从它们到绿色箭头的虚线显示了对排序的影响。
1.CPU 0,s对A的释放被CPU 1,s获取A的事实确保了CPU 0在其发布之前执行的任何访问将被CPU 1在其获取之后执行的任何访问所看到。
2.CPU1,B的释放是由CPU 2读取的,这确保了CPU 1在发布之前执行的任何访问将被CPU 2执行的任何访问看到。
3.还要注意,CPU 0,A的释放由CPU1获取A,先于CPU1,由CPU 2释放B。总之,所有这些确保CPU 0执行的任何访问将在获取后被CPU 2执行的任何访问看到。
对于那些喜欢文字而不是图表的人,当一个获取加载一个版本存储的值时,如第15.2.7.4节中讨论的那样,那么该版本之后的代码将看到获取之前的所有访问。更准确地说,如果CPU 0进行了加载CPU1所存储的值的获取,那么CPU 0执行的所有后续访问将在发布之前看到所有CPU1的访问。
类似地,该释放访问之前的访问将被保护,不会看到获取访问之后的访问。(更精确的部分是留给读者的一个练习。)
发布和获取可以被链接,例如CPU0,发布存储CPU 1加载的值,CPU 1以后的发布存储CPU2加载的值,获取,等等。给定获取之后的访问将看到链中每个先前发布之前的访问,相反,给定发布之前的访问将受到保护,不会看到链中每个后续获取之后的访问。一些长链的例子说明了清单15.22,15.23,和15.24。
看见和看不见的访问的工作方式与第15.6.1.2节中描述的工作方式相同。
但是,如清单15.27所示,获取访问必须完全加载发布访问所存储的内容。任何本身不属于同一释放-收购连锁店的干预商店都将打破这条连锁店。
然而,适当构建的释放-获取链是可传递的、直观的和有用的。
15.6.1.4 RCU的直觉
如第228页第9.5.2节所述,RCU提供了一些订购保证。
第一个机制是在第228页的第9.5.2.1节中描述的发布-订阅机制。这类似于上一节中讨论的获取-释放链,但替代了smp_load_获取()的rcu_dereference()原语族的一个成员。与smp_load_acquire()不同,rcu_取消引用()所暗示的顺序只适用于取消引用该rcu_ dereference()返回的指针的后续访问,如图229页上的9.10所示。
第二个保证说,如果RCU读侧临界部分的任何部分先于一个宽限期的开始,那么整个临界部分先于该宽限期的结束,如第231页的图9.11所示。
第三个保证说,如果RCU读侧临界部分的任何部分在宽限期结束之后,那么整个临界部分在宽限期开始之后,如图232页的9.12所示。
这两种保证都在第230页的第9.5.2.2节中进行了讨论,在第233页和第234页的图9.13和9.14中显示了更多的例子。这两个保证有进一步的版本维护后果,这将在第235页的第9.5.2.3节中讨论。
这些保证在第15.4.3节中进行了更正式的讨论。
RCU的许多复杂性不在于它的保证,而在于它的用例,这是从第251页开始的第9.5.4节的主题。
一个更极端的例子是,每对传递性之间至少有一个smp_mb()
的访问。任何给定访问所看到的所有访问也将被以后的所有访问所看到。
由此产生的程序将被完全订购,如果有点慢。这样的程序将依次保持一致,并深受专门从事20世纪80年代可靠验证技术的正式验证专家的喜爱。但是不管是不是慢,当你需要它的时候,smp_mb()总是在那里!
然而,在有些情况下,我们还是不能用这些直观的计算方法来解决的。因此,下一节介绍了一组更完整的,如果不少传递的经验规则。
前一节中提出的传递直觉非常吸引人,至少在记忆模型中是这样。不幸的是,当一个线程的存储覆盖一个由其他线程加载或存储的值时,硬件没有义务提供时间上的因果错觉。从软件的角度来看,较早的存储很可能会覆盖较晚的存储的值,但前提是这两个存储是由不同的线程执行的,如图15.13所示。类似地,稍后的加载很可能读取被早期存储覆盖的值,但同样只有当该加载和存储由不同的线程执行时,如图15.12所示。如第15.2.7.2节所述,为了实现足够的性能,需要缓冲存储,从而导致这种反直觉的行为。
因此,一个线程读取其他线程编写的值的情况,比一个线程覆盖其他线程加载或存储的值的顺序要弱得多。这些差异可以通过以下的经验法则来捕获。
第一条经验法则是,只有在至少两个线程之间共享的至少两个变量之间的交互时,才需要内存排序操作,这是Section15.6.1.1中呈现的单一直观幸福的基础。根据中间的材料,这句话包含了第15.1.3节的许多基本经验法则,例如,请记住,“记忆障碍配对”是“循环”的一个双线程特例。而且,像往常一样,如果一个单线程程序能够提供足够的性能,为什么还要使用并行性呢?毕竟,避免并行性也避免了内存排序操作增加的成本和复杂性。
第二条经验法则涉及到加载缓冲情况:如果给定周期中的所有线程之间的通信都使用存储到加载链接(即,下一个线程的负载返回前一个线程存储的值),那么最小排序就足够了。最小排序包括依赖关系和获取以及所有更强的排序操作。因为锁获取必须加载该锁的任何预先释放所存储的锁字值,所以这个经验法则是Section15.6.1.2中呈现的锁直觉的基础。
第三条经验法则涉及释放-获取链:如果给定周期中除了一个链接都是存储到加载链接,那么对每个存储到加载链接使用释放-获取对就足够了,如清单15.23和15.24所示。本规则是第15.6.1.3节中提出的释放-获取直觉的基础。
您可以在允许的环境中使用依赖关系来替换给定的获取,请记住,C11标准的内存模型并不完全尊重依赖关系。因此,导致负载的依赖关系必须由READ_ ONCE()或rcu_dereference()引导:普通c语言负载是不够的。此外,请仔细检查第15.3.2节和第15.3.3节,因为一个依赖项被打破
您的编译器将不会订购任何东西。共享唯一的非存储到加载链接的两个线程有时一方面可以用WRITE_ONCE()+smp_wmb()代替smp_store_版本(),另一方面可以用READ_ONCE()+smp_rmb()代替smp_load_获得()。然而,明智的开发人员将仔细检查这些替代品,例如,使用第12.3节中所述的群体工具。
第四条也是最后一条经验法则确定了需要完整内存障碍(或更强)的位置:如果给定循环包含两个或两个以上非存储到加载链接(即总共两个或多个加载到存储或存储到存储链接),则该循环中每对非存储到加载链接之间至少需要一个完整障碍,如清单15.19以及快速测试15.25的答案所示。完整的障碍包括smp_mb()、成功的全强度非空原子RMW操作,以及在_smp_mb__()或smp_mb__after_atomic()之前结合的其他原子RMW操作。RCU的任何宽限期等待原语(synchronize_rcu()和朋友)也充当了完整的障碍,但代价比smp_mb()大得多。强度带来代价,尽管完全障碍对性能的影响通常大于可伸缩性。这一经验法则的极端逻辑终点是在第15.6.1.5节中提出的完全有序的直觉的基础。
正在重新捕获规则:
1.只有当至少两个线程共享了至少两个变量时,才需要进行内存排序操作。
2.如果一个循环中的所有链接都是存储到加载的链接,那么最小排序就足够了。
3.如果一个循环中除了一个链接之外的所有链接都是存储到加载链接,那么每个存储到加载链接都可以使用一个释放-获取对。
4.否则,在每对非存储到加载的链接之间至少需要一个完整的屏障。
请注意,如第15.5节中所讨论的,体系结构被允许提供更强的保证,但这些保证只能在仅为该体系结构运行的代码中使用。此外,更精确的内存模型[AMM+ 18]可能比这些经验法则以更低的开销操作提供更强的保证,尽管代价是牺牲更大的复杂性。在这些更正式的内存排序文件中,存储到加载链接是从读取(rf)链接的例子,加载到存储链接是从读取(fr)链接的例子,存储到存储链接是一致性(co)链接的例子。
最后一个建议是:使用原始内存排序原语是最后的手段。使用现有的原语几乎总是更好的,比如锁定或RCU,从而让这些原语为您执行内存排序。