Memory Models & Visibility

Double-Checked Locking: The Canonical Cautionary Tale

Check without the lock, lock, check again, initialise. It looks like a pure optimization and it is the standard example of why memory-model reasoning matters — the fast path can hand out a reference to an object it cannot see. The fix exists in every language and is different in each.

▶ Run the lab

The question this answers

The question

Why is the obvious "check, lock, check again" lazy initialization broken, and why is the fix language-specific?

The work

Creating one connection pool the first time any request thread needs it, and never again.

What is shared

The instance reference and every field of the pool it points at.

The invariant — what must stay true under every interleaving

Exactly one pool is ever constructed, and every thread that receives the reference sees it fully constructed.

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 version everybody writes

The reasoning is seductive and almost right. Taking a lock on every access to something initialised once is wasteful, so check first without the lock; if it is already there, return it. Only if it is missing do you take the lock, check again in case someone beat you, and construct. The second check is genuinely necessary and genuinely correct. The first one is the problem.

The first check reads instance with no synchronization. If it observes a non-null value, it returns that reference — and, as Safe Publication: Handing Over a Finished Object establishes, a non-null reference is not evidence that the object's fields are visible. The fast path can hand the caller a pointer to a pool whose connection list is empty and whose maximum size is zero.

In C++ specifically it is worse than a wrong value: the unsynchronized read races with the write performed under the lock, which is a data race, which is undefined behaviour. The compiler is entitled to optimise on the assumption that it does not occur, and the resulting code can behave in ways that are not explicable as any interleaving at all.

1// BROKEN. Do not ship this. Shown because everybody writes it once.
2Pool* instance = nullptr; // plain pointer
3std::mutex m;
4
5Pool* getPool() {
6 if (instance == nullptr) { // (1) UNSYNCHRONIZED read.
7 // Races with the store at (3).
8 // In C++ this is UB, not just a
9 // possibly-stale value.
10 std::lock_guard<std::mutex> g(m);
11 if (instance == nullptr) { // (2) correct and necessary:
12 // another thread may have won
13 instance = new Pool(32); // (3) construct, then store.
14 // The store may become visible
15 // BEFORE the constructor's writes.
16 }
17 }
18 return instance; // (4) fast path returns a reference
19} // whose object may be invisible
The classic broken form. Every line looks reasonable; the first check is the bug.

The schedule that breaks it

The trace below is the one to hold in your head. T1 takes the lock and constructs correctly. T2 never takes the lock at all — it takes the fast path, sees a non-null pointer, and returns it. There is no moment at which T2 could have checked anything that would have saved it.

Notice that the mutex is doing its job perfectly. Exactly one pool is constructed; the second check prevents the double-initialization that Initialization Races is about. The failure is entirely on the visibility axis, which is why adding more locking to the slow path does not help and why the bug survived years of confident code review in several languages.

The second failure mode in the same code is simpler and worth naming: without the *second* check, two threads that both pass the first check both construct a pool, one of which is silently discarded along with whatever it had already allocated — an orphaned connection pool holding thirty-two sockets nobody will ever close.

T1 initialises under the lock. T2 takes the fast path and never locks at all.SIMULATED
Invariant · Exactly one pool exists, and every thread receiving it sees it fully constructed
#Thread 1 (initialises)Thread 2 (fast path)State
1check (1): instance == nullptr -> true·instance=null
2acquire mutex; check (2): still nullptr -> true·instance=null mutex=held by T1
3allocate Pool; write maxSize = 32; write conns = [32 sockets]·instance=null T1: pool built=yes
4store instance = &pool (plain store, under the lock)·instance=&pool
5·check (1): instance != nullptr -> takes the FAST PATH, never locksinstance=&pool T2.p=&pool
✕ T2 acquired the reference without ever participating in the mutex, so no happens-before edge relates T1's field writes to T2's reads.
6·return p; caller reads p->maxSize -> 0T2.maxSize=0
✕ A valid pointer to a pool with a maximum size of zero. Every checkout fails, and the stack trace points at the caller.
7·[no second check variant] both threads pass (1), both constructpools created=2
✕ Two pools, sixty-four sockets, one of them orphaned and never closed. This is the failure the second check exists to prevent.
The mutex was correct and did exactly what a mutex does. The bug is that the fast path opts out of it, and opting out of a lock means opting out of the visibility edge it provides — not just the waiting.

The fix, per language

There is no single portable fix, and that is the real lesson. Every language solved this, and each solved it differently, so knowing "double-checked locking is broken" is only half of what you need — the other half is which construct your language provides instead.

C++ gives you the best answer available anywhere: a function-local static is guaranteed by the standard (since C++11) to be initialised exactly once, thread-safely, with the visibility edge included. It is one line, it has no fast-path bug, and it is what you should write. std::call_once covers the cases where a plain static does not fit. Java fixed double-checked locking properly in Java 5 by strengthening volatile: a volatile field makes the pattern correct, and the idiomatic answer is usually a static holder class instead.

JavaScript sidesteps it: an ES module's top-level code is evaluated at most once per realm and the agent is single-threaded, so a module-level constant is a correct singleton with no locking of any kind. CPython (3.12) has two answers: module-level initialisation runs once under the import machinery, and threading.Lock covers the rest. Note the trap in functools.lru_cache and functools.cache — the cache itself is thread-safe, but on a concurrent miss the wrapped function may be invoked more than once for the same key, so it is a memoiser and not an exactly-once guarantee.

Initialise exactly once, safely, in each language's own idiom. — Lazily create one shared resource on first use, exactly once, visible correctly to every thread.
C++LANGUAGE-SPECIFIC
1// Magic statics: guaranteed thread-safe one-time init since C++11.
2Pool& getPool() {
3 static Pool instance(32); // exactly once, with the edge included
4 return instance;
5}
6
7// When a plain static does not fit:
8std::once_flag flag;
9std::unique_ptr<Pool> p;
10Pool& getPool2() {
11 std::call_once(flag, []{ p = std::make_unique<Pool>(32); });
12 return *p;
13}

The standard requires concurrent callers to block until the initialisation completes, and the resulting object is safely published. There is no fast-path check to get wrong.

JavaScriptRUNTIME-SPECIFIC
1// An ES module's top level is evaluated at most once per realm,
2// and an agent is single-threaded. No lock, no check, no race.
3export const pool = createPool(32)
4
5// If construction must be deferred, memoise the PROMISE, not the value:
6let poolPromise = null
7export function getPool() {
8 if (!poolPromise) poolPromise = createPoolAsync(32)
9 return poolPromise // safe: one agent, no preemption between
10} // the check and the assignment

There is no thread to race with inside one agent, so the check-then-act is atomic with respect to other JS code. Memoising the promise rather than the value is what prevents two concurrent callers starting two async initialisations.

TypeScriptBROWSER
1// Same runtime, same guarantee. Types add nothing here.
2let poolPromise: Promise<Pool> | null = null
3
4export function getPool(): Promise<Pool> {
5 poolPromise ??= createPoolAsync(32)
6 return poolPromise
7}
8// Across web workers this does NOT give one shared pool:
9// each worker is a separate agent with its own module instance.

The important caveat is scope: module-level state is per agent. Two workers each get their own pool, which is usually what you want and is occasionally a nasty surprise.

PythonCPYTHON
1import threading, functools
2
3# Simplest: module-level init runs once under the import machinery.
4pool = create_pool(32)
5
6# Deferred, with an explicit lock:
7_pool = None
8_lock = threading.Lock()
9
10def get_pool():
11 global _pool
12 if _pool is None: # fast path: safe HERE only because
13 with _lock: # CPython name binding is one bytecode
14 if _pool is None:
15 _pool = create_pool(32)
16 return _pool
17
18# TRAP: functools.cache is thread-safe as a CACHE, but on a concurrent
19# miss the wrapped function may run MORE THAN ONCE for the same key.
20# It is a memoiser, not an exactly-once guarantee.

The fast-path check is defensible in CPython 3.12 because a name binding is a single bytecode and no thread runs bytecode concurrently. That is an implementation property, not a language guarantee, and the free-threaded build of 3.13 changes the reasoning.

What actually differs
  • C++ has the strongest built-in answer: function-local statics are specified to be thread-safe and safely published, so the pattern should simply never be hand-written.
  • Java made double-checked locking correct in Java 5 by giving volatile real memory-model semantics — but the idiomatic answer is a static holder class, which needs no volatile at all.
  • JavaScript has no threads within an agent, so the whole problem is structural rather than a race; the real hazard is two concurrent callers starting two async initialisations, fixed by memoising the promise.
  • CPython 3.12's single-bytecode name binding is why the naive fast path happens to work there. It is an implementation detail, it is not portable, and the free-threaded build invalidates it.
  • The portable conclusion: never hand-write double-checked locking. Every one of these languages ships a construct that is correct by construction.

Key points

  • The second check is correct and necessary. The first, unsynchronized check is the bug: it can return a reference to an object whose fields are not yet visible.
  • A thread taking the fast path never acquires the mutex, so it never gets the visibility edge the mutex would have provided.
  • In C++ the unsynchronized read is a data race and therefore undefined behaviour, not merely a possibly-stale value.
  • Without the second check the failure is different and simpler: two objects constructed, one silently orphaned along with its resources.
  • Every language ships a correct construct — C++ function-local statics and call_once, Java's volatile or a holder class, an ES module constant, CPython's import-time init or a Lock. Use those; never hand-write the pattern.

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 fast path reads the reference without synchronization, so it may observe the store performed under the lock without observing anything that preceded it.
  • The slow path takes the lock, re-checks so that only one thread constructs, constructs, and stores the reference.
  • The mutex provides mutual exclusion for construction and a happens-before edge — but only between threads that both take it.
  • A thread that returns from the fast path has taken no part in that edge, so the object's fields are unordered with respect to its reads.
  • The correct constructs work by ensuring every caller — including the ones that find it already initialised — participates in the edge, at a cost their implementations make close to zero.
Interleavings that matter
  • T1 locks, constructs, stores; T2 fast-path reads non-null and returns it; T2's caller reads maxSize = 0. Exactly one pool, correctly excluded, invisibly published.
  • No second check: T1 and T2 both pass the first check, both take the lock in turn, both construct. Two pools, one orphaned with thirty-two open sockets.
  • With a C++ function-local static: T2 either blocks until T1's initialisation completes or observes it fully. No schedule breaks the invariant.
  • With Java volatile on the field: the fast-path read is a volatile read, which is an acquire, so it participates in the edge. The pattern becomes correct.
  • CPython 3.12: T1 and T2 both evaluate _pool is None; only one holds the lock and binds the name; the other sees the completed binding because binding is one bytecode and no thread runs bytecode concurrently.
  • JavaScript: no interleaving exists within an agent. Two concurrent callers of an async initialiser is the real risk, and memoising the promise removes it.
What it guarantees — and does not
  • Promises (with a correct construct): exactly one initialisation, and every caller sees a fully constructed object.
  • Promises (mutex alone): mutual exclusion among threads that take it, and an edge between them.
  • Does NOT promise (hand-written DCL): that the fast path sees a constructed object. This is the whole failure.
  • Does NOT promise: that a working build proves anything. This bug hides on x86-64 and appears on ARM, and it hides at -O0 and appears at -O2.
  • Does NOT promise: that Java's fix transfers. volatile in Java carries memory-model semantics; volatile in C++ does not. See Reordering: The Compiler and the CPU Both Do It.
  • Does NOT promise: exactly-once semantics from a memoisation decorator. functools.cache may invoke the wrapped function more than once on a concurrent miss.
Where contention appears
  • The pattern exists to avoid lock acquisition on every access — an optimization that is worth much less than it appears, because an uncontended mutex is typically a single atomic operation.
  • The correct constructs are cheap on the already-initialised path: a C++ function-local static compiles to a guard-variable check that is a load and a predictable branch after the first call.
  • Where the initialised object is then read by many threads, the remaining cost is coherence traffic on the reference's line, which is read-shared and effectively free. See What a Shared Write Costs.
  • During initialisation itself, concurrent callers block until it completes, so a slow constructor is a startup latency spike affecting every caller at once — which is a Thundering Herd in miniature.
How it fails
  • Partially visible object handed out by the fast path — the flagship failure. See Safe Publication: Handing Over a Finished Object.
  • Double initialization when the second check is omitted, orphaning resources. See Initialization Races.
  • Undefined behaviour in C++ from the unsynchronized read racing with the write.
  • Silent success on x86-64 at -O0, failure on ARM at -O2, so it escapes development entirely.
  • Exception during initialisation leaving the flag set or the reference half-assigned, so every subsequent caller gets a broken object. The correct constructs specify this: call_once does not consider the flag satisfied if the callable throws.
  • A memoisation decorator assumed to give exactly-once semantics when it gives at-least-once on concurrent misses.
When it helps
  • Lazy initialisation is genuinely useful when the object is expensive and may not be needed — a connection pool in a process that sometimes never touches the database.
  • It defers startup cost, which matters for CLI tools, serverless cold starts and test suites.
  • Recognising the pattern in review is high-yield: a hand-written double-check is nearly always replaceable with a one-line language construct.
When it hurts
  • When eager initialisation would have been fine. Constructing at startup removes the entire problem and is usually the right answer.
  • When the object is cheap to construct, where lazy initialisation adds a branch and a hazard to save nothing.
  • When the constructor can fail, since failure during a shared lazy initialisation is a much harder story than failure at startup.
  • When it is hand-written at all, in any language on this list, given that each ships something correct.
How you would know
  • A thread sanitizer flags the unsynchronized fast-path read against the locked write directly. This is the tool that catches it.
  • Count constructions. An initialisation counter that exceeds one is the missing-second-check failure and is trivial to detect.
  • Assert a sentinel field on the returned object at every call site during testing — a version number or a magic value written last in the constructor.
  • Exercise the race deliberately: start many threads that all call the getter simultaneously at process start, which is the only moment the window exists. See Stress Testing: A Test That Passed Once Proves Nothing.
  • Test on ARM at production optimization levels. This bug is close to invisible on x86-64 debug builds.
Complexity it introduces
  • A hand-written version adds a subtle memory-model argument to what looks like a five-line optimization, and the argument is different in every language.
  • Lazy initialisation makes failure timing unpredictable: the constructor now runs on whichever request happens to be first, so its latency and its errors land on a user rather than on startup.
  • Every caller must go through the accessor, and one direct read of the underlying field reintroduces the bug.
  • The correct constructs remove nearly all of this, which is the strongest argument for using them.
Simpler alternatives
  • Eager initialisation at startup. No laziness, no race, no memory-model argument, and failures happen at deploy time rather than in a request. The right default.
  • The language's one-time-init construct: a C++ function-local static or std::call_once, Java's static holder idiom, an ES module constant, CPython's module-level initialisation.
  • Dependency injection — construct once at composition time and pass it in, which removes global lazy state entirely.
  • A mutex on every access. If the accessor is not hot, this is correct and needs no reasoning at all. See Mutexes: What They Protect and What They Do Not.
  • For async initialisation, memoise the future or promise rather than the value, so concurrent callers share one in-flight initialisation. See Single-Flight Coalescing.

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.

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.

Single-flight: N callers, one call

Single-flight — N callers want the same key
64 requests for the same cache key arrive while it is being recomputed. Coalescing lets the first one do the work and parks the rest on its result.
Callers
1 leader issues the call · 63 followers park on its promise
Downstream
1 call against a service sized for 20 concurrent
downstream calls
1
work saved
63 calls (98.4%)
caller latency
60 ms
callers that see an error
0
without    64 callers →  64 downstream calls   latency 192 ms  (queued behind each other)
with       64 callers →   1 downstream call    latency 60 ms  (everyone waits for the leader)

failure   one attempt, 64 disappointed callers — the blast radius of a single bad call is now N
retry     the followers cannot retry independently; they only ever saw the leader's outcome
64 callers, 1 downstream call, 98.4% of the load gone. The mechanism is a map from key to in-flight promise: the first caller creates the entry and does the work, everyone else finds it and awaits it, and the entry is removed when it settles. What you buy is load reduction; what you pay is coupling — every caller now has the latency and the fate of the leader, and a slow leader makes all 64 slow. Flip the failure toggle to see the sharp edge.
attempts against the origin this window: 1
SIMULATED

What people believe, and what is true

Claim

The lock makes it safe; the outer check is just an optimization.

Reality

The outer check opts out of the lock, and opting out of a lock means opting out of its visibility edge, not just its waiting. That is the bug.

Claim

Java fixed it, so it is fine everywhere now.

Reality

Java fixed it in Java 5 by strengthening volatile. C++ volatile carries no such semantics, and each language's fix is its own.

Claim

It has been in production for two years without incident, so it works.

Reality

The window exists only during the first initialisation, on weakly ordered hardware, at production optimization levels. Two uneventful years is very little evidence.

Go deeper

Overview

Check without the lock, lock, check again, create. The first check can return a reference to an object the caller cannot fully see, which is why the pattern is broken.

Practical

Do not write it. Use your language's one-time initialisation construct, or initialise eagerly at startup. Both are shorter and neither has a memory-model argument attached.

Advanced

If you must reason about it: the fix is to make the fast-path read an acquire operation, so every caller participates in the edge. Java's volatile does exactly that, and it is why the pattern became correct there in Java 5.

Internals

A C++ function-local static compiles to a guard-variable check with acquire semantics plus a slow path that takes a lock, constructs, and release-stores the guard — precisely the correct version of double-checked locking, generated by the compiler and specified by the standard so you cannot get it wrong.

Apply it