6

众所周知,自 C++11 以来,有 6 个内存顺序,并且在文档中编写了以下内容std::memory_order_acquire

memory_order_acquire

具有此内存顺序的加载操作在受影响的内存位置上执行获取操作:在此加载之前,当前线程中的内存访问不能重新排序。这确保了释放相同原子变量的其他线程中的所有写入在当前线程中都是可见的。

1.非原子加载可以在atomic-acquire-load之后重新排序:

即它不保证非原子加载不能在获取原子加载之后重新排序。

static std::atomic<int> X;
static int L;
...

void thread_func() 
{
    int local1 = L;  // load(L)-load(X) - can be reordered with X ?

    int x_local = X.load(std::memory_order_acquire);  // load(X)

    int local2 = L;  // load(X)-load(L) - can't be reordered with X
}

加载int local1 = L;后可以重新排序X.load(std::memory_order_acquire);吗?

2. 我们可以认为,在 atomic-acquire-load 之后,non-atomic-load 不能重新排序:

一些文章包含一张图片,展示了获取-释放语义的本质。这很容易理解,但可能会引起混乱。

在此处输入图像描述

在此处输入图像描述

例如,我们可能认为std::memory_order_acquire不能重新排序任何一系列的 Load-Load 操作,即使是非原子加载也不能在 atomic-acquire-load 之后重新排序。

3.非原子加载可以在atomic-acquire-load之后重新排序:

澄清的好处是:获取语义可防止读取-获取的内存重新排序,并以程序顺序跟随它的任何读取或写入操作。http://preshing.com/20120913/acquire-and-release-semantics/

但也知道:在强排序系统(x86、SPARC TSO、IBM 大型机)上,对于大多数操作来说,发布-获取排序是自动的。

第 34 页的 Herb Sutter 显示:https ://onedrive.live.com/view.aspx?resid=4E86B0CF20EF15AD!24884&app=WordPdf&authkey=!AMtj_EflYn2507c

在此处输入图像描述

4. 即再次,我们可以认为在 atomic-acquire-load 之后 non-atomic-load 不能重新排序:

即对于 x86:

  • 对于大多数操作,release-acquire 排序是自动
  • 读取不会与任何读取一起重新排序。(任何 - 即无论是否年龄较大)

那么可以在 C++11 中的 atomic-acquire-load 之后重新排序 non-atomic-load 吗?

4

4 回答 4

5

我相信这是在 C++ 标准中推理您的示例的正确方法:

  1. X.load(std::memory_order_acquire)(我们称它为“操作”)可能与(操作)(A)上的某个释放操作同步- 大致而言,将值分配给该操作的操作是读取。X(R)X(A)

[atomics.order]/2A原子对象执行释放操作的原子操作与执行获取操作M的原子操作同步,并从以.BMA

  1. L这种 synchronizes-with 关系可能有助于在 的某些修改和 assignment之间建立happens-before关系local2 = L。如果对Lhappens-before的修改(R),那么,由于(R)与读取同步(A)(A)在读取之前排序,因此在读取之前对发生的L修改进行了修改。LL

  2. (A)对分配没有任何影响local1 = L。它既不会导致涉及此分配的数据竞争,也不会有助于防止它们。如果程序是无竞争的,那么它必须采用某种其他机制来确保L对)。


在 C++ 标准的四个角落里谈论“指令重新排序”是没有意义的。人们可能会谈论由特定编译器生成的机器指令,或者这些指令由特定 CPU 执行的方式。但从标准的角度来看,这些仅仅是不相关的实现细节,只要该编译器和该 CPU 产生与标准描述的抽象机器的一种可能执行路径一致的可观察行为(As-If 规则)。

于 2016-07-31T03:02:21.603 回答
4

您引用的参考非常清楚:在加载之前您不能移动读取。在您的示例中:

static std::atomic<int> X;
static int L;


void thread_func() 
{
    int local1 = L;  // (1)
    int x_local = X.load(std::memory_order_acquire);  // (2)
    int local2 = L;  // (3)
}

memory_order_acquire意味着 (3) 不能在 (2) 之前发生((2) 中的加载在 (3) 中的 thr 加载之前排序)。它没有说明(1)和(2)之间的关系。

于 2016-07-30T18:40:40.243 回答
0

具有此内存顺序的加载操作在受影响的内存位置上执行获取操作:在此加载之前,当前线程中的内存访问不能重新排序。

这就像编译器代码生成的经验法则。

但这绝对不是 C++ 的公理。

有很多情况,有些是微不足道的,有些需要更多的工作,其中对 V 上的内存 Op 的操作可以证明可以用 A 上的原子操作 X 重新排序。

最明显的两个案例:

  • 当 V 是一个严格的局部变量时:不能被任何其他线程(或信号处理程序)访问,因为它的地址在函数之外不可用;
  • 当 A 是一个严格的局部变量时。

(请注意,编译器的这两个重新排序对于为 X 指定的任何可能的内存排序都是有效的。)

在任何情况下,转换都是不可见的,它不会改变有效程序的可能执行。

这些类型的代码转换有效的情况不太明显。有些是人为的,有些是现实的。

我可以很容易地想出这个人为的例子:

using namespace std;

static atomic<int> A;

int do_acq() {
  return A.load(memory_order_acquire);
}

void do_rel() {
  A.store(0, memory_order_release);
} // that's all folks for that TU

笔记:

使用静态变量能够看到对对象的所有操作,对单独编译的代码;访问原子同步对象的函数不是静态的,可以从所有程序中调用。

作为同步原语,A 上的操作建立同步关系:有一个:

  • do_rel()在点 pX调用的线程 X
  • do_acq()和在点 pY调用的线程 Y

A 的修改 M 有一个明确定义的顺序,对应do_rel()于不同线程中对的调用。每次调用do_acq()

  • 观察调用do_rel()at pX_i 的结果,并通过拉入 pX_i 上 X 的历史记录与线程 X 同步
  • 观察 A 的初始值

另一方面,该值始终为 0,因此调用代码只能从中获取 0,do_acq()无法从返回值中确定发生了什么。它可以先验地知道 A 的修改已经发生,但它不能只后验地知道。先验知识可以来自另一个同步操作。先验知识是线程 Y 历史的一部分。无论哪种方式,获取操作都没有知识,也不会添加过去的历史:获取操作的已知部分是空的,它不能可靠地获取其中的任何内容线程 Y 在 pY_i 的过去。所以 A 上的获取是没有意义的,可以优化出来。

换句话说:对 M 的所有可能值有效的程序必须在do_acq()看到 Y 的最新do_rel()历史记录时有效,即在所有可以看到的 A 修改之前的那个。所以 do_rel() 一般不会添加任何内容:do_rel()可以在某些执行中添加非冗余 synchronize-with,但它添加的最小值 Y 什么都没有,所以一个正确的程序,一个没有竞争条件的程序(表示为:它的行为取决于 M,例如它的正确性是获取 M 允许值的某个子集的函数)必须准备好处理从 中获取任何内容do_rel();所以编译器可以做do_rel()一个NOP。

[注意:参数行不容易推广到所有读取 0 并存储 0 的 RMW 操作。它可能不适用于 acq-rel RMW。换句话说,acq+rel RMW 比单独的加载和存储更强大,因为它们的“副作用”。]

总结:在那个特定的例子中,不仅内存操作可以相对于原子获取操作上下移动,原子操作也可以完全删除。

于 2019-12-14T04:22:50.997 回答
0

只是为了回答您的标题问题:是的,任何负载(无论是原子负载还是非原子负载)都可以在原子负载之后重新排序。类似地,任何存储都可以在原子存储之前重新排序。

但是,原子存储不一定允许在原子加载之后重新排序,反之亦然(原子加载在原子存储之前重新排序)。

请参阅 Herb Sutter 44:00 左右的演讲

于 2020-02-26T23:02:37.533 回答