Concurrencyrace conditionlost updatememory modelvisibilityreordering

A Taxonomy of Concurrency Bugs

Concurrency bugs come in seven recognisable shapes — race, lost update, visibility/ordering, deadlock, livelock, starvation, priority inversion — each with a distinct mechanism and a distinct production symptom, and naming the shape is most of the diagnosis.

ConceptualC++CPythonLinux
▶ InteractiveInterview question
Progress

The problem

A test passes 999 times and fails once. A service hangs at 3 a.m. with 0% CPU. A counter is off by a few hundred per day. A rover on Mars keeps rebooting. These are not four unrelated mysteries; they are four entries in a short catalogue, and each has a known signature.

Races and lost updates: two threads, one variable

A race condition is any outcome that depends on the relative timing of threads (or processes, or requests) when the program did nothing to control that timing. The simplest carrier is a lost update: thread A reads balance = 100, thread B reads balance = 100, A writes 90, B writes 80; one withdrawal has vanished. Nothing crashed, no error was raised — the only evidence is that the number is wrong. Race Conditions takes this apart instruction by instruction.

Races hide behind anything that is read-then-written: counter++, if (!cache.has(k)) cache.set(k, compute()), if (!exists(path)) create(path), "check the balance then debit it". The database domain calls the same shape by the same name — the lost update anomaly (Concurrency Anomalies) — and the cure is the same family: make the read-modify-write atomic (a lock, an atomic instruction, UPDATE … SET n = n + 1, or compare-and-swap).

In production a race looks like drift: a metric slightly under the true count, a duplicate row a few times a week, a cache entry computed twice. In test suites it looks like flakiness — the interleaving that loses the update is rare, so the test passes on the laptop (two cores, light load) and fails on CI (many cores, heavy load, different timing). "Works on my machine" is often a literal description of the scheduler on that machine.

A lost update: both threads read before either writes
1shared balance = 100
2
3Thread A Thread B
4r = balance # 100
5 r = balance # 100
6balance = r - 10 # 90
7 balance = r - 20 # 80 <- A's withdrawal is gone
8expected 70, actual 80

Visibility and ordering: the memory model

C++

The second family has no interleaving at all — the program is "correct" if you assume every write is instantly seen by every thread in the order written. Hardware and compilers do not promise that. A visibility bug: thread A loops while (!done) {}; thread B sets done = true; A never exits. The compiler, seeing that nothing in the loop writes done, hoisted the load out of it; or the CPU keeps the stale value in a register. In C++ this is a data race and formally undefined behaviour; in Java the fix is volatile; in Go the race detector flags it; in every language the real fix is an atomic or a lock, which carry the ordering guarantees.

An ordering bug is subtler. Thread A writes data = 42 then ready = true; thread B reads ready == true then reads data and sees 0. On x86 that specific pattern cannot happen (stores are not reordered with other stores), but the compiler may reorder the two stores, and on ARM and POWER the CPU itself may — their memory models are weaker. The store-buffer reordering that x86 *does* permit (a store followed by a load to a different address may become visible in the other order) breaks Dekker-style mutual exclusion, which is why lock implementations end with a full fence. Atomic Operations covers acquire/release, the vocabulary for stating what order you need.

Symptom in production: works on x86 developer laptops and CI, fails on ARM servers (Graviton, Apple Silicon); or works at -O0 and fails at -O2. Tests almost never catch it because the reordering window is nanoseconds wide. Tools do: ThreadSanitizer (-fsanitize=thread) reports data races in C++ and Go; Java’s JCStress hunts reorderings; perf shows nothing because nothing is slow.

Deadlock, livelock, starvation

Linux

Deadlock: thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1; neither can proceed, ever. The process is alive, uses no CPU, and answers nothing. Deadlocks gives the four conditions and the cycle-detection view. Signature: a hang with 0% CPU; every thread in futex_wait or pthread_cond_wait in a stack dump; jstack literally prints "Found one Java-level deadlock". The cure is to break one condition — usually by imposing a global lock order.

Livelock is deadlock with motion. Two threads each detect the conflict, back off, retry at the same moment, conflict again. Two people in a corridor stepping the same way. Symptom: 100% CPU, no progress, no lock held long enough to show in a dump. Real cases: optimistic retries with fixed delays, two transactions repeatedly aborting each other, try_lock loops without randomised backoff. The fix is asymmetry — randomised or exponential backoff, or a priority rule so one side yields.

Starvation: a thread that is always eligible to run or acquire a lock but never does, because others keep winning. A reader-writer lock with a steady stream of readers never lets the writer in; a low-priority thread never gets the CPU on a saturated box; a lock without FIFO fairness lets a thread that just released it re-acquire it while a waiter is still waking (the "barging" that makes unfair locks fast and occasionally cruel). Symptom: one request’s latency is unbounded while the median is fine; one background job never completes. Fixes: fair queues, writer preference, bounded spinning, aging.

The deadlock signature in a stack dump: every thread waiting, nothing running
$ gdb -p 41822 -batch -ex 'thread apply all bt 3' 2>/dev/null | grep -E '^Thread|futex|cond_wait'
Thread 12 (LWP 41901): #0 futex_wait ... #1 __pthread_mutex_lock (mutex=0x55d3c0 <accounts_lock>)
Thread 11 (LWP 41900): #0 futex_wait ... #1 __pthread_mutex_lock (mutex=0x55d400 <audit_lock>)
Thread 10 (LWP 41899): #0 futex_wait ... #1 pthread_cond_wait (cond=0x55d480 <work_available>)
...
Thread 1  (LWP 41822): #0 futex_wait ... #1 __pthread_mutex_lock (mutex=0x55d3c0 <accounts_lock>)
$ top -p 41822 -bn1 | tail -1
  41822 app  20 0  1.2g 310m  0.0  0.5  hung-server

Priority inversion

A low-priority thread L holds a lock. A high-priority thread H needs it and blocks. A medium-priority thread M, needing nothing, becomes runnable and — being higher priority than L — runs instead of L. H, the most important thread in the system, now waits on M, which has no relationship to it at all. This is priority inversion, and it is not academic: the Mars Pathfinder lander in 1997 kept resetting because a high-priority bus-management task missed its deadline waiting on a low-priority meteorological task that a medium-priority communications task kept preempting; the watchdog concluded the system had hung.

The fix Pathfinder received by remote patch is priority inheritance: while L holds a lock that H wants, L temporarily runs at H’s priority so M cannot preempt it. POSIX exposes it as PTHREAD_PRIO_INHERIT on mutex attributes; the Linux real-time patches and futex support it (FUTEX_LOCK_PI); the alternative, priority ceiling, raises any lock holder to the highest priority of any thread that may take the lock. General-purpose servers rarely hit this because they rarely use strict priorities — but anything with real-time threads (audio, control loops, SCHED_FIFO on Linux) can, and the symptom is deadline misses that correlate with *unrelated* load.

  • Needs three priority levels and a shared lock; the middle one is the villain.
  • Priority inheritance is per-mutex opt-in on POSIX; default mutexes do not have it.
  • Same shape in The Scheduling Problem terms: the scheduler is doing what it was told, and what it was told is wrong.

Reading the symptom back to the shape

Most of the diagnostic value of the taxonomy is that the symptoms barely overlap. Wrong numbers with no errors: a race or lost update. Works on x86, fails on ARM, or works unoptimised: visibility/ordering. Hang at 0% CPU with all threads waiting: deadlock. Hang at 100% CPU with no progress: livelock, or a spinlock convoy (Mutexes). Median latency fine, tail unbounded, one job never finishes: starvation. Deadline misses under unrelated load in a real-time system: priority inversion.

The same catalogue exists one floor up. Database transactions suffer lost updates, write skew and deadlocks (Locks and Deadlocks) for identical reasons, and the database’s remedies — locks, versioning, deadlock detection, retries with backoff — are the same remedies with different names. If you can name the shape in one domain you can name it in the other.

Shape → mechanism → what you see
BugMechanismProduction symptomTypical fix
Race / lost updateunsynchronised read-modify-writewrong counts, duplicates, flaky testslock, atomic RMW, CAS, DB atomic update
Visibilitycompiler/CPU keeps a stale valueloop never exits; works at -O0atomic / volatile / lock
Orderingstores or loads reorderedreads a flag before its data; fails on ARMacquire/release, fences, lock
Deadlockcircular wait on lockshang, 0% CPU, all threads in futex_waitlock ordering, try-lock + backoff, timeouts
Livelockeveryone yields and retries in lockstephang, 100% CPU, no lock heldrandomised / exponential backoff
Starvationunfair arbitrationone thread/request never completes; tail latencyfair locks, writer preference, aging
Priority inversionlow-priority holder preempted by mediumdeadline misses under unrelated loadpriority inheritance / ceiling

Key points

  • Seven shapes: race/lost update, visibility, ordering, deadlock, livelock, starvation, priority inversion. Each has a distinct mechanism and a distinct symptom.
  • A lost update is an unsynchronised read-modify-write; the evidence is a wrong number, never an error.
  • Visibility and ordering bugs come from compilers and CPUs, not interleaving; they surface as "works on x86 / at -O0" and are caught by sanitizers, not tests.
  • Deadlock is a hang at 0% CPU; livelock is a hang at 100%; starvation is unbounded tail latency for one party.
  • Priority inversion needs three priorities and a shared lock; priority inheritance is the fix and is opt-in.
  • Databases have the same catalogue — lost update, write skew, deadlock — under the same names.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why do concurrency bugs escape testing so reliably?

Because the failing interleaving or reordering is a window of nanoseconds that the test machine may never hit; the program is nondeterministic and the test observes one sample. Sanitizers and model checkers reason about all interleavings; a test runs one.

Why is a deadlock silent and a livelock loud?

Deadlocked threads are asleep in the kernel waiting for a wake that never comes, so they consume nothing. Livelocked threads keep running their retry logic, so they consume everything. Same lack of progress, opposite CPU graphs.

Why does the memory model matter if I use locks?

Locks are implemented with the same atomics and fences and give you their guarantees for free; the model matters the moment you read shared data without one — a flag, a "cheap" unlocked check, a counter you thought was harmless.

Concurrency bug zoo

Concurrency bug zoo
Pick a bug. The same code with a different interleaving sometimes works — which is exactly why these bugs survive tests.
tThread AThread B
1load r ← count (0)
2load r ← count (0)
3store count ← 1
4store count ← 1
Result: count = 1 — one increment vanished. Nothing in the code changed — only who ran first.
Symptom in production
A metrics counter under-reports by a fraction of a percent. A test passes 10,000 times locally and fails once in CI at 3 a.m.
Fix
Make the read-modify-write one indivisible step: an atomic `fetch_add`, or a mutex around load+add+store.
Fails per run (model)
≈ 35%
showing the failing interleavingEducational model

How it fails

What the failure looks like from inside real software.

  • A metrics counter is consistently 0.3% below the true value: unsynchronised increments from several threads.
  • CI is flaky at 1 in 500 runs on a 64-core runner and never locally: a real race exposed by more parallelism.
  • A spin-wait on a plain boolean never exits when the build is optimised: the load was hoisted; the flag needed to be atomic.
  • Service hangs at 0% CPU after a burst; every thread is in futex_wait; a restart "fixes" it: deadlock on two locks taken in opposite orders.
  • Two workers retrying an optimistic update each abort the other forever at 100% CPU: livelock from synchronous retry timing.
  • A nightly compaction job never finishes on a busy node: reader-writer lock starvation of the writer.