我将您的示例放在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 是您的目标机器,您也必须在 编译 时阻止编译器重新排序存储。 (例如,通常您希望编译器将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 障碍 a
。 store(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 或其他设备上进行测试,甚至尝试寻找在实践中发生的排序问题。
参考: