Skip to content

Concurrency & Thread Safety

Shared mutable state, race conditions, locks and deadlock — and why the best concurrent code is the code that shares nothing.

Advanced17 minUpdated 27 July 2026

Single-threaded code is easy to reason about because statements happen in order. The moment two threads touch the same data, that guarantee disappears — and the bugs that follow are the least reproducible you will ever meet.

The whole subject reduces to one sentence: problems come from shared mutable state. Remove either word — share nothing, or mutate nothing — and most of this page stops applying.

The bug that a single line hides

counter++;   // one line of source, three operations

That is read, add, write. Two threads can interleave between any of them:

Thread A reads  counter = 5
Thread B reads  counter = 5
Thread A writes counter = 6
Thread B writes counter = 6      ← one increment lost

Two increments, one result. This is a race condition: correctness depends on timing you do not control. It appears under load, disappears under a debugger, and passes every test you write.

The same shape hides in code that does not look like arithmetic — check-then-act is the common one:

if (!map.containsKey(key)) {   // both threads see "absent"
    map.put(key, compute());   // both compute, one result is discarded
}

Each operation may be individually thread-safe. The sequence is not, and that is the distinction most people miss.

Making an operation atomic

Atomic means indivisible: other threads see the state before or after, never midway.

Locks (mutexes) are the general tool. One thread holds the lock, others wait. Correct, and the cost is contention — waiting threads do no work, so a lock on a hot path becomes the bottleneck the rest of your design was trying to avoid.

Atomic primitives use hardware compare-and-swap instructions for single values — counters, flags, references. Much cheaper than a lock, but only for one variable at a time. They cannot make a multi-step sequence atomic.

Concurrent collections already handle their own synchronisation internally, usually with finer-grained locking than you would write. Prefer them to wrapping an ordinary collection in your own lock.

Deadlock, and the rule that prevents it

Conceptual diagramwaits for Bwaits for AThread 1 holds ALock BThread 2 holds BLock A
Diagram, as text
  • Thread 1 holds A leads to Lock B (waits for B)
  • Thread 2 holds B leads to Lock A (waits for A)
  • Lock B leads to Thread 2 holds B
  • Lock A leads to Thread 1 holds A

Two threads, two locks, opposite order:

Thread A: holds Lock 1 → waits for Lock 2
Thread B: holds Lock 2 → waits for Lock 1

Neither can proceed and neither will time out. The application does not crash — it simply stops, which is harder to diagnose than a crash.

Deadlock needs four conditions simultaneously, and breaking any one prevents it. The practical lever is ordering: always acquire locks in the same global order, everywhere in the codebase. If every thread takes Lock 1 before Lock 2, the cycle cannot form.

Two supporting habits. Use timeouts on lock acquisition so a mistake degrades into a recoverable error rather than a permanent hang. And never call out to unknown code while holding a lock — a callback, a listener, or a remote call may acquire another lock, and you no longer control the order.

The related failure is livelock: threads are running and responding to each other but no work completes — two people stepping aside in a corridor, repeatedly. Randomised backoff breaks the symmetry, the same trick that jitter performs for retries.

Hold locks for as little as possible

A lock's cost is proportional to how long it is held and how many threads want it.

Never do I/O inside a lock. A network call inside a critical section holds it for milliseconds while every other thread waits. Compute what you need, take the lock, mutate, release.

Lock the data, not the method. Synchronising an entire class serialises unrelated operations. Separate locks for separate state let independent work proceed.

Prefer read-write locks where reads dominate. Many readers can safely share; only writers need exclusivity.

The approaches that avoid the problem

Every technique above manages shared mutable state. These remove it, and they are usually the better answer.

Immutability. An object that cannot change after construction is automatically safe to share — no locks, no races, no ordering rules. "Changing" it means producing a new one. This is the single highest-leverage habit in concurrent design.

Confinement. Give each thread its own copy and there is nothing to contend over. Thread-local state and the shared-nothing worker model both work this way.

Message passing. Threads own their state privately and communicate by sending messages through a queue. The queue is the only synchronised thing, and it is written once by people who specialise in it.

Visibility, not just atomicity

A subtler failure. Even without an interleaved write, one thread may simply never observe another's change — compilers and CPUs reorder and cache values in registers, so a variable written by thread A can remain stale in thread B indefinitely.

The classic symptom is a polling loop on a boolean running flag that never notices the flag was set to false.

Languages provide a way to mark a variable as requiring visible, ordered access. Locks provide the same guarantee as a side effect of acquiring and releasing. This is why a plain flag shared between threads needs more care than it appears to — and another reason to prefer the tools above to hand-rolled coordination.

Check your understanding

Common mistakes

Assuming a single statement is atomic. counter++ is three operations.

Check-then-act on a thread-safe collection. The operations are safe; the sequence is not.

Inconsistent lock ordering. The direct cause of nearly every deadlock.

Doing I/O while holding a lock. Turns a microsecond critical section into a millisecond one.

Synchronising whole classes. Serialises operations that never conflicted.

Sharing a plain flag between threads. A write may never become visible to the reader.

Testing concurrency on one core with light load. Races need contention to appear.

Reaching for locks first. Immutability and confinement solve the problem instead of managing it.

Practice

A booking method reads a seat's status, checks it is free, and writes it as taken. Under load, two customers occasionally get the same seat. Give three fixes at different layers and the trade-offs.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

The bug is that the check and the write are not one operation. There is more than one place to make them so.

Show a strong answer

The defect is a check-then-act race: between reading free and writing taken, another thread does the same. Three places to fix it:

  • In the database, with a conditional update. UPDATE seats SET status='taken', holder=:id WHERE id=:seat AND status='free', then check how many rows were affected. Exactly one caller gets 1; the others get 0 and know they lost. Trade-off: almost none — this is the right answer in most systems. It needs no additional infrastructure and the database is already the point of serialisation. It requires the check to be expressible as a WHERE clause.
  • In the database, with a transaction and row locking. Select the row for update inside a transaction, so the second caller blocks until the first commits. Trade-off: correct, but holds a lock for the duration of the transaction. If anything slow happens inside it — a payment call — you have serialised on a lock across a network round trip, which is how throughput collapses.
  • In the application, with a lock. A mutex around the section. Trade-off: only works within a single process. The moment there are two application instances, each has its own lock and the bug returns — usually discovered after a scale-out, in production. A distributed lock fixes that and brings the lease and fencing problems with it.

Preference: the conditional update. It makes the check and the write a single atomic operation at the layer that already provides atomicity, and it keeps working when you add a second instance.

One thing all three need: decide what the loser sees. Not an error page — a clear "that seat has just gone, here are alternatives".

Two methods each acquire lock A and lock B, and the system deadlocks intermittently. Explain the mechanism, the rule that prevents it, and one design that avoids locks entirely.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Look at the order each method acquires them in.

Show a strong answer

The mechanism. Method one takes A then B; method two takes B then A. Thread 1 holds A and waits for B; thread 2 holds B and waits for A. Neither can proceed and neither will release. It is intermittent because it needs both threads to be between their two acquisitions at the same instant — which is rare under light load and common under heavy load.

The rule that prevents it: acquire locks in a globally consistent order. Define a total order over lockable resources — by ID, by name, anything deterministic — and require every code path to take them in that order. If both methods take A before B, the cycle cannot form. This is cheap, needs no runtime machinery, and is the standard answer.

Two supporting practices:

  • Use a timeout on acquisition so a deadlock becomes a recoverable failure rather than a permanent hang. This detects rather than prevents, but it turns an outage into an error.
  • Hold locks for as short a time as possible, and never across a network call — the longer the window, the more likely the overlap.

A design that avoids locks entirely: give each piece of shared state a single owner and communicate by passing messages to it. A queue per account, processed by one consumer, means no two threads ever touch the same account concurrently — so there is nothing to lock. Ordering is preserved by the queue and throughput scales by adding accounts rather than by adding contention.

The trade-off is that the work becomes asynchronous and the caller receives an acknowledgement rather than a completed result — which is fine for a transfer and wrong for something that must answer immediately.

Key takeaways

  • Every concurrency bug traces to shared mutable state — remove the sharing or the mutation and it disappears.
  • A race condition makes correctness depend on timing: it appears under load and hides under a debugger.
  • Thread safety does not compose. Safe operations in sequence are not a safe sequence; use compound atomic operations.
  • Prevent deadlock with a consistent global lock order, acquisition timeouts, and no foreign calls while holding a lock.
  • Hold locks briefly: never across I/O, scoped to data rather than methods, read-write where reads dominate.
  • Immutability and confinement beat locking — they eliminate the problem rather than managing it.
  • Visibility is a separate guarantee from atomicity; a shared plain flag can go unnoticed forever.

Further reading

  • Brian Goetz et al., Java Concurrency in Practice (Addison-Wesley, 2006) — atomicity, visibility and the memory model.
  • Leslie Lamport, "Time, Clocks, and the Ordering of Events in a Distributed System" (CACM, 1978).
  • Edsger Dijkstra, "Cooperating Sequential Processes" (1965) — mutual exclusion and deadlock, stated first.
  • Maurice Herlihy and Nir Shavit, The Art of Multiprocessor Programming, second edition (Morgan Kaufmann, 2020).
  • Joe Armstrong, Making Reliable Distributed Systems in the Presence of Software Errors (doctoral thesis, 2003) — message passing instead of shared state.

Check yourself

3 questions in this lesson. Answer each once to complete it.

Practice this

Next lessonConfiguration Management
Lessons in this track