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

时间:2018-10-02 10:27:13

标签: c++ language-lawyer atomic stdatomic memory-barriers

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

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的描述,

  

具有此内存顺序的加载操作将在受影响的内存位置上执行获取操作:在此加载之前,无法重新排序当前线程中的读写。

很明显,同时运行r1 == 0 && r2 == 0thread1之后,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);
}

现在在同时执行r1r2之后是否可以使thread1thread2都等于0?如果没有,那么哪个C ++规则可以阻止这种情况?

6 个答案:

答案 0 :(得分:10)

该标准未定义C ++内存模型的方式,即如何围绕具有特定排序参数的原子操作对操作进行排序。 取而代之的是,对于获取/释放排序模型,它定义了正式的关系,例如“与……同步”和“在……之前发生”,它们指定了如何在线程之间同步数据。

N4762,第29.4.2节-[atomics.order]

  

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

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

在第二个示例(第一个示例甚至更弱)中,没有建立“与...同步”(因此在线程间发生同步)关系,因为缺少了运行时关系(用于检查负载的返回值)。
但是,即使您确实检查了返回值,也没有用,因为exchange操作实际上不会“释放”任何内容(即,在这些操作之前不对任何内存操作进行排序)。 Neiter不会执行原子加载操作“获取”任何东西,因为在加载之后没有顺序进行操作。

因此,根据标准,两个示例中四个可能的载荷结果(包括0 0)均有效。 实际上,该标准给出的保证在所有操作上都不比memory_order_relaxed强。

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

答案 1 :(得分:3)

在原始版本中,可能会看到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

您需要 more 个线程来获取“获取/释放”对的“违反因果关系”,这是正常的排序规则,结合“成为可见的副作用”规则会强制至少其中之一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 :(得分:3)

Release-Acquire ordering中,为了在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 上的

此处同步点存储释放和负载获取(它们从存储释放中获得价值!)。作为结果存储区N = u在线程 A 中(在 M 上发布之前)在 {{1} } 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到main的加载值将与释放之前(但再次在不交换任何内容之前)与存储 之后(在代码中不存在任何获取之后)同步加载。代码)。

接下来是正确的解决方案(寻找几乎完全question):

x.exchange

假设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] acq_rel 包括 release 交换包括商店
  • y B 获得负载(y[By]
  • 存储的值
  • 完成原子负载获取(在[Ay]上)后,线程y完成 保证可以看到线程B之前写入内存的所有内容 商店下达(在A上)。因此它查看了y-和[Ax]的副作用

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

r2 == 1
再次

因为原子变量 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]之前,反之亦然。

  1. 如果[B1]之前的[B1]-[A3]读取了[A3] => [B1]存储的值。

  2. 如果r1 == 1之前的[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](存储1至x)将在[A1]之前,并具有可见的效果(加载x并将结果保存到[B3])。因此将从r21

加载x
r2==1

答案 3 :(得分:3)

您已经对此的语言律师部分有了答案。但是我想回答一个相关的问题,即如何理解为什么在使用LL/SC for RMW atomics的可能的CPU架构上的asm中可以做到这一点。

对于C ++ 11来说,禁止这种重新排序没有任何意义:在某些CPU架构可以避免这种情况的情况下,这将需要一个存储加载屏障。

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

在PowerPC64上,具有acq_rel交换和获取负载(使用指针args代替静态变量)的函数按gcc6.3 -O3 -mregnames进行编译。这来自C11版本,因为我想查看MIPS和SPARC的clang输出,而Godbolt的clang设置适用于C11 <atomic.h>,但是当您使用{{1}时,不适用于C ++ 11 <atomic> }。

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

-target sparc64

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 不是存储负担的障碍;它只需要前面的指令就可以在本地完成(从内核的乱序部分退出)。它不等待刷新存储缓冲区,以便其他线程可以看到较早的存储。

因此,作为交换一部分的SC(isync)存储可以位于存储缓冲区中,并且在紧随其后的纯获取负载之后 成为全局可见的。 / strong>事实上,另一个问答已经问过了,答案是我们认为这种重新排序是可能的。 Does `isync` prevent Store-Load reordering on CPU PowerPC?

如果纯负载为stwcx.,则PowerPC64 gcc将seq_cst放在sync之前。使ld exchange不会阻止重新排序。请记住,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上,我们是针对thread1的原始C ++版本得到的:

seq_cst

不能在那里进行重新排序,因为AArch64发行版存储是顺序发行版,而不是普通发行版。这意味着他们无法在以后的装载中重新排序。

但是在一个假设的机器上,它也具有或具有普通版本的LL / SC原子,很容易看到acq_rel不会阻止以后加载到不同缓存行的操作在LL之后但在LL之前变得全局可见交易所的SC。


如果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 是通过x86上的单个事务实现的,那么加载和存储在内存操作的全局顺序中是相邻的,那么肯定以后的操作将无法通过exchange交换进行重新排序基本上等同于acq_rel

但是LL / SC不必是真正的原子事务,即可赋予该位置RMW原子性

实际上,单个asm seq_cst指令可能具有宽松或acq_rel语义。 SPARC64的swap指令周围需要membar指令,因此与x86的swap不同,它不是单独的seq-cst。 (SPARC具有非常好的/人类可读的指令助记符,特别是与PowerPC相比。基本上,任何东西都比PowerPC更具可读性。)

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

答案 4 :(得分:3)

由于语言律师的推理很难遵循,我想补充一点,一个理解原子的程序员将如何对您问题中的第二个片段进行推理:

由于这是对称代码,仅从一侧看就足够了。 由于问题是关于r1(r2)的值,所以我们先来看

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

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

因此,假设我们读取了零,那么我们可以说,如果该零是由另一个具有memory_order_release的线程写入的,那么该线程在存储释放之前对内存的所有其他写入操作也将对该线程可见。但是,我们读取的零值是x的初始化值,并且初始化值是非原子的-更不用说“释放”了-并且在写入该值方面肯定没有任何“有序”记忆因此,我们无话可说。换句话说,同样,“获取”是不相关的。

因此,我们可以得出r1 = 0,而我们使用获取的事实是无关紧要的。然后,对r2进行同样的推理。因此结果可以是r1 = r2 = 0。

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

*)但是,有可能通过进一步的推理来表明某些值由于与内存模型的不一致而永远不会出现-但这在这里没有发生。

答案 5 :(得分:0)

我尝试用另一句话来解释它。

想象一下,每个线程同时在不同的CPU Core中运行,线程1在Core A中运行,线程2在Core B中运行。

核心B无法知道核心A中的REAL运行顺序。内存顺序的含义就是,运行结果要从核心A显示给核心B。

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)的请求。 (具有类似“带有memory_order_release的变量y”的代码) 并且在核心B中的(4)将A应用于特定顺序:(0)->(1)->(4)。

对于不同的请求,他们可能在其他线程中看到不同的顺序。 (如果现在有了核心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”的变量“ x”,找到(2),因此向核心A排列一个有序列的显示列表:[var2 = 999,x.exchange(1)]

  • (4)使用'memory_order_release'查找var y,可以在(1)找到它。因此,现在我们站在核心B上,我们可以看到核心向我显示的源代码:“在var1=99之前必须有y.exchange(1)”。

  • 这个想法是:我们可以在y.exchange(1)之前看到源代码为var1 = 99的源代码,因为我们对其他内核进行了请求,并且对我产生了内核A响应。 (请求为y.load(std::acquire))。如果还有其他一些核心也想观察A的源代码,他们将找不到该结论。

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

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

也就是说,针对特定内核的操作确实可以,但是没有告诉其他内核,因此“其他内核中的本地缓存”可能是错误的。

因此相关代码有(0,0)的机会。