重新排序和 memory_order_relaxed

Reordering and memory_order_relaxed

Cppreference 给出 following example 关于 memory_order_relaxed:

Atomic operations tagged memory_order_relaxed are not synchronization operations, they do not order memory. They only guarantee atomicity and modification order consistency.

然后解释说,xy 最初为零,此示例代码

// Thread 1:
r1 = y.load(memory_order_relaxed); // A
x.store(r1, memory_order_relaxed); // B

// Thread 2:
r2 = x.load(memory_order_relaxed); // C 
y.store(42, memory_order_relaxed); // D

被允许生产 r1 == r2 == 42 因为:

  1. 尽管 A 在线程 1 中先于 B,而 C 在线程 2 中先于 D,
  2. y 的修改顺序中,没有什么可以阻止 D 出现在 A 之前,而 B 在 x 的修改顺序中出现在 C 之前。

现在我的问题是:如果 A 和 B 不能在线程 1 中重新排序,同样地,C 和 D 在线程 2 中不能重新排序(因为它们中的每一个都是 sequenced-before 在其线程中),第 1 点和第 2 点不矛盾吗?换句话说,没有重新排序(正如第 1 点似乎需要的那样),第 2 点中的场景,如下图所示,甚至可能吗?

T1 ..................T2

................. D(y)

A(y)

B(x)

............. C(x)

因为在这种情况下,C 将不是 在线程 2 中先于 D,正如第 1 点所要求的那样。

你对文本的理解是错误的。让我们分解一下:

Atomic operations tagged memory_order_relaxed are not synchronization operations, they do not order memory

这意味着这些操作不保证事件的顺序。正如原文中该声明之前所解释的那样,允许多线程处理器在单个线程内重新排序操作。这会影响写入、读取或两者。此外,编译器 被允许在编译时做同样的事情(主要是为了优化目的)。要查看这与示例的关系,假设我们根本不使用 atomic 类型,但我们确实使用了原子设计的原始类型(8 位值...)。让我们重写这个例子:

// Somewhere...
uint8_t y, x;

// Thread 1:
uint8_t r1 = y; // A
x = r1; // B

// Thread 2:
uint8_t r2 = x; // C 
y = 42; // D

考虑到编译器和 CPU 都允许在每个线程中重新排序操作,很容易看出 x == y == 42 是如何实现的。


声明的下一部分是:

They only guarantee atomicity and modification order consistency.

这意味着唯一的保证是每个操作都是原子的,也就是说,一个操作不可能被观察到"mid way though"。这意味着如果 x 是一个 atomic<someComplexType>,一个线程不可能观察到 x 具有介于两个状态之间的值。


应该已经很清楚它在哪里有用,但让我们检查一个具体示例(仅用于演示建议,这不是您想要的编码方式):

class SomeComplexType {
  public:
    int size;
    int *values;
}

// Thread 1:
SomeComplexType r = x.load(memory_order_relaxed);
if(r.size > 3)
  r.values[2] = 123;

// Thread 2:
SomeComplexType a, b;
a.size = 10; a.values = new int[10];
b.size = 0; b.values = NULL;
x.store(a, memory_order_relaxed);
x.store(b, memory_order_relaxed);

atomic 类型为我们所做的是保证线程 1 中的 r 不是状态之间的对象,具体来说,它是 size & values属性同步。

根据此 post 中的 STR 类比:C++11 introduced a standardized memory model. What does it mean? And how is it going to affect C++ programming?,我创建了一个可视化图,其中(据我了解)如下所示:

线程 1 首先看到 y=42,然后它执行 r1=yx=r1 之后。线程 2 首先看到 x=r1 已经是 42,然后它执行 r2=x,并且 aftery=42.

行表示各个线程的 "views" 内存。这些 lines/views 不能交叉用于特定线程。但是,使用宽松的原子,一个线程的 lines/views 可以跨越其他线程。

编辑:

我想这与以下程序相同:

atomic<int> x{0}, y{0};

// thread 1:
x.store(1, memory_order_relaxed);
cout << x.load(memory_order_relaxed) << y.load(memory_order_relaxed);

// thread 2:
y.store(1, memory_order_relaxed);
cout << x.load(memory_order_relaxed) << y.load(memory_order_relaxed);

可以在输出上产生 0110(SC 原子操作不会发生这样的输出)。

with no reordering (as point 1 seems to require)

第 1 点不代表 "no reordering"。它意味着执行线程内的事件排序。编译器将在 B 之前为 A 发出 CPU 指令,在 D 之前为 C 发出 CPU 指令(尽管这可能会被 as-if 规则破坏),但是 CPU 有没有义务按那个顺序执行它们,caches/write buffers/invalidation 队列没有义务按那个顺序传播它们,内存没有义务保持一致。

(个别架构可能会提供这些保证)

专看C++内存模型(不谈编译器或硬件重排序),导致r1=r2=42的唯一执行是:

这里我用a替换了r1,用b替换了r2。 和往常一样,sb 代表 sequenced-before 并且只是 inter-thread 排序(指令在源代码中出现的顺序)。 rf 是 Read-From 边,意味着一端的 Read/load 读取另一端的值 written/Stored。

循环,涉及 sb 和 rf 边缘,如绿色突出显示,是结果所必需的:y 在一个线程中写入,在另一个线程中读入 a 并从那里写入 x,which在前一个线程中再次读入 b(即 sequenced-before 对 y 的写入)。

有两个原因导致像这样构造的图不可能:因果关系和因为 rf 读取隐藏的副作用。在这种情况下,后者是不可能的,因为我们只对每个变量写入一次,所以很明显,一个写入不能被另一个写入隐藏(覆盖)。

为了回答因果关系问题,我们遵循以下规则:当循环涉及单个内存位置并且 sb 边的方向在循环中的任何地方都处于同一方向时,循环是不允许的(不可能的)在这种情况下,射频边缘的方向不相关);或者,循环涉及多个变量,所有边(sb 和 rf)都在同一方向,并且最多一个变量在不同线程之间具有一个或多个 rf 边,这些线程不是 release/acquire.

在这种情况下存在循环,涉及两个变量(x 的一个 rf 边和 y 的一个 rf 边),所有边都在同一方向,但是两个变量有一个 relaxed/relaxed rf 边(即 x 和 y)。因此没有违反因果关系,这是一个符合C++内存模型的执行。