Problem Compilers and CPUs reorder memory operations, so lock-free code, flags and custom locks can break in ways one thread never sees. This guide answers what ordering guarantee each atomic operation needs.
What Relaxed, Acquire, Release and SeqCst atomics actually guarantee, what they cost on x86 and ARM, and which one each pattern needs.
atomicsmemory-modelacquire-releaselock-freec11
Overview
Compilers and CPUs reorder memory operations as long as a single thread cannot tell. An atomic's ordering says what other threads may observe. Mental model: a Release store publishes every earlier write; an Acquire load that reads it receives them all. Relaxed is atomic but orders nothing. SeqCst adds one global order, needed only when a thread stores one location and then loads another (Dekker pattern).
Key facts
x86-64 (TSO): plain loads are acquire, plain stores release; only store→load reorders. A SeqCst store is an xchg, order of 20 cycles.
AArch64: acquire/release (and SeqCst) are ldar/stlr; relaxed is plain ldr/str.
A non-atomic data race is undefined behaviour in C, C++ and Rust, even on x86.
Contention (order of 50-100+ ns per line transfer) costs far more than any ordering.
OS kernels: the Linux kernel documents its own barrier rules and memory model (LKMM, memory barriers).
Language runtimes and standard libraries: reference counts as in Rust's Arc (decrement Release, then an Acquire fence before free).
Networking stacks: DPDK's rte_ring publishes ring tails with C11 acquire and release orderings (rte_ring_c11_pvt.h).
Avoid when
A Mutex, channel, OnceLock or Arc already does the job.
Deep dive
Decision table
Situation
Ordering
Why
Event/statistics counter, read approximately
fetch_add(Relaxed)
Nothing else is published through it
Publish data via a flag or pointer
store Release, load Acquire
Message passing: writes before the store are visible after the load
Lock acquire / unlock
CAS success Acquire, unlock store Release
Critical-section contents stay inside
Refcount increment
Relaxed
You already hold a reference
Refcount decrement
fetch_sub(Release); if it hit 0: fence(Acquire) then free
All other owners' uses happen-before the free
Store my flag, then read yours (Dekker, sleep/wake flags)
SeqCst on both sides, or fence(SeqCst) between store and load
Only SeqCst forbids store→load reordering
CAS retry loop that publishes
compare_exchange_weak(.., AcqRel or Release, Relaxed)
Failure path publishes nothing
Lazy one-time init
OnceLock / std::call_once
Handles the races and blocking for you
Reading many relaxed atomics, then ordering once
relaxed loads + one fence(Acquire)
Fence upgrades all prior loads
Pointer to immutable data (RCU style)
Acquire load
consume is unimplemented in practice; compilers promote it to acquire
Operation
x86-64
AArch64 (ARMv8.1+ LSE)
load Relaxed / Acquire / SeqCst
mov / mov / mov
ldr / ldar (or ldapr) / ldar
store Relaxed / Release / SeqCst
mov / mov / xchg
str / stlr / stlr
fetch_add any ordering
lock xadd
ldadd (+a/l/al suffix)
fence(Acquire/Release)
compiler barrier only
dmb ishld / dmb ish
fence(SeqCst)
mfence (or a locked op)
dmb ish
Code
use std::cell::UnsafeCell;
use std::sync::atomic::{fence, AtomicBool, AtomicUsize, Ordering::*};
// 1. Message passing (one-shot). Release/Acquire make the plain write to `data` visible.
pub struct Mailbox { ready: AtomicBool, data: UnsafeCell<u64> }
unsafe impl Sync for Mailbox {}
impl Mailbox {
pub fn send(&self, v: u64) { // call at most once
unsafe { *self.data.get() = v; }
self.ready.store(true, Release);
}
pub fn recv(&self) -> Option<u64> {
if self.ready.load(Acquire) { Some(unsafe { *self.data.get() }) } else { None }
}
}
// 2. Reference count, as in std::sync::Arc.
pub fn rc_inc(rc: &AtomicUsize) { rc.fetch_add(1, Relaxed); }
pub fn rc_dec(rc: &AtomicUsize) -> bool { // true => caller frees
if rc.fetch_sub(1, Release) != 1 { return false; }
fence(Acquire); // synchronize with every other owner's Release decrement
true
}
// 3. Store buffering: "a == 0 && b == 0" is impossible only with SeqCst.
pub static X: AtomicBool = AtomicBool::new(false);
pub static Y: AtomicBool = AtomicBool::new(false);
pub fn thread_a() -> bool { X.store(true, SeqCst); Y.load(SeqCst) }
pub fn thread_b() -> bool { Y.store(true, SeqCst); X.load(SeqCst) }
// With Release/Acquire both can return false: each store sits in its core's store buffer.
// 4. Spin on a relaxed load, then one acquire fence (cheaper loop body on ARM).
pub fn wait_ready(flag: &AtomicBool) {
while !flag.load(Relaxed) { std::hint::spin_loop(); }
fence(Acquire);
}
Efficiency
Cost: on x86-64 acquire loads and release stores are plain mov; a SeqCst store is an xchg, order of 20 cycles. On AArch64 they are ldar/stlr, relaxed is ldr/str. Contention costs far more than ordering: order of 50-100+ ns per line transfer. Bound: coherence traffic, not the fence.
Levers:
Relaxed for counters that publish nothing — plain lock xadd or ldadd, no fences — statistics, refcount increments
Release/Acquire instead of SeqCst — plain mov on x86, no xchg or mfence (table above) — message passing, locks
relaxed spin loop plus one fence(Acquire) — cheaper loop body on ARM — flag waits
build for ARMv8.1 LSE (-march=armv8.1-a, Rust +lse) — single-instruction atomics instead of LL/SC retry loops (see Pitfalls) — Graviton, Apple Silicon
remove the shared write instead of weakening its order (sharded counters) — contention dominates — hot counters
Naive → standard → state of the art:
SeqCst on every access · an xchg per store, order of 20 cycles
Release/Acquire per pattern (decision table) · plain mov on x86
relaxed accesses with a single fence, LSE atomics, no shared writes on the hot path · order of 1 ns per operation
Rules that matter
Happens-before comes from a release operation and an acquire operation on the same atomic, where the acquire reads the value written by the release (or a later RMW in its release sequence).
Acquire/release on different variables gives no ordering between them.
RMWs (fetch_add, swap, CAS) always read the latest value in that location's modification order, even when Relaxed; plain Relaxed loads may read stale values.
Every atomic location has a single total modification order that all threads agree on (coherence), even with Relaxed.
Fences: a fence(Release) before a relaxed store acts like a release store; a fence(Acquire) after a relaxed load acts like an acquire load.
SeqCst operations form one total order consistent with happens-before; mixing SeqCst with weaker accesses on the same locations is subtle (C++20 repaired the rules after Lahav et al. 2017).
Pitfalls
"It works on x86": TSO hides missing acquire/release at the hardware level, but the compiler can still reorder. Test on ARM (Apple Silicon, Graviton) and with model checkers.
load then store instead of an RMW: lost updates.
volatile (C/C++) or read_volatile (Rust) is not atomic and orders nothing across threads.
Using Acquire on a store or Release on a load: Rust panics at runtime for load(Release); C++ makes it undefined.
Flag-based sleep/wake (set "sleeping", check queue; producer pushes, checks "sleeping") with only acquire/release: both sides can miss each other. Needs SeqCst fences or a lock.
Pre-LSE ARM (ARMv8.0) atomics are LL/SC loops that livelock-ish under contention; build with LSE (-march=armv8.1-a, Rust +lse, or GCC outline atomics).
Benchmarks of "SeqCst is slow" usually measure contention, not the fence.
Tooling
Rust: loom (exhaustive interleavings under the C11 model), Miri (detects data races and some weak-memory behaviours), ThreadSanitizer (-Zsanitizer=thread).
C/C++: ThreadSanitizer, GenMC, CDSChecker, herd7 litmus tests for hardware models.
herd7 litmus tests for hardware and language models
Sources
Hans-J. Boehm, Sarita V. Adve, Foundations of the C++ Concurrency Memory Model, PLDI 2008. paper
Peter Sewell, Susmit Sarkar, Scott Owens, Francesco Zappa Nardelli, Magnus O. Myreen, x86-TSO: A Rigorous and Usable Programmer's Model for x86 Multiprocessors, Communications of the ACM 53(7), 2010. paper
Ori Lahav, Viktor Vafeiadis, Jeehoon Kang, Chung-Kil Hur, Derek Dreyer, Repairing Sequential Consistency in C/C++11, PLDI 2017. paper
Mara Bos, Rust Atomics and Locks, O'Reilly, 2023. book
Paul E. McKenney, Is Parallel Programming Hard, And, If So, What Can You Do About It?, kernel.org (continuously updated). book
parallel tree search · plannedSearch one game tree on many threads: shared lossy transposition table (Lazy SMP), split points (YBWC), virtual loss for MCTS