memory_order 强内存模型保证内存顺序就好

在C++11标准原子库中,大多数函数接收一个memory_order参数:

 
  1. enum memory_order {

  2. memory_order_relaxed,

  3. memory_order_consume,

  4. memory_order_acquire,

  5. memory_order_release,

  6. memory_order_acq_rel,

  7. memory_order_seq_cst

  8. };

上面的值被称为内存顺序约束。每一个都有自己的目的。在它们之中,memory_order_consume很可能是最少被正确理解的。它是最复杂的排序约束,也最难被正确使用。尽管如此,然而还是吸引着好奇的程序员去弄懂它--或者只是想解开它的神秘面纱。这就是这篇文章的目的所在。

首先,让这个术语直白着:一个使用memory_order_consume的操作具有消费语义(consume semantics)。我们称这个操作为消费操作(consume operations)。

也许对于memory_order_consume最的价值的观察结果就是总是可以安全的将它替换成memory_order_acquire。那是因为获取操作(acquire operations)提供了消费操作(consume operations)的所有保证,而且还更多。换句话说,获取语义更强。

消费和获取都为了同一个目的:帮助非原子信息在线程间安全的传递。就像获取操作一样,消费操作必须与另一个线程的释放操作一起使用。它们之间主要的区别在于消费操作可以正确起作用的案例更少。相对于它的使用不便,反过来也就意味着消费操作在某些平台使用更有效。我将使用一个例子演示所有这些问题点。

---------------------------------------------------------------------------------------------------------------------------------------

对获取和释放语义的简要介绍

这个例子将从使用获取和释放语义在线程间传递少量数据开始。然后,我们将使用消费语义替代它。

首先,让我们声明两个共享变量。Guard是一个C++11原子整数,而Payload只是一个普通int。两个变量初始值都 为0。

 
  1. atomic<int> Guard(0);

  2. int Payload = 0;

主线程有一个循环,它反复尝试下面一系列读操作。基本上,Guard的目的是使用获取语义保护对Payload的访问。主线程不会从Payload中读取到数据直到Guard等于非0。

 
  1. g = Guard.load(memory_order_acquire);

  2. if (g != 0)

  3. p = Payload;

同时,一个异步任务(运行在另一个线程)给Payload赋值42,然后使用释放语义对Guard赋值1。

 
  1. Payload = 42;

  2. Guard.store(1, memory_order_acquire);

读者现在应该熟悉这一样式;在以前的文章中我们应该见过它很多次。一旦异步任务写到Guard中,主线程将读到它,这意味着写-释放与读-获取同步(synchronized-with)了。在这种情况下,我们保证p会等于42,不管这个例子运行在什么平台。

我们使用获取和释放语义在线程间传递简单的非原子整数payload,但是此模式在传递大数据量时也能工作的很好,就如同在以前文章中演示的那样。
---------------------------------------------------------------------------------------------------------------------------------------

获取语义的开销

为了测量memory_order_acquire的开销,我在3 个不同的多核处理器中编译运行以上例子。对于每个架构,我选择对c++11原子支持最好的编译器。你们将在GitHub上找到完整的代码。

让我们来看一下读-获取附近代码产生的机器码:

 
  1. g = Guard.load(memory_order_acquire);

  2. if (g != 0)

  3. p = Payload;

Intel x86-64

在Intel x86-64上,Clang编译器给这个例子产生了紧凑的机器码--每行C++代码对应一条机器指令。这一处理器家族采用强内存模型,所以编译器不需要放置特定有内存栅栏以实现读-获取。只需要保证机器指令的顺序正确就行。
PowerPC

PowerPC是弱排序CPU,这就意味着编译器在多核系统中必须放置内存栅栏指令以保证获取语义。在这个例子中,GCC使用了这里推荐的由3个指令组成的一串指令:cmp;bne;isync。(单个指令lwsync也可以完成相同的工作)
ARMv7

ARM也是弱排序CPU,所以编译器在多核系统中也必须放置内存栅栏指令以保证获取语义。在ARMv7中,dmb ish是最合适的指令,尽管也是一个内存栅栏。

如下就是我们例子的主循环在测试机器上每循环一次的计时:

在PowerPC和ARMv7上,内存栅栏指令造成的性能惩罚,但它们对正确运行是必须的。事实上,如果你从ARMv7机器码中删除dmb ish指令,同时保留其它指令,在iPhone 4S上内存重排序能被直接观察到。
---------------------------------------------------------------------------------------------------------------------------------------

数据依赖顺序

我已说过PowerPC和ARM是弱排序CPU,但事实上,在机器指令级别上执行内存排序时总会有一些情况是不需要显式的使用内存栅栏指令的。特别是那些使用数据依指令保持内存排序的处理器。

当两个机器指令在同一个线程执行时,如果第一个指令的输出值会被第二个指令作为输入用到,那它们就是数据依赖(data-dependent)的。输出值可能会被写入寄存器,就如同下方PowerPC所示的那样。这里,第一个指令加载值到r9,第二个指令会在接下来的加载过程中将r9作为一个指针:

因为在这两个指令之间存在数据依赖(data-dependency),加载将按顺序执行。

你们可能认为这是很明显的。然而,在第一个指令加载了r9之前第二个指令怎么知道从哪个地址加载?很显然不知道。记住,加载指令也可能从不同的缓存读取数据。如果另外一个CPU内核正在并发修改内存,第二个指令的缓存不会像第一个指令那样及时更新,那样也会导致内存重排!PowerPC 提供了其它技术路径避免这种情况,即通过保持每个缓存是最新的从而确保数据依赖排序总是被保持。

数据依赖不只是会通过寄存器建立;它们也能通过内存位置建立。在这个列表中,第一个指令写值到内存,第二个指令将值读出,从而在两个指令之间建立了数据依赖:

当多个指令彼此之间相互数据依赖时,我们称之为数据依赖链(data dependency chain)。在如下的PowerPC列表中,有两个独立的数据依赖链:

数据依赖顺序保证所有的沿着同一条链的内存访问将按顺序执行。例如,在上面的列表中,在第一个蓝色的加载与最后一个蓝色的加载之间内存顺序将会被保证;在第一个绿色的加载与最后一个绿色的加载之间内存顺序将也会被保证。另一方面,独立的链之间内存顺序是没有保证的!所以,每一个蓝色的加载也可以有效的在任意一个绿的的加载之后发生。

其它一些处理器族也可以保持数据依赖顺序。Itanium, PA-RISC, SPARC (in RMO mode) and zSeries也在机器指令级别遵从数据依赖顺序。事实上,唯一知道的不可以保持数据依赖顺序的弱排序处理器是 DEC Alpha

更不用说像Intel x86, x86-64 and SPARC (in TSO mode)这样的强排序CPU,也同样是遵从数据依赖顺序的。
---------------------------------------------------------------------------------------------------------------------------------------

消费语义就是被设计来使用这一特性的

当你使用消费语义,你就是想让编译器在所有那些处理器族上利用数据依赖。这就是为什么简简单单的将memory_order_acquire改为memory_order_consume是不够的。你必须确定在C++源代码级别存在数据依赖。

在源代码级别,依赖链是一串表达式,它的值将给其它代码提供一个依赖(Carries-a-dependency)。提供一个依赖是在C++标准§1.10.9中定义的。在大多数据情况下,也就是说当第一个的值被用来作为第二个的操作数时,一个代码的值提供一个依赖给其它代码。这种描述就像是机器级数据依赖的程序语言级版本。(其实在c++11中有一套严格的条件说明什么构成了提供一个依赖,但我不会在这里详细论述。)

现在主我们回过头来修改原先的例子以使用消费语义。首先,我们将改变Guard的类型,从atomic<int> 改为atomic<int*>:

 
  1. atomic<int*> Guard(nullptr);

  2. int Payload = 0;

我们这样做是因为,在异步任务中,我们想存储一个指针给Payload,以便说明payload准备好了:

 
  1. Payload = 42;

  2. Guard.store(&Payload, memory_order_release);

最终,在主线程,我们将memory_order_acquire替代为memory_order_consume,我们经由从g获取的指针间接加载p。从p加载而不是直接从payload中读取,这是关键!这使得第一行代码给第三行代码提供了一个依赖,在这个例子中,为了使用消费语义这点是至关重要的:

 
  1. g = Guard.load(memory_order_consume);

  2. if (g != nullptr)

  3. p = *g;

可以在GitHub上找到完整的代码。

现在,这个被修改的例子可以跟原先的例子一样可靠的运行。一旦异步任务写入Guard,同时主线程读取它,C++标准保证p将等于42,不管代码运行在什么平台。不同之处在于这次没有在任何地方使用synchronizes-with关系。这次我们使用的关系被称为dependency-ordered-before关系。


在任何dependency-ordered-before关系中,依赖链从消费操作开始,在写-释放执行前的所有内存操作被保证在链上可见。
---------------------------------------------------------------------------------------------------------------------------------------

消费语义的值

现在,我们看一下使用消费语义修改后的例子生成的机器码。

Intel x86-64


机器码加载Guard到寄存器rcx,然后,如果rcx是空,使用rcx加载payload,这样在两个加载指令之间创建一个数据依赖。数据依赖并没有产生什么实质的不同。x86-64的强内存模型总能保证卡莉法按顺序执行,即使没有数据依赖。
PowerPC

机器码加载Guard到寄存器r9,然后,使用r9加载payload,这样在两个加载指令之间创建一个数据依赖。它起作用了,这个数据依赖让我们完全避免了在原先例子中构成内存栅栏的cmp;bne;isync指令序列,同时仍然会确保两个卡加载会按顺序执行。
ARMv7

机器码加载Guard到寄存器r4,然后,使用r4加载payload,这样在两个加载指令之间创建一个数据依赖。这个数据依赖让我们完全避免了在原先例子客户出现的dmb ish指令,同时仍然会确保两个卡加载会按顺序执行。

最终,根据我前面提供的汇编列表,下面是主循环每次迭代的最新计时:

一点也不让人奇怪,消费语义在Intel x86-64上几乎没什么变化,但通过去除昂贵的内存栅栏,它们在PowerPC产生了巨大的变化,在ARMv7上也产生了显著的变化。当然,请记住这些是微基准测试。在实际应用中,性能获取将依赖于获取操作被执行的频率。

在真实世界中使用这一技术--利用数据依赖顺序以避免内存栅栏--的例子就是Linux内核。Linux提供了一个对读-复制-更新(RCU)的实现,它适合构建在多个线程中需要频繁读取但写入不频繁的数据结构。然而,在本文写作期间,Linux实际上没有使用C++11消费语义来去除那些内存栅栏。相反,它依靠它自己的API和规范。其实起初RCU就被看作是给C++11添加消费语义的动机
---------------------------------------------------------------------------------------------------------------------------------------

现在缺少编译器的支持

我不得不坦白,我展示给你们的那些针对PowerPC和ARMv7汇编代码列表,是捏造的。对不起,GCC 4.8.3和Clang 4.6其实不会为消费操作生成机器码!我知道,这多少有点让人失望。但是这篇文章的目的是展示memory_order_consume的目的。不幸的是,事实是现在的编译器还没有依此行事。

你会看到,针对弱排序处理器,编译器会从两种策略中选择实现memory_order_consume的方式:一种的高效的策略和一种代价高昂的策略。高效的策略是这篇文章所描述过的。如果处理器遵从数据依赖顺序,编译器会避免放置内存栅栏指令,只要它在消费操作开始时为每个程序代码级依赖链输出机器级依赖链。而在代价高昂的策略中,编译器会简单的把memory_order_consume看成作是memory_order_acquire,并忽略整个依赖链。

当前版本的GCC和Clang/LLVM总是使用代价高昂的策略(除了在当前版本GCC中已知的bug)。结果就是,如果你在PowerPC和 ARMv7中使用当前的编译器编译memory_order_consume,将会产生不必要的内存栅栏指令,破坏了初衷。

这表明在遵从C++11规范时实现高效的策略对编译器作者是很难的。这里有一些提案帮助提高规范,目的就是为了让编译器很容易实现。我不会在这里评论这些细节;可能会写相关文章加以论述。

如果编译器确实实现了高效策略,你可以使用它优化针对双重检查锁定的惰性加载、有意义类型的无锁哈希表、无锁栈和无锁队列。记住,只会在特定的处理器族中获得性能提升,并且很可能在加载-消费执行次数少于,比如10000次每秒时,性能提升会微不足道。

猜你喜欢

转载自blog.csdn.net/linuxheik/article/details/83059183
今日推荐