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:
-
On x86-64, a
Relaxedload and anAcquireload 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. - 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.
-
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=threadon nightly. This grader is a single-filerustc -Obuild, 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
- Ordering controls what other memory is visible, not speed and not flushing.
-
Release/Acquireon the same variable creates the happens-before edge that publishes everything written before the store. -
Relaxedis correct exactly when the variable stands alone. -
SeqCstis a global claim and therefore a review flag, not a safe default. -
spawn,join, mutexes, barriers and channels already give you edges — use them before you hand-roll.