On x86 (Level 13) the hardware reorders exactly one thing: a store followed by a later load. AArch64 is weakly ordered: without explicit ordering instructions, the hardware may reorder almost any two memory accesses to different addresses. This is where the C++ memory orders stop being theory and become instructions.
One honest caveat up front: the programs in this course run under qemu on an x86 host. qemu translates every ARM load and store into an x86 one, so the host's TSO ordering leaks through and you will not see ARM's weak reorderings in a litmus run here. Real ARM machines (phones, Apple Silicon, Graviton) show them readily. So this skill checks claims the way a reviewer would: by reading the instructions the compiler emits, and by reasoning about which outcomes the architecture allows.
1. What the hardware may reorder
For two accesses to different addresses, with no dependency and no barrier between them:
| earlier → later | x86-64 (TSO) | AArch64 |
|---|---|---|
| load → load | kept | may reorder |
| load → store | kept | may reorder |
| store → store | kept | may reorder |
| store → load | may reorder | may reorder |
Accesses to the same location are always coherent (every core agrees on
one order of writes to it), and a load that depends on an earlier load's
value through an address (p->x after loading p) stays after it.
AArch64 is also other-multi-copy-atomic: once a store becomes visible to another core, it is visible to all of them — a store can't reach core 2 before core 3. (Older ARM and POWER didn't promise even that.) The C++ model was designed to be implementable here with acquire/release instructions and a full barrier, which is exactly what the compiler uses.
2. Message passing goes wrong without orders
ready == 1 and still read the old data.With relaxed atomics (or plain variables — then it's a data race and UB) two things can each break this on ARM: the producer's two stores can become visible out of order, and the consumer's two loads can be satisfied out of order. On x86 neither happens, which is how "works on my laptop" code fails on a phone. The fix is the same in C++ on both: release the flag, acquire it:
#include <atomic>
#include <cstdio>
#include <thread>
int data = 0;
std::atomic<int> ready{0};
int main() {
std::thread producer([] {
data = 42;
ready.store(1, std::memory_order_release); // stlr
});
std::thread consumer([] {
while (ready.load(std::memory_order_acquire) == 0) { } // ldar / ldapr
std::printf("data = %d\n", data); // always 42
});
producer.join();
consumer.join();
}data = 42 happens before r = data. The compiler emits exactly stlr and ldar.Step through both versions on a model of the machine. With plain str
and ldr, the flag's store can leave the store buffer before the data's,
or the consumer's second load can run first:
- ▸
str data, 42 -
str flag, 1
- ▸
ldr r1, flag -
ldr r2, data
flag before data (stores to different addresses drain in any order), so the consumer sees the flag and the old data. Switch to x86-TSO: in-order buffers make it impossible there.- ▸
str data, 42 -
stlr flag, 1
- ▸
ldar r1, flag -
ldr r2, data
stlr can't drain until data's store has, and ldr r2, data can't run before ldar completes. Whenever r1 = 1, r2 = 42.3. One-way barriers: ldar and stlr
AArch64 builds acquire and release into the load and store instructions:
ldar moves above it, nothing before an stlr moves below it. And an stlr never passes a later ldar.And between themselves, an stlr is never reordered with a later ldar.
That last rule is why seq_cst needs no extra fence on AArch64: a seq_cst
store is an stlr and a seq_cst load is an ldar, and the pair can't swap
— exactly the store→load ordering that costs x86 an xchg.
| C++ | AArch64 (GCC 16, -O2) |
|---|---|
load(relaxed) |
ldr |
load(acquire) |
ldar (ldapr with -march=armv8.3-a or later) |
load(seq_cst) |
ldar |
store(relaxed) |
str |
store(release) |
stlr |
store(seq_cst) |
stlr |
atomic_thread_fence(acquire) |
dmb ishld |
atomic_thread_fence(release) |
dmb ishld + dmb ishst |
atomic_thread_fence(seq_cst) |
dmb ish |
ldapr (ARMv8.3 "RCpc") is a slightly weaker acquire: it may be
reordered with an earlier stlr to another address. That's all C++'s
acquire asks for, and it lets the core start the load early. A seq_cst
load still needs the full ldar. dmb ish is a full barrier for the inner
shareable domain (all the cores running your process); ishld/ishst
order only loads / only stores.
Explore these in the compiler pane: set the architecture to AArch64 and compare against x86-64.
#include <atomic>
std::atomic<int> flag;
int load_acq() { return flag.load(std::memory_order_acquire); } // ldar
void store_rel(int v){ flag.store(v, std::memory_order_release); } // stlr
void store_sc(int v) { flag.store(v); } // stlr, too
void fence_sc() { std::atomic_thread_fence(std::memory_order_seq_cst); } // dmb ish4. Atomic read-modify-write: three shapes
fetch_add, exchange and compare_exchange need the read and the write to
be indivisible. ARMv8.0 had only load-linked / store-conditional: the
load marks the address exclusive, the store fails (returns 1) if anyone else
wrote it in between, and the code loops:
fetch_add(1) with -march=armv8-a -mno-outline-atomics:
.L1: ldaxr w0, [x1] // load-acquire exclusive
add w2, w0, 1
stlxr w3, w2, [x1] // store-release exclusive; w3 = 0 on success
cbnz w3, .L1 // someone interfered: try againstlxr returns 1 and the loop retries with a fresh ldaxr. No update is lost; under heavy contention cores can retry a lot — which LSE fixes.ARMv8.1 added LSE (Large System Extensions): single instructions that do the whole RMW in the cache or interconnect, which scales far better under contention:
| C++ (seq_cst) | LSE instruction |
|---|---|
fetch_add |
ldaddal |
fetch_or / fetch_and (as clear) |
ldsetal / ldclral |
exchange |
swpal |
compare_exchange_strong |
casal |
The al suffix is "acquire + release" (seq_cst and acq_rel both use it);
a, l or no suffix give acquire, release or relaxed.
But a binary built for plain ARMv8.0 must still run on old cores. So GCC's
default is outline atomics: each RMW becomes a call to a tiny helper
in libgcc, such as __aarch64_ldadd4_acq_rel, which checks once at startup
whether the CPU has LSE and uses it or falls back to an LL/SC loop.
#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>
int main() {
std::atomic<long> hits{0};
std::vector<std::thread> ts;
for (int t = 0; t < 4; ++t)
ts.emplace_back([&] { for (int i = 0; i < 10000; ++i) hits.fetch_add(1, std::memory_order_relaxed); });
for (auto& t : ts) t.join();
std::printf("%ld\n", hits.load()); // 40000: RMWs never lose updates
}Even relaxed RMWs are atomic — they just don't order other memory.
That differs from x86, where every RMW is a full barrier (lock) whatever
order you ask for.
5. What x86 lets you get away with
Code that is correct only on TSO:
- Plain or relaxed flag + data (message passing). x86 keeps store→store and load→load order, so it "works". On ARM it's broken.
volatileas synchronisation.volatilestops the compiler from removing or merging accesses; it emits plainldr/strwith no ordering at all. On ARM the hardware still reorders them.- "Acquire is free, so I'll sprinkle seq_cst". On x86 a seq_cst store
costs an
xchg; on ARMstlris cheap but everydmb ishyou add with explicit fences is not. - Assuming dependent loads are free.
memory_order_consume(deprecated in C++26) tried to exploit address dependencies; compilers treat it asacquire. Use acquire.
The opposite surprise also exists: a seq_cst fence (dmb ish) is the
expensive thing on ARM, where on x86 the expensive thing is a seq_cst
store.
6. A single-producer single-consumer queue
The classic lock-free structure that needs exactly acquire/release:
head, the producer owns tail; each reads the other's index with acquire and publishes its own with release. Slot = counter % N.- The producer reads its own
tailrelaxed (only it writes it), readsheadwith acquire (so it doesn't overwrite a slot the consumer is still reading), writes the slot, then publishes with a release store oftail + 1. - The consumer mirrors it: relaxed
head, acquiretail, read the slot, releasehead + 1.
No RMWs, no fences: on ARMv8.3 the push is ldr, ldapr, str, stlr.
That's the build task. Because qemu can't show you reorderings, the codegen
stage checks the orders you chose — just as a reviewer reads the assembly.