原子操作,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
所以,为我澄清一下:
- 是否有任何其他线程将“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
在我的小脑袋里,哪个看起来更直接,但是为什么不同的方法,每个的优点是什么?
我将您的示例放在 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 或其他设备上进行测试,甚至尝试查找实际发生的排序问题。
参考文献:
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=67458:我报告了我发现的 gcc 错误 b=2; a.store(1, MO_release); b=3;
在 x86 上生成 a=1;b=3
,而不是 b=3; a=1;
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=67461:我还报告了一个事实,即 ARM gcc 连续使用两个 dmb sy
作为 a=1; a=1;
,而 x86 gcc 可能需要更少的 mfence操作。我不确定每个商店之间是否需要 mfence
来保护信号处理程序不做出错误的假设,或者它是否只是缺少优化。
The Purpose of memory_order_consume in C++11 (already linked above) 涵盖了使用标志在线程之间传递非原子负载的情况。
StoreLoad barriers (x86 mfence) 的用途:演示需求的工作示例程序:http://preshing.com/20120515/memory-reordering-caught-in-the-act/
- 数据依赖障碍(只有 Alpha 需要这种类型的显式障碍,但 C++ 可能需要它们来防止编译器进行推测加载):http://www.mjmwired.net/kernel/Documentation/memory-barriers.txt#360
控件依赖障碍:http://www.mjmwired.net/kernel/Documentation/memory-barriers.txt#592
Doug Lea 说 x86 只需要 LFENCE
用于用 "streaming" 写入的数据,如 movntdqa
或 movnti
。 (NT = 非时间)。除了绕过缓存,x86 NT loads/stores 还具有弱序语义。
http://preshing.com/20120612/an-introduction-to-lock-free-programming/(指向他推荐的书籍和其他东西)。
有趣 thread on realworldtech 是无处不在的障碍还是强大的内存模型更好,包括数据依赖在硬件中几乎是免费的这一点,所以跳过它并放置一个大的是愚蠢的软件的负担。 (Alpha(和 C++)没有的东西,但其他所有东西都有)。回过头来看看 Linus Torvalds 的一些有趣的侮辱,然后他开始解释他的论点的更详细/技术原因。
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
在我的小脑袋里,哪个看起来更直接,但是为什么不同的方法,每个的优点是什么?
我将您的示例放在 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
的线程都将获得旧值或新值,而不是一些写入一半的值。这个 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)
.
请注意,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 或其他设备上进行测试,甚至尝试查找实际发生的排序问题。
参考文献:
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=67458:我报告了我发现的 gcc 错误
b=2; a.store(1, MO_release); b=3;
在 x86 上生成a=1;b=3
,而不是b=3; a=1;
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=67461:我还报告了一个事实,即 ARM gcc 连续使用两个
dmb sy
作为a=1; a=1;
,而 x86 gcc 可能需要更少的 mfence操作。我不确定每个商店之间是否需要mfence
来保护信号处理程序不做出错误的假设,或者它是否只是缺少优化。The Purpose of memory_order_consume in C++11 (already linked above) 涵盖了使用标志在线程之间传递非原子负载的情况。
StoreLoad barriers (x86 mfence) 的用途:演示需求的工作示例程序:http://preshing.com/20120515/memory-reordering-caught-in-the-act/
- 数据依赖障碍(只有 Alpha 需要这种类型的显式障碍,但 C++ 可能需要它们来防止编译器进行推测加载):http://www.mjmwired.net/kernel/Documentation/memory-barriers.txt#360
控件依赖障碍:http://www.mjmwired.net/kernel/Documentation/memory-barriers.txt#592
Doug Lea 说 x86 只需要
LFENCE
用于用 "streaming" 写入的数据,如movntdqa
或movnti
。 (NT = 非时间)。除了绕过缓存,x86 NT loads/stores 还具有弱序语义。http://preshing.com/20120612/an-introduction-to-lock-free-programming/(指向他推荐的书籍和其他东西)。
有趣 thread on realworldtech 是无处不在的障碍还是强大的内存模型更好,包括数据依赖在硬件中几乎是免费的这一点,所以跳过它并放置一个大的是愚蠢的软件的负担。 (Alpha(和 C++)没有的东西,但其他所有东西都有)。回过头来看看 Linus Torvalds 的一些有趣的侮辱,然后他开始解释他的论点的更详细/技术原因。