CodeRigor

FREE LESSON

The AArch64 memory model

What weakly ordered ARM hardware may reorder (almost everything), what ldar/stlr/ldapr and dmb buy you, how atomic read-modify-writes compile (LL/SC, LSE, outline atomics), and why code that works on x86 breaks here.

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

Relaxed message passing on ARM: r == 0 is allowed
With no ordering, the two stores may become visible in either order, and the two loads may be satisfied in either order — the consumer can see ready == 1 and still read the old data.
Relaxed message passing on ARM: r == 0 is allowed
With no ordering, the two stores may become visible in either order, and the two loads may be satisfied in either order — the consumer can see 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:

C++
#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();
}
The fix: stlr publishes, ldar subscribes
The release store synchronizes with the acquire load that reads 1, so data = 42 happens before r = data. The compiler emits exactly stlr and ldar.
The fix: stlr publishes, ldar subscribes
The release store synchronizes with the acquire load that reads 1, so 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:

Plain str/ldr message passing on a weak machine
The run drains 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.
Plain str/ldr message passing on a weak machine
The run drains 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.
stlr publishes, ldar subscribes
stlr can't drain until data's store has, and ldr r2, data can't run before ldar completes. Whenever r1 = 1, r2 = 42.
stlr publishes, ldar subscribes
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:

Acquire and release are one-way fences
Ops may move into the critical region but never out: nothing after an ldar moves above it, nothing before an stlr moves below it. And an stlr never passes a later ldar.
Acquire and release are one-way fences
Ops may move into the critical region but never out: nothing after an 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.

C++
#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 ish

4. 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:

Text
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 again
LL/SC: the store fails if anyone wrote in between
Core 1's store breaks core 0's exclusive monitor, so stlxr 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.
LL/SC: the store fails if anyone wrote in between
Core 1's store breaks core 0's exclusive monitor, so stlxr 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.

C++
#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.
  • volatile as synchronisation. volatile stops the compiler from removing or merging accesses; it emits plain ldr/str with 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 ARM stlr is cheap but every dmb ish you 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 as acquire. 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:

SPSC ring: each index has exactly one writer
The consumer owns head, the producer owns tail; each reads the other's index with acquire and publishes its own with release. Slot = counter % N.
SPSC ring: each index has exactly one writer
The consumer owns 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 tail relaxed (only it writes it), reads head with acquire (so it doesn't overwrite a slot the consumer is still reading), writes the slot, then publishes with a release store of tail + 1.
  • The consumer mirrors it: relaxed head, acquire tail, read the slot, release head + 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.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Will Deacon introduces Arm ordering, barrier domains and dmb versus dsb in the Linux kernel. Follow with the linked AArch64 guide for the current model and this lesson’s ldar/stlr examples; emulation does not establish real weak-memory outcomes.