原子操作,std :: atomic<>和写入的顺序

时间:2015-09-03 20:30:20

标签: c++ assembly compiler-construction x86

GCC汇编了这个:

#include <atomic>
std::atomic<int> a; 
int b(0);

void func()
{
  b = 2; 
  a = 1;
}

到此:

func():
    mov DWORD PTR b[rip], 2
    mov DWORD PTR a[rip], 1
    mfence
    ret

所以,为我澄清一些事情:

  • 读取'a'为1的任何其他线程保证将'b'读为2。
  • 为什么在写入“a”之后才发生了MFENCE。
  • 无论如何,对“a”的写入保证是原子(在狭窄的非C ++意义上)操作,这是否适用于所有英特尔处理器?我假设这个输出代码。

另外,clang(v3.5.1 -O3)这样做:

mov dword ptr [rip + b], 2
mov eax, 1
xchg    dword ptr [rip + a], eax
ret

这似乎对我的小脑子更直接,但为什么不同的方法,每个方法的优势是什么?

1 个答案:

答案 0 :(得分:14)

我把你的例子放在Godbolt compiler explorer, and added some functions上来读取,增加或组合(a+=b)两个原子变量。我还使用a.store(1, memory_order_release);而不是a = 1;来避免获得超出需求的订单,因此它只是x86上的一个简单商店。

请参阅下文(希望是正确的)解释。 更新:我只有"release" semantics与StoreStore屏障混淆了。我想我已经解决了所有错误,但可能会留下一些错误。

首先是一个简单的问题:

  

'a'的写入是否保证是原子的?

是的,任何读取a的线程都将获得旧值或新值,而不是某些半值。这个happens for free on x86和大多数其他架构都有适合寄存器的对齐类型。 (例如,在32位上不是int64_t。)因此,在许多系统上,b也是如此,这也是大多数编译器生成代码的方式。

有些类型的商店在x86上可能不是原子的,包括跨越高速缓存行边界的未对齐商店。但是std::atomic当然保证了必要的对齐。

读取 - 修改 - 写入操作是有趣的。一次在多个线程中完成的a+=3的1000次评估将始终产生a += 3000。如果a不是原子的,那么你可能会减少。

有趣的事实:签名的原子类型保证了两个补码环绕,与普通的签名类型不同。 C和C ++仍然坚持在其他情况下保留有符号整数溢出的想法。有些CPU没有算术右移,所以保留负数的右移不确定是有道理的,但是否则只是感觉像是一个荒谬的箍,现在所有CPU都使用2的补码和8位字节。 </rant>

  

读取'a'为1的任何其他线程是否保证将'b'读为2。

是的,因为std::atomic提供了保证。

现在我们正在进入该语言的memory model及其运行的硬件。

C11和C ++ 11的内存排序模型非常弱,这意味着允许编译器重新排序内存操作,除非你告诉它不要。 (来源:Jeff Preshing's Weak vs. Strong Memory Models)。即使x86是您的目标计算机,您也必须停止编译器在 compile 时重新排序存储。 (例如,通常你希望编译器将a = 1从一个也写入b的循环中提升出来。)

默认情况下,使用C ++ 11原子类型可以为程序的其余部分提供关于它们的操作的完全顺序一致性排序。这意味着他们不仅仅是原子的。请参阅下文,了解订购所需的内容,以避免昂贵的围栏操作。

  

为什么在写入“a”之后才发生MFENCE。

StoreStore fences是x86强大内存模型的无操作,因此编译器只需将商店放到b商店之前a来实现源代码排序。

完整的顺序一致性还要求商店在按程序顺序加载之前全局排序/全局可见。

x86可以在加载后重新订购商店。实际上,发生的是乱序执行在指令流中看到一个独立的负载,并在仍在等待数据准备就绪的存储之前执行它。无论如何,顺序一致性禁止这样做,因此gcc使用MFENCE,这是一个完整的障碍,包括StoreLoad(the only kind x86 doesn't have for free。(LFENCE/SFENCE仅对弱序操作有用,例如{{1} }}))

另一种表达方式是C ++文档使用的方式:顺序一致性保证所有线程都能看到相同顺序中的所有更改。每个原子存储之后的MFENCE保证该线程看到来自其他线程的存储。 否则,在其他线程的负载看到我们的商店之前,我们的货物会看到我们的商店。 StoreLoad屏障(MFENCE)将我们的负载延迟到需要先发生的商店之后。

movnt的ARM32 asm是:

b=2; a=1;

我不了解ARM asm,但到目前为止我所知道的通常是# get pointers and constants into registers str r1, [r3] # store b=2 dmb sy # Data Memory Barrier: full memory barrier to order the stores. # I think just a StoreStore barrier here (dmb st) would be sufficient, but gcc doesn't do that. Maybe later versions have that optimization, or maybe I'm wrong. str r2, [r3, #4] # store a=1 (a is 4 bytes after b) dmb sy # full memory barrier to order this store wrt. all following loads and stores. ,但是加载和存储总是首先拥有寄存器操作数而且内存操作数第二。如果您习惯使用x86,这真的很奇怪,其中内存操作数可以是大多数非向量指令的源或目标。加载立即数常量也需要很多指令,因为固定的指令长度只为op dest, src1 [,src2](移动字)/ movw(移动顶部)的16b有效载荷留出空间。

发布/获取

单向内存屏障的release and acquire命名来自锁:

  • 一个线程修改共享数据结构,然后释放一个锁。在所有加载/存储到其保护的数据之后,解锁必须全局可见。 (StoreStore + LoadStore)
  • 另一个线程获取锁(读取或带有发布存储的RMW),并且在获取全局可见后,必须对共享数据结构执行所有加载/存储。 (LoadLoad + LoadStore)

请注意,即使对于与load-acquire或store-release操作稍有不同的独立fence,std:atomic也会使用这些名称。 (参见下面的atomic_thread_fence)。

发布/获取语义比生产者 - 消费者需要的更强。这只需要单向StoreStore(生产者)和单向LoadLoad(消费者),而不需要LoadStore订购。

由读取器/写入器锁(例如)保护的共享哈希表需要获取 - 加载/释放 - 存储原子读 - 修改 - 写操作来获取锁。 x86 movt是一个完整的障碍(包括StoreLoad),但ARM64具有load-linking-store-release版本的load-linked / store-conditional,用于执行原子读取 - 修改 - 写入。据我了解,即使是锁定,也可以避免使用StoreLoad屏障。

使用较弱但仍然充足的顺序

默认情况下,对lock xadd类型的写入按源代码(加载和存储)中的每个其他内存访问进行排序。您可以使用std::memory_order来控制强加的排序。

在您的情况下,您只需要生产商确保商店以正确的顺序全局可见,即商店之前的StoreStore屏障std::atomica包含此内容以及更多内容。 store(memory_order_release)只是所有商店的单向StoreStore屏障。 x86免费提供StoreStore,因此编译器所要做的就是将存储按源顺序放置。

发布而不是seq_cst将是一场重大的表现胜利,尤其是。在像x86这样的架构上,发布很便宜/免费。如果无争用案例很常见,情况就更是如此。

读取原子变量还会对所有其他负载和存储施加负载的完全顺序一致性。在x86上,这是免费的。 LoadLoad和LoadStore障碍是无操作,并隐含在每个内存操作中。您可以使用std::atomic_thread_fence(memory_order_release)

在弱排序的ISA上使代码更高效

请注意std::atomic standalone fence functions confusingly reuse the "acquire" and "release" names for StoreStore and LoadLoad fences that order all stores (or all loads) in at least the desired direction。实际上,它们通常会发出硬件指令,这些指令是双向StoreStore或LoadLoad障碍。 This doc是关于什么成为当前标准的提案。您可以看到memory_order_release如何映射到SPARC RMO上的a.load(std::memory_order_acquire),我认为其中包含的部分是因为它分别具有所有屏障类型。 (嗯,cppref网页只提到订购商店,而不是LoadStore组件。但它不是C ++标准,所以也许完整的标准会说更多。)

#LoadStore | #StoreStore对于此用例来说还不够强大。 This post讨论了使用标记表明其他数据已准备就绪的情况,并讨论了memory_order_consume

如果你的标志是指向memory_order_consume的指针,或者甚至是指向结构或数组的指针,那么

consume就足够了。但是,没有编译器知道如何进行依赖关系跟踪以确保它在asm中按正确顺序放置,因此当前实现始终将b视为consume。这太糟糕了,因为除了DEC alpha(和C ++ 11的软件模型)之外的每个架构都免费提供这种排序。 According to Linus Torvalds, only a few Alpha hardware implementations actually could have this kind of reordering, so the expensive barrier instructions needed all over the place were pure downside for most Alphas.

生产者仍然需要使用acquire语义(StoreStore屏障),以确保在更新指针时新的有效负载可见。

使用release编写代码并不是一个坏主意,如果您确定自己了解其含义并且不依赖于consume无法做出的任何事情&# 39;保证。将来,一旦编译器变得更加智能,即使在ARM / PPC上,您的代码也将在没有屏障指令的情况下进行编译。实际数据移动仍然必须在不同CPU上的缓存之间发生,但在弱内存模型机器上,您可以避免等待任何不相关的写入可见(例如,生产者中的暂存缓冲区)。

请记住您实际上无法通过实验测试consume代码,因为当前的编译器为您提供了比代码请求更强的排序。

无论如何,很难通过实验来测试这些,因为它对时间敏感。此外,除非编译器重新命令操作(因为你没有告诉它),生产者 - 消费者线程永远不会在x86上出现问题。您需要在ARM或PowerPC上进行测试,甚至需要尝试查找实际中发生的排序问题。

的引用: