问题 C ++内存模型中的哪些确切规则会阻止在获取操作之前重新排序?


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

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);
}

给出描述 std::memory_order_acquire 在cppreference页面上(https://en.cppreference.com/w/cpp/atomic/memory_order那个

具有此内存顺序的加载操作会对受影响的内存位置执行获取操作:在此加载之前,不能对当前线程中的读取或写入进行重新排序。

很明显,永远不会有结果 r1 == 0 && r2 == 0 跑完之后 thread1 和 thread2 同时。

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

编辑:正如评论中所建议的那样,实际上可以使r1和r2都等于零。我已经更新了程序以使用load-acquire,如下所示:

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);
}

现在可以得到两者兼而有之 r1 和 r2 并发执行后等于0 thread1 和 thread2?如果没有,哪些C ++规则阻止了这个?


6990
2017-10-02 10:27


起源

只是为了确认:你想要[语言 - 律师]回答为什么两个宽松的载荷仍然被认为是读取,因此受到前面的重新排序障碍的约束? - MSalters
在具体的代码中可以 r1==0 && r2 == 0  - 2个线程之间没有任何同步点 - RbMm
实际上,我认为我对替换交换的讨论感到困惑。它 是 有可能看到 r1==0 && r2 == 0 这里,因为获取 - 释放(和消费 - 释放)是 配对。只有一个线程获取(并释放)每个原子,因此以下宽松负载可以看到“旧”值 - Caleth
不,您可以获得负载以及在交易所上发布 - Caleth
@OlegAndreev - 我的意思是 相同 原子变量。在你的代码中不存在它 - RbMm


答案:


该标准没有根据如何使用特定排序参数围绕原子操作排序操作来定义C ++内存模型。 相反,对于获取/发布排序模型,它定义了正式关系,例如“synchronize-with”和“before-before”,用于指定线程之间数据的同步方式。

N4762,§29.4.2 - [atomics.order]

对原子对象M执行释放操作的原子操作A与对M执行获取操作的原子操作B同步   并以A为首的释放序列中的任何副作用取其值。

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

在第二个示例(第一个甚至更弱)中没有建立“synchronize-with”(因此发生线程间发生)关系,因为缺少运行时关系(检查来自负载的返回值)。
但即使你确实检查了返回值,它也没有用 exchange 操作实际上并不“释放”任何东西(即在这些操作之前没有对存储器操作进行排序)。 Neiter做原子加载操作'获取'任何东西,因为在加载后没有操作排序。

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

如果要在代码中排除0 0结果,则必须使用所有4个操作 std::memory_order_seq_cst。这保证了相关操作的单一总订单。


5
2017-10-03 06:02



在多次重读标准的相关部分后,我觉得这是正确的答案。这很不幸,因为它与许多人认为的不同(获取基本上是LoadLoad + LoadStore,释放是LoadStore + StoreStore,如果只涉及两个线程,则RMW上的acq_rel等同于seq_cst)。我想的好消息是,对于所有相关的archs,acq_rel RMW编译为与seq_cst RMW相同的代码。 - Oleg Andreev
@OlegAndreev - 你可以做下一个 x.store(1, memory_order_relaxed); atomic_thread_fence(memory_order_acq_rel); r1=y.exchange(1, memory_order_relaxed); 和 y.store(1, memory_order_relaxed); atomic_thread_fence(memory_order_acq_rel); r2 = x.exchange(1, memory_order_relaxed); 在这种情况下可以证明 r1+r2 != 0 或者下一个 x.exchange(1, memory_order_acq_rel); r1 = y.exchange(1, memory_order_acq_rel) 和 y.exchange(1, memory_order_acq_rel); r2 = x.exchange(1, memory_order_acq_rel); - RbMm
@PeterCordes哦,我最感兴趣的是ARMv8(aarch64)。至少gcc和clang为acq_rel和seq_cst xchg生成了相同的代码: godbolt.org/z/uDcqRJ 这个代码实际上是否是一个正确的障碍甚至存在一些不确定性:lists.infradead.org/pipermail/linux-arm-kernel/2014-February/... 要么 stackoverflow.com/questions/21535058/... VS community.arm.com/processors/f/discussions/6558/...)最后商店(seq_cst)看起来不错:) - Oleg Andreev
@OlegAndreev第一点。这些人没有错.C ++委员会通过将线程间行为提升到略高的抽象级别来做得非常出色。通过定义这些形式关系(sync-with,HB),它们提供了一个框架,在该框架中,只要原子操作适合该框架,它就可以很好地定义。对于不适合它的代码(例如您的示例,没有定义的HB),它们不会通过允许任何可能的结果来指定其排序行为..... - LWimsey
....通过遵循这种方法,C ++标准使程序员免于处理内存重新排序的混乱世界。但是从内存重新排序方法(acquire = #LoadLoad /#LoadStore等)来看这一点非常好。即使标准允许0 0结果,但基于那些较低级别的属性实际上是不可能的。该标准并不禁止,但只是不想鼓励这样的代码。 - LWimsey


在原始版本中,可以看到 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上的版本,反之亦然。在抽象机器中,与值不一致 x 和 y 在线程上

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

你需要 更多 通过获取/释放对获得“违反因果关系”的线程,正常的排序规则,与“成为可见的副作用”规则相结合,强制至少一个 load要看 1

不失一般性,让我们假设线程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 

线程1上的发布与线程2上的获取形成一对,抽象机器描述了一致性 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

2
2017-10-02 15:05





发布 - 获取订购 为了在2个线程之间创建同步点,我们需要一些原子对象 M 这将是 相同 在这两个行动中

原子操作 A 在...上执行释放操作   原子对象 M 与原子操作同步 B   执行获取操作 M 从任何人那里获取其价值   释放顺序中的副作用 A

或者更详细地说:

如果一个原子商店在线程中 A 被标记 memory_order_release   和线程中的原子加载 B 从相同的变量标记    memory_order_acquire,所有内存写入(非原子和放松   原子)发生在原子商店之前的观点   线程 A,成为线程中可见的副作用 B。那   是,一旦原子加载完成,线程 B 保证   看到一切线程 A 写信给记忆。

仅在释放的线程之间建立同步   并获得 相同 原子变量。

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

这里有同步点 M store-release和load-acquire(从store-release获取值!)。作为结果存储 N = u 在线程中 A (在商店发布之前 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 ?说 x ? x.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将是同步加载  通过商店获得(在获得任何存在之后的代码中) 之前 释放(但在代码之前没有交换之前)。

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

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

对任何特定原子变量的所有修改都在总计中发生   特定于这一个原子变量的顺序。

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

  • A 写信给 x  - [Ax]
  • A 做商店发布 [Ay] 至 y 在这之后 ( acq_rel 包括 发布交换 包括 商店
  • B 从中获取负载 y ([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. 如果 [B1] 之前 [A3]  - [A3] 读取存储的值 [B1] => r1 == 1

  2. 如果 [A3] 之前 [B1]  - [B1] 是由...存储的读取值 [A3]  从 围栏栅栏同步

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

  • 存在原子对象 y
  • 存在原子写入 [A3] (有任何记忆顺序)那个 修改 y 在线程中 A
  • [A2] 之前是排序的 [A3] 在线程中 A
  • 存在原子读数 [B1] (在任何内存顺序中)在线程中 B

  • [B1] 读取写的值 [A3]

  • [B1] 之前是排序的 [B2] 在线程中 B

在这种情况下,所有商店([A1])之前是排序的 [A2] 在线程中 A 将在所有负载之前发生([B3])来自相同的位置(x)线程制作 B 后 [B2] 

所以 [A1] (商店1到x)将在之前并且具有可见效果 [B3]  (加载表单x并将结果保存到 r2 )。所以将被加载 1 从 x 和 r2==1

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

2
2017-10-03 13:00





你已经有了语言律师部分的答案。但我想回答一个相关的问题,即如何理解为什么在可能的CPU架构中使用asm是可能的 LL / SC用于RMW原子

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

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

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

(来源+ asm 在Godbolt上用于MIPS32R6,SPARC64,ARM 32和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.存储交换的一部分可以位于存储缓冲区中并变为全局可见 跟随它的纯粹的获取负载。  事实上,另一个Q&A已经问过这个,答案是我们认为这种重新排序是可能的。 `isync`是否会阻止CPU PowerPC上的Store-Load重新排序?

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

所以PowerPC有一些从C ++ 11到asm for atomics的不寻常的映射。大多数系统在商店上放置较重的障碍,允许seq-cst负载更便宜或仅在一侧具有屏障。我不确定这是否是PowerPC着名的弱内存排序所必需的,或者是否可能有其他选择。

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


在AArch64上,我们得到了原始C ++版本的thread1:

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发布商店是 顺序 - 发布,不是简单发布。这意味着他们无法对以后的加载重新排序。

但是在一个假想的机器上,或者说有一个简单释放的LL / SC原子,很容易看出acq_rel不会阻止以后加载到不同的缓存行,在LL之后但在交换的SC之前变为全局可见。


如果 exchange 使用x86上的单个事务实现,因此加载和存储在内存操作的全局顺序中是相邻的,那么当然不能在以后的操作中重新排序 acq_rel 交换,它基本上相当于 seq_cst

但LL / SC并不一定是真正的原子事务来赋予RMW原子性 为那个位置

事实上,单一的asm swap 指令可以放宽或acq_rel语义。 SPARC64需要 membar 关于它的说明 swap 指令,所以不像x86的 xchg 它本身不是seq-cst。 (SPARC具有非常好的/人类可读的指令助记符,特别是与PowerPC相比。基本上任何东西都比PowerPC更具可读性。)

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


2
2017-10-03 21:35



谢谢!不知道AArch64发布商店是连续发布的(在ldaxr之前看似没有dmb,在stlxr之后为防止重新排序让我感到困惑)。 - Oleg Andreev
很好的补充..它表明标准委员会真正理解他们在做什么 - LWimsey
所以,如果我理解正确,即使我在这样一个假想的架构上使用seq_cst RWM,它仍然不会成为两个轻松商店之间的障碍,对吗?我的意思是以下重新排序似乎是可能的: st r0, [r1]; ll r2, [r3]; sc r4, [r3]; st r5, [r6]  - > ll r2, [r3]; st r5, [r6]; st r0, [r1]; sc r4, [r3]。我是否正确理解, std::atomic_thread_fence(seq_cst) 会阻止这样的重新排序吗? - Oleg Andreev
@OlegAndreev:在一个假设的ARM上也有普通发布商店和商店条件,商店的一部分 seq_cst RMW会 stlxr (顺序发布),而不是放松的商店。如果架构只有acq_rel和轻松的LL / SC,seq-cst交换可能会使用宽松的LL / SC然后是完全屏障,因为在每个普通ISA上,任何包含StoreLoad的屏障也包括所有其他屏障。我不知道你说的是哪两个“轻松商店”,因为这个例子有两个发行商店。 - Peter Cordes
我在谈论一个想象中的ISA和以下代码: a.store(relaxed); b.exchange(acq_rel); c.store(relaxed);, 是否 a 和 c 商店可以重新订购。 IIUC seq_cst访问必须仅在其他seq_cst访问周围具有总排序,否则它们与acq / rel / acq_rel具有相同的规则。 - Oleg Andreev