Memory Models & Visibility

Memory Barriers Constrain Ordering, Not Caches

A barrier says which of your memory operations may not move across this point. It does not "flush the cache" — caches are already coherent. Getting that distinction right is what separates a correct ordering argument from a plausible-sounding one.

▶ Run the lab

The question this answers

The question

What does a memory barrier actually constrain, and why is "flush the cache" the wrong mental model?

The work

A ring-buffer producer writing an entry into slot i and then publishing it by bumping the write index.

What is shared

The ring slots and the atomic write index.

The invariant — what must stay true under every interleaving

If a consumer observes writeIndex > i, then slot i is fully written and safe to read.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The orderings, and what each one constrains

Start with the correction, because it prevents a whole family of wrong arguments. On the machines you are writing for, caches are *already coherent*: the hardware guarantees that all cores eventually agree on the value of any location, without software doing anything. A barrier does not push data out of a cache and does not make anything visible that was going to stay invisible. What it constrains is the order in which *this core's* memory operations become visible relative to each other.

With that fixed, the orderings are learnable as a small set. relaxed: atomic, orders nothing. acquire on a load: nothing after it may move before it. release on a store: nothing before it may move after it. acq_rel on a read-modify-write: both. seq_cst: all of the above, plus a single total order over all sequentially consistent operations that every thread agrees on.

Acquire and release are deliberately one-way. That is what makes them cheaper than a full fence and exactly sufficient for publication: the producer needs its prior writes not to sink past the publish, and the consumer needs its later reads not to hoist above the observe. Neither needs the other direction, and paying for it is a real cost on architectures that implement it with an instruction.

OrderingWhat may not move across itTotal order across threads?Typical use
relaxedNothing. The operation is atomic and orders nothing elseNoA statistics counter nothing else depends on
acquire (loads)Later operations may not move before itNoThe consumer half of a publication
release (stores)Earlier operations may not move after itNoThe producer half of a publication
acq_rel (RMW)Both directions, on one read-modify-writeNoA CAS that both consumes and publishes
seq_cstBoth directions, plus a single total order all threads agree onYesThe default; correct by construction, most expensive where fences are needed
standalone fenceBoth directions at that point, not attached to any one locationDepends on the fenceRare; almost always the wrong tool versus an ordered atomic
The orderings, in the terms that matter for building an argument.

The publication pair on a ring buffer

The ring buffer is the cleanest place to see why one release and one acquire are exactly what is required. The producer writes an arbitrary amount of data into slot i — a whole struct, several fields, a memcpy — and then publishes with a single release store of the index. The consumer acquires the index and, if it is greater than i, may read the slot.

None of the slot fields is atomic and none needs to be. The release/acquire pair covers all of them by transitivity, which is the same economy described in Happens-Before: The Edge That Makes a Write Visible. This is the pattern behind every SPSC queue, every lock-free logging buffer and every shared-memory ring between processes, and getting the pair right is essentially the entire correctness argument.

Note what the barrier does *not* do here. It does not make the slot writes happen sooner, it does not push them anywhere, and it does not prevent the consumer from reading a slot it was never entitled to read. It says only: if you observed my index, you will also observe everything I wrote before publishing it.

1struct Entry { uint64_t ts; uint32_t level; char msg[96]; };
2
3Entry ring[CAP];
4std::atomic<uint64_t> writeIndex{0}; // the ONLY atomic here
5
6// ---- producer ----
7void publish(const Entry& e) {
8 uint64_t i = writeIndex.load(std::memory_order_relaxed); // sole writer
9 ring[i % CAP] = e; // plain writes: ts, level, 96 bytes
10
11 // RELEASE: none of the writes above may move after this store.
12 // That single constraint publishes the whole struct.
13 writeIndex.store(i + 1, std::memory_order_release);
14}
15
16// ---- consumer ----
17bool consume(uint64_t i, Entry& out) {
18 // ACQUIRE: no read below may move above this load.
19 if (writeIndex.load(std::memory_order_acquire) <= i) return false;
20
21 out = ring[i % CAP]; // guaranteed to see the producer's
22 return true; // writes to this slot
23}
24
25// NOTE: this is the ordering argument only. A real ring also needs a
26// read index, wraparound protection and overwrite handling.
Single-producer, single-consumer ring. One release, one acquire, no atomic fields.

What relaxed gives you, and what it does not

The most instructive failure is the one where every access is atomic and the invariant still breaks. Make both the slot and the index relaxed atomics: every read returns some value that was actually written, nothing tears, a race detector reports nothing — and the consumer can still observe the new index with the old slot contents, because relaxed constrains nothing but atomicity.

This is the concrete meaning of "atomic does not imply ordered", and it is worth having seen once as a schedule rather than as a sentence. The step marked below is legal: relaxed atomics permit exactly this observation, on any architecture whose hardware permits the corresponding reordering.

The practical rule that follows: start with seq_cst, which is the default in C++ and in JavaScript's Atomics.*, and weaken only with a specific argument about which edge you still need and a measurement showing the stronger ordering cost something. Relaxed is correct for a counter nothing is ordered against and is a bug in every publication pattern.

All accesses relaxed-atomic. No data race, no torn read, invariant broken anyway.SIMULATED
Invariant · If the consumer observes writeIndex > i, slot i is fully written
#ProducerConsumerState
1relaxed store ring[0].level = 3·C sees ring[0].level=0 C sees writeIndex=0
2relaxed store ring[0].ts = 1699999999·C sees ring[0].ts=0 C sees writeIndex=0
3relaxed store writeIndex = 1·C sees writeIndex=0
4·relaxed load writeIndex -> 1C sees writeIndex=1
5·relaxed load ring[0].ts -> 0C sees ring[0].ts=0
✕ The consumer observed writeIndex == 1 and read an unwritten slot. Every access was atomic; none was ordered. This is the difference between atomicity and ordering, in one step.
6[with release on writeIndex, acquire on the load] store writeIndex = 1·C sees ring[0].ts=1699999999
Atomicity and ordering are independent axes. Relaxed buys the first and none of the second, which makes it correct for a standalone counter and wrong for every publication.

Key points

  • A barrier constrains the order in which this core's memory operations become visible. It does not flush caches — caches are already coherent.
  • Acquire and release are one-way: nothing after an acquire moves before it, nothing before a release moves after it. That asymmetry is why they are cheap and sufficient.
  • One release plus one acquire publishes an arbitrarily large amount of plain data, by transitivity.
  • Relaxed gives atomicity and no ordering, so a fully relaxed publication is broken while looking fully synchronized.
  • Start at seq_cst and weaken only with a specific edge argument plus a measurement. The reverse order produces subtle bugs for unmeasured gains.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • The ordering constraint attached to an atomic operation tells the compiler which movements across it are forbidden.
  • The compiler emits whatever the target needs to obtain the same constraint from the hardware — nothing at all on some targets, a dedicated instruction or an explicit fence on others.
  • A release store means: every memory operation sequenced before it in this thread is visible to any thread that acquires this value.
  • An acquire load means: every memory operation sequenced after it in this thread happens after whatever the releasing thread published.
  • Sequential consistency additionally places all such operations in one total order that every thread observes identically, which is what makes it the easiest to reason about and the most expensive to provide.
Interleavings that matter
  • Producer writes slot then release-stores the index; consumer acquire-loads the index and reads the slot correctly. No schedule breaks it.
  • All relaxed: consumer observes the new index and an unwritten slot. Atomic throughout, ordered nowhere.
  • Release on the producer, plain load on the consumer: no edge, because an edge is a pair. The consumer may observe the index without the slot.
  • Two producers on the same relaxed index: both compute the same slot and both write it. The barrier question is untouched — this is a mutual-exclusion problem, and barriers do not provide mutual exclusion.
  • seq_cst throughout: correct, and on a weakly ordered target it emits more fences than the acquire/release version needs. Correct-and-slower is the right starting point.
What it guarantees — and does not
  • Promises: the specified operations may not be reordered across the barrier by the compiler or by the hardware.
  • Promises: with a paired release and acquire, everything the releasing thread did before is visible to the acquiring thread after.
  • Promises (seq_cst only): a single total order over sequentially consistent operations that all threads agree on.
  • Does NOT promise: mutual exclusion. Two threads may execute the same region simultaneously; a barrier orders and does not exclude.
  • Does NOT promise: that anything is "flushed". Coherence already guarantees eventual agreement on a location's value; the barrier orders operations, not caches.
  • Does NOT promise: an edge from one half of a pair. A release with no acquire orders nothing between threads.
  • Does NOT promise: that relaxed atomics are ordered. They are not, and that is their entire specification.
Where contention appears
  • Barriers are not themselves contention, but on targets that implement them with instructions they prevent the CPU from overlapping work, which shows up as reduced throughput on the barriered path.
  • seq_cst is the most constrained ordering and costs the most where fences are real instructions; on x86-64 a release store and an acquire load are plain instructions and cost nothing extra, while a seq_cst store needs a fence.
  • The location being ordered is still a shared line, and the coherence traffic on it is a separate cost from the barrier. See What a Shared Write Costs.
How it fails
  • Relaxed publication — atomic everywhere, ordered nowhere; the flagship failure of this lesson.
  • One-sided pairing — a release with no acquire, or vice versa, producing code that reads as synchronized and orders nothing.
  • Cargo-culted fences — a standalone fence added because a bug went away, ordering something unrelated and leaving the real edge missing.
  • Confusing barriers with mutual exclusion, producing two threads correctly ordered and simultaneously in the same region.
  • Premature weakening — orderings relaxed for performance without a measurement, trading a correctness argument for nothing.
When it helps
  • Publication patterns: ring buffers, SPSC queues, configuration swaps, lock-free structures — anywhere a small atomic publishes a large amount of plain data.
  • Hot read paths where taking a mutex would dominate, and the read genuinely needs only an acquire.
  • Cross-process shared memory, where a mutex may not be usable at all and the ordering argument is the only tool available.
When it hurts
  • In ordinary application code where a mutex would be correct, obvious and fast enough.
  • When weakened orderings are chosen by intuition rather than measurement, which converts an easy correctness argument into a hard one for no measured gain.
  • When a standalone fence is used in place of an ordered atomic, since the fence is not attached to a location and the argument becomes much harder to check.
How you would know
  • Verify correctness with a thread sanitizer and on a weakly ordered target before measuring anything. An ordering bug that only appears on ARM will not be found by a benchmark on x86-64.
  • To justify weakening, measure the seq_cst version and the acquire/release version on the real workload and on the target architecture. On x86-64 the difference is frequently zero for loads and stores.
  • Inspect generated assembly on the weakly ordered target: the barrier the source implies should be visible, and its absence means the ordering argument is wrong somewhere.
  • Use a formal tool where the stakes justify it — a memory-model checker such as CppMem or herd7 can exhaustively check small publication idioms in a way testing cannot.
  • For the ring specifically, assert a magic value or a sequence number inside each slot on the consumer side; a mismatch is a visible ordering failure rather than a silent one.
Complexity it introduces
  • Every atomic gains an ordering argument that is part of its contract and must be re-checked whenever surrounding code moves.
  • The argument is not local: it spans the producer and the consumer, which may be in different files owned by different people.
  • Weakened orderings create a second layer of reasoning on top of the first, and the payoff is often architecture-specific.
  • Testing has weak power here, so confidence has to come from review, sanitizers and multi-architecture CI rather than from a passing suite.
Simpler alternatives
  • A mutex, which supplies both ordering and exclusion with no per-operation ordering argument. See Mutexes: What They Protect and What They Do Not.
  • A library ring buffer or concurrent queue whose ordering argument has already been made and reviewed. See Concurrent Queues.
  • seq_cst everywhere as a deliberate default, weakening only where a profile shows a cost. Correct-and-slower is a legitimate engineering position.
  • Message passing or process isolation, where the runtime supplies the edge and the question does not arise. See Message Passing.
  • Immutable publication through a single atomic pointer, which reduces the entire ordering surface to one location and one pair. See Safe Publication: Handing Over a Finished Object.

Both threads read 0, and both wrote first

Both threads read 0, and both wrote first
Two threads, two variables, four operations. Reason sequentially about it and one outcome is provably impossible: whichever store happened first, the other thread's load comes after it. Then let the load move up one line.
x = y = 0

Thread 1                Thread 2
    x  = 1;                 y  = 1;
    r1 = y;                 r2 = x;

Sequential reasoning: one of the two stores must land first,
so at least one load must see a 1. r1 == 0 && r2 == 0 is impossible.
0 of 6 schedules give (0,0)
r1=0, r2=0
never
r1=0, r2=1
1 schedule
r1=1, r2=0
1 schedule
r1=1, r2=1
4 schedules
Invariant · r1 == 0 && r2 == 0 cannot both hold — at least one thread must observe the other's store.
#Thread 1Thread 2State
1x ← 1·x=1 y=0 r1=0 r2=0
2r1 ← y·x=1 y=0 r1=0 r2=0
3·y ← 1x=1 y=1 r1=0 r2=0
4·r2 ← xx=1 y=1 r1=0 r2=1
As written, all 6 interleavings are enumerated above and none produces (0,0). That is what sequential consistency buys you: the program behaves as if there is one global order of operations that respects each thread's program order. Every argument you make about concurrent code by "walking through it" assumes this — and no mainstream language guarantees it for ordinary variables. The repair is not to reason harder about timing; it is to declare the ordering you need. A release store paired with an acquire load, a sequentially-consistent atomic, or an explicit fence turns the pair of accesses into a happens-before edge the compiler and the CPU must both respect. Ordering is something you *request*, and the cost of requesting it is the reason it is not the default.
CPU-SPECIFICWhether this reordering is observable depends on the processor, the compiler and the language memory model. x86 is strong and still permits exactly this case; ARM and POWER permit far more. The hardware mechanism — store buffers, invalidation queues, speculative loads — belongs to Computer Architecture; this lab only shows what the *program* can observe.

Publish a value, then a flag — which edge makes it visible?

Publish a value, then a flag
The writer fills in the data and sets a ready flag. The reader waits for the flag and reads the data. It looks airtight, and without a synchronization edge between the two threads it is not — no matter how long the reader waits.
Writer                          Reader
    data  = 42;                     while (ready == 0) { }
    ready = 1;                      use(data);
reader starts
edge
none
reader delay
immediately
values data may show
42 or 0
guarantee
none
Invariant · if the reader observes ready == 1, it observes data == 42.
#WriterReaderState
1data ← 42·data=42 ready=0 reader sees=—
2ready ← 1 (plain store)·data=42 ready=1 reader sees=—
3·read ready → 1data=42 ready=1 reader sees=—
4·read data → 0data=42 ready=1 reader sees=0
✕ the reader observed ready = 1 and data = 0 — it saw the flag that announces the write without seeing the write
Without an edge the reader can observe ready = 1 and data = 0. Both writes happened, in that order, in the writer's source. The reader simply has no relation to them: the two plain stores may be published out of order, and the reader's two plain loads may be satisfied out of order or from a stale cached value. Happens-before is a partial order the language defines over operations, built from program order plus specific synchronizing pairs — a lock release and the next acquire of the same lock, a release store and the acquire load that reads it, a thread start, a thread join, a channel send and its receive. Two operations with no path between them in that order are concurrent, and a data race on them is undefined behaviour rather than a stale value: the compiler is entitled to assume it never happens. So the question to ask of any cross-thread visibility argument is never "could the other thread really be that fast?" — it is which edge makes this visible?
SIMPLIFIEDModelled at the level of a language memory model: an edge exists or it does not. Which spellings create one is language-specific — release/acquire in C++ and Rust, a lock or a volatile write in Java, a channel send or a lock in Go.

What people believe, and what is true

Claim

A barrier flushes the cache so other cores can see my write.

Reality

Caches are coherent already; the write will be seen regardless. The barrier constrains the order in which this core's operations become visible relative to each other.

Claim

Everything is atomic, so the ordering is fine.

Reality

Relaxed atomics order nothing. Atomicity and ordering are independent, and a fully relaxed publication is broken.

Claim

A memory barrier gives mutual exclusion.

Reality

It orders operations. Two threads can be inside the same region simultaneously with every barrier correctly placed.

Go deeper

Overview

A barrier says which of your memory operations may not cross this point. It is about order, not about pushing data anywhere.

Practical

Publication is one release on the producer and one acquire on the consumer. That pair covers everything written before it, so the payload does not need to be atomic.

Advanced

Weaken from seq_cst only with both halves of a justification: which edge you still need, and a measurement showing the stronger ordering cost something on the target you ship. On x86-64 the load and store cases often cost nothing, so the weakening buys nothing either.

Internals

CPU-SPECIFIC: on x86-64 the fence instructions are mfence, lfence and sfence, and only the seq_cst store path typically needs one. On 64-bit ARM the relevant instructions are LDAR and STLR plus the DMB family. These names are useful for reading disassembly and are not something to write code against.

Apply it