Memory Models & Visibility

Safe Publication: Handing Over a Finished Object

Making a reference visible is not the same as making the object visible. A reader can hold a perfectly valid pointer to an object whose fields it cannot yet see — a half-constructed object, from the reader's point of view, with no null and no error to warn it.

▶ Run the lab

The question this answers

The question

How does one thread safely hand a newly constructed object to another without the other seeing it half-built?

The work

A loader thread constructs a four-hundred-entry routing table and publishes it to a shared reference that request threads read on every request.

What is shared

The shared reference, and every field of the object it points at.

The invariant — what must stay true under every interleaving

Any thread that observes a non-null published reference sees a fully constructed object — every field written before publication is visible.

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 half-constructed read

The failure is specific and worth stating precisely, because the everyday intuition is so strong: the reader gets a non-null reference to a real, allocated, fully-constructed-in-memory object, and reads a field as zero. Nothing is null. Nothing throws. The object is genuinely finished in the writer's thread. The reader simply cannot see all of it yet, because no happens-before edge relates the writer's field writes to the reader's field reads.

The mechanism is either of the two from Reordering: The Compiler and the CPU Both Do It — the compiler may sink the field writes past the reference store, or the hardware may make the reference store visible before them — and, once again, the program cannot tell which and does not need to. Both are ruled out by the same construct.

The consequence in production is a particularly unpleasant class of bug. A partially visible object flows into normal code paths as if it were valid, so the failure surfaces far from the publication: a routing table with zero routes, a config object whose timeout is 0, a validated request that fails validation for no reason. The stack trace points at the consumer and the bug is in the producer.

A publishes a fully constructed table with a plain store. B holds a valid pointer to an object it cannot see.SIMULATED
Invariant · A thread observing a non-null reference sees every field written before publication
#Loader threadRequest threadState
1allocate Table t·current=null
2write t.version = 7; t.timeoutMs = 3000; t.routes = [400 entries]·current=null A: t fully built=yes
3store current = &t (PLAIN)·current=&t
4·load current -> &t (non-null; no null check will help)B.t=&t
5·read t.timeoutMs -> 0B.timeoutMs=0
✕ B holds a valid pointer to a constructed object and reads an unwritten field. The request is issued with a zero timeout and fails instantly, three call frames away from anything to do with publication.
6·read t.routes -> emptyB.routes=0
✕ Same cause, second symptom: a routing table with no routes. Every request 404s until the line happens to become visible.
7[with RELEASE store and B doing an ACQUIRE load] store current = &t·B.timeoutMs=3000 B.routes=400
A non-null reference is not evidence that the object is visible. Publication is a synchronization act, and the null check that most code performs is checking the one thing that was never in doubt.

Every safe way to publish

The safe mechanisms are a short list, and every one of them works by creating a happens-before edge between construction and observation — they are applications of Happens-Before: The Edge That Makes a Write Visible rather than separate ideas. Knowing the list means you can always name which one you are using, and being unable to name one is the diagnosis.

Two entries deserve emphasis. Initialising before starting the thread is the cheapest correct answer and is used constantly without being recognised as synchronization: thread creation is an edge, so anything constructed before spawn is safely published to the child. And storing into a properly synchronized container — a concurrent queue, a channel, a map with internal synchronization — publishes safely because the container's own edge covers your object.

The Java entry is genuinely language-specific and genuinely useful: an object all of whose fields are final, and which does not leak this during construction, is safely published even through a data race, because the JMM gives final fields a freeze semantic at the end of the constructor. C++ has no equivalent guarantee, and assuming one is a real source of ported bugs.

MechanismThe edge it createsCost on the read pathScope
Initialise before starting the reader threadThread startNone at allUniversal; the cheapest correct answer where it fits
Release store / acquire load of the referenceRelease-acquire pair on the referenceOne acquire load; free on x86-64C++, Java (volatile), Rust, C#
Construct and publish under a mutex readers also takeUnlock / lock of the same mutexA lock acquisition per readUniversal; simplest to review
Hand the object through a concurrent queue or channelThe container's own send/receive edgeWhatever the container costsUniversal; hardest to get wrong
All-final immutable object (Java only)Final-field freeze at end of constructionNoneJAVA ONLY. C++ has no equivalent; do not assume it
Store into a synchronized container the reader reads fromThe container's internal synchronizationThe container's read costUniversal, if the container documents it
postMessage to a worker (JavaScript)Structured clone — no shared object existsA copyJS agents; sidesteps the problem rather than solving it
A plain store of the referenceNoneNoneBROKEN. This is the bug, not a mechanism
Mechanisms that safely publish, and the edge each one relies on.

The cheapest correct pattern

For read-mostly shared state — configuration, routing tables, feature flags, compiled rules — the pattern below is close to optimal and is worth knowing by shape. Build a new immutable object entirely off to the side, publish it with one release store, and let readers acquire-load the pointer once per request. Readers take no lock and touch a line that is read-shared between publications, which is the free case from What a Shared Write Costs.

Immutability is doing real work here beyond the edge. Because nothing mutates the object after publication, there is no second synchronization problem for readers holding it while a new version is published. The old version simply stays valid for whoever already has it, which is why this composes with Copy-on-Write as a Concurrency Strategy so naturally.

The lifetime question is the one thing this pattern does not solve for you in a language without a garbage collector: readers may hold the old table long after a new one is published, so freeing it requires knowing when the last reader is done. A reference-counted pointer handles it; in a GC language it is free. That is the same reclamation problem as A Lock-Free Stack, and What the Teaching Version Omits, in a much friendlier form.

Plain pointer publication. A null check that checks the wrong thing.
1Table* current = nullptr; // plain pointer
2
3void reload() {
4 Table* t = new Table();
5 t->version = 7;
6 t->timeoutMs = 3000;
7 t->routes = loadRoutes();
8 current = t; // PLAIN store: publishes the pointer,
9} // not the object
10
11void handle(Request& r) {
12 Table* t = current; // PLAIN load
13 if (!t) return; // checks the one thing that was never wrong
14 r.timeout = t->timeoutMs; // may read 0
15}
Release store, acquire load, immutable payload. No lock on the read path.
1std::atomic<const Table*> current{nullptr};
2
3void reload() {
4 Table* t = new Table(); // built entirely off to the side
5 t->version = 7;
6 t->timeoutMs = 3000;
7 t->routes = loadRoutes();
8 // RELEASE: everything written above is visible to any acquiring reader.
9 current.store(t, std::memory_order_release);
10 // Lifetime: the previous table may still be held by in-flight readers.
11 // Use shared_ptr, or a reclamation scheme, or leak deliberately.
12}
13
14void handle(Request& r) {
15 const Table* t = current.load(std::memory_order_acquire);
16 if (!t) return;
17 r.timeout = t->timeoutMs; // guaranteed 3000
18}

The only change is the ordering on the reference's store and load, and it covers every field of an arbitrarily large object. Note that the null check is identical in both versions and useless in the broken one — the pointer was always the part that arrived. Note also that the fix introduces a lifetime obligation the broken version did not have, which is the honest cost.

Key points

  • Publishing a reference is not publishing an object. A reader can hold a valid, non-null pointer and read unwritten fields.
  • A null check does not help — the reference is exactly the part that arrived. There is no defensive check on the reader side that fixes this.
  • One release store paired with one acquire load publishes every field written before it, however many there are, none of them atomic.
  • The safe mechanisms are a short list: initialise before starting the thread, release/acquire, a mutex both sides take, a concurrent container, or Java's all-final objects.
  • Java's final-field guarantee is real and is Java's alone. C++ has no equivalent, and assuming otherwise is a common porting bug.

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 writer constructs the object while it is unreachable from any other thread, so no synchronization is needed during construction.
  • The writer performs a publishing operation that creates a happens-before edge: a release store, an unlock, a queue send, a thread start.
  • The reader performs the matching operation — an acquire load, a lock, a queue receive — which is the other half of the edge.
  • By transitivity, every field written before the publish is ordered before every read after the observe, so the reader cannot see an unwritten field.
  • If the object is immutable after publication, no further synchronization is needed for its whole lifetime, which is what makes this pattern so cheap.
Interleavings that matter
  • A builds the table, plain-stores the pointer; B plain-loads a non-null pointer and reads timeoutMs = 0. Valid pointer, invisible fields.
  • A builds the table, release-stores the pointer; B acquire-loads and reads every field correctly. One edge, four hundred entries.
  • A release-stores; C plain-loads. No edge, because an edge is a pair: C may still read unwritten fields while B, which acquires, sees everything.
  • A builds the table before spawning B; B reads it. Correct with no atomics at all, because thread creation is an edge.
  • A publishes table v2 while B still holds v1 from an earlier load. Both are valid; B finishes its request against v1. This is correct behaviour and the reason immutability matters — v1 must not be mutated or freed underneath B.
  • A constructs an object that registers itself in a global registry from inside its own constructor: it is reachable before it is finished, and no publishing mechanism can help, because the object escaped before publication.
What it guarantees — and does not
  • Promises: with a paired publish and observe, everything written before publication is visible after observation.
  • Promises: the payload fields need no synchronization of their own — one edge covers all of them.
  • Promises: an immutable object, once safely published, needs no further synchronization for readers, ever.
  • Does NOT promise: anything if the object escapes during construction. Registering this in a shared structure from a constructor defeats every mechanism on the list.
  • Does NOT promise: anything about subsequent mutation. Safe publication publishes a snapshot; a mutable object needs synchronization for every later write too.
  • Does NOT promise: a lifetime answer. Readers may hold the old version indefinitely, and in a language without a collector, freeing it is your problem.
  • Does NOT promise: that a null check protects the reader. It checks the reference, which is the part that always arrives first.
Where contention appears
  • The read path is an acquire load of a line that is read-shared between publications — no ownership transfer, no lock, effectively free.
  • Each publication invalidates that line in every reader's cache, so a very high publication rate turns a read-mostly line into a write-shared one. See What a Shared Write Costs.
  • The mutex-based variant serialises every read, which is why it is the wrong choice for a hot read path even though it is the easiest to review.
  • Reference counting on the published pointer reintroduces a shared atomic on the read path, which can cost more than the read itself; this is why some systems deliberately leak old versions or reclaim them in a background epoch.
How it fails
  • Partially visible object — the flagship failure; a valid reference to fields that read as zero or empty.
  • One-sided publication — release on the writer, plain load on the reader, producing code that reads as synchronized and is not.
  • Escaped this — an object registered somewhere shared from inside its own constructor, reachable before it is finished. No publication mechanism can rescue this.
  • Assumed final-field semantics — porting Java's all-final guarantee to C++, where it does not exist.
  • Use-after-free on the old version — freeing the previous object while an in-flight reader still holds it.
  • Mutation after publication — treating a published object as still-editable, which needs a completely different synchronization argument.
When it helps
  • Read-mostly shared state: configuration, routing tables, feature flags, compiled rules, loaded models. The read path is a single acquire load.
  • Any handoff of a constructed object between threads, which is most of what worker pools and pipelines do.
  • It makes immutability pay off concretely — the reason immutable objects are easy to share is precisely that publication is the only synchronization they ever need.
  • It is a clean review question: for every shared object, which mechanism publishes it? A name or a bug, with no third answer.
When it hurts
  • When the object is small and mutable, where a mutex around the whole thing is simpler than publishing snapshots.
  • When publication is frequent enough that rebuilding the object dominates, at which point in-place mutation under a lock may be cheaper.
  • When lifetime management of superseded versions becomes more complex than the synchronization it replaced.
  • When applied to data that is never shared across threads, adding an atomic and implying a sharing that does not exist.
How you would know
  • A thread sanitizer reports the missing edge directly: the field writes and the field reads are conflicting accesses with no happens-before path. This is the tool that finds it.
  • Add a sentinel field written last before publication and asserted first after observation — a magic number or a non-zero version. If the sentinel reads wrong, publication is unsafe.
  • Test on 64-bit ARM. This bug is frequently invisible on x86-64 and immediate on weakly ordered hardware.
  • Republish under load rather than only at startup. Startup-only publication is edge-covered by thread creation and hides the problem entirely. See Stress Testing: A Test That Passed Once Proves Nothing.
  • Audit for escaped this: any constructor that registers itself, starts a thread, or passes itself to a callback is publishing an unfinished object regardless of what the caller does.
Complexity it introduces
  • The publication mechanism becomes part of the object's contract and must be documented, because readers cannot infer it from the type.
  • Superseded versions create a lifetime problem that did not exist when the object was mutated in place.
  • Immutability, which makes the pattern work, constrains the API — no setters, and every change rebuilds.
  • The correctness argument is invisible in the reader's code, which is a single load, so it needs a comment or it will be simplified away.
Simpler alternatives
  • Construct everything before starting the reader threads, so thread creation is the edge. Free, correct, and sufficient for a large amount of real code.
  • A mutex both sides take. Easiest to review, correct, and appropriate whenever the read path is not hot. See Mutexes: What They Protect and What They Do Not.
  • Hand the object through a concurrent queue or channel, where the edge cannot be half-applied. See Channels and Message Passing.
  • Copy the data to the consumer instead of sharing it — structured clone to a worker, a message to a process. See Copy or Share? and Web Workers.
  • A language-provided lazy initialiser: std::call_once, a function-local static, functools.cache, a module-level constant. See Double-Checked Locking: The Canonical Cautionary Tale.

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.

Immutability lab

Mutate in place, or replace the whole thing
One structure, one writer moving 10 between two fields, and readers arriving at the worst possible moment.
Strategy
Invariant · a + b == 100 — every reader sees a total of exactly 100, whatever else is happening
#WriterReaderState
1account.a -= 10·a=40 b=50 a+b=90
2·read account.a, account.ba=40 b=50 a+b=90
✕ the reader observed a total of 90 — a state no writer ever intended
3account.b += 10·a=40 b=60 a+b=100
4·read account.a, account.ba=40 b=60 a+b=100
the structure is now half-updated
torn reads possible
yes
live versions
1
peak memory
0 MB
allocation churn
none
Mutation in place has no atomic step: the structure is inconsistent between the two field writes, and any reader arriving in that window observes a total of 90. Nothing is corrupted and no field is half-written — every individual value is fine. The relationship between them is what broke, and that is exactly the class of bug tests do not catch, because the window is two instructions wide and your test suite is single-threaded.
The honest price: 2 MB at peak against 0 MB, because the new version, the old version and every snapshot a reader is still holding are all alive at once — and a fresh allocation on every write. Structural sharing (persistent data structures, copy-on-write pages) shrinks the copy to the changed path rather than the whole object, which is why immutability at scale is a data-structure decision and not a coding style. If your structure is large, written constantly and read rarely, mutation under a lock is the cheaper answer and you should take it.
1/4 · mutationILLUSTRATIVE

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.

What people believe, and what is true

Claim

The reference is not null, so the object is ready.

Reality

The reference is exactly the part that arrives first. Non-null tells you nothing about whether the fields are visible.

Claim

The constructor finished, so the object is fully built.

Reality

It is fully built in the writer's thread. Whether another thread can see all of it is a separate question answered only by a publishing edge.

Claim

Making every field atomic would fix it.

Reality

It would, expensively and clumsily, and it is the wrong shape. One ordered store of the reference publishes all the fields at once.

Go deeper

Overview

Handing another thread a pointer does not hand it the object. You need a synchronization construct on both sides, or the reader may see a valid pointer to blank fields.

Practical

Build the object privately, publish with one release store (or under a mutex, or through a queue), and have every reader use the matching half. Never publish with a plain store, and never trust a null check to protect you.

Advanced

Immutable object plus atomic pointer swap is the standard read-mostly pattern: lock-free reads, one release store per publication, and the only remaining problem is when to free the superseded version.

Internals

Java's final-field semantics insert a freeze at the end of the constructor, which is why an all-final object is safely published even through a race. C++ has no freeze, so the ordering must be supplied explicitly at the publication point — the same guarantee bought at a different layer.

Apply it