The question this answers
What does a memory model actually define, and why is the language's model not the CPU's?
Thread A writes data = 42 and then ready = true. Thread B spins until it observes ready, then reads data.
Two ordinary, non-atomic variables: data and ready.
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.
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.
| Question | What the model specifies | With no synchronization | Construct that supplies it |
|---|---|---|---|
| Atomicity | Which operations cannot be observed half-complete | A wide value may be read as two halves from different writes (a torn read) | Atomic types; a mutex |
| Visibility | Whether and when another thread will observe your write | No guarantee it is ever observed — a spin on a plain flag may loop forever | Atomic store/load; releasing and acquiring a mutex |
| Ordering | Which orders of your operations another thread may observe | Any order consistent with your own single-threaded semantics | Acquire/release or sequentially consistent atomics; barriers |
| Synchronization | Which constructs create a cross-thread ordering relation | No relation exists; the two threads' operations are unordered | Mutex, atomic release/acquire pair, thread start and join, channel send/receive |
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.
| # | Thread A (producer) | Thread B (consumer) | State |
|---|---|---|---|
| 1 | write data = 42 (plain) | · | A view: data=42 B view: data=0 |
| 2 | write ready = true (plain) | · | A view: ready=true B view: data=0 |
| 3 | · | read ready -> true | B view: ready=true B view: data=0 |
| 4 | · | read data -> 0 | B 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 -> 42 | B view: data=42 |
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.
1#include <atomic>2int data = 0; // plain; the atomic below orders it3std::atomic<bool> ready{false};4 5// producer6data = 42;7ready.store(true, std::memory_order_release);8 9// consumer10while (!ready.load(std::memory_order_acquire)) { /* spin */ }11assert(data == 42); // guaranteed by the release/acquire pairA 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.
1// Ordinary objects are never shared between agents; only SharedArrayBuffer is.2const buf = new SharedArrayBuffer(8)3const cell = new Int32Array(buf) // [0] = data, [1] = ready4 5// producer agent6Atomics.store(cell, 0, 42)7Atomics.store(cell, 1, 1) // Atomics.* are sequentially consistent8 9// consumer agent10while (Atomics.load(cell, 1) === 0) {}11Atomics.load(cell, 0) // 42ECMAScript 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.
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.
1import threading2 3data = 04ready = threading.Event() # the documented synchronization construct5 6def producer():7 global data8 data = 429 ready.set() # Event provides the cross-thread ordering10 11def consumer():12 ready.wait()13 assert data == 42The 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.
- 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
threadingprimitives; 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.
- • 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.
- • 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,
readyatomic 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
readyand 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.
- • 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
relaxedgives only the first.
- • 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.
- • 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.
- • 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 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.
- • 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.
- • 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.
- • 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, anEvent, aCountDownLatch— 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
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.| # | Thread 1 | Thread 2 | State |
|---|---|---|---|
| 1 | x ← 1 | · | x=1 y=0 r1=0 r2=0 |
| 2 | r1 ← y | · | x=1 y=0 r1=0 r2=0 |
| 3 | · | y ← 1 | x=1 y=1 r1=0 r2=0 |
| 4 | · | r2 ← x | x=1 y=1 r1=0 r2=1 |
Publish a value, then a flag — which edge makes it visible?
Writer Reader
data = 42; while (ready == 0) { }
ready = 1; use(data);| # | Writer | Reader | State |
|---|---|---|---|
| 1 | data ← 42 | · | data=42 ready=0 reader sees=— |
| 2 | ready ← 1 (plain store) | · | data=42 ready=1 reader sees=— |
| 3 | · | read ready → 1 | data=42 ready=1 reader sees=— |
| 4 | · | read data → 0 | data=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 |
What people believe, and what is true
The other thread will see my write eventually.
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.
I am on x86, which does not reorder, so I do not need the atomic.
The compiler reorders regardless of the target, and the reasoning does not survive the first ARM build. Reason at the language level.
Atomic means ordered.
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.