Every shared data structure in a service is individually thread-safe, yet operations still produce inconsistent results under load. Explain why per-object thread safety is not enough, and how you decide where the atomicity boundary belongs in a design.
answer
- thread-safe operation ≠ correct sequence
- invariant defines the atomic unit
- 'and' across two objects → one owner
- expose putIfAbsent, not contains + put
- dissolve it: immutable, confined, partitioned
basics
~20 sThread-safe components make each individual operation atomic, but invariants usually span several operations or several objects. Those sequences are compound actions and stay racy. The atomicity boundary must be drawn around the invariant — every piece of state that must change together needs one owner, one lock, one transaction, or one conditional operation.
solid answer
~1 minThread safety is a property of a *single operation on a single object*. Correctness is a property of an **invariant**, and invariants rarely fit inside one call. "If absent, insert" over a thread-safe map is two atomic calls with a racy gap. "Move an item from list A to list B" is atomic on each list and momentarily inconsistent across both. "Read the size, then act on it" acts on a value that is already stale. So the design question is not "is this collection thread-safe?" but **"what set of state must change as a unit, and who enforces that?"** My sequence: 1. Write down the invariant explicitly, including which state it spans. 2. Choose the smallest unit that fully contains it, and give that unit one owner: one lock guarding all of it, one actor/single thread owning it, one transaction, or one compound primitive (conditional insert, compare-and-swap, `UPDATE ... WHERE`). 3. Make the atomic unit visible in the API — expose `putIfAbsent`-style operations rather than `contains` plus `put`, so callers cannot construct a racy sequence. 4. Prefer designs where the question does not arise: immutable snapshots, per-thread state, or message passing to a single owner. The failure mode to name: a boundary drawn per object, when the invariant spans objects.
code
text · 6 lines# racy: two atomic calls, one gap
if not registry.contains(key):
registry.put(key, value) # both threads can reach here
# atomic: one operation enforces the invariant
created = registry.putIfAbsent(key, value)go deeper
Know that using thread-safe collections does not make code correct: a check followed by an action is still two steps with a gap in between.
Show the three shapes — compound action on one object, invariant across two objects, deciding on a stale returned value — and fix them with a fused operation or a lock covering both mutations.
Drive from the invariant: state it, list the state it spans, choose the mechanism whose scope matches, and encapsulate the unit so callers cannot reconstruct the race.
Frame it as ownership architecture — who is the authority for each invariant, which boundary (lock, transaction, service, actor) enforces it, and when to dissolve the problem entirely via immutability, confinement or partitioning versus accepting eventual consistency with explicit compensation.
## The false comfort of 'thread-safe' "Thread-safe" means each operation on that object appears atomic to other threads: no torn state, no corrupted internals, no lost internal bookkeeping. It says nothing about sequences of operations, and nothing about relationships between objects. Correctness lives in **invariants** — statements that must always be true of your state. "The item is in exactly one of the two lists." "There is at most one account per email." "Reserved plus available equals total." "The cache entry matches the row it was derived from." An invariant is almost never contained in a single method call on a single object, and that is precisely where per-object thread safety runs out. ## The three classic shapes **1. Compound action on one thread-safe object.** ``` if not map.contains(key): # atomic map.put(key, value) # atomic ``` Two atomic operations with a gap; two threads can both observe absence. Every individual call was safe; the sequence was not. The fix is a single primitive that fuses them (`putIfAbsent`, conditional insert, compare-and-swap). **2. Invariant spanning several objects.** ``` listA.remove(item) # atomic listB.add(item) # atomic ``` Between the two, a reader sees the item in neither list. Each collection's own lock protects its own internals; nothing protects the relationship between them. No amount of making each collection more thread-safe fixes this — the unit that must be atomic is *both* mutations, so it needs a lock or transaction that spans both, or a single object that owns both lists. **3. Acting on a returned value.** ``` n = queue.size() # atomic, and already stale on return if n < LIMIT: queue.add(x) ``` Any aggregate you read out of a concurrent structure is a snapshot of the past. Decisions based on it are check-then-act by construction. The fix is a bounded structure that rejects the add, or an operation that applies the limit internally. ## Deciding where the boundary goes A method I can defend in a design review: **Step 1 — state the invariant, and its extent.** Write it as a sentence. Then list every variable, object, row, or service that appears in it. That list is the minimum extent of the atomic unit. If the sentence needs the word "and" across two objects, per-object locking is already insufficient. **Step 2 — pick the enforcement mechanism** matching that extent: - entirely within one process and one object → a lock private to that object, with the compound operation as a method on it; - across several objects in one process → one lock ordered over all of them (and now you own a lock-ordering problem), or a single owning component that encapsulates them; - across a process boundary → the datastore is the only honest authority: a transaction with the right isolation, explicit row locks, a unique constraint, or a conditional update carrying an expected version; - across services → one service is the authority for the invariant, exposing one operation that enforces it; otherwise you are choosing eventual consistency and must design the compensation. **Step 3 — make the boundary un-bypassable in the API.** If the type exposes `contains` and `put` but not `putIfAbsent`, callers will write the race, and code review will not reliably catch it. Encapsulate the compound action as a single method on the component that owns the state; do not export the lock and hope callers take it. **Step 4 — keep the unit as small as the invariant, but no smaller.** Too small and you have the bugs above. Too large — one coarse lock around everything — and you have serialized the system and probably created lock-ordering and latency problems. Size is set by the invariant, not by taste. **Step 5 — prefer designs where the question dissolves.** The cheapest atomicity boundary is the one you never have to enforce: - **Immutability**: publish a new immutable snapshot with a single conditional/atomic reference swap; readers never see a partial state. - **Confinement / single ownership**: one thread, actor, or partition owns the state and applies changes serially from a queue; every compound action is trivially atomic because there is no concurrency inside the owner. - **Partitioning**: shard the state by key so each invariant lives entirely inside one shard. ## What I would say about the failing service The defect is a boundary drawn per object while the invariant spans objects and calls. I would inventory the invariants, find each place where two or more operations must be a unit, and move that unit behind a single owner — a method on the owning component with one lock, a single conditional datastore operation, or a single-owner actor — then delete the API shapes that let callers reconstruct the racy sequence. Adding more thread-safe components, or more locks in more places, would not change the outcome.
- When does the right atomicity boundary stop being a lock and become the database or a single service?As soon as the invariant spans state that outlives or escapes one process. An in-process lock cannot constrain another replica, another instance after a restart, or a second service writing the same rows, so the authority must be whatever component actually owns the state — usually the datastore, through a transaction, a unique constraint, or a conditional update carrying an expected version. If the invariant spans several services, one of them must be designated the authority and expose a single operation enforcing it; otherwise you have chosen eventual consistency and owe the design a compensation path.
- What is the cost of erring on the side of a larger atomic unit — one coarse lock around everything?It is correct but serializes unrelated work, so throughput collapses to single-threaded on the hot path and latency becomes contention-dependent and unpredictable. Coarse units also tend to be held across slow operations such as I/O, which multiplies the damage, and they encourage nesting that creates lock-ordering hazards. The discipline is to size the unit exactly to the invariant, then reduce sharing — partitioning, immutability, single ownership — rather than shrinking the lock below what correctness requires.
- How would you make the racy sequence impossible for future callers rather than just fixing today's call sites?Encapsulate the compound action as a single method on the component that owns the state, and remove the primitives that let callers build the racy version — export `move`, `reserveIfAvailable`, `putIfAbsent` rather than `remove`+`add` or `contains`+`put`. Do not publish the lock and rely on documentation; a boundary that depends on every future caller reading a comment will be broken. Where the state can be made immutable or confined to one owner, the racy sequence stops being expressible at all.
Two clerks each keep a perfectly accurate ledger, but a transfer means subtracting in one book and adding in the other. Each book is never wrong on its own; money still vanishes between them unless one person makes both entries under one pen stroke.
saying these in an interview costs you the question
- 'We used thread-safe collections everywhere, so we're safe'
- Locking each object individually when the invariant spans several of them
- Exposing check and act as separate public operations and documenting that callers must synchronize
- Making the lock as small as possible for performance regardless of what the invariant requires
- Treating a size or existence value read from a concurrent structure as still true when you act on it