加载/存储松弛原子变量和普通变量有什么区别?

Lon*_*gLT 4 c++ atomic memory-barriers c++11 stdatomic

正如我从测试用例中看到的:https : //godbolt.org/z/K477q1

生成的程序集加载/存储原子放松与普通变量相同:ldr 和 str

那么,松弛原子变量和普通变量之间有什么区别吗?

Den*_*son 9

不同之处在于,不能保证正常的加载/存储是无撕裂的,而轻松的原子读/写则是。此外,原子保证编译器不会以类似于volatile保证的方式重新排列或优化内存访问。

(C++11 之前,volatile是滚动你自己的原子的重要组成部分。但现在它已经过时了。它在实践中仍然有效,但从不推荐:何时将 volatile 与多线程一起使用? - 基本上从不。)

在大多数平台上,架构默认提供无撕裂的加载/存储(对于对齐intlong),因此如果加载和存储没有得到优化,它在 asm 中的效果相同。请参阅为什么在 x86 上对自然对齐的变量原子进行整数赋值?例如。在 C++ 中,您可以在源代码中表达如何访问内存,而不是依赖特定于体系结构的功能来使代码按预期工作。

如果您在 asm 中手写,那么当值保存在寄存器中与加载/存储到(共享)内存时,您的源代码已经确定了。在 C++ 中,告诉编译器何时可以/不能将值保持私有是std::atomic<T>存在的部分原因。

如果您阅读有关此主题的一篇文章,请查看此处的 Preshing:https ://preshing.com/20130618/atomic-vs-non-atomic-operations/

也可以试试这个来自 CppCon 2017 的演示:https : //www.youtube.com/watch?v= ZQFzMfHIxng


进一步阅读的链接:


另请参阅 Peter Cordes 的链接文章:https : //electronics.stackexchange.com/q/387181
以及有关 Linux 内核的相关文章:https : //lwn.net/Articles/793253/

没有撕裂只是您得到的一部分std::atomic<T>- 您还可以避免数据竞争未定义的行为。

  • 除了缺乏撕裂(在大多数平台上的 asm 中免费发生)之外,`std::atomic&lt;T&gt;` 为您提供的另一个关键部分(即使使用 mo_relaxed)是明确定义的行为,即使在不同步读写的情况下也是如此。由于 as-if 规则 + 数据争用 UB,非原子读取可以被提升到循环之外。参见[MCU编程-C++ O2优化中断while循环](https:// electronics.stackexchange.com/q/387181) (3认同)
  • 另外,[谁害怕一个糟糕的优化编译器?](https://lwn.net/Articles/793253/) 解释了如果您尝试使用普通的非原子共享,可能会出错的*许多*事情的一些细节变量。(它是从 Linux 内核的上下文中编写的,其中他们的解决方案是使用“易失性”手动滚动原子,而不是 C11“_Atomic”或 C++11“std::atomic”,但有相同的区别;假设底层的 asm 操作不被撕裂,这使得“易失性”成为一种传统的原子处理方式。此外,硬件具有一致的缓存。) (2认同)
  • UB = 未定义的行为。(https://blog.llvm.org/posts/2011-05-13-what-every-c-programmer-should-know/)。CSE = [公共子表达式消除](https://en.wikipedia.org/wiki/Common_subexpression_elimination),例如,将负载从循环中提升出来,就像在该链接的问题中一样。哦,我看到我的链接答案使用了这些术语,但没有定义它们;编辑修复。感谢您指出了这一点。 (2认同)
  • 有一些罕见的板,例如带有微控制器+ DSP 的ARM,它们具有非一致的共享内存,但我们不会在它们之间运行同一进程的线程。例如,在 ARM 内存模型中,假设线程在同一内部可共享域的内核上运行。我编辑了你的答案,以修正错误的说法,即这在实践中会成为一个问题。 (2认同)
  • CPU 与 MESI 的某种等价物保持一致性;在该核心拥有该行的*独占*所有权之前,存储无法提交到 L1d 缓存(所有其他副本均无效)。如果该线路尚未拥有,商店必须等待 RFO(读取所有权)。获取和释放与单个内存位置的一致性完全无关,仅与排序有关。其他地点。(此外,OP正在询问mo_relaxed)。此外,很难跨 CPU 定义“立即”一词。另请参阅[此问答](/sf/ask/2992275541/)。 (2认同)
  • 这种缓存暂时不同步的想法是一种常见的误解,但“一致”缓存的全部目的是确保这种情况永远不会发生。(类似的效果是通过单个核心内部的存储转发创建的,其中核心可以尽早看到自己的存储,以维持自己按程序顺序运行的错觉,但存储缓冲区不是全局可见的。) (2认同)
  • 另请注意,数据可以在内核之间传输,而无需一直写回 RAM。共享 L3 缓存是一致性流量的后盾。 (2认同)
  • 既然您已经分享了很多链接,让我再添加一个链接:[C/C++ 程序员的内存模型](https://arxiv.org/abs/1803.04432) (2认同)

Dav*_*aim 9

实际上,这是一个很好的问题,当我开始学习并发时,我也问了同样的问题。

我会尽可能简单地回答,即使答案有点复杂。

从不同线程*读取写入相同的非原子变量是未定义的行为 -保证一个线程读取另一个线程写入的值。

使用原子变量解决了这个问题 - 通过使用原子,即使内存顺序放宽,所有线程也保证读取最新的写入值。

事实上,无论内存顺序如何,原子始终是线程安全的! 内存顺序不适用于原子 -> 它适用于非原子数据

事情是这样的——如果你使用锁,你就不必考虑那些低级的事情。内存顺序用于需要同步非原子数据的无锁环境

这是无锁算法的美妙之处,我们使用始终线程安全的原子操作,但我们使用内存顺序“小猪包装”这些操作来同步这些算法中使用的非原子数据。

例如,无锁链表。通常,无锁链表节点看起来像这样:

Node:
   Atomic<Node*> next_node;
   T non_atomic_data
Run Code Online (Sandbox Code Playgroud)

现在,假设我将一个新节点推入列表中。next_node始终是线程安全的,另一个线程将始终看到最新的原子值。但是谁允许其他线程看到 的正确值呢non_atomic_data

没有人。

这是使用内存顺序的一个完美示例 - 我们next_node通过添加同步 值的内存顺序来“搭载”原子存储和加载non_atomic_data

因此,当我们将新节点存储到列表中时,我们使用memory_order_release将非原子数据“推送”到主内存。当我们通过读取来读取新节点时next_node,我们使用memory_order_acquire然后从主内存中“拉”出非原子数据。这样我们就可以确保 和next_node始终non_atomic_data在线程间同步。

memory_order_relaxed不同步任何非原子数据,它仅同步其本身 - 原子变量。使用此功能时,开发人员可以假设原子变量不引用由写入原子变量的同一线程发布的任何非原子数据。换句话说,该原子变量不是非原子数组的索引,也不是指向非原子数据的指针,也不是指向某些非线程安全集合的迭代器。(使用宽松的原子存储和加载作为常量查找表或单独同步的索引就可以了。如果指向或索引的数据是由同一线程写入的,则仅需要 acq/rel 同步。)这比使用更强的内存顺序更快(至少在某些体系结构上),但可以在较少的情况下使用。

很好,但这还不是完整的答案。我说过内存顺序不用于原子。我是半撒谎的。

在宽松的内存顺序下,原子仍然是线程安全的。但它们有一个缺点——可以重新订购。看下面的代码片段:

a.store(1, std::memory_order_relaxed);
b.store(2, std::memory_order_relaxed);
Run Code Online (Sandbox Code Playgroud)

事实上,a.store可能会发生在之后 b.store。CPU 一直这样做,这称为乱序执行,它是 CPU 用于加速执行的优化技术之一。a并且b仍然是线程安全的,即使线程安全存储可能以相反的顺序发生。

现在,如果订单有意义的话会发生什么?许多无锁算法的正确性取决于原子操作的顺序。

内存顺序也用于防止重新排序。这就是为什么内存顺序如此复杂,因为它们同时做两件事。

memory_order_acquire告诉编译器和 CPU不要执行在它之后、之前发生的操作

相似性, memory_order_release告诉编译器和CPU不要执行它之前的操作,在它之后的操作

memory_order_relaxed告诉编译器/CPU 原子操作可以重新排序是可能的,类似地,只要有可能,非原子操作就会重新排序。

  • 内存排序不仅适用于非原子数据,也适用于非原子数据。获取/释放同步还保证了“mo_relaxed”存储的可见性。所以关键点是订购是重要的。*其他*对象,无论它们是否是原子的。但是,是的,同意您提出的总体观点。 (4认同)
  • 唔。“线程安全”比简单的“免受数据竞争”具有更强烈的含义,我认为这就是轻松原子所能带来的一切。另外,我认为您过于简化了内存顺序语义,以至于产生了误导。 (3认同)
  • @DavidHaim,没有人要求你深入、血淋淋的细节,但*特别是*对于新手来说,给人误导性印象的措辞比仅仅省略或掩盖细节更糟糕。 (3认同)
  • *CPU 一直这样做,这称为乱序执行* - 准确地说,内存重新排序(来自其他线程的 POV)可以与乱序*执行*分开。它可能发生在有序 CPU 上,尤其是 StoreLoad 重新排序(通过拥有存储缓冲区),但像您的示例一样的 StoreStore 重新排序可能发生在任何允许从存储缓冲区到 L1d 缓存的无序提交的 CPU 上。(例如,如果第一个存储在缓存中丢失,则允许第二个存储更早提交)。[乱序指令执行:提交顺序是否保留?](//stackoverflow.com/q/39670026) (3认同)
  • 这就是我们需要 C++ 标准的原因:C++ 基本上告诉开发人员“让我们讨论一个具有多个内核的抽象 CPU。编译器将检测我们在想象的世界中讨论的内容之间的差异,并且编译器将做正确的事情,例如在 x86 上将原子存储转变为非原子存储,因为这对于特定的 CPU 来说并不重要。” (3认同)

Pet*_*des 5

atomic<T> 约束优化器不假设值在同一线程中的访问之间保持不变。

atomic<T>也使得确保该对象被充分地对齐:例如,用于32位的ISA一些C ++实现具有alignof(int64_t) = 4alignof(atomic<int64_t>) = 8使无锁64位操作。(例如,用于 32 位 x86 GNU/Linux 的 gcc)。在这种情况下,通常需要一个特殊的指令,编译器可能不会以其他方式使用,例如 ARMv8 32 位ldp负载对,或 x86 SSE2,movq xmm然后再跳转到整数 regs。


在ASM中对于大多数的ISA,纯负荷和纯店内自然对齐intlong是原子为免费的,所以atomic<T>memory_order_relaxed 编译为相同的ASM作为纯变量; 原子性(无撕裂)不需要任何特殊的汇编。例如:为什么在 x86 上对自然对齐的变量原子进行整数赋值? 根据周围的代码,编译器可能无法优化对非原子对象的任何访问,在这种情况下,代码生成在普通Tatomic<T>mo_relaxed之间将相同。

反之则不然:像在 asm 中编写 C++ 一样编写 C++ 一点也不安全。 在 C++ 中,多个线程同时访问同一个对象是数据竞争未定义行为,除非所有访问都是读取。

因此,根据“as-if”优化规则,C++ 编译器可以假设没有其他线程正在更改循环中的变量。如果bool done不是原子的,一个类似的循环while(!done) { }将编译成if(!done) infinite_loop;,将负载提升到循环之外。有关编译器 asm 输出的详细示例,请参阅多线程程序卡在优化模式但在 -O0 中正常运行。(在禁用优化的情况下进行编译非常类似于使每个对象volatile:内存与 C++ 语句之间的抽象机同步,以实现一致的调试。)


同样显然 RMW 操作像+=var.fetch_add(1, mo_seq_cst)是原子的,并且必须编译为与非原子不同的 asm +=num++ 可以是“int num”的原子吗?


原子操作对优化器的约束类似于什么volatile。在实践中volatile是一种滚动自己的方式mo_relaxed atomic<T>,但没有任何简单的方法来获得订购 wrt。其他操作。某些编译器(例如 GCC)事实上支持它,因为 Linux 内核使用它。 但是atomic<T>通过 ISO C++ 标准保证工作;什么时候在多线程中使用 volatile?-有几乎没有理由推出自己的,只是使用atomic<T>mo_relaxed

还相关:为什么编译器不合并冗余 std::atomic 写入?/编译器是否可以优化两个原子负载?- 编译器目前根本不优化原子,因此atomic<T>目前相当于volatile atomic<T>,等待进一步的标准工作,为程序员提供方法来控制何时/什么优化是可以的。


归档时间:

查看次数:

822 次

最近记录:

5 年,2 月 前