75

我读了一章,我不太喜欢它。我仍然不清楚每个内存顺序之间有什么区别。这是我目前的猜测,在阅读了更简单的http://en.cppreference.com/w/cpp/atomic/memory_order后我理解了

以下是错误的,所以不要试图从中学习

  • memory_order_relaxed:不同步,但在不同原子变量中从另一个模式完成订单时不会被忽略
  • memory_order_consume:同步读取这个原子变量,但是它不同步在此之前编写的宽松变量。但是,如果线程在修改 Y(并释放它)时使用 var X。其他消耗 Y 的线程也会看到 X 被释放?我不知道这是否意味着这个线程推出了 x(显然是 y)的变化
  • memory_order_acquire:同步读取这个原子变量,并确保在此之前写入的宽松变量也被同步。(这是否意味着所有线程上的所有原子变量都已同步?)
  • memory_order_release:将原子存储推送到其他线程(但前提是它们使用消耗/获取读取 var)
  • memory_order_acq_rel:用于读/写操作。是否进行获取,以便您不修改旧值并释放更改。
  • memory_order_seq_cst:与获取释放相同,除了它强制在其他线程中看到更新(如果a在另一个线程上放松存储。我b用 seq_cst 存储。a 用放松读取的第三个线程会看到变化以及b任何其他原子变量? )。

我想我理解但如果我错了请纠正我。我找不到任何用易于阅读的英语解释它的东西。

4

3 回答 3

87

GCC Wiki通过代码示例提供了非常全面且易于理解的解释。

(摘录已编辑,并添加了重点)

重要的:

在将我自己的措辞添加到答案的过程中重新阅读从 GCC Wiki 复制的以下引用时,我注意到引用实际上是错误的。他们以完全错误的方式获取消费。release-consume操作仅提供对依赖数据的排序保证,而release-acquire操作提供该保证,而不管数据是否依赖于原子值。

第一个模型是“顺序一致的”。这是未指定时使用的默认模式,也是最严格的。也可以通过 明确指定memory_order_seq_cst。它提供了与顺序程序员天生熟悉的相同的限制和限制移动负载,除了它适用于跨线程
[...]
从实际的角度来看,这相当于所有原子操作都充当了优化障碍。可以在原子操作之间重新排序,但不能跨操作重新排序。线程本地的东西也不受影响,因为其他线程看不到。[...] 这种模式还提供了所有线程的一致性。

相反的做法是memory_order_relaxed。该模型通过删除发生前的限制来减少同步。这些类型的原子操作还可以对其执行各种优化,例如死存储删除和共享。[...] 如果没有任何发生前的边缘,任何线程都不能指望来自另一个线程的特定排序。当程序员只是希望变量本质上是原子的而不是使用它来同步线程以获取其他共享内存数据时,最常使用
宽松模式。

第三种模式 ( memory_order_acquire/ memory_order_release) 是其他两种模式的混合。获取/释放模式类似于顺序一致模式,只是它只对因变量应用发生前的关系。这允许放宽独立读取和独立写入之间所需的同步。

memory_order_consume是对释放/获取内存模型的进一步细微改进,它通过在对非依赖共享变量进行排序之前删除发生的事件来稍微放宽要求。
[...]
真正的区别归结为硬件必须刷新多少状态才能同步。由于消费操作可能因此执行得更快,因此知道自己在做什么的人可以将其用于性能关键的应用程序。

以下是我自己尝试的更平凡的解释:

另一种看待它的方法是从重新排序读取和写入的角度来看待问题,包括原子的和普通的:

所有原子操作都保证在其自身内部是原子的(两个原子操作的组合不是一个整体的原子!)并且在它们出现在执行流的时间轴上的总顺序中是可见的。这意味着在任何情况下都不能对原子操作进行重新排序,但其他内存操作很可能会被重新排序。编译器(和 CPU)通常会进行这种重新排序作为优化。
这也意味着编译器必须使用任何必要的指令来保证在任何时候执行的原子操作将看到之前执行的每个其他原子操作的结果,可能在另一个处理器内核(但不一定是其他操作)上.

现在,放松就是这样,最低限度。除此之外,它什么也不做,也不提供任何其他保证。这是最便宜的手术。对于强排序处理器架构(例如 x86/amd64)上的非读-修改-写操作,这归结为一个普通的普通移动。

顺序一致的操作正好相反,它不仅对原子操作强制执行严格的排序,而且对之前或之后发生的其他内存操作也强制执行严格的排序。任何人都无法跨越原子操作强加的障碍。实际上,这意味着失去优化机会,并且可能必须插入栅栏指令。这是最昂贵的型号。

释放操作防止普通加载和存储在原子操作之后重新排序,获取操作防止普通加载和存储在原子操作之前重新排序。其他一切仍然可以移动。
防止存储在之后移动以及在相应原子操作之前移动负载的组合确保了获取线程所看到的任何内容都是一致的,只损失了少量的优化机会。
人们可能会认为这就像一个不存在的锁,它正在被释放(由作者)和获取(由读者)。除了...没有锁。

在实践中,release/acquire 通常意味着编译器不需要使用任何特别昂贵的特殊指令,但它不能自由地根据自己的喜好重新排序加载和存储,这可能会错过一些(小的)优化机会。

最后,consume与acquire是相同的操作,只是排序保证仅适用于依赖数据。相关数据将是例如由原子修改的指针指向的数据。
可以说,这可能会提供一些获取操作不存在的优化机会(因为更少的数据受到限制),但是这是以更复杂和更容易出错的代码和非平凡任务为代价的获得正确的依赖链。

目前不鼓励在规范修订时使用消费排序。

于 2012-12-30T15:12:47.573 回答
39

这是一个相当复杂的课题。尝试阅读http://en.cppreference.com/w/cpp/atomic/memory_order几次,尝试阅读其他资源等。

这是一个简化的描述:

编译器CPU 可以重新排序内存访问。也就是说,它们的发生顺序可能与代码中指定的顺序不同。大多数时候这很好,当不同的线程尝试通信并且可能会看到破坏代码不变量的内存访问顺序时,就会出现问题。

通常您可以使用锁进行同步。问题是它们很慢。原子操作要快得多,因为同步发生在 CPU 级别(即 CPU 确保没有其他线程,即使在另一个 CPU 上,也不会修改某些变量等)。

因此,我们面临的一个问题是内存访问的重新排序。memory_order枚举指定编译器必须禁止的重新排序类型。

relaxed- 没有限制。

consume- 任何依赖于新加载值的负载都不能重新排序。原子负荷。即,如果它们在源代码中的原子加载之后,它们也会在原子加载之后发生

acquire- 没有负载可以重新排序。原子负荷。即,如果它们在源代码中的原子加载之后,它们也会在原子加载之后发生

release- 没有商​​店可以重新订购。原子商店。即,如果它们在源代码中的原子存储之前,它们也会在原子存储之前发生

acq_rel-acquirerelease结合。

seq_cst- 更难理解为什么需要这种排序。基本上,所有其他排序仅确保特定不允许的重新排序不会仅发生在消耗/释放相同原子变量的线程中。内存访问仍然可以以任何顺序传播到其他线程。这种排序确保不会发生这种情况(因此是顺序一致性)。对于需要这样做的情况,请参阅链接页面末尾的示例。

于 2012-09-10T12:01:07.580 回答
3

其他答案解释了相对于各种原子操作可以或不能重新排序哪些操作,但我想提供一个替代的、更高级的解释:各种内存顺序可以实际用于什么。


要忽略的事情:

memory_order_consume- 显然没有主要的编译器实现它,他们默默地用更强的memory_order_acquire. 甚至标准本身也说要避免它。

关于内存订单的 cppreference 文章的很大一部分涉及“消费”,因此删除它可以大大简化事情。

它还可以让您忽略相关功能,例如[[carries_dependency]]std::kill_dependency


数据竞争:从一个线程写入非原子变量,同时从另一个线程读取/写入它被称为数据竞争,并导致未定义的行为。


memory_order_relaxed是最弱的,据说是最快的记忆顺序。

对原子的任何读/写都不会导致数据竞争(以及随后的 UB)。relaxed只为单个变量提供这个最低限度的保证。它不为其他变量(原子或非原子)提供任何保证。

宽松的操作可以以奇怪的顺序在线程之间传播,并且随着不同的不可预测(但很小)的延迟,对不同变量的宽松更改可以以不同的顺序对不同的线程变得可见,等等。

唯一的规则是:

  • 每个线程将按照您告诉它的确切顺序访问每个单独的变量。例如,a.store(1, relaxed); a.store(2, relaxed);将写1, then 2,从不以相反的顺序。但是在同一个线程中对不同变量的访问仍然可以相对于彼此重新排序。
  • 如果线程 A 多次写入一个变量,然后线程 B 读取几次,它将以相同的顺序获取值(当然它可以读取一些值多次,或者跳过一些,如果你不同步其他方式的线程)。

示例用途:任何不尝试使用原子变量来同步对非原子数据的访问:各种计数器(仅供参考),或“停止标志”以指示其他线程停止。另一个例子:在shared_ptrs 上增加引用计数的操作在内部使用relaxed.


栅栏: atomic_thread_fence(relaxed);什么都不做。


memory_order_releasememory_order_acquire做所有事情relaxed,等等(所以它应该更慢或等效)。

只有商店(写入)可以使用release. 只有加载(读取)可以使用acquire. 诸如读取-修改-写入操作fetch_add都可以是 ( memory_order_acq_rel),但它们不是必须的。

那些让你同步线程:

  • 假设线程 1 读取/写入某个内存 M(任何非原子或原子变量,都没有关系)。

  • 然后线程 1 对变量 A 执行释放存储。然后它停止接触该内存。

  • 如果线程 2 然后执行相同变量 A 的获取加载,则称此加载与线程 1 中的相应存储同步。

  • 现在线程 2 可以安全地读取/写入该内存 M。

您只与最新的作者同步,而不是之前的作者。

您可以跨多个线程链接同步。

有一个特殊的规则,即同步在任意数量的读取-修改-写入操作中传播,而不管它们的内存顺序如何。例如,如果线程 1 执行a.store(1, release);,则线程 2 执行a.fetch_add(2, relaxed);,然后线程 3 执行a.load(acquire)线程 1 与线程 3 成功同步,即使中间有一个宽松的操作。

在上面的规则中,一个释放操作 X,以及对同一个变量 X 的任何后续读-修改-写操作(在下一个非读-修改-写操作处停止)称为以 X 为首的释放序列。(所以如果获取从释放序列中的任何操作读取,它与序列的头部同步。)

如果涉及读-修改-写操作,没有什么能阻止您同步多个操作。在上面的例子中,如果fetch_add使用acquireor acq_rel,它也会与线程 1 同步,反之,如果使用releaseor acq_rel,线程 3 除了与 1 之外,还会与 2 同步。


使用示例: shared_ptr使用类似fetch_sub(1, acq_rel).

原因如下:假设线程 1 读取/写入ptr->...,然后销毁其副本ptr,递减 ref 计数。然后线程 2 销毁最后剩余的指针,同时减少 ref 计数,然后运行析构函数。

由于线程 2 中的析构函数将访问线程 1 先前访问的内存,因此需要进行acq_rel同步fetch_sub。否则,您将面临数据竞赛和 UB。


Fences:使用atomic_thread_fence,您基本上可以将轻松的原子操作转变为释放/获取操作。单个栅栏可以应用于多个操作,和/或可以有条件地执行。

如果您从一个或多个变量执行轻松读取(或以任何其他顺序),然atomic_thread_fence(acquire)​​后在同一个线程中执行,则所有这些读取都算作获取操作。

相反,如果你这样做atomic_thread_fence(release)了,然后是任意数量的(可能是放松的)写入,这些写入都算作释放操作。

acq_rel栅栏结合了栅栏acquirerelease栅栏的效果。


与其他标准库功能的相似之处:

几个标准库特性也导致了与关系的类似同步。例如,锁定一个互斥锁与最新的解锁同步,就好像锁定是一个获取操作,而解锁是一个释放操作。


memory_order_seq_cst做一切acquire/release做,等等。这应该是最慢的顺序,但也是最安全的。

seq_cst读取算作获取操作。seq_cst写入算作释放操作。seq_cst读-修改-写操作都算作两者。

seq_cst操作可以相互同步,也可以与获取/释放操作同步。小心混合它们的特殊效果(见下文)。

seq_cst是默认顺序,例如 given atomic_int x;x = 1;does x.store(1, seq_cst);

seq_cst与获取/释放相比,有一个额外的属性:seq_cst整个程序中的所有读取和写入都发生在一个全局顺序中。

除其他事项外,此顺序尊重上述获取/释放描述的同步关系,除非(这很奇怪)同步来自seq-cst 操作与获取/释放操作混合(释放与 seq-cst 同步,或seq-cst 与获取同步)。这种混合本质上将受影响的 seq-cst 操作降级为获取/释放(它可能保留了一些 seq-cst 属性,但你最好不要指望它)。


示例使用:

atomic_bool x = true;
atomic_bool y = true;
// Thread 1:
x.store(false, seq_cst);
if (y.load(seq_cst)) {...}
// Thread 2:
y.store(false, seq_cst);
if (x.load(seq_cst)) {...}

假设您只希望一个线程能够进入if正文。seq_cst允许你这样做。获取/释放或较弱的订单在这里是不够的。


栅栏: atomic_thread_fence(seq_cst);做栅栏所做的一切acq_rel,外加一个附加功能。

比方说:

  • 线程 1 使用seq_cstorder 访问变量 X,然后
  • 线程 2 线程使用任何顺序访问同一个变量 X(不一定会导致同步),然后
  • 线程 2 可以atomic_thread_fence(seq_cst)

然后在线程 1 中的 seq-cst 访问之后,线程 2 中的任何操作都将在线程 1 中发生(但线程 1 中的 seq-cst 操作之前的非 seq-cst 操作不一定会在线程 2 中的围栏之前发生)。

反过来也有效:

  • 线程 1 执行atomic_thread_fence(seq_cst),然后
  • 线程 1 使用任意顺序访问变量 X,然后
  • seq_cst线程 2 使用order访问相同的变量 X。

那么线程 1 中栅栏之前的任何操作都将发生在线程 2 中的 seq-cst 操作之前,但不一定在线程 2 中后续的非 seq-cst 操作之前。

您也可以在两侧设置栅栏:

  • 线程 1 执行atomic_thread_fence(seq_cst),然后
  • 线程 1 使用任意顺序访问变量 X,然后
  • 线程 2 使用任意顺序访问同一个变量 X,然后
  • 线程 2 可以atomic_thread_fence(seq_cst)

然后在栅栏之前线程 1 中的任何事情都会发生在栅栏之后线程 2 中的任何事情之前。


不同订单之间的互操作

总结以上内容:

relaxed release seq-cst
relaxed加载 - - -
acquire加载 - 与*同步
seq-cst加载 - 与*同步

* = 参与的 seq-cst 操作得到一个混乱的 seq-cst 顺序,实际上被降级为获取/释放操作。这在上面已经解释过了。


使用更强的内存顺序是否会使线程之间的数据传输更快?

不,似乎没有。


无数据竞争程序的顺序一致性

该标准解释说,如果您的程序仅使用seq_cst访问(和互斥体),并且没有数据竞争(导致 UB),那么您不需要考虑所有花哨的操作重新排序。该程序的行为就像一次只执行一个线程,线程不可预测地交错。

于 2022-01-04T22:32:53.133 回答