Race Conditions and Deadlocks
Concept
A race condition occurs when the correctness of a program depends on the relative timing of concurrent operations — two or more threads/processes access shared mutable state, and the outcome differs depending on which one gets scheduled first. The canonical example is a read-modify-write on a counter: thread A reads count = 5, thread B reads count = 5 before A writes back, A writes 6, B writes 6 — one increment is silently lost. Neither thread did anything individually wrong; the bug exists only in the interleaving. A related and especially dangerous variant is TOCTOU (time-of-check to time-of-use): code checks a condition ("does this file exist?", "is this seat still available?") and then acts on it, but another thread changes the underlying state in the gap between the check and the act.
A deadlock is a different failure mode: two or more threads each hold a resource the other needs, and each is waiting for the other to release it, so neither ever proceeds. Edward Coffman's 1971 paper formalized the four necessary conditions for deadlock to occur — all four must hold simultaneously:
- Mutual exclusion — a resource can be held by only one thread at a time.
- Hold and wait — a thread holds one resource while waiting for another.
- No preemption — a resource can't be forcibly taken from the thread holding it.
- Circular wait — a cycle exists in the "waiting for" graph (A waits for B's resource, B waits for A's).
Breaking any single one of the four prevents deadlock entirely — which is why the standard fixes (below) each target exactly one condition rather than trying to eliminate all of them at once.
Tradeoffs
| Prevention strategy | Coffman condition broken | Cost |
|---|---|---|
| Fine-grained locking (narrowest lock around the smallest critical section) | N/A — reduces race window, doesn't address deadlock directly | More locks to reason about; easy to under-protect a shared field if the boundary is drawn wrong |
| Consistent lock ordering (always acquire locks A, then B, everywhere in the codebase) | Circular wait | Requires discipline across the whole codebase — one call site acquiring locks out of order reintroduces the risk silently |
| Lock timeouts / try-lock with backoff | Hold and wait (indirectly — a timed-out thread releases and retries instead of waiting forever) | Doesn't prevent the deadlock, just bounds how long it lasts; adds retry/backoff complexity and can thrash under contention |
| Lock-free / wait-free data structures (atomics, CAS loops) | Mutual exclusion (no lock to deadlock on at all) | Significantly harder to design and verify correct; not a general substitute for locking in most application code |
There's no free option here: reducing the race-condition surface (narrower locks) tends to increase the number of distinct locks in play, which is exactly what increases deadlock risk if ordering discipline isn't enforced. The two failure modes pull against each other, which is why "just add more locking" is not, by itself, a safe fix for a race.
When to use / when not to
- Reach for a lock (mutex) whenever multiple threads read and write the same mutable state — reads-only shared state under concurrent access doesn't need one.
- Establish and document a global lock-acquisition order the first time a code path needs to hold two locks at once — retrofitting ordering discipline after a deadlock has already shipped is far more expensive than establishing it at the second lock's introduction.
- Use a lock-free structure (an atomic counter, a CAS-based queue) only when profiling shows the lock itself is the bottleneck under real contention — reaching for lock-free code by default, before measuring, trades a correctness bug you can reason about (a lock) for a subtler one (memory-ordering bugs) that's harder to debug.
- Don't rely on "it hasn't happened in testing" as evidence a race-prone code path is safe — races are timing-dependent by definition, and a low-contention test environment can pass thousands of runs while production, under real concurrent load, hits the interleaving that breaks it.
Common pitfall
Fixing a reported race by wrapping the symptom's line of code in a lock, without identifying the actual shared state and its full set of access points. A classic version: a bug report shows two threads corrupting a shared list, so a lock gets added around the one append() call that crashed — but a remove() call elsewhere in the codebase, iterating without holding the same lock, still races against it. The lock only protects call sites that acquire it; every access path to the shared state needs to go through the same lock (or none of them are actually safe), and finding all of them requires tracing the shared state itself, not just the stack trace of the one crash that got reported.
Engineering Lens
Race conditions and deadlocks are the two faces of the same underlying fact: shared mutable state under concurrency is where correctness bugs concentrate, and the fixes for each failure mode pull in opposite directions (narrower locking reduces races but multiplies the surface for deadlock; coarser locking reduces deadlock risk but widens the window for races and kills throughput). The Principal-level habit worth having is reasoning about the shared state's full access graph before writing the first lock, not adding locks reactively as bugs get reported — and being able to name, for any lock in the system, exactly which invariant it protects and what order it's acquired relative to every other lock a single code path might hold. That discipline is what actually prevents the class of incident where a system runs fine for months and then deadlocks or double-processes under a specific load pattern nobody tested for.
Sources
- System Deadlocks — E. G. Coffman, M. Elphick, A. Shoshani, ACM Computing Surveys, 1971
- Race Condition — MDN Web Docs Glossary
- Deadlock — Wikipedia