Immutability & Concurrency Control

Initialization Races

"If the singleton is missing, create it" is a check-then-act, and two threads running it produce two singletons — or, far worse, one thread handing out a reference to an object the other thread has not finished building. Once-initialization primitives exist because this is genuinely hard to get right by hand.

▶ Run the lab

The question this answers

The question

What happens when two threads both discover that the thing they need does not exist yet?

The work

The first two requests after a deploy, both calling getConnectionPool() on a pool that is created lazily on first use.

What is shared

The instance slot holding the pool reference, and the pool object itself once it exists. Both are read by every request and written exactly once — in theory.

The invariant — what must stay true under every interleaving

Exactly one pool is ever constructed, and every reference handed to a caller points at a pool that is fully constructed — never one whose fields are still being assigned.

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?

Two threads, one missing object

The lazy singleton is a check-then-act, and it is the most-written check-then-act in software: read instance, find it null, construct, assign. Between the read and the assignment there is a window, and under concurrent first use two threads land inside it. Both see null, both construct, both assign — and only one assignment survives.

Two objects is the *benign* outcome, and even that is rarely benign. Two connection pools means twice the configured connections against a database sized for one pool. Two metrics registries means half the counters are never scraped. Two caches means a 50% hit-rate cliff nobody can explain. And because the loser is garbage collected, the evidence disappears.

The dangerous outcome is worse and less obvious: publishing the reference before the object is finished. A compiler or CPU may make the write to instance visible before the writes to the object's fields are visible, so another thread reads a non-null reference and dereferences a half-built object. This is the reason Double-Checked Locking: The Canonical Cautionary Tale is a cautionary tale rather than an optimization, and the reason Safe Publication: Handing Over a Finished Object is a separate concept from mutual exclusion.

Lazy singleton under concurrent first use. Two failures, one after the other.SIMULATED
Invariant · Exactly one pool exists, and every reference handed out points at a fully constructed pool.
#Request thread 1Request thread 2State
1read instance -> null·instance=null
2·read instance -> nullinstance=null
3new Pool(max=20) — opens 20 sockets·instance=null sockets=20
4·new Pool(max=20) — opens 20 more socketsinstance=null sockets=40
✕ Two pools exist. The database sees 40 connections against a configured maximum of 20 and starts refusing.
5instance = poolA·instance=poolA sockets=40
6·instance = poolBinstance=poolB sockets=40
7--- second failure: publication, with a lock and a fast-path read ---·instance=null
8lock; construct Pool: allocate object, then assign fields·instance=null obj=allocated, fields pending
9instance = obj (store becomes visible before the field stores)·instance=poolC obj=fields pending
10·fast path: read instance -> non-null, skip the lock, use itinstance=poolC
✕ T2 holds a reference to a pool whose maxConnections is still 0 and whose socket list is null. One pool, still broken.
The first failure is a race condition on a check-then-act and is fixed by mutual exclusion. The second is a visibility failure and is not — a lock on the slow path does nothing for a fast path that reads the reference without one. This is why the answer is a once-primitive rather than a hand-rolled guard.

The primitives that already solve it

Every mainstream platform ships something for this, and every one of them handles both halves — mutual exclusion during construction and correct publication afterwards. Use them. Hand-rolled double-checked locking is the single most reliably wrong pattern in this domain, and it has been rewritten wrongly in production code for thirty years.

The mechanics differ meaningfully. C++ gives you std::call_once and, since C++11, the guarantee that a function-local static is initialized exactly once with correct publication — which makes the Meyers singleton the right default there. Java has the class initializer and the holder idiom. Python has functools.lru_cache and module import, both of which are serialized by an import lock. JavaScript has module evaluation, which happens once per module per realm and is not concurrent at all on one event loop.

What none of them fix is *what the initializer does*. If construction can fail, a once-primitive may cache the failure or leave the state uninitialized for a retry, and those are very different behaviours to depend on. If construction blocks — opening sockets, reading a file — every other thread waits on it, so a lazy singleton over a slow dependency turns first-request latency into a stampede. And if construction of A calls something that initializes B which initializes A, the once-primitive deadlocks on itself, which is a genuinely confusing stack trace.

Initialize exactly once, and publish safely, in four runtimes. — Create one connection pool on first use, visible correctly to every subsequent caller
C++LANGUAGE-SPECIFIC
1// Function-local static: initialized exactly once, with a
2// happens-before edge to every later read. Guaranteed since C++11.
3Pool& pool() {
4 static Pool instance{20}; // thread-safe initialization
5 return instance;
6}
7
8// Explicit form when the initializer needs arguments:
9std::once_flag flag;
10std::unique_ptr<Pool> p;
11void init() { std::call_once(flag, []{ p = std::make_unique<Pool>(20); }); }

The compiler emits a guard variable and the required barriers. Hand-written double-checked locking without atomics is undefined behaviour here, not merely risky. If the initializer throws, the static is not considered initialized and the next call retries.

JavaScriptNODE.JS
1// Module evaluation runs once per module per realm.
2export const pool = new Pool(20)
3
4// Lazy + async: memoize the promise, not the value, so concurrent
5// callers await the same in-flight construction instead of racing.
6let poolPromise
7export function getPool() {
8 poolPromise ??= createPool(20).catch((e) => { poolPromise = undefined; throw e })
9 return poolPromise
10}

One event loop means no preemption between the read and the assignment, so the two-object race cannot happen synchronously. It absolutely can happen across an await — which is why the memoized value must be the promise, and why the catch clears it rather than caching a failure forever.

TypeScriptNODE.JS
1let instance: Pool | undefined
2let building: Promise<Pool> | undefined
3
4export function getPool(): Promise<Pool> {
5 if (instance) return Promise.resolve(instance)
6 building ??= createPool(20).then((p) => { instance = p; building = undefined; return p })
7 return building
8}

Same runtime semantics as JavaScript; the types only document the three states (absent, in flight, ready). The in-flight state is the one hand-rolled versions forget, and it is exactly where the duplicate construction happens.

PythonCPYTHON
1import functools, threading
2
3@functools.lru_cache(maxsize=1) # not a lock: two threads can both run it
4def pool() -> Pool:
5 return Pool(20)
6
7_lock = threading.Lock()
8_instance: Pool | None = None
9def pool_locked() -> Pool:
10 global _instance
11 if _instance is None: # fast path is safe here for a different reason
12 with _lock:
13 if _instance is None:
14 _instance = Pool(20)
15 return _instance

lru_cache is not a synchronization primitive — under threads two callers can both execute the function and one result is discarded. The locked form is correct in CPython because reference assignment happens at bytecode granularity and the interpreter provides the ordering; the identical code in C++ or Java without atomics is not.

What actually differs
  • C++ needs an explicit publication guarantee; the language provides one for function-local statics and call_once, and nothing for a hand-rolled null check.
  • JavaScript and TypeScript cannot race synchronously on one event loop but race freely across await — so the thing to memoize is the in-flight promise.
  • CPython's reference assignment gives publication for free, which makes double-checked locking accidentally correct there and a dangerous habit to carry to another language.
  • None of the four decides what happens when the initializer fails; that is your policy, and caching a failed singleton forever is a common production bug.

Do not initialize lazily unless laziness is the requirement

The whole problem is optional. Constructing the pool at startup, before any request thread exists, removes every schedule in this lesson — there is no concurrency during initialization because there is nothing else running yet. Eager initialization also moves failure to a place where it is useful: a bad database URL crashes the process at boot instead of failing the first request twenty minutes after the deploy looked green.

Lazy initialization earns its keep when construction is expensive and often unnecessary — an optional client for a feature most deployments do not enable — or when the configuration it needs genuinely is not available at startup. Those are real cases. "It felt tidier" is not, and it is the reason most of these races exist.

Where laziness is required, prefer a once-primitive with a documented failure policy, keep the initializer free of anything that could re-enter the same initializer, and measure the first-call latency: everyone who arrives during construction is waiting, so a slow lazy init under a cold start is a thundering herd on the first request after every deploy. See Thundering Herd and Single-Flight Coalescing for the coalescing pattern that generalizes this.

1type State =
2 | { tag: 'absent' }
3 | { tag: 'building'; promise: Promise<Pool> }
4 | { tag: 'ready'; pool: Pool }
5
6let state: State = { tag: 'absent' }
7
8export function getPool(): Promise<Pool> {
9 if (state.tag === 'ready') return Promise.resolve(state.pool)
10 if (state.tag === 'building') return state.promise // coalesce: one construction
11
12 const promise = createPool(20).then(
13 (pool) => { state = { tag: 'ready', pool }; return pool },
14 (err) => { state = { tag: 'absent' }; throw err }, // policy: failures are retryable
15 )
16 state = { tag: 'building', promise }
17 return promise
18}
The three-state initializer, with a failure policy stated rather than implied.

Key points

  • "If missing, create it" is a check-then-act. Two threads inside the window construct two objects and one assignment survives.
  • Duplicate construction is rarely harmless: doubled connection pools, split caches and orphaned resources all present as something other than an initialization bug.
  • The worse failure is publishing the reference before the object is fully built — a visibility problem that a lock on the construction path does not solve.
  • Use the platform's once-primitive. Hand-rolled double-checked locking is the most reliably wrong pattern in this domain.
  • Eager initialization at startup removes every one of these schedules and moves failure to boot, where it is useful. Be lazy only when laziness is the requirement.

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
  • A caller checks whether the instance exists. This read is the first half of a check-then-act and is not atomic with what follows.
  • If absent, the caller constructs the object — allocating it and then assigning its fields, in that order.
  • The caller stores the reference into the shared slot, making the object reachable by everyone.
  • A once-primitive wraps steps two and three: it serializes construction so exactly one runs, and it inserts the ordering that makes the constructed fields visible before the reference is.
  • Later callers take a fast path that reads the slot and returns — correct only if that read is ordered against the constructing thread's writes.
Interleavings that matter
  • Double construction: T1 reads instance (null); T2 reads instance (null); T1 constructs and assigns; T2 constructs and assigns — two pools built, 40 sockets open, one pool unreachable.
  • Benign-looking loss: T1 assigns poolA, T2 assigns poolB, T1 continues using its local reference to poolA while every later caller gets poolB — two live pools with different callers, indefinitely.
  • Unsafe publication: T1 allocates the object, the reference store becomes visible before the field stores, T2 reads a non-null instance and dereferences a pool with a null socket list.
  • Correct with once: T1 enters the once-region and constructs; T2 calls in and blocks inside the primitive; T1 finishes and publishes; T2 returns the same fully constructed instance.
  • Failure caching: T1's initializer throws because the database is briefly unreachable; the once-primitive records "already run"; every subsequent call returns the broken or absent instance until the process restarts.
  • Re-entrant deadlock: the initializer for A calls code that initializes B, whose initializer calls getA(); the once-primitive sees the flag as in-progress on the same thread and either deadlocks or returns a half-built A.
  • Async duplication: two callers both await createPool() because the code memoized the resolved value instead of the in-flight promise — no threads involved, two pools anyway.
What it guarantees — and does not
  • A once-primitive guarantees the initializer body runs at most once across all threads, and that its writes are visible to every caller that returns from it.
  • It guarantees callers arriving during construction block until it completes rather than proceeding with a partial object.
  • It does NOT guarantee the initializer is fast, non-blocking, or free of I/O — every waiter pays its full duration.
  • It does NOT define a failure policy. Whether a throwing initializer is retried or permanently recorded as run is platform-specific and load-bearing.
  • It does NOT detect re-entrancy in general. An initializer that transitively calls itself may deadlock rather than produce a helpful error.
  • A plain null check plus a lock does NOT guarantee safe publication for a fast path that reads without the lock — the lock orders the writers, not the unsynchronized reader.
  • Memoizing the resolved value in async code does NOT prevent duplicate construction; only memoizing the in-flight promise does.
Where contention appears
  • Contention exists only during the very first calls, but every caller that arrives in that window waits for the full construction time.
  • A slow initializer converts a cold start into a stampede: the first N requests after a deploy all block on one socket-opening operation.
  • After initialization the cost should be one read of an already-cached line — if your fast path takes a lock every call, you have made every request pay for a race that lasted 40 microseconds once.
  • Nested initializers serialize their dependency chain, so a deep graph of lazy singletons has a startup critical path nobody planned.
How it fails
  • Duplicate construction, leaking whatever the loser allocated — sockets, threads, file handles, background timers that keep running.
  • Unsafe publication of a partially constructed object, producing null fields and impossible states far from the initialization code.
  • Cached failure: a transient error during initialization permanently recorded, requiring a restart to clear.
  • Deadlock or half-initialized return from a re-entrant initializer.
  • Thundering herd on first use after every deploy, when initialization is slow and traffic is immediate.
  • Resource-limit breach downstream — the database refusing connections because two pools each opened their configured maximum.
  • Async duplicate construction with no threads present at all, from memoizing the value rather than the promise.
When it helps
  • Lazy initialization helps when construction is genuinely expensive and frequently unnecessary — an optional integration most tenants never use.
  • It helps when the configuration needed to construct the object does not exist at process start.
  • A once-primitive helps whenever laziness is required, because it handles exclusion and publication together and you will not.
  • Memoizing the in-flight promise helps in async code generally, not just for singletons — it is Single-Flight Coalescing applied to construction.
When it hurts
  • Lazy initialization hurts when it moves a startup failure into the request path, where it fails a user instead of a deploy.
  • It hurts when construction is slow and traffic is immediate, turning every cold start into a stampede.
  • It hurts in a deep dependency graph, where lazy initializers call other lazy initializers and the ordering becomes emergent and re-entrant.
  • Double-checked locking hurts everywhere it is written by hand, because the version that is correct in one language is undefined behaviour in another.
How you would know
  • Count constructions. A counter incremented in the initializer that ever exceeds one is the entire bug, proven, in one number.
  • Downstream resource counts against configuration: connections observed at the database versus the pool maximum you set.
  • First-call latency and the number of callers that blocked on it, per process lifetime — the stampede signal.
  • Initializer failure count and whether subsequent calls recovered, which tells you what your failure policy actually is rather than what you assumed.
  • Stack depth or a re-entrancy assertion inside the initializer, which turns a mysterious deadlock into an immediate, readable error.
Complexity it introduces
  • A correct lazy initializer has three states, not two: absent, in flight, and ready. Most bugs come from modelling only two.
  • The failure policy must be chosen explicitly and documented, because both retry-forever and cache-the-failure are defensible and produce opposite incidents.
  • Re-entrancy has to be reasoned about across the whole dependency graph, and nothing in the type system flags it.
  • The fast path's memory ordering is load-bearing and invisible; getting it wrong produces a bug that never reproduces on the developer's architecture.
  • Eager initialization has none of this complexity, which is the strongest argument available for it.
Simpler alternatives
  • Construct at startup, before any concurrency exists. No schedule in this lesson can occur, and failure moves to boot where it belongs.
  • Dependency injection: build the object once in a composition root and pass it in, which removes both the global and the race.
  • Module-level initialization, where the runtime already guarantees once-per-module semantics.
  • Single-flight coalescing on the in-flight promise, when initialization is asynchronous. See Single-Flight Coalescing.
  • A supervised background initializer that publishes when ready and lets callers fail fast until then, when blocking the first requests is unacceptable.

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

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

Double-checked locking is fine now that everyone has a modern CPU.

Reality

It is correct only with the right language-level ordering — an atomic or volatile with acquire/release semantics. Without it, it is undefined behaviour in C++ and broken on weakly ordered hardware, and the version that works on x86-64 will fail on ARM.

Claim

A single-threaded runtime cannot have an initialization race.

Reality

It cannot have one *synchronously*. Two callers can both pass the null check before either awaits construction, producing two objects with no threads anywhere. Memoize the promise, not the value.

Claim

Duplicate construction is harmless because one copy gets garbage collected.

Reality

The loser holds sockets, threads and timers that the collector does not urgently reclaim and may never run finalizers for. What you observe is a connection limit at the database, not a memory issue.

Go deeper

Overview

Two threads both find the object missing and both create it. Use the language's once-primitive, or better, create the object at startup so there is no concurrency to race with.

Practical

Model three states: absent, in flight, ready. Decide what a failed initializer does. Do not put I/O in an initializer that request threads block on unless you have measured the first-call stampede.

Advanced

Watch for re-entrancy across the lazy dependency graph, and count constructions in production — one counter turns an unreproducible bug into a proven one.

Internals

The hard half is publication, not exclusion. Without a release store on the publishing side and an acquire load on the reading side, a fast path can observe a non-null reference whose target fields are not yet visible. This is exactly why double-checked locking needs an atomic, and why the language guarantee for function-local statics is worth using.

Apply it