The question this answers
Why is the obvious "check, lock, check again" lazy initialization broken, and why is the fix language-specific?
Creating one connection pool the first time any request thread needs it, and never again.
The instance reference and every field of the pool it points at.
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.
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 pointer3std::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 a9 // possibly-stale value.10 std::lock_guard<std::mutex> g(m);11 if (instance == nullptr) { // (2) correct and necessary:12 // another thread may have won13 instance = new Pool(32); // (3) construct, then store.14 // The store may become visible15 // BEFORE the constructor's writes.16 }17 }18 return instance; // (4) fast path returns a reference19} // whose object may be invisibleThe 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.
| # | Thread 1 (initialises) | Thread 2 (fast path) | State |
|---|---|---|---|
| 1 | check (1): instance == nullptr -> true | · | instance=null |
| 2 | acquire mutex; check (2): still nullptr -> true | · | instance=null mutex=held by T1 |
| 3 | allocate Pool; write maxSize = 32; write conns = [32 sockets] | · | instance=null T1: pool built=yes |
| 4 | store instance = &pool (plain store, under the lock) | · | instance=&pool |
| 5 | · | check (1): instance != nullptr -> takes the FAST PATH, never locks | instance=&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 -> 0 | T2.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 construct | pools 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 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.
1// Magic statics: guaranteed thread-safe one-time init since C++11.2Pool& getPool() {3 static Pool instance(32); // exactly once, with the edge included4 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.
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 = null7export function getPool() {8 if (!poolPromise) poolPromise = createPoolAsync(32)9 return poolPromise // safe: one agent, no preemption between10} // the check and the assignmentThere 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.
1// Same runtime, same guarantee. Types add nothing here.2let poolPromise: Promise<Pool> | null = null3 4export function getPool(): Promise<Pool> {5 poolPromise ??= createPoolAsync(32)6 return poolPromise7}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.
1import threading, functools2 3# Simplest: module-level init runs once under the import machinery.4pool = create_pool(32)5 6# Deferred, with an explicit lock:7_pool = None8_lock = threading.Lock()9 10def get_pool():11 global _pool12 if _pool is None: # fast path: safe HERE only because13 with _lock: # CPython name binding is one bytecode14 if _pool is None:15 _pool = create_pool(32)16 return _pool17 18# TRAP: functools.cache is thread-safe as a CACHE, but on a concurrent19# 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.
- 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
volatilereal 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.
- • 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.
- • 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
volatileon 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.
- • 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.
volatilein Java carries memory-model semantics;volatilein C++ does not. See Reordering: The Compiler and the CPU Both Do It. - • Does NOT promise: exactly-once semantics from a memoisation decorator.
functools.cachemay invoke the wrapped function more than once on a concurrent miss.
- • 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.
- • 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_oncedoes 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.
- • 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 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.
- • 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.
- • 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.
- • 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?
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 |
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 |
Single-flight: N callers, one call
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
What people believe, and what is true
The lock makes it safe; the outer check is just an optimization.
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.
Java fixed it, so it is fine everywhere now.
Java fixed it in Java 5 by strengthening volatile. C++ volatile carries no such semantics, and each language's fix is its own.
It has been in production for two years without incident, so it works.
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.