Memory Models & Visibility

What a Memory Model Defines

Threads do not necessarily observe memory operations in the order your source code wrote them. A memory model is the contract that says which orders are possible — and the language's contract is a different object from the CPU's.

▶ Run the lab

The question this answers

The question

What does a memory model actually define, and why is the language's model not the CPU's?

The work

Thread A writes data = 42 and then ready = true. Thread B spins until it observes ready, then reads data.

What is shared

Two ordinary, non-atomic variables: data and ready.

The invariant — what must stay true under every interleaving

If B observes ready == true, then B reads data == 42.

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?

Four questions, one contract

A memory model answers four separate questions, and confusing them is the source of most of the confusion in this module. *Atomicity*: which operations can be observed half-done. *Visibility*: whether a write in one thread will ever be seen by another, and when. *Ordering*: which orderings of operations another thread may observe. *Synchronization*: which constructs create a guaranteed relationship between threads — the happens-before edges of Happens-Before: The Edge That Makes a Write Visible.

The invariant above depends on all four. It needs data's write to be atomic (no torn value), visible to B, ordered before ready's write as B observes it, and it needs some construct to establish that relationship. Two plain variables give you none of these guarantees, in any of these languages, and the reason people expect them to is that on a lightly loaded single-socket machine it usually works — which is the worst possible kind of usually.

The critical framing: without synchronization, "later in the source" is not a fact another thread can rely on. Program order is a property of one thread's own execution. Cross-thread order is a property you must construct, and the memory model is the specification of how to construct it.

QuestionWhat the model specifiesWith no synchronizationConstruct that supplies it
AtomicityWhich operations cannot be observed half-completeA wide value may be read as two halves from different writes (a torn read)Atomic types; a mutex
VisibilityWhether and when another thread will observe your writeNo guarantee it is ever observed — a spin on a plain flag may loop foreverAtomic store/load; releasing and acquiring a mutex
OrderingWhich orders of your operations another thread may observeAny order consistent with your own single-threaded semanticsAcquire/release or sequentially consistent atomics; barriers
SynchronizationWhich constructs create a cross-thread ordering relationNo relation exists; the two threads' operations are unorderedMutex, atomic release/acquire pair, thread start and join, channel send/receive
The four things a memory model defines, and what you get without one.

The message-passing idiom, unsynchronized

The schedule below is the smallest program that demonstrates the problem, and it is worth memorising because every real instance is a dressed-up version of it. A produces a value and sets a flag; B waits on the flag and reads the value; B reads a stale value. Nothing crashes, nothing logs, and the wrong value flows onward.

Note what the trace deliberately does not say: *why* B saw the old data. It could be the compiler having reordered A's two stores, having hoisted B's load of data above its loop, or the hardware having made the stores visible in the other order. All three are permitted here, all three produce the identical symptom, and which one it was is not knowable from the symptom. That indistinguishability is precisely why the fix is stated at the language level and not the hardware level. See Reordering: The Compiler and the CPU Both Do It.

The fix is to make ready an atomic with a release store on A's side and an acquire load on B's side. That single pair creates the edge that orders A's write to data before B's read of it, and it constrains both the compiler and the hardware in one statement.

Two plain variables. Every step is legal; the invariant is not maintained.SIMULATED
Invariant · If B observes ready == true, B reads data == 42
#Thread A (producer)Thread B (consumer)State
1write data = 42 (plain)·A view: data=42 B view: data=0
2write ready = true (plain)·A view: ready=true B view: data=0
3·read ready -> trueB view: ready=true B view: data=0
4·read data -> 0B view: data=0
✕ B observed ready == true and read data == 0. The invariant is broken and no operation in this trace was illegal.
5·[with atomic release/acquire on ready] read data -> 42B view: data=42
Source order is not a cross-thread guarantee. The compiler may reorder, the hardware may reorder, and B may cache a stale value — and no symptom distinguishes them. Only a synchronization construct rules all three out at once.

Language memory model is not CPU memory model

These are two different contracts at two different layers, and this distinction is the single most useful thing in this lesson. The *language* memory model specifies what your program may observe: it is what you write code against, it is what the compiler must preserve, and it is portable. The *CPU* memory model specifies what a particular architecture's hardware may reorder: it is what the compiler compiles down to, it differs between x86-64 and ARM, and you should almost never write code against it.

Reasoning at the wrong layer produces a specific, recognisable class of bug. "x86 does not reorder stores, so I do not need the atomic" is wrong twice over — first because the compiler is a reorderer too and is entirely unconstrained by what x86 does, and second because the code will be built for ARM eventually. Conversely, code that is correct under the language model needs no knowledge of the target at all; the compiler emits whatever barriers that target requires, and emits none where the target needs none.

The four languages differ enormously in how much of this they even specify. C++11 has a full formal model where a data race is undefined behaviour. ECMAScript has had a formal memory model since ES2017, covering SharedArrayBuffer accesses, where racy accesses are weakly defined rather than undefined. TypeScript specifies nothing of its own. The Python language reference does not define a memory model at all, so every claim about CPython behaviour is a claim about that implementation and that version.

Publish a value and a flag, so the reader is guaranteed to see the value. — One thread writes data then sets a flag; another observes the flag and must be guaranteed to see the data.
C++LANGUAGE-SPECIFIC
1#include <atomic>
2int data = 0; // plain; the atomic below orders it
3std::atomic<bool> ready{false};
4
5// producer
6data = 42;
7ready.store(true, std::memory_order_release);
8
9// consumer
10while (!ready.load(std::memory_order_acquire)) { /* spin */ }
11assert(data == 42); // guaranteed by the release/acquire pair

A fully specified model. Concurrent conflicting access to a non-atomic object is a data race and therefore undefined behaviour — not "a wrong value", but no defined behaviour at all.

JavaScriptRUNTIME-SPECIFIC
1// Ordinary objects are never shared between agents; only SharedArrayBuffer is.
2const buf = new SharedArrayBuffer(8)
3const cell = new Int32Array(buf) // [0] = data, [1] = ready
4
5// producer agent
6Atomics.store(cell, 0, 42)
7Atomics.store(cell, 1, 1) // Atomics.* are sequentially consistent
8
9// consumer agent
10while (Atomics.load(cell, 1) === 0) {}
11Atomics.load(cell, 0) // 42

ECMAScript has had a formal memory model since ES2017. Atomics.* accesses are sequentially consistent; plain TypedArray accesses to shared memory are unordered but still yield some value that was written — weakly defined, not undefined behaviour.

TypeScriptLANGUAGE-SPECIFIC
1// TypeScript has no memory model. This is a JS program with types.
2const cell = new Int32Array(new SharedArrayBuffer(8))
3
4const publish = (v: number): void => {
5 Atomics.store(cell, 0, v)
6 Atomics.store(cell, 1, 1)
7}
8// 'readonly' and 'const' are compile-time only and constrain no thread.

Every guarantee here comes from the host runtime. Immutability in the type system is erased at runtime and provides no visibility or ordering property whatsoever.

PythonCPYTHON
1import threading
2
3data = 0
4ready = threading.Event() # the documented synchronization construct
5
6def producer():
7 global data
8 data = 42
9 ready.set() # Event provides the cross-thread ordering
10
11def consumer():
12 ready.wait()
13 assert data == 42

The Python language reference defines no memory model, so the guarantee comes from the threading primitives rather than from the language. In CPython 3.12 the GIL additionally means only one thread executes bytecode at a time; that is an implementation property, not a specification.

What actually differs
  • C++ is the only one of the four where getting this wrong is undefined behaviour rather than a wrong value; the others give weak-but-defined or implementation-defined results.
  • JavaScript agents share nothing but SharedArrayBuffer, so the whole question only arises for byte-buffer offsets — ordinary object graphs cannot be raced on at all.
  • TypeScript contributes no runtime semantics; every claim about a TypeScript program is a claim about Node or the browser.
  • Python has no specified memory model, so correctness rests on threading primitives; relying on GIL behaviour is relying on an implementation detail that the free-threaded build of 3.13 changes.
  • Across all four, the portable discipline is identical: use the language's synchronization construct and never reason from what a particular CPU does.

Key points

  • A memory model defines four things: atomicity, visibility, ordering, and which constructs create cross-thread synchronization.
  • Threads do not necessarily observe memory operations in source order. Program order is a per-thread property, not a cross-thread guarantee.
  • The language memory model and the CPU memory model are different contracts at different layers. Write code against the language's.
  • "x86 does not reorder that" is wrong reasoning twice: the compiler reorders too, and the code will be built for another target.
  • The four languages here specify wildly different amounts: C++ has a full formal model, JavaScript has one for shared buffers, TypeScript has none, and Python's reference defines none at all.

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
  • Each thread executes its own operations in an order consistent with its own single-threaded semantics — nothing more is promised by default.
  • The compiler may reorder, merge, hoist or eliminate memory operations as long as single-threaded observable behaviour is preserved.
  • The hardware may make writes visible to other cores in an order different from the one in which they were issued.
  • A synchronization construct — a mutex, a release/acquire pair, a thread join, a channel send — creates a happens-before edge that constrains both the compiler and the hardware.
  • The language's compiler translates that edge into whatever the target architecture actually requires, which may be several fence instructions or none at all.
Interleavings that matter
  • A writes data=42; A writes ready=true; B reads ready=true; B reads data=0 — the invariant broken with no illegal operation anywhere.
  • Same program, ready atomic with release/acquire: B's acquire load of true orders A's write to data before B's read, so B reads 42. No schedule breaks it.
  • B spins on a plain ready and never terminates, because the compiler hoisted the load out of the loop into a register. The write became visible; B stopped looking.
  • Both variables atomic but relaxed: each read returns a value that was written, and B can still observe ready=true with data=0, because relaxed constrains nothing but atomicity. See Memory Barriers Constrain Ordering, Not Caches.
  • Under a mutex held by both threads around both variables: every schedule maintains the invariant, because unlock-then-lock is the canonical happens-before edge.
What it guarantees — and does not
  • Promises: within one thread, operations behave as if executed in program order. Your own thread never sees its own writes out of order.
  • Promises: with a properly paired synchronization construct, everything the writer did before the release is visible to whoever performs the matching acquire.
  • Does NOT promise: that a plain write is ever visible to another thread, or that two plain writes become visible in the order issued.
  • Does NOT promise: that "it works on my machine" transfers. x86-64 hides many reorderings that ARM exposes, and the compiler hides neither.
  • Does NOT promise: anything about a data race. In C++ a data race is undefined behaviour, so the compiler may assume it does not happen and optimise accordingly. See Data Race Is Not Race Condition.
  • Does NOT promise: that atomic implies ordered. Atomicity and ordering are separate axes, and relaxed gives only the first.
Where contention appears
  • Synchronization is what makes writes visible, and making a write visible to other cores costs coherence traffic on the line it touches. See What a Shared Write Costs.
  • Sequentially consistent atomics are the most constrained and therefore the most expensive ordering; acquire/release is cheaper on architectures that need explicit barriers and free on x86-64.
  • A spin loop on an atomic flag generates continuous read traffic on that line; pausing or backing off inside the loop is the standard mitigation. See Busy Waiting.
How it fails
  • Data race — unsynchronized conflicting access with at least one write. In C++ this is undefined behaviour, not merely a wrong value.
  • Stale read — the reader observes the flag but not the payload, so a fully constructed object is read as garbage. See Safe Publication: Handing Over a Finished Object.
  • Infinite spin — a loop on a plain flag hoisted out by the compiler, so the thread never re-reads memory.
  • Torn read — a wide value assembled from two different writes on targets where plain access to that width is not atomic.
  • Works-in-testing — the entire class of bug reproduces far more readily on weakly ordered hardware than on x86-64, so testing on one architecture proves little about the other.
When it helps
  • Any time two threads communicate through memory rather than through a queue or a channel — which is every lock-free structure and every publication of shared configuration.
  • Reviewing code that uses atomics: "which happens-before edge makes this visible?" is the question that finds these bugs, and it has a specific answer or the code is broken.
  • Porting to a new architecture, where reasoning at the language level is the difference between a recompile and a bug hunt.
When it hurts
  • When applied to code that shares nothing. A worker that receives a copy and returns a result needs none of this. See Message Passing.
  • When it motivates hand-tuned relaxed orderings in code where sequential consistency was never measurably a cost.
  • When it becomes a reason to reason about the target CPU, which is exactly the layer confusion this lesson exists to prevent.
How you would know
  • Run under a thread sanitizer. Unsynchronized conflicting accesses are precisely what TSan detects, and it will find the plain-flag version quickly under load.
  • Test on weakly ordered hardware — 64-bit ARM — as well as x86-64. Many of these bugs are simply invisible on x86-64 and routine on ARM.
  • Inspect the generated assembly for the publication path. On ARM you should see the barrier the release/acquire pair implies; if it is absent, the atomic is not doing what you think.
  • Build at the optimisation level you ship. Compiler reordering and hoisting are optimisation-dependent, so a debug build can hide the bug entirely.
  • Stress with more threads than cores and with a reader that spins, which maximises the window. See Stress Testing: A Test That Passed Once Proves Nothing.
Complexity it introduces
  • The correctness of any shared-memory communication now depends on an argument about edges, which does not appear in the code and cannot be checked by reading one function.
  • Ordering choices become part of the API contract of any type that exposes an atomic.
  • The bug class is architecture-sensitive, so CI must cover more than one architecture to have any detection power at all.
  • Every developer touching the code needs enough of the model to not "simplify" an atomic into a plain variable, which is a real and recurring code-review event.
Simpler alternatives
  • A mutex around both variables. It supplies visibility, ordering and mutual exclusion in one construct that everyone already understands. See Mutexes: What They Protect and What They Do Not.
  • A channel or queue: move the data instead of sharing it, and the send/receive pair carries the edge for you. See Channels.
  • An immutable object published once, so there is nothing to observe half-written after publication. See Immutability as a Concurrency Strategy.
  • Higher-level primitives — a Future, an Event, a CountDownLatch — which are specified to establish the edge and are much harder to misuse. See Futures & Promises.
  • Process isolation with message passing, which removes shared memory from the design entirely. See Process versus Thread.

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

The other thread will see my write eventually.

Reality

With no synchronization, nothing in the model promises it ever does. A spin on a plain flag can loop forever, and the compiler is entitled to make that happen by hoisting the load.

Claim

I am on x86, which does not reorder, so I do not need the atomic.

Reality

The compiler reorders regardless of the target, and the reasoning does not survive the first ARM build. Reason at the language level.

Claim

Atomic means ordered.

Reality

Atomicity and ordering are separate. A relaxed atomic is indivisible and orders nothing else at all.

Go deeper

Overview

A memory model is the rulebook for what one thread can see of another thread's writes. Without using its synchronization constructs, you get almost no guarantees.

Practical

For every piece of data shared between threads, name the construct that makes it visible: this mutex, this release/acquire pair, this queue. If you cannot name one, the code is broken even if it currently works.

Advanced

Separate atomicity from ordering deliberately. Sequential consistency is the default because it is the easiest to reason about; weaker orderings are an optimisation that must be justified by a measurement and by an argument about which edges you still need.

Internals

The compiler translates language-level edges into target instructions: on x86-64 a release store is a plain store and an acquire load is a plain load, while seq_cst stores need an explicit fence; on ARM the same source emits explicit barriers or uses load-acquire/store-release instructions. The reason those instructions exist belongs to Computer Architecture.

Apply it