C++ 内存模型中有哪些确切的规则可以防止在获取操作之前重新排序?

What exact rules in the C++ memory model prevent reordering before acquire operations?

我对以下代码中的操作顺序有疑问:

std::atomic<int> x;
std::atomic<int> y;
int r1;
int r2;
void thread1() {
  y.exchange(1, std::memory_order_acq_rel);
  r1 = x.load(std::memory_order_relaxed);
}
void thread2() {
  x.exchange(1, std::memory_order_acq_rel);
  r2 = y.load(std::memory_order_relaxed);
}

鉴于 cppreference 页面 (https://en.cppreference.com/w/cpp/atomic/memory_order) 上 std::memory_order_acquire 的描述,

A load operation with this memory order performs the acquire operation on the affected memory location: no reads or writes in the current thread can be reordered before this load.

很明显,在 运行 thread1thread2 之后,永远不会有 r1 == 0 && r2 == 0 的结果。

但是,我在 C++ 标准中找不到任何措辞(现在正在查看 C++14 草案),它保证两个松弛负载不能通过获取-释放交换重新排序。我错过了什么?

编辑:正如评论中所建议的,实际上可以使 r1 和 r2 都为零。我已将程序更新为使用加载获取,如下所示:

std::atomic<int> x;
std::atomic<int> y;
int r1;
int r2;
void thread1() {
  y.exchange(1, std::memory_order_acq_rel);
  r1 = x.load(std::memory_order_acquire);
}
void thread2() {
  x.exchange(1, std::memory_order_acq_rel);
  r2 = y.load(std::memory_order_acquire);
}

现在是否可以同时执行thread1thread2,让r1r2都为0?如果不是,哪些 C++ 规则可以防止这种情况发生?

在原始版本中,可以看到 r1 == 0 && r2 == 0,因为不要求存储在读取之前传播到另一个线程。这不是任一线程操作的重新排序,而是例如读取过时的缓存。

Thread 1's cache   |   Thread 2's cache
  x == 0;          |     x == 0;
  y == 0;          |     y == 0;

y.exchange(1, std::memory_order_acq_rel); // Thread 1
x.exchange(1, std::memory_order_acq_rel); // Thread 2

线程 2 忽略线程 1 上的释放,反之亦然。在抽象机中,线程

上的xy的值不一致
Thread 1's cache   |   Thread 2's cache
  x == 0; // stale |     x == 1;
  y == 1;          |     y == 0; // stale

r1 = x.load(std::memory_order_relaxed); // Thread 1
r2 = y.load(std::memory_order_relaxed); // Thread 2

你需要更多个线程来获得"violations of causality"与获取/释放对,作为正常的排序规则,结合"becomes visible side effect in"规则至少强制要查看 1load 之一。

不失一般性,我们假设线程 1 首先执行。

Thread 1's cache   |   Thread 2's cache
  x == 0;          |     x == 0;
  y == 0;          |     y == 0;

y.exchange(1, std::memory_order_acq_rel); // Thread 1

Thread 1's cache   |   Thread 2's cache
  x == 0;          |     x == 0;
  y == 1;          |     y == 1; // sync 

Thread 1 上的 release 与 Thread 2 上的 acquire 形成一对,抽象机在两个线程上描述一致 y

r1 = x.load(std::memory_order_relaxed); // Thread 1
x.exchange(1, std::memory_order_acq_rel); // Thread 2
r2 = y.load(std::memory_order_relaxed); // Thread 2

该标准没有根据如何围绕具有特定排序参数的原子操作对操作进行排序来定义 C++ 内存模型。 相反,对于 acquire/release 排序模型,它定义了正式的关系,例如 "synchronizes-with" 和 "happens-before",它们指定了数据如何在线程之间同步。

N4762,§29.4.2 - [atomics.order]

An atomic operation A that performs a release operation on an atomic object M synchronizes with an atomic operation B that performs an acquire operation on M and takes its value from any side effect in the release sequence headed by A.

在 §6.8.2.1-9 中,该标准还规定,如果存储 A 与负载 B 同步,则在线程间 A 之前排序的任何内容 "happens-before" 在 B 之后排序的任何内容。

没有 "synchronizes-with"(因此线程间发生之前)关系在你的第二个例子中建立(第一个甚至更弱)因为运行时关系(检查 return 的值负载)丢失。
但即使您确实检查了 return 值,它也无济于事,因为 exchange 操作实际上并没有 'release' 任何东西(即在这些操作之前没有内存操作被排序)。 Neiter 执行原子加载操作 'acquire' 任何操作,因为在加载之后没有操作被排序。

因此,根据标准,两个示例中的负载的四种可能结果(包括0 0)中的每一种都是有效的。 事实上,标准给出的保证在所有操作上并不比 memory_order_relaxed 强。

如果要在代码中排除0 0 结果,所有4 个操作都必须使用std::memory_order_seq_cst。这保证了所涉及操作的单一总顺序。

in Release-Acquire ordering 为了在 2 个线程之间创建同步点,我们需要一些原子对象 M 这将是 same 在两个操作中

An atomic operation A that performs a release operation on an atomic object M synchronizes with an atomic operation B that performs an acquire operation on M and takes its value from any side effect in the release sequence headed by A.

或更多详情:

If an atomic store in thread A is tagged memory_order_release and an atomic load in thread B from the same variable is tagged memory_order_acquire, all memory writes (non-atomic and relaxed atomic) that happened-before the atomic store from the point of view of thread A, become visible side-effects in thread B. That is, once the atomic load is completed, thread B is guaranteed to see everything thread A wrote to memory.

The synchronization is established only between the threads releasing and acquiring the same atomic variable.

     N = u                |  if (M.load(acquire) == v)    :[B]
[A]: M.store(v, release)  |  assert(N == u)

这里是 M store-release 和 load-acquire 的同步点(从 store-release 获取值!)。结果在线程 A 中存储 N = u(在 M 上存储发布之前)在 B (N == u) 在同一 M

上加载获取后

如果举个例子:

atomic<int> x, y;
int r1, r2;

void thread_A() {
  y.exchange(1, memory_order_acq_rel);
  r1 = x.load(memory_order_acquire);
}
void thread_B() {
  x.exchange(1, memory_order_acq_rel);
  r2 = y.load(memory_order_acquire);
}

对于常见的原子对象 M 我们可以 select 做什么?说 xx.load(memory_order_acquire); 将是与 x.exchange(1, memory_order_acq_rel) 的同步点(memory_order_acq_rel 包括 memory_order_release(更强)和 exchange 包括 store)如果 x.load x.exchange 和 main 的加载值将同步加载 after acquire(在代码中 acquire 之后什么都不存在)与存储 before release (但在代码中什么也不交换之前再次出现。

正确的解决方案(寻找几乎完全 question )可以是下一个:

atomic<int> x, y;
int r1, r2;

void thread_A()
{
    x.exchange(1, memory_order_acq_rel); // [Ax]
    r1 = y.exchange(1, memory_order_acq_rel); // [Ay]
}

void thread_B()
{
    y.exchange(1, memory_order_acq_rel); // [By]
    r2 = x.exchange(1, memory_order_acq_rel); // [Bx]
}

假设 r1 == 0.

All modifications to any particular atomic variable occur in a total order that is specific to this one atomic variable.

我们对 y 进行了 2 次修改:[Ay][By]。因为 r1 == 0 这意味着 [Ay]y 的总修改顺序中发生在 [By] 之前。由此 - [By] 读取 [Ay] 存储的值。所以我们有下一个:

  • A 写入 x - [Ax]
  • A 商店发布 [Ay]y 之后(acq_rel包括发布, 交换包括商店)
  • By 加载获取([By] 值存储在 [Ay]
  • 一旦原子加载-获取(在y)完成,线程B 保证看到线程 A 之前写入内存的所有内容 商店发布(y)。所以它查看 [Ax]r2 == 1
  • 的副作用

另一个可能的解决方案使用 atomic_thread_fence

atomic<int> x, y;
int r1, r2;

void thread_A()
{
    x.store(1, memory_order_relaxed); // [A1]
    atomic_thread_fence(memory_order_acq_rel); // [A2]
    r1 = y.exchange(1, memory_order_relaxed); // [A3]
}

void thread_B()
{
    y.store(1, memory_order_relaxed); // [B1]
    atomic_thread_fence(memory_order_acq_rel); // [B2]
    r2 = x.exchange(1, memory_order_relaxed); // [B3]
}

又是因为原子变量y的所有修改都是按全序进行的。 [A3] 将在 [B1] 之前,反之亦然。

  1. if [B1] before [A3] - [A3] 读取 [B1] 存储的值 => r1 == 1.

  2. if [A3] before [B1] - [B1] 是由 [A3] 存储的读取值 并且来自 Fence-fence 同步:

线程 A 中的释放栅栏 [A2] 与线程 B 中的获取栅栏 [B2] 同步,如果:

  • 存在一个原子对象y,
  • 存在一个原子写 [A3](具有任何内存顺序) 在线程 A
  • 中修改 y
  • [A2] 在线程 A
  • 中排在 [A3] 之前
  • 线程中存在原子读取[B1](任意内存顺序) B

  • [B1]读取[A3]

  • 写入的值
  • [B1] 在线程 B

  • 中排在 [B2] 之前

在这种情况下,线程 A 中排序在 [A2] 之前的所有存储 ([A1]) 将先于来自线程的所有加载 ([B3])在 [B2]

之后,线程 B 中的相同位置 (x)

所以 [A1] (存储 1 到 x)将在 [B3] 之前并且对 [B3] 有可见的影响(加载表单 x 并将结果保存到 r2 )。所以将从 xr2==1

加载 1
[A1]: x = 1               |  if (y.load(relaxed) == 1) :[B1]
[A2]: ### release ###     |  ### acquire ###           :[B2]
[A3]: y.store(1, relaxed) |  assert(x == 1)            :[B3]

您已经对其中的语言律师部分有了答案。但我想回答相关问题,即如何理解为什么在使用 LL/SC for RMW atomics.

的可能 CPU 架构上,这在 asm 中是可能的

C++11 禁止这种重新排序是没有意义的:在这种情况下它需要存储加载屏障,而某些 CPU 架构可以避免这种情况。

考虑到它们将 C++11 内存顺序映射到 asm 指令的方式,使用 PowerPC 上的真实编译器实际上是可能的。

在 PowerPC64 上,具有 acq_rel 交换和获取加载(使用指针参数而不是静态变量)的函数使用 gcc6.3 -O3 -mregnames 编译如下。这是来自 C11 版本,因为我想查看 MIPS 和 SPARC 的 clang 输出,而 Godbolt 的 clang 设置适用于 C11 <atomic.h> 但在使用 [=16= 时对于 C++11 <atomic> 失败].

#include <stdatomic.h>   // This is C11, not C++11, for Godbolt reasons

long foo(_Atomic long *a, _Atomic int *b) {
  atomic_exchange_explicit(b, 1, memory_order_acq_rel);
  //++*a;
  return atomic_load_explicit(a, memory_order_acquire);
}

(来源 + asm on Godbolt for MIPS32R6, SPARC64, ARM 32, and PowerPC64.)

foo:
    lwsync            # with seq_cst exchange this is full sync, not just lwsync
                      # gone if we use exchage with mo_acquire or relaxed
                      # so this barrier is providing release-store ordering
    li %r9,1
.L2:
    lwarx %r10,0,%r4    # load-linked from 0(%r4)
    stwcx. %r9,0,%r4    # store-conditional 0(%r4)
    bne %cr0,.L2        # retry if SC failed
    isync             # missing if we use exchange(1, mo_release) or relaxed

    ld %r3,0(%r3)       # 64-bit load double-word of *a
    cmpw %cr7,%r3,%r3
    bne- %cr7,$+4       # skip over the isync if something about the load? PowerPC is weird
    isync             # make the *a load a load-acquire
    blr

isync 不是存储负载障碍;它只需要前面的指令在本地完成(从核心的乱序部分退出)。它不会等待存储缓冲区被刷新,因此其他线程可以看到较早的存储。

因此,作为交换的一部分的 SC (stwcx.) 存储可以位于存储缓冲区中并在 纯获取之后变得全局可见-load 紧随其后。 事实上,另一个问答已经问过这个问题,答案是我们认为这种重新排序是可能的。

如果纯负载是 seq_cst,PowerPC64 gcc 会在 ld 之前放置一个 sync。使 exchange seq_cst 不会 阻止重新排序。请记住,C++11 仅保证 SC 操作的单个总顺序,因此交换和加载都需要为 C++11 的 SC 来保证它。

因此 PowerPC 从 C++11 到原子 asm 的映射有点不寻常。大多数系统在商店设置更重的障碍,允许 seq-cst 负载更便宜或仅在一侧有障碍。我不确定这是否是 PowerPC 出名的弱内存排序所必需的,或者是否可以进行其他选择。

https://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html 显示了各种架构上的一些可能实现。它提到了 ARM 的多个替代方案。


在 AArch64 上,我们得到这个问题的原始 C++ 版本的线程 1:

thread1():
    adrp    x0, .LANCHOR0
    mov     w1, 1
    add     x0, x0, :lo12:.LANCHOR0
.L2:
    ldaxr   w2, [x0]            @ load-linked with acquire semantics
    stlxr   w3, w1, [x0]        @ store-conditional with sc-release semantics
    cbnz    w3, .L2             @ retry until exchange succeeds

    add     x1, x0, 8           @ the compiler noticed the variables were next to each other
    ldar    w1, [x1]            @ load-acquire

    str     w1, [x0, 12]        @ r1 = load result
    ret

重新排序不会在那里发生,因为 AArch64 获取加载与发布存储交互以提供顺序一致性,而不仅仅是简单的 acq/rel。发布商店无法重新订购以后的获取负载。

(他们可以在纸面上和可能在某些真实硬件中重新订购以后的普通负载。AArch64 seq_cst 可以比其他 ISA 便宜,如果你避免在发布存储后立即获取负载。 但不幸的是,它使 acq/rel 比 x86 更糟糕。这已通过 ARMv8.3-A LDAPR 修复,这是一种仅获取而非顺序获取的负载。它允许较早的商店,甚至 STLR,用它重新订购。所以你只得到 acq_rel,允许 StoreLoad 重新排序但不允许其他重新排序。 (这也是 ARMv8.2-A 中的可选功能。)

在一台机器上也有或取而代之的是普通释放 LL/SC 原子,很容易看出 acq_rel 不会停止以后加载到不同的缓存行在 LL 之后但在交易所的 SC 之前变得全局可见。


如果 exchange 是像 x86 那样用单个事务实现的,那么加载和存储在内存操作的全局顺序中是相邻的,那么后面的操作肯定不能用 [=25= 重新排序]交换,基本等同于seq_cst.

但是 LL/SC 不必是真正的原子事务来为该位置提供 RMW 原子性

事实上,单个 asm swap 指令可以放宽或 acq_rel 语义。 SPARC64 需要围绕其 swap 指令的 membar 指令,因此与 x86 的 xchg 不同,它本身不是 seq-cst。 (SPARC 具有非常好的/人类可读的指令助记符,尤其是与 PowerPC 相比。嗯,基本上任何东西都比 PowerPC 更具可读性。)

因此,C++11 要求它这样做是没有意义的:它会损害 CPU 上不需要存储加载屏障的实现。

由于语言律师的推理很难遵循,我想我应该添加一个理解原子的程序员如何推理你问题中的第二个片段:

由于是对称代码,只看一侧即可。 由于问题是关于r1(r2)的值,所以我们先看

r1 = x.load(std::memory_order_acquire);

根据r1的值,我们可以说说其他值的可见性。但是,由于未测试 r1 的值 - acquire 无关紧要。 在任何一种情况下,r1 的值都可以是曾经写入它的任何值(过去或将来 *))。因此它可以为零。尽管如此,我们可以假设它为零,因为我们感兴趣的是整个程序的结果是否可以为 0 0,这是一种测试 r1 的值。

因此,假设我们读取了零,那么我们可以说,如果该零是由另一个线程使用 memory_order_release 写入的,那么该线程在存储释放之前对内存所做的所有其他写入也将对此可见线。但是,我们读取的零值是 x 的初始化值,并且初始化值是非原子的 - 更不用说 'release' - 当然它们前面没有任何 "ordered"将该值写入内存;所以关于其他内存位置的可见性我们无话可说。换句话说,'acquire' 是无关紧要的。

所以,我们可以得到 r1 = 0 并且我们使用 acquire 的事实是无关紧要的。同样的推理也适用于 r2。所以结果可以是 r1 = r2 = 0.

事实上,如果您假设 r1 的值在加载获取后为 1,并且该 1 是由线程 2 写入并释放内存顺序的(必须是这种情况,因为这是唯一一个值为1 曾经被写入 x) 那么我们所知道的是,thread2 在存储释放之前 写入内存的所有内容也将对 thread1 可见(提供 thread1 读取 x == 1 这样!)。但是 thread2 在写入 x 之前不会写入任何内容,因此整个释放-获取关系再次无关紧要,即使在加载值 1 的情况下也是如此。

*) 然而,通过进一步的推理,可以证明某些值永远不会出现,因为与内存模型不一致——但这里不会发生这种情况。

我试着换句话解释。

假设每个线程运行同时在不同的CPU核心中,线程1运行在核心A中,线程2运行在核心B中

核心B无法知道核心A中的真实运行顺序。内存顺序的含义只是,运行结果显示给核心B,来自核心A。

std::atomic<int> x, y;
int r1, r2, var1, var2;
void thread1() { //Core A
  var1 = 99;                                  //(0)
  y.exchange(1, std::memory_order_acq_rel);   //(1)
  r1 = x.load(std::memory_order_acquire);     //(2)
}
void thread2() { //Core B
  var2 = 999;                                 //(2.5)
  x.exchange(1, std::memory_order_acq_rel);   //(3)
  r2 = y.load(std::memory_order_acquire);     //(4)
}

例如,(4) 只是 (1) 的请求。 (有类似 'variable y with memory_order_release' 的代码) 并且 (4) 在核心 B 中将 A 应用于特定顺序:(0)->(1)->(4).

对于不同的REQUEST,他们可能会在其他线程中看到不同的顺序。 (如果现在我们有核心 C 和一些与核心 A 交互的原子变量,核心 C 可能会看到与核心 B 不同的结果。)

好的,现在有一个详细的一步一步的解释:(对于上面的代码)

我们从核心 B 开始:(2.5)

  • (2.5)var2 = 999;

  • (3)acq:用 'memory_order_release' 查找变量 'x',什么都没有。现在核心A中的顺序我们可以猜测[(0),(1),(2)]或者[(0),(2),(1)]都是合法的,那么我们(B)就没有限制了重新排序 (3) 和 (4)。

  • (3)rel: 用 'memory_order_acquire' 找到 var 'x', 发现 (2), 所以制作一个有序的显示列表到核心 A : [var2=999, x.exchange(1)]

  • (4) 用'memory_order_release' 找到var y,ok 在(1) 找到了。所以现在我们站在核心B,我们可以看到核心显示给我的源代码:'There's must have var1=99 before y.exchange(1)'.

  • 思路是:在y.exchange(1)之前我们可以看到源码中有var1=99,因为我们向其他核发出了REQUEST,而核对此结果做出了响应大部头书。 (REQUEST是y.load(std::acquire))如果有其他内核也想观察A的源代码,他们是找不到那个结论的。

  • 我们永远无法知道 (0) (1) (2) 的真实 运行 顺序。

    • A的顺序本身就可以保证正确的结果(好像是单线程)
    • B 的请求对 A 的真实 运行 订单也没有任何影响。
  • 这也适用于B(2.5)(3)(4)

也就是说,特定核心的操作确实存在,但没有告诉其他核心,所以'local cache in other cores'可能是错误的。

所以有问题的代码有 (0, 0) 的机会。