Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

I have a question regarding the order of operations in the following code:

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

Given the description of std::memory_order_acquire on the cppreference page (https://en.cppreference.com/w/cpp/atomic/memory_order), that

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.

it seems obvious that there can never be an outcome that r1 == 0 && r2 == 0 after running thread1 and thread2 concurrently.

However, I cannot find any wording in the C++ standard (looking at the C++14 draft right now), which establishes guarantees that two relaxed loads cannot be reordered with acquire-release exchanges. What am I missing?

EDIT: As has been suggested in the comments, it is actually possible to get both r1 and r2 equal to zero. I've updated the program to use load-acquire as follows:

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

Now is it possible to get both and r1 and r2 equal to 0 after concurrently executing thread1 and thread2? If not, which C++ rules prevent this?

like image 248
Oleg Andreev Avatar asked Oct 02 '18 10:10

Oleg Andreev


2 Answers

The standard does not define the C++ memory model in terms of how operations are ordered around atomic operations with a specific ordering parameter. Instead, for the acquire/release ordering model, it defines formal relationships such as "synchronizes-with" and "happens-before" that specify how data is synchronized between threads.

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.

In §6.8.2.1-9, the standard also states that if a store A synchronizes with a load B, anything sequenced before A inter-thread "happens-before" anything sequenced after B.

No "synchronizes-with" (and hence inter-thread happens-before) relationship is established in your second example (the first is even weaker) because the runtime relationships (that check the return values from the loads) are missing.
But even if you did check the return value, it would not be helpful since the exchange operations do not actually 'release' anything (i.e. no memory operations are sequenced before those operations). Neiter do the atomic load operations 'acquire' anything since no operations are sequenced after the loads.

Therefore, according to the standard, each of the four possible outcomes for the loads in both examples (including 0 0) is valid. In fact, the guarantees given by the standard are no stronger than memory_order_relaxed on all operations.

If you want to exclude the 0 0 result in your code, all 4 operations must use std::memory_order_seq_cst. That guarantees a single total order of the involved operations.

like image 69
LWimsey Avatar answered Oct 02 '22 07:10

LWimsey


You already have an answer to the language-lawyer part of this. But I want to answer the related question of how to understand why this can be possible in asm on a possible CPU architecture that uses LL/SC for RMW atomics.

It doesn't make sense for C++11 to forbid this reordering: it would require a store-load barrier in this case where some CPU architectures could avoid one.

It might actually be possible with real compilers on PowerPC, given the way they map C++11 memory-orders to asm instructions.

On PowerPC64, a function with an acq_rel exchange and an acquire load (using pointer args instead of static variables) compiles as follows with gcc6.3 -O3 -mregnames. This is from a C11 version because I wanted to look at clang output for MIPS and SPARC, and Godbolt's clang setup works for C11 <atomic.h> but fails for C++11 <atomic> when you use -target sparc64.

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

(source + 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 is not a store-load barrier; it only requires the preceding instructions to complete locally (retire from the out-of-order part of the core). It doesn't wait for the store buffer to be flushed so other threads can see the earlier stores.

Thus the SC (stwcx.) store that's part of the exchange can sit in the store buffer and become globally visible after the pure acquire-load that follows it. In fact, another Q&A already asked this, and the answer is that we think this reordering is possible. Does `isync` prevent Store-Load reordering on CPU PowerPC?

If the pure load is seq_cst, PowerPC64 gcc puts a sync before the ld. Making the exchange seq_cst does not prevent the reordering. Remember that C++11 only guarantees a single total order for SC operations, so the exchange and the load both need to be SC for C++11 to guarantee it.

So PowerPC has a bit of an unusual mapping from C++11 to asm for atomics. Most systems put the heavier barriers on stores, allowing seq-cst loads to be cheaper or only have a barrier on one side. I'm not sure if this was required for PowerPC's famously-weak memory ordering, or if another choice was possible.

https://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html shows some possible implementations on various architectures. It mentions multiple alternatives for ARM.


On AArch64, we get this for the question's original C++ version of 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 

The reordering can't happen there because AArch64 acquire loads interact with release stores to give sequential consistency, not just plain acq/rel. Release stores can't reorder with later acquire loads.

(They can reorder with later plain loads, on paper and probably in some real hardware. AArch64 seq_cst can be cheaper than on other ISAs, if you avoid acquire loads right after release stores. But unfortunately it makes acq/rel worse than x86. This is fixed with ARMv8.3-A LDAPR, a load that's just acquire not sequential-acquire. It allows earlier stores, even STLR, to reorder with it. So you get just acq_rel, allowing StoreLoad reordering but not other reordering. (It's also an optional feature in ARMv8.2-A).)

On a machine that also or instead had plain-release LL/SC atomics, it's easy to see that an acq_rel doesn't stop later loads to different cache lines from becoming globally visible after the LL but before the SC of the exchange.


If exchange is implemented with a single transaction like on x86, so the load and store are adjacent in the global order of memory operations, then certainly no later operations can be reordered with an acq_rel exchange and it's basically equivalent to seq_cst.

But LL/SC doesn't have to be a true atomic transaction to give RMW atomicity for that location.

In fact, a single asm swap instruction could have relaxed or acq_rel semantics. SPARC64 needs membar instructions around its swap instruction, so unlike x86's xchg it's not seq-cst on its own. (SPARC has really nice / human readable instruction mnemonics, especially compared to PowerPC. Well basically anything is more readable that PowerPC.)

Thus it doesn't make sense for C++11 to require that it did: it would hurt an implementation on a CPU that didn't otherwise need a store-load barrier.

like image 41
Peter Cordes Avatar answered Oct 02 '22 08:10

Peter Cordes