Patterns & Anti-Patterns

What "Thread-Safe" Actually Means

A type or operation is thread-safe when its documented guarantees permit correct concurrent use under stated conditions. Not "it has locks" — locks are one implementation. And the trap that catches everyone: a thread-safe method does not make a sequence of thread-safe calls atomic.

▶ Run the lab

The question this answers

The question

What does the phrase "this class is thread-safe" actually promise me?

The work

Two request handlers calling cache.get(key) and, on a miss, cache.put(key, expensiveLoad(key)) — on a map whose documentation says thread-safe.

What is shared

The cache and its entries. The map internally synchronizes each operation; nothing synchronizes the get-then-put sequence, and nothing in the type system indicates that difference.

The invariant — what must stay true under every interleaving

At most one expensive load runs per key, and every caller that receives a value receives the value that is actually stored in the cache.

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?

Thread safety is a contract, not an implementation

"Thread-safe" is a statement about a specification: this type behaves correctly when used concurrently in these documented ways. It says nothing about how — an immutable type is thread-safe with no locks at all, a value confined to one thread is thread-safe by never being shared, and a lock-free queue is thread-safe with atomics and no mutual exclusion anywhere. Equating thread safety with locking gets both directions wrong: it implies immutable types need locks, and it implies anything with a lock is safe.

The useful mental model is a ladder of guarantees, and the documentation should tell you which rung a type sits on. Immutable: safe under all use with no coordination. Thread-safe: every operation is atomic and the object maintains its own invariants under concurrent calls. Conditionally thread-safe: safe per call, but some sequences require external synchronization — this is the rung almost everything actually sits on, and the one most documentation fails to say. Not thread-safe: safe only under external synchronization or confinement. Thread-hostile: unsafe even with external synchronization, usually because it mutates global state.

The practical consequence is that "is this thread-safe?" is not answerable in general. The answerable question is "which of my usages does this type's contract cover?" — and the get-then-put sequence is the canonical usage it does not.

LevelPromiseWhat you still must doTypical example
ImmutableCorrect under all concurrent use, with no coordination whatsoeverPublish the reference safely — that is allA frozen config object, a value type
Thread-safeEvery individual operation is atomic; the object's own invariants holdSynchronize any multi-call sequence you depend onA concurrent map, an atomic counter
Conditionally thread-safeIndividual operations are safe; documented sequences are notRead the documentation and lock the sequences it namesA collection whose iterator requires external locking
Not thread-safeNothing under concurrent useConfine to one thread, or lock every accessAn ordinary array, list or dictionary
Thread-hostileUnsafe even when you synchronize your own accessAvoid, or isolate it in a single dedicated thread or processSomething mutating a process-wide global such as a locale or an environment variable
The ladder, with what each rung actually promises.

The trap: safe calls, unsafe sequence

Here is the entire misconception in one line of code: if (!map.containsKey(k)) map.put(k, compute(k)). Both calls are atomic. The sequence is not. Two threads can both observe the key as absent, both compute, and both put — and the guarantee the map gave you is intact the whole time, because it promised per-call atomicity and delivered exactly that.

This is a check-then-act race, and the invariant it breaks is an *application* invariant: "at most one expensive load per key". The map has no idea that invariant exists, and no amount of internal locking could protect it. Only the caller knows the sequence is meant to be indivisible, so only the caller can make it so — with a lock over the sequence, or by using a single compound operation the type provides for exactly this reason (computeIfAbsent, putIfAbsent, SETNX, an upsert).

The habit worth building: when you use a thread-safe type, identify every sequence of two or more calls whose intermediate state matters, and treat each one as an unprotected critical section until proven otherwise. Iteration is the other classic — a snapshot iterator gives you a consistent view, a weakly consistent one gives you neither the old nor the new state reliably, and a fail-fast one throws.

A thread-safe map, used in a way its contract does not cover.SIMULATED
Invariant · At most one expensive load per key, and every caller sees the stored value.
#Handler 1Handler 2State
1cache.get("user:9") -> miss [atomic call]·cache=empty
2·cache.get("user:9") -> miss [atomic call]cache=empty
3expensiveLoad("user:9") — 400 ms, one DB query·cache=empty queries=1
4·expensiveLoad("user:9") — 400 ms, second DB querycache=empty queries=2
✕ Two loads for one key. Under a cache stampede this is 300 identical queries, and the map's thread-safety guarantee is not violated at any point.
5cache.put("user:9", vA) [atomic call]·cache=vA queries=2
6·cache.put("user:9", vB) [atomic call]cache=vB queries=2
7--- fixed: one compound operation ---·cache=empty queries=0
8cache.computeIfAbsent("user:9", load) [atomic]·cache=vA queries=1
9·cache.computeIfAbsent("user:9", load) -> vA [atomic]cache=vA queries=1
The map kept every promise it made. The bug is that the caller depended on a promise the map never made — atomicity across calls. Either wrap the sequence in your own lock, or use a compound operation the type provides.

Documenting it, and what to demand of others

Because thread safety is a contract, undocumented thread safety is not thread safety — it is an implementation detail that will change in a minor version. A type that is safe today because it happens to hold a lock, with nothing written down, is a type whose maintainer may replace that lock next quarter and break you.

A usable statement names four things: which operations are atomic, which sequences are not and need external synchronization, what iteration or snapshot semantics apply, and what the object promises about visibility of writes made by other threads. "Thread-safe" alone answers none of those, and is the reason this misconception is so durable.

When you write the safe wrapper yourself, the same rules apply and one extra: do not let a reference escape. A class that synchronizes every method and then returns its internal list has published mutable state that no lock protects, and the caller will iterate it outside the lock. Return a copy, an immutable view, or nothing. See Safe Publication: Handing Over a Finished Object and Shared Mutable State.

Every method locked. Still not safe.
1class Registry:
2 def __init__(self):
3 self._lock = threading.Lock()
4 self._items: list[Item] = []
5
6 def add(self, item):
7 with self._lock:
8 self._items.append(item)
9
10 def items(self):
11 with self._lock:
12 return self._items # the reference escapes the lock
13
14# Caller iterates outside the lock while another thread appends.
15for it in registry.items(): # RuntimeError, or a torn view
16 ...
The contract stated, and no reference escaping
1class Registry:
2 """Thread-safe. add() and snapshot() are individually atomic.
3 A sequence of calls is NOT atomic: use with_lock() if you need one.
4 snapshot() returns an immutable copy taken under the lock."""
5
6 def __init__(self):
7 self._lock = threading.Lock()
8 self._items: list[Item] = []
9
10 def add(self, item):
11 with self._lock:
12 self._items.append(item)
13
14 def snapshot(self) -> tuple[Item, ...]:
15 with self._lock:
16 return tuple(self._items) # a copy, not the internal list
17
18 @contextlib.contextmanager
19 def with_lock(self): # for sequences the caller needs atomic
20 with self._lock:
21 yield self._items

The first class is fully locked and fully broken: the lock is released before the caller iterates, and the caller is iterating the live list. The second returns a snapshot taken under the lock, and — crucially — documents that multi-call sequences are the caller's problem, plus provides the tool to solve them.

Key points

  • Thread safety is a documented contract about permitted concurrent usage, not the presence of locks.
  • Immutable types and confined types are thread-safe with no synchronization at all; lock-free types are thread-safe with no mutual exclusion.
  • A thread-safe method does not make a sequence of calls atomic. Check-then-act across two safe calls is a race.
  • Most types are conditionally thread-safe, and the condition is usually undocumented — treat every multi-call sequence as unprotected until proven otherwise.
  • A class that synchronizes every method and then returns its internal collection has published unprotected mutable state.

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
  • Read the type's documentation for which operations are atomic and what it says about sequences, iteration and visibility.
  • List your usages of the type, and mark every place you make two or more calls whose intermediate state matters.
  • For each such sequence, either find a compound operation the type provides, or wrap the sequence in your own lock.
  • Check whether any method hands out a reference to internal mutable state; if so, the type's safety ends at that return statement.
  • When writing a safe type, state the contract explicitly: atomic operations, non-atomic sequences, iteration semantics and visibility.
Interleavings that matter
  • Check-then-act: H1 sees key absent; H2 sees key absent; H1 computes and puts; H2 computes and puts — two expensive loads, and the caller of H1 holds a value the cache no longer stores.
  • Compound operation: H1 calls computeIfAbsent and the type holds its own lock across check and insert; H2 blocks inside the call and receives H1's value — one load, one value.
  • Escaped reference: H1 calls items() which returns the internal list under a lock and then releases it; H1 iterates while H2 appends — the iterator sees a structurally modified collection and throws, or silently skips.
  • Compound increment: H1 reads counter (7); H2 reads counter (7); H1 writes 8; H2 writes 8 — the field access may be atomic and the read-modify-write is not. See The Atomicity Illusion.
  • Two safe objects, one invariant: H1 debits accountA atomically, is preempted, and H2 reads the total across both accounts before H1 credits accountB — each object is thread-safe and the cross-object invariant is broken.
  • Visibility: H1 mutates an object stored in a thread-safe map without going through the map; H2 reads it from the map and may see the old field values — the map guarded the reference, never the referent.
What it guarantees — and does not
  • A thread-safe type guarantees each documented operation is atomic and that its own internal invariants hold under concurrent calls.
  • It guarantees the visibility of writes made through its own operations to subsequent callers of those operations.
  • It does NOT guarantee atomicity across two or more calls, which is the single most common misuse.
  • It does NOT guarantee your application invariants, which it has never been told about.
  • It does NOT guarantee anything about objects it stores. A thread-safe map holding mutable values protects the mapping, not the values.
  • It does NOT guarantee iteration is consistent unless it says so — fail-fast, weakly consistent and snapshot are three different and incompatible behaviours.
  • It does NOT guarantee anything after a reference to internal state escapes.
  • It does NOT compose: two thread-safe objects give you no guarantee about an invariant spanning both.
Where contention appears
  • A fully synchronized type serializes every operation, so under high concurrency it is a global lock with a nicer name.
  • Fine-grained or striped locking preserves concurrency across keys, and contention concentrates on hot keys rather than on the type.
  • Lock-free thread-safe types move contention to cache lines and CAS retries rather than to waiting, which changes the metric you watch, not whether contention exists.
  • Caller-side locking around sequences reintroduces coarse serialization on top of a fine-grained type, which is correct and frequently the reason a concurrent collection performs no better than a plain locked one.
  • Snapshot iteration trades contention for allocation: no lock held while iterating, one copy per snapshot.
How it fails
  • Check-then-act race across two individually safe calls — duplicate work, lost updates, or two callers disagreeing about the stored value.
  • Lost update from a read-modify-write built out of atomic reads and atomic writes.
  • ConcurrentModification or torn iteration when a returned internal collection is iterated outside the lock.
  • Cross-object invariant violation, where each object is safe and the relationship between them is not.
  • Stale reads of a mutable object retrieved from a thread-safe container and then mutated outside it.
  • Deadlock from caller-side locking around a type that also locks internally, when the two orders disagree.
  • A silent regression when a dependency's undocumented internal locking is removed in a minor version.
When it helps
  • Thread-safe types genuinely help when the usage is single-call — a counter, a registry of independent entries, a cache with compound operations.
  • They help by encapsulating the invariant with the data, so no caller can forget the lock for a single operation.
  • Compound operations (computeIfAbsent, putIfAbsent, atomic upserts) help exactly where the trap is, and are the correct first reach.
  • Explicit documentation of the contract helps most of all, because it converts a runtime race into a review-time question.
When it hurts
  • When the label is treated as a guarantee that any usage is safe, which is where the check-then-act bugs come from.
  • When a fully synchronized type is used at high concurrency and becomes a serialization point that no metric attributes to it.
  • When a thread-safe container is used to hold mutable objects and the mutation happens outside the container.
  • When caller-side locking is layered over internal locking without a lock order, which turns a race into a deadlock.
How you would know
  • Count of expensive operations per key against the number of distinct keys — the direct detector for the duplicate-work failure.
  • Lock wait time inside the type, if it exposes it; a "concurrent" collection with high internal wait is not buying you concurrency.
  • Duplicate-work or cache-stampede counters, which surface the check-then-act failure without needing to reproduce the schedule.
  • A race detector or thread sanitizer run against the sequence, which will flag unsynchronized access to the referents even when the container is safe. See Race Detectors: What They Find, and What They Structurally Cannot.
  • Static review count: how many two-call sequences exist against each shared type. This is a code-review metric and it finds more bugs here than any runtime tool.
Complexity it introduces
  • The caller must now reason about which sequences need protection, which is invisible in the code and only present in documentation.
  • Layering caller locks over internal locks introduces an ordering obligation between locks you own and locks you do not.
  • Writing the contract down means committing to it across versions, which is a real maintenance burden and the reason so few libraries do it.
  • Snapshot semantics have to be chosen and stated: copying costs allocation, weak consistency costs predictability, fail-fast costs availability.
Simpler alternatives
  • Immutability, which is thread-safe at every rung with no contract to read. See Immutability as a Concurrency Strategy.
  • Confinement: keep the object in one thread or one task and pass messages, so concurrency never touches it. See Message Passing.
  • A compound operation provided by the type, which converts the unsafe sequence into a single guaranteed one.
  • A single owning actor per entity, making sequences atomic by construction. See The Actor Model.
  • An explicit lock owned by your code over your invariant, which is honest: the invariant is yours, so the lock should be too. See Finding the Critical Section.

if (balance >= 100) withdraw(100) — drive it until it overdraws

if (balance >= 100) withdraw(100)
Two withdrawals of 100 from an account holding 100. The check and the debit are separate operations; you decide who runs when.
balance = 100

withdraw(amount):        # both tasks run this concurrently
    b = read(balance)    # 1
    if b >= amount:      # 2  <- decided on a value that may already be stale
        debit(amount)    # 3
0 schedules tried
balance
0
paid out
100
A decided
withdraw
B decided
Invariant · balance >= 0 — the account is never overdrawn.
#Withdrawal A (100)Withdrawal B (100)State
1rA ← read balance·balance=100 paidOut=0
2if rA >= 100·balance=100 paidOut=0
3debit 100·balance=0 paidOut=100
Balance is 0 and nothing has broken yet. Watch for the shape: both tasks passing step 2 before either reaches step 3. That is check-then-act, and the check is only as good as the instant it was made.
SIMPLIFIEDThe debit itself is modelled as atomic. The bug is the gap between the check and the act — not the arithmetic.

Two increments, twenty schedules: find the one that loses an update

Two increments, twenty schedules
Both tasks run counter++ on the same variable. Drive the schedule yourself: read, add, write are three separate steps, and the scheduler may cut between any two of them.
6/6 steps
counter
2
increments completed
2
rA / rB
1 / 2
invariant
holds
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=0
2rA ← rA + 1·counter=0 rA=1 rB=0
3counter ← rA·counter=1 rA=1 rB=0
4·rB ← countercounter=1 rA=1 rB=1
5·rB ← rB + 1counter=1 rA=1 rB=2
6·counter ← rBcounter=2 rA=1 rB=2
counter = 2, and both callers are right. This schedule happens to be safe because one task finished entirely before the other started. Safe once is not safe: press "Enumerate all" to see how many of the possible schedules do not. Testing samples this space; it does not cover it.
SIMPLIFIEDcounter++ modelled as three indivisible steps. Real compilers and CPUs can split it further, or fuse it into one atomic instruction.

What people believe, and what is true

Claim

Thread-safe means it has locks.

Reality

It means the documented contract permits concurrent use. Immutable types have no locks and are maximally thread-safe; confined types are safe by never being shared; lock-free types are safe with atomics. Locks are one implementation of the contract, not the contract.

Claim

If every method is synchronized, the class is safe.

Reality

Not if a method returns internal mutable state, and not for any sequence of calls. A fully synchronized class that returns its internal list is unsafe in the most ordinary usage there is — iterating it.

Claim

Using a concurrent collection removes the need to think about synchronization.

Reality

It removes it for single operations and relocates it to sequences. The bug moves from inside the collection to the three lines around it, where no library can help you.

Claim

If the type is thread-safe, the objects it holds are protected too.

Reality

The container guards the mapping. Mutating a stored object goes straight past the container's lock, and other threads may see stale or torn field values.

Go deeper

Overview

Thread-safe means the documentation says concurrent use is correct, in the ways it describes. It does not mean any usage at all is correct, and it does not imply locks.

Practical

Find every place you call two methods in a row and depend on nothing happening in between. Each one is an unprotected critical section. Use a compound operation, or take your own lock over the sequence.

Advanced

Write the contract for types you own: atomic operations, non-atomic sequences, iteration semantics, visibility. Never let a reference to internal mutable state escape a synchronized method.

Internals

Thread safety has two halves — mutual exclusion and visibility. A type that serializes operations but does not establish the right happens-before edges can still let a reader observe a stale value. This is why the contract must speak about visibility and not only about atomicity. See Happens-Before: The Edge That Makes a Write Visible.

Apply it