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

Atomic operations, std::atomic<> and ordering of writes

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

所以,为我澄清一下:

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

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

在我的小脑袋里,哪个看起来更直接,但是为什么不同的方法,每个的优点是什么?

我将您的示例放在 Godbolt compiler explorer, and added some functions 上以读取、递增或组合 (a+=b) 两个原子变量。我还使用 a.store(1, memory_order_release); 而不是 a = 1; 来避免获得比需要更多的订单,所以它只是 x86 上的一个简单存储。

请参阅下面的(希望是正确的)解释。 更新:我曾 "release" semantics 与 StoreStore 障碍混淆。我想我修正了所有的错误,但可能还留下了一些错误。


首先是简单的问题:

Is the write to ‘a’ guaranteed to be an atomic?

是的,任何读取 a 的线程都将获得旧值或新值,而不是一些写入一半的值。这个 和大多数其他具有适合寄存器的任何对齐类型的体系结构。 (例如,在 32 位上不是 int64_t。)因此,在许多系统上,这恰好适用于 b,这是大多数编译器生成代码的方式。

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

读取-修改-写入操作是这变得有趣的地方。在多个线程中一次完成的 a+=3 的 1000 次评估总是会产生 a += 3000。如果 a 不是原子的,你可能会得到更少。

有趣的事实:有符号原子类型保证二进制补码环绕,这与普通有符号类型不同。 C 和 C++ 仍然坚持在其他情况下不定义有符号整数溢出的想法。一些 CPU 没有算术右移,因此保留负数的右移未定义是有道理的,但除此之外,既然所有 CPU 都使用 2 的补码和 8 位字节,那么跳过去感觉就像一个荒谬的圈套。 </rant>


Is any other thread reading ‘a’ as 1 guaranteed to read ‘b’ as 2.

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

现在我们要了解 memory model 语言及其运行的硬件。

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

默认情况下,使用 C++11 原子类型可为您提供相对于程序其余部分的完整顺序一致性操作顺序。这意味着它们不仅仅是原子的。请参阅下文,了解将排序放宽到只需要什么,从而避免昂贵的栅栏操作。


Why does the MFENCE happen after the write to ‘a’ not before.

StoreStore fences 是 x86 强内存模型的空操作,因此编译器只需在存储到 a 之前将存储到 b 即可实现源代码订购。

完全顺序一致性还要求存储在程序顺序中的任何后续加载之前全局排序/全局可见。

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

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


发布/获取

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

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

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

Release/Acquire 语义比生产者-消费者要求的强。这只需要单向 StoreStore(生产者)和单向 LoadLoad(消费者),无需 LoadStore 排序。

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


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

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

强加的顺序

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

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

读取原子变量还强制加载相对于所有其他加载和存储的完全顺序一致性。在 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. In practice, they will usually emit HW instructions that are 2-way StoreStore or LoadLoad barriers. This doc 是成为当前标准的提案。您可以看到 memory_order_release 如何映射到 SPARC RMO 上的 #LoadStore | #StoreStore,我认为它被包括在内的部分原因是它分别具有所有屏障类型。 (嗯,cppref 网页只提到了排序存储,没有提到 LoadStore 组件。虽然它不是 C++ 标准,所以也许完整的标准说得更多。)


memory_order_consume 对于这个用例来说不够强大。 This post 讲到你用标志表示其他数据准备好了的情况,讲到memory_order_consume.

如果您的标志是指向 b 的指针,甚至是指向结构或数组的指针,

consume 就足够了。然而,没有编译器知道如何进行依赖性跟踪以确保它在 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 或其他设备上进行测试,甚至尝试查找实际发生的排序问题。


参考文献: