Skip to content
← All articles

Memory orderings, honestly

The most over-claimed topic on the internet, and the one place in this course where no test can tell you whether you were right. Release/acquire, why SeqCst is a smell, and why you must reason about this instead of running it.

Start with the admission, because it is the most useful thing on this page.

This is the one item in the course that cannot be a problem, and the reason is exactly why memory orderings are dangerous.

Three independent reasons:

  1. On x86-64, a Relaxed load and an Acquire load compile to the same instruction. mov. The hardware’s memory model (TSO) already provides the ordering, so a program with a wrong ordering produces correct output 100% of the time on the machine most people develop on.
  2. On aarch64 a wrong ordering may manifest — but never deterministically. A test would be flaky in the worst possible direction: usually passing, for the wrong reason, until the day it does not.
  3. The tools that would catch it are not available here. Miri (an MIR interpreter that detects data races and UB) needs nightly and cargo. loom (exhaustive interleaving exploration under a relaxed model) is an external crate. ThreadSanitizer needs -Zsanitizer=thread on nightly. This grader is a single-file rustc -O build, and nothing in it can distinguish a correct ordering from a lucky one.

So: you must reason about this rather than test it. That is not a limitation of this course. It is the actual working condition of everyone who writes lock-free code, and it is why the discipline in this article matters more than any formalism.

What ordering is for — and what it is not

Kill three misconceptions first, because each of them makes the rest incomprehensible.

Ordering is not about latency. Release does not “flush faster” and Relaxed is not “eventually consistent”. Every atomic store is visible to other threads promptly on real hardware; the orderings say nothing about when.

Ordering is not about flushing caches. Caches are coherent. That is the hardware’s job and it is already done.

Turning off optimisation does not make orderings unnecessary. Even a strictly in-order CPU with -O0 needs them, because the compiler is only one of the reorderers and the store buffer is the other. A simple in-order core with a store buffer still lets another thread observe your writes in a different order than you issued them.

What ordering actually controls: which of your other, non-atomic writes are guaranteed visible to a thread that reads this atomic. It is a rule about the relationship between one atomic operation and all the ordinary memory around it.

The four orderings

Rust follows the C++20 memory model, minus consume.

Relaxed — atomicity, and a per-variable total modification order. Nothing else. Every thread agrees on the sequence of values this one variable took; no promises about any other memory. Correct for counters, statistics, and any variable that stands entirely alone.

Release (store) / Acquire (load) — the workhorse. If thread A does a Release store and thread B does an Acquire load that reads that value, then everything A wrote before the store is visible to B after the load. This is the happens-before edge, and it is the whole mechanism by which non-atomic data gets published between threads.

// thread A                        // thread B
data = compute();              //  if READY.load(Acquire) {
READY.store(true, Release);    //      use(data);   // guaranteed to see it
                               //  }

Swap either one for Relaxed and B may read true while seeing stale data — a real bug, invisible on x86, occasionally fatal on ARM.

AcqRel — for read-modify-write operations that both consume and publish: acquire on the read half, release on the write half.

SeqCst — everything AcqRel gives you, plus a single global total order over all SeqCst operations in the entire program.

Why SeqCst is a code smell, not a safe default

The reflex is to write SeqCst everywhere and stop worrying. Mara Bos, who wrote the book on this (Rust Atomics and Locks), argues it should be treated as a code-review warning sign, and the argument is worth understanding rather than memorising.

SeqCst‘s guarantee is global. Whether your SeqCst operation does what you want depends on every other SeqCst operation in the program, including in dependencies. So it is the one ordering whose correctness cannot be reasoned about locally. It also does not help at all with the ordinary publish pattern above — release/acquire already covers that — so a SeqCst in a diff usually means “the author did not know which one to pick”.

The genuine uses are rare and specific: algorithms like Dekker’s that need two threads to agree on the interleaving of stores to different variables. If you are not writing one of those, SeqCst is buying you nothing but a fence.

Reach for release/acquire. Use Relaxed when the variable stands alone. Write down why, in a comment, because nothing else in your toolchain records the reasoning.

💡A colleague argues their lock-free queue must be correct because they ran it a million times on their laptop with no failures. What is wrong with that argument? click to reveal

Their laptop is almost certainly x86-64, which has a strong memory model (Total Store Order). It reorders far less than the abstract machine permits, and in particular it does not reorder loads with loads or stores with stores. A missing Acquire therefore compiles to identical machine code and is literally untestable on that hardware.

On aarch64 — every Apple Silicon Mac, every Graviton instance, every phone — the same source compiles to genuinely different instructions and the bug becomes possible. Not certain: it needs the right timing, the right cache state, the right contention. It might appear once per billion operations, in production, as corrupted data with no stack trace.

Running a million iterations proves the code works on this hardware, this time. Memory-ordering bugs are one of the few classes where testing is nearly worthless and reasoning is nearly everything. The tools that change that — loom for exhaustive interleaving, Miri for UB detection — are the reason those tools exist.

The good news you should exploit first

thread::spawn and JoinHandle::join create happens-before edges themselves. So does acquiring and releasing a Mutex. So does Barrier::wait, Condvar signalling, and sending through a channel.

Which means a great deal of concurrent code needs no explicit ordering at all: everything the parent wrote before spawn is visible to the child; everything the child wrote is visible to the parent after join. The Game of Life problem in the previous track used Relaxed atomics throughout and was correct, because the Barrier supplied every edge that mattered.

The right instinct is: get your ordering from a synchronisation primitive you already needed. Choose orderings by hand only when you are building the primitive.

Fences

std::sync::atomic::fence(Ordering) decouples the ordering from any particular operation — useful when several Relaxed operations should be ordered by one barrier rather than each paying for it. It is a sharper tool and a rarer one. If you are not sure you need it, you do not.

The one thing the compiler does check for you

rustc’s invalid_atomic_ordering lint is deny-by-default:

error: `Release` is not a valid ordering for a load operation

A load cannot be Release, a store cannot be Acquire, and a compare_exchange failure ordering may not be stronger than its success ordering. Those are the syntactically impossible combinations, and they are caught for free.

It cannot tell you that Relaxed was too weak for what you were doing. Only you can.

Carry these five

  1. Ordering controls what other memory is visible, not speed and not flushing.
  2. Release/Acquire on the same variable creates the happens-before edge that publishes everything written before the store.
  3. Relaxed is correct exactly when the variable stands alone.
  4. SeqCst is a global claim and therefore a review flag, not a safe default.
  5. spawn, join, mutexes, barriers and channels already give you edges — use them before you hand-roll.