【问题标题】:Atomic operations, std::atomic<> and ordering of writes原子操作、std::atomic<> 和写入顺序
【发布时间】:2015-11-29 20:03:23
【问题描述】:

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。
  • 为什么 MFENCE 发生在写入“a”之后而不是之前。
  • 写入“a”是否保证是原子操作(狭义的,非 C++ 意义上的)操作,这适用于所有英特尔处理器吗?我从这个输出代码中假设是这样。

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

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

这对我的小脑袋来说似乎更直接,但为什么要采用不同的方法,每种方法的优势是什么?

【问题讨论】:

  • 同样,英特尔“写入内存不会与其他写入重新排序 [后跟异常列表]”。所以我认为这回答了我的前两个要点。
  • 我相信 C++11 中的默认内存排序是顺序一致性,这比您的第一个问题暗示的发布一致性要多。 memory-fence 仅用于顺序一致性。
  • @EOF:是的,默认模型使用 C++11 原子 是顺序一致性。其他操作的默认模型与 DEC Alpha 一样弱。
  • @PeterCordes:是的,我的评论应该在问题的上下文中阅读。无论如何这只是一个评论,现在有你的好而全面的答案,所以我认为我的简洁不会造成任何伤害。

标签: c++ assembly compiler-construction x86


【解决方案1】:

我将您的示例放在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 位字节,这感觉就像一个荒谬的循环。 &lt;/rant&gt;


是否有任何其他线程将“a”读为 1,并保证将“b”读为 2。

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

现在我们正在研究语言的memory model,以及它运行的硬件。

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

默认情况下,使用 C++11 原子类型可以使您对它们的操作相对于程序的其余部分保持完整的顺序一致性。这意味着它们不仅仅是原子的。请参阅下文,将订购放宽到所需的程度,从而避免昂贵的围栏操作。


为什么 MFENCE 发生在写入“a”之后而不是之前。

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

完全顺序一致性还要求存储在以后按程序顺序加载之前是全局排序/全局可见的。

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

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

b=2; a=1; 的 ARM32 汇编是:

# 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.

我不知道 ARM asm,但到目前为止我发现通常它是 op dest, src1 [,src2],但加载和存储总是首先有寄存器操作数,然后是内存操作数。如果您习惯于 x86,这真的很奇怪,其中内存操作数可以是大多数非向量指令的源或目标。加载立即数常量也需要大量指令,因为固定指令长度只为movw(移动字)/movt(移动顶部)的 16b 有效载荷留出了空间。


发布/获取

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

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

请注意,std:atomic 使用这些名称甚至用于与加载获取或存储释放操作略有不同的独立栅栏。 (参见下面的 atomic_thread_fence)。

发布/获取语义比生产者-消费者需要的更强大。这只需要单向 StoreStore(生产者)和单向 LoadLoad(消费者),无需 LoadStore 排序。

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


使用较弱但仍然足够的排序

默认情况下,写入std::atomic 类型的顺序相对于源代码中的所有其他内存访问(加载和存储)。您可以使用std::memory_order 控制强加的排序。

在您的情况下,您只需要您的生产者确保商店以正确的顺序在全球范围内可见,即商店之前的 StoreStore 障碍 astore(memory_order_release) 包括这个和更多。 std::atomic_thread_fence(memory_order_release) 只是所有商店的单向 StoreStore 屏障。 x86 免费提供 StoreStore,因此编译器所要做的就是将存储按源顺序排列。

Release 而不是 seq_cst 将是一个巨大的性能胜利,尤其是。在 x86 这样的架构上,发布价格便宜/免费。如果无争用情况很常见,则更是如此。

读取原子变量还强制加载相对于所有其他加载和存储的完全顺序一致性。在 x86 上,这是免费的。 LoadLoad 和 LoadStore 屏障是无操作的,并且隐含在每个内存操作中。您可以使用 a.load(std::memory_order_acquire) 使您的代码在弱序 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.在实践中,它们通常会发出 2 路 StoreStore 或 LoadLoad 屏障的硬件指令。 This doc 是成为当前标准的提案。您可以看到 memory_order_release 如何映射到 SPARC RMO 上的 #LoadStore | #StoreStore,我认为它被包括在内的部分原因是它分别具有所有屏障类型。 (嗯,cppref 网页只提到了排序存储,没有提到 LoadStore 组件。不过,它不是 C++ 标准,所以完整的标准可能会说得更多。)


memory_order_consume 对于这个用例来说不够强大。 This post 谈到您使用标志来指示其他数据已准备好的情况,并谈到memory_order_consume

consume 如果您的标志是指向b 的指针,甚至是指向结构或数组的指针就足够了。但是,没有编译器知道如何进行依赖跟踪以确保它在 asm 中以正确的顺序放置事物,因此当前的实现总是将consume 视为acquire。这太糟糕了,因为除了 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.

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

使用consume 编写代码不是一个坏主意,如果你确定你理解其中的含义并且不依赖任何consume 不保证的东西。将来,一旦编译器更智能,即使在 ARM/PPC 上,您的代码也将无障碍指令编译。实际的数据移动仍然必须在不同 CPU 上的缓存之间进行,但在弱内存模型机器上,您可以避免等待任何不相关的写入可见(例如生产者中的暂存缓冲区)。

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

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


参考:

【讨论】:

  • @preshing:我链接到你的一堆博客文章以获得这个答案。我发现它们真的很有帮助。尤其是preshing.com/20120710/… 对我来说很棒,因为我已经知道一些东西,但是在术语和 x86 内存模型、ARM/PPC 内存模型和 C++11 内存模型之间的区别上很模糊。
  • @JCx:我一直在学习一些关于内存模型等的东西,但没有研究过 C++11 是如何做事的。我本来想这样做的,而你的问题让我开始深入研究并将我已经收集到的部分拼凑起来。
  • 参考。未对齐的商店,您如何看待英特尔系统编程手册中的“8.1.1 保证原子操作”。它说“适合缓存线”,但我相信缓存线本身是对齐的。他们的意思是“不跨越缓存线边界”还是缓存线比我想的更灵活......
  • @JCx:我确定他们的意思是“不跨越缓存线边界”。缓存行确实是对齐的。所有最新的 Intel 和 AMD x86 CPU 都使用 64B 高速缓存线。英特尔有时会在 P4 左右切换。早期的 Intel CPU,直到早期的 P6(PII 和 PIII)都使用 32B/​​line。只要您的数据与其大小的倍数对齐,您就可以自动加载/存储它。使用 CMPXCHG16B,Atomic RMW 最高可达 16B(在 64 位模式下)。显然,即使对齐,SSE/AVX 存储也不能保证是原子的。 :/ 这是有道理的:Sandybridge 在两个 128b 周期中进行 256b 存储。奔腾 M 拆分 128b 运算。