ReliabilityLANGUAGE-SPECIFICGENERALCONTESTED

The Thread-Safety Contract

Whether a type may be used concurrently is part of its interface. Leaving it unsaid does not make it safe — it makes every caller guess, and the guesses are wrong at different times.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

May two callers use this object at the same time, and where does the answer live?

The requirement

A RateLimiter is created once and shared by every request handler. Nothing in it says whether that is allowed. It works, until a counter update under load produces a limit that is occasionally too generous.

The obvious build

It is obvious from the code. Anyone can look at the implementation and see whether there is a lock, and reviewers will catch misuse.

Why it breaks

Callers do not read implementations — that is the entire point of an interface. The first thing they read is the constructor call in someone else's code, and they copy it (Information Hiding).

How it breaks as requirements change
  • Callers do not read implementations — that is the entire point of an interface. The first thing they read is the constructor call in someone else's code, and they copy it (Information Hiding).
  • Presence of a lock does not imply safety. A type can synchronise each method individually and still be unusable concurrently, because a caller doing check-then-act across two methods has a race the type cannot prevent (The Atomicity Illusion in Concurrency).
  • It changes silently. Someone adds a cache field for performance and a type that was safe becomes unsafe, with no change to any signature and nothing to fail in review.
  • The failure is probabilistic and load-dependent, so it appears in production, disappears in staging, and is blamed on infrastructure (Heisenbugs: The Bug That Leaves When You Look at It in Concurrency).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The type is used by teams who will never read its implementation.
  • The language has no way to express thread safety in the type system, so whatever is chosen has to be a convention plus documentation.
  • The object is already shared in production, so a change to its contract is a change to running code.
Invariants
  • A type's concurrency contract must be discoverable at the call site, not by reading the implementation (Designing a Module Interface).
  • If a type says it is safe for concurrent use, that must hold for every public method and every combination of them.
  • A contract that is not tested is not a contract (Enforcing Invariants).

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The type owns declaring one of four things: immutable, thread-safe, conditionally safe with a stated protocol, or not safe.
  • The type owns keeping that declaration true across every change to its implementation — which means the declaration has to be somewhere a change would touch.
  • The caller owns following the protocol for conditionally-safe types, and owns confinement for unsafe ones (State Ownership).
  • The test suite owns proving the declaration, because an unverified concurrency claim is the least trustworthy kind of documentation (Documentation Decay).
Boundaries
  • The contract belongs at the type's public boundary — its name, its documentation comment, ideally its type — because that is the surface a caller actually sees (Designing a Module Interface).
  • Where a language can express it, express it: Rust's Send/Sync, an immutable value type, a wrapper that hands out only a synchronised handle. A compiler-checked contract does not decay (Making Illegal States Unrepresentable).
  • The safe boundary for composite operations is the type, not the method. If callers must combine two methods atomically, the type must offer a combined operation instead (Cost-Aware Interfaces).

Four contracts, and what each one asks of the caller

There are only four useful answers, and the value is in picking one out loud. The third is the one that causes trouble, because "safe if you hold a lock" is a protocol, and protocols that live in prose get broken by people who did not read the prose.

Read the last column as the real question: what does a caller have to do, and what happens if they do not?

ContractWhat it meansCaller mustIf the caller gets it wrong
ImmutableNo observable state changes after constructionNothing at all — share freelyCannot get it wrong. The only contract that survives a later refactor of the implementation (Immutability)
Thread-safeAny method, any thread, any order, including combinationsNothing — but check whether composite operations are coveredUsually nothing, unless the type only synchronised individual methods and the caller does check-then-act
Conditionally safeSafe under a stated protocol: external lock, single writer, one instance per requestFollow the protocol exactly, and know it existsIntermittent corruption under load, appearing far from the misuse. The most dangerous of the four
Not thread-safeOne thread at a time; confine itCreate per use, or confine to one owner (State Ownership)Corruption — but at least the contract said so, so the bug has an obvious owner

Where the race actually is

The instructive case is a type where every method is individually synchronised and callers still race. Nothing is wrong with any method; the race is in the gap between two of them, and only the type can close it.

This is why "thread-safe" is not enough on its own: the contract has to say whether composite operations are atomic, and the better fix is to offer the composite rather than to document that it is not.

Safe methods, unsafe usage, and the fix
1/**
2 * CONTRACT: conditionally thread-safe.
3 * Individual methods are atomic. Combinations are NOT.
4 * Prefer tryAcquire(); do not compose exceeded() with increment().
5 */
6class RateLimiter {
7 exceeded(key: string): boolean // atomic
8 increment(key: string): void // atomic
9
10 // the composite the callers actually wanted, made atomic here
11 tryAcquire(key: string): boolean {
12 return this.counts.incrementIfBelow(key, this.limit)
13 }
14}
15
16// the caller's race, with two perfectly safe calls:
17if (!limiter.exceeded(key)) { // <- two requests both read false
18 limiter.increment(key) // <- both increment; limit exceeded
19}
20
21// no race available:
22if (!limiter.tryAcquire(key)) return tooManyRequests()

The comment above the class is doing real work: it names the category, states what is atomic, and points at the operation callers should use instead. But the durable fix is tryAcquire — an interface that makes the unsafe composition unnecessary, because a documented prohibition is only as good as the reader (Function Design).

The contract that quietly became false

The most common way a concurrency contract breaks is not misuse. It is a performance change: someone adds a memoisation field to a type that was immutable and correct, and the type's contract changes without any signature changing.

This is worth naming as a smell because it is invisible in review unless you are specifically looking, and the review comment that catches it — "does this field change the concurrency contract?" — is one line.

smellMutable field on a shared type

looks like A private cache, lastComputed, hitCount or lazily-initialised field on a type that is constructed once and shared — often added in a commit whose message mentions performance, with no change to any public signature.

suggests The type's concurrency contract has changed and nothing recorded it. An immutable type has become conditionally safe; a thread-safe one may now have an unsynchronised path. Lazy initialisation in particular is a classic: two threads both see the field empty and both compute, which is benign for a pure value and corrupting for anything else (Initialization Races in Concurrency).

fix Decide which of the four contracts the type now has and update the declaration in the same commit. If it was immutable, prefer keeping it so and moving the cache to the caller, where it is confined by construction — a shared cache on an immutable value is usually a caller's optimisation wearing the value's clothes (State Ownership).

when this is fine It is genuinely correct when the field is a pure memo of a deterministic function of immutable inputs, and a duplicate computation is harmless — two threads computing the same hash and both storing it is wasteful and not wrong. It is also fine when the type is documented as not thread-safe and is genuinely confined to one owner, where a mutable field is just an ordinary field. The property that makes it fine is that either the computation is idempotent and pure, or the object is not shared at all.

How to build it

Most important first.

  • Pick one of four labels and state it: immutable (safe by construction), thread-safe (any methods, any threads, any order), conditionally safe (safe under a stated protocol — external locking, one writer, per-request instance), not thread-safe (confine it).
  • Prefer immutable. It is the only category that cannot be broken by a later change to the implementation, and it needs no documentation to stay true (Immutability).
  • Design out check-then-act. If callers need if (!limiter.exceeded(k)) limiter.increment(k), provide tryAcquire(k) — the composite is where the race lives and only the type can make it atomic (Function Design).
  • Make the unsafe usage hard rather than documented. A type that can only be obtained per-request cannot be shared by accident, which is stronger than any comment (Capability Passing).
  • Say what safe means precisely. "Thread-safe" without qualification is ambiguous about iteration, about composite operations, and about whether returned collections are snapshots or views — and each ambiguity is a bug someone will find.
  • Put the declaration where a change would touch it: on the type, next to the fields, so adding a mutable cache field forces the author past it (Docs Close to Code).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • With the contract declared: adding a field costs the author reading the label directly above the fields and deciding, and a reviewer has something specific to check. The next concurrency-related change is a local decision.
  • Without it: the same field silently changes the type's contract, and the cost is discovered as an intermittent production defect weeks later, plus the days spent proving it is not infrastructure.
  • Retrofitting a contract onto a widely-shared type is expensive in a specific way: you must audit every existing usage before you can declare anything, because declaring "not thread-safe" on something already shared is a bug report about live code (What "Legacy" Actually Means).
  • Choosing immutability instead makes the next change cheapest of all: there is nothing to declare and nothing that a later edit can invalidate (Immutability).
What the recommended approach costs
  • Thread-safe types are slower for the majority of callers who use them from one thread, which is why many standard libraries offer unsynchronised defaults and make the caller opt in.
  • Documenting the contract is a maintenance obligation that nothing enforces in most languages, so it will occasionally be wrong — and a wrong contract is more dangerous than an absent one.
  • Immutability, the strongest answer, costs allocation and can be impractical for large mutable structures on hot paths (Allocation and Copies).

What can go wrong

Failure modes
  • The contract is documented and then broken by a performance optimisation, because the comment is not something a compiler checks.
  • The type is declared thread-safe, each method is synchronised, and callers still race across methods — safe methods, unsafe usage, and the label made it worse by inviting trust.
  • A returned collection is a live view rather than a snapshot, so a caller iterating it while another thread mutates gets a concurrent-modification failure that the type's contract did not mention.
  • Everything is made thread-safe defensively, so every access pays for synchronisation that almost no caller needs, and a correctness label becomes a latency problem (Premature Optimization, Reclaimed).
  • The contract is stated for the process and forgotten across instances — a thread-safe counter shared by twelve processes is not a shared counter at all (Concurrency by Design).
Dependencies, and their direction
  • Every caller depends on the declared contract, which makes it part of the public API and subject to the same compatibility rules as a signature (API Stability).
  • A thread-safe type's internals depend on the language's memory model for visibility, not merely on mutual exclusion, and the two are different guarantees (What a Memory Model Defines in Concurrency).
  • A conditionally-safe type creates a dependency on caller discipline, which is the weakest dependency available and the reason to prefer the other three categories.
Misreads
  • "It has a lock, so it is thread-safe." Per-method synchronisation does not make composite operations atomic, and composite operations are what callers actually write (The Atomicity Illusion in Concurrency).
  • "Thread-safe means fast under concurrency." It means correct. A globally locked type is thread-safe and a throughput ceiling (Concurrency Is Always Bought With Complexity in Concurrency).
  • "Make everything thread-safe to be careful." Then everyone pays for synchronisation nobody needs, and the ceiling is systemic.
  • "This is only relevant for multithreaded languages." An event-loop runtime still has interleaving across await points, so an object mutated across a suspension has exactly this problem under a different name (Await Is a Yield Point in Concurrency).
Smells this explains
  • hidden-global-state

Testing it, and how it ages

What to test, and at which boundary
  • For a type declared thread-safe, a stress test with many threads asserting the invariant. A claim with no test behind it is decoration (Stress Testing: A Test That Passed Once Proves Nothing in Concurrency).
  • A test for the composite operation — tryAcquire under contention — because that is where the real race is and single-method tests will not find it.
  • A test that a returned collection is a snapshot, if the contract says so; iterate it while mutating.
  • Run the race detector where the language has one; it finds what inspection does not (Race Detectors: What They Find, and What They Structurally Cannot in Concurrency).
How this design ages
  • Contracts decay under performance work, which is where mutable caches get added. That is the specific moment to re-read the label, and a good place for an automated reminder (What to Automate Out of Review).
  • As a codebase matures, the valuable move is reducing the number of types that need a contract at all by making more of them immutable (Immutability).
  • A single-process contract eventually meets multi-instance deployment and stops meaning what it said, which is not a decay of the contract but a change in what the boundary is (What Changes at the Network Boundary).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • LANGUAGE-SPECIFICRust encodes this in the type system with Send and Sync, so an unsafe sharing does not compile and the contract cannot decay. Java has annotations that document but do not enforce. Go has a convention and a runtime race detector. Single-threaded event-loop runtimes have the same problem across suspension points with none of the vocabulary. The advice is identical everywhere; only whether the compiler helps differs.
  • GENERALThat a caller cannot determine concurrency safety without being told is a property of interfaces, so it applies wherever objects are shared, including across coroutines and async tasks rather than only OS threads.
  • CONTESTEDA reasonable opposing view is that thread-safety documentation is a category of comment that reliably rots, and that the honest response is to stop writing it and instead make sharing structurally impossible — per-request instances everywhere, immutable values, and no long-lived mutable objects at all. That position is strong, and where it can be applied wholesale it is better than any documentation. It runs out at the edges every real system has: connection pools, caches, rate limiters and metric registries are shared by nature, and something has to say what using them concurrently means.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — stress harnesses, race detectors and deterministic replay are how a declared contract gets verified rather than asserted.
  • Programming Languages & Runtime Internals — what a language can express about sharing (Sync, ownership, immutability by default) determines whether this contract is checked or merely written down, and that is the single largest variable in this lesson.