Engineering Atlas › Low-Level Design

Handling thread safety in design questions

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Step 1: mark every field

Take the class diagram you already drew and mark each field as one of three things: immutable after construction, confined to one thread, or shared and mutable. Only the third kind needs any concurrency design. In Design Parking Lot the shared mutable state is the free-spot index and the active-ticket map; the Vehicle is an immutable value and needs nothing. In Design LRU Cache the surprise is that get is a write: it moves a node to the front of the list, so a "read-only" path mutates shared state and a read-write lock does not help.

Step 2: write the invariant down

An invariant is a sentence that must be true whenever no thread is in the middle of an operation. "A seat belongs to at most one active hold." "The sum of all account balances is constant." "The queue holds at most capacity items." "size equals the number of nodes in the list." Writing it down tells you two things: which fields must change together (they share a lock), and where the operation begins and ends (the critical section).

Step 3: recognise the three bug shapes

Lost update. count += 1 is three steps: read count into a register, add one, write it back. Two threads both read 7 and both write 8; one increment vanishes. The same shape appears as SELECT balance followed by UPDATE balance = ? in a database, and as two browser tabs saving the same document.

Check-then-act. if seat.is_free(): seat.hold(user) is correct with one thread and wrong with two, because the check is stale by the time the act runs. The general name is time-of-check to time-of-use (TOCTOU), and it is also a classic security bug: check a file's permissions, then open it, and an attacker swaps the file in between. The fix is never "check again"; it is to make check and act one atomic step.

Deadlock. Thread 1 locks account A then B to transfer A to B; thread 2 locks B then A to transfer B to A. Each holds one lock and waits forever for the other. Deadlock needs four conditions at once: mutual exclusion, hold-and-wait, no preemption, and a circular wait. Break any one and it cannot happen. In application code the cheap one to break is circular wait: give locks a global order (by account id, by memory address, by level in a hierarchy) and always acquire in that order.

Two quieter shapes are worth naming if time allows. Unsafe publication: one thread builds an object and stores a reference, another sees the reference before the fields, because nothing ordered the writes. Liveness failures: starvation (a writer that never gets a read-write lock because readers keep arriving) and livelock (two threads that keep backing off and retrying in step).

Step 4: pick the mechanism

Start with the cheapest option that preserves the invariant.

Step 5: wait correctly

When a thread must wait for a condition (queue not empty, seat released), use a condition variable or a channel, never a sleep loop. Recheck the condition in a while loop after waking, because wakeups can be spurious and another thread may have consumed the condition first.

The same shape elsewhere

The three bug shapes are about interleaved steps over shared state, not about threads. Microservices have lost updates across two HTTP calls, and databases name the same shapes as isolation anomalies. Spot them in a class and you can spot them in a distributed system, which is why interviewers ask.

Down the stack

Layer 1The contract

A correct concurrent object is linearizable: every method call appears to take effect at one instant between its call and its return, and the resulting history is one that a single thread could have produced. A mutex promises two things, and engineers often remember only the first: mutual exclusion, and visibility. In the Go memory model, the n-th Unlock of a mutex is synchronized before the (n+1)-th Lock returns, so everything written before the unlock is visible after the next lock. A program with a data race (two unsynchronized accesses to one location, at least one a write) gets no such promise; in C and C++ its behaviour is undefined, and in Go and Java it may observe torn or stale values.

Layer 2The mechanism

A mutex is a word in memory plus a wait queue. Go's sync.Mutex is a 32-bit state holding a locked bit, a woken bit, a starving bit and a waiter count, plus a semaphore. Lock first tries one compare-and-swap from 0 to locked; if that fails it spins briefly, then parks the goroutine on the semaphore. If a waiter has waited more than 1 ms, the mutex switches to starvation mode and hands ownership directly to the oldest waiter, trading throughput for fairness. On Linux, pthread mutexes and the JVM's inflated monitors sleep through the futex syscall: user space handles the uncontended case alone, and the kernel is involved only when a thread must actually sleep or be woken. A read-write lock adds a reader count and must decide whether waiting writers block new readers; Go's RWMutex does, to avoid writer starvation.

The Linux kernel's lockdep records every observed "held A while acquiring B" pair as an edge between lock classes and reports a cycle the first time it appears, even if that run did not hang: the ordering rule from Step 3, enforced by a tool.

Layer 3The machine

Cores do not share memory directly; they share 64-byte cache lines (128 bytes on some ARM designs) kept coherent by a protocol in the MESI family. An uncontended lock or atomic on a line already in the local cache costs around 10 to 20 ns. When several cores hammer the same line, each atomic must pull the line exclusively into its own cache, and costs rise to roughly 50 to 200 ns, more across sockets. False sharing is the same cost for unrelated variables that happen to share a line: two per-thread counters 8 bytes apart can run several times slower than counters padded 64 bytes apart.

Atomics compile to real instructions: LOCK CMPXCHG on x86, LDXR/STXR pairs or the newer CAS instruction on ARMv8. CPUs also reorder memory. Each x86 core has a store buffer, so in the "store buffering" test (thread 1 writes x then reads y, thread 2 writes y then reads x) both threads can read 0, an outcome no interleaving of the source code allows. Locks and atomics include fences that forbid this; plain fields do not. A parked thread costs a futex syscall and a context switch, a few microseconds, which is why runtimes spin briefly before sleeping.

Why it's built this way

  1. Cores run in parallel and memory is far slower than registers, so each core caches and buffers writes, and the hardware reorders them.
  2. Reordering breaks naive reasoning, so languages define a memory model and give you a few primitives (locks, atomics) that restore order at known points.
  3. Restoring order costs cache-line transfers and fences, so primitives are designed to be cheap when uncontended and to pay only under contention.
  4. Contention cannot be engineered away by a primitive, so the design-level answer is to share less: confinement, immutable snapshots and single owners.
  5. Bugs depend on timing that tests rarely reproduce, so the tooling controls or records the schedule (race detectors, lockdep, model checkers) instead of hoping to hit it.

Diagram

sequenceDiagram
    participant T1 as Thread 1
    participant M as "Shared count (7)"
    participant T2 as Thread 2
    T1->>M: read count
    M-->>T1: 7
    T2->>M: read count
    M-->>T2: 7
    T1->>T1: 7 + 1
    T2->>T2: 7 + 1
    T1->>M: write 8
    T2->>M: write 8
    Note over M: two increments, one recorded
    Note over T1,T2: Fix: lock, read, add, write, unlock as one step

Worked example

An in-process wallet service moves money between accounts. Version one has no locking and loses updates under load. Version two adds a lock per account:

def transfer(src, dst, amount):
    with src.lock:
        with dst.lock:          # deadlocks when another thread runs transfer(dst, src)
            src.balance -= amount
            dst.balance += amount

def transfer_ordered(src, dst, amount):
    first, second = (src, dst) if src.id < dst.id else (dst, src)
    with first.lock:
        with second.lock:
            if src.balance < amount:
                raise ValueError("insufficient funds")
            src.balance -= amount
            dst.balance += amount

The ordered version breaks circular wait, and the balance check sits inside the locks, so it is not a check-then-act race.

Now ask whether per-account locks were needed at all. Suppose the service handles 2,000 transfers per second and the critical section is 2 us of in-memory work. One global lock is busy 2,000 x 2 us = 4 ms per second, 0.4 percent of the time. Queueing delay at that utilisation is negligible, and the deadlock risk disappears because there is only one lock. At 200,000 transfers per second the same lock is busy 40 percent of the time and tail latency starts to climb (an M/M/1 queue at 40 percent utilisation already waits about 0.7 times the service time on average). That is the point to stripe or go per-account, and the arithmetic, not taste, should drive it.

Trade-offs

MechanismStrengthsWeaknessesChoose when
Confinement or single ownerNo locks on the hot path; easy to reason aboutCross-thread reads need messages; owner can become a bottleneckState has a natural owner (a game room, a shard)
Immutable snapshot, copy-on-writeReaders never block; trivially consistent readsEach write copies; writers must serialiseRead-heavy data that changes rarely (config, routing)
One coarse lockObviously correct; no ordering rulesSerialises everything; slow work inside it stalls all callersCritical section is short and load is moderate
Fine-grained or striped locksScales with independent keysOrdering rules, deadlock risk, harder multi-key invariantsMeasured contention on a coarse lock
Atomics and CAS loopsNo blocking; fastest for one wordOnly one word at a time; ABA problem; retry stormsCounters, sequence numbers, pointer swaps
Optimistic versioningNo locks held while computingWasted work and retries under conflictConflicts are rare; work spans a user think time

Pitfalls in production

Leadership lens

Check yourself

  1. A cache's get updates an LRU list. Why does a read-write lock with get under the read lock not make it safe?

    Show answer

    get mutates the shared list, so concurrent readers would race on it. Either take the write lock for get or redesign so reads do not mutate (for example, record access with an approximate clock bit).

  2. Two threads run transfer(a, b) and transfer(b, a) with one lock per account. What breaks, and what is the smallest fix?

    Show answer

    They can deadlock through a circular wait. Always acquire the two locks in a global order, such as by account id.

  3. Why is "check the seat is free, then hold it" wrong even if both steps are individually thread-safe?

    Show answer

    Another thread can act between the check and the act, so the decision is based on stale state. The check and the hold must be one atomic operation under one lock or one conditional update.

  4. Why can thread 1 read y == 0 and thread 2 read x == 0 in the store-buffering test on x86, and why can't the practice explorer below produce that outcome?

    Show answer

    Each core's store buffer delays its write while its own later read goes to memory. The explorer models sequential consistency, where every read sees the latest write in one global order, so it shows only what fences and locks guarantee.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →