The idea: count++ is three steps, not one
Two threads share int count = 0 and each runs count++ once. You expect 2. But the CPU cannot add to memory directly in plain code: it loads count into a register, adds 1 to the register, and stores the register back. Each CPU has its own registers, so each thread works on its own private copy between the load and the store.
A race condition is a bug where the result depends on the timing of threads. Here the timing that matters is whether one thread's load falls between the other thread's load and store.
Interleaving and the lost update
The OS can switch threads after any instruction, and on two cores both run at once. Take this order: T1 loads 0, T2 loads 0, both add 1, T1 stores 1, T2 stores 1. Two increments ran and count = 1. T2's store was computed from a value that was already out of date, so it wiped out T1's work. That is a lost update (Demo: interleaved, an update is lost). If T2 loads only after T1 has stored, T2 sees 1 and the result is 2 (Demo: one after the other).
How many orders lose an update
Each thread's three instructions keep their own order, so an interleaving is a choice of which 3 of the 6 slots belong to T1: C(6, 3) = 20 orders. Only 2 of them give the right answer.
| Order | How many | count at the end |
|---|---|---|
| All of T1, then all of T2 (or the reverse) | 2 | 2 ✓ |
| One thread loads before the other has stored | 18 | 1 ✗ |
In a real program the window between load and store is a few nanoseconds, so most runs are fine. Run the loop a million times on two cores and many updates are lost. That is why race bugs pass tests and fail in production.
Fixes: make the three steps indivisible
The read-modify-write must look like one step to every other thread. Two common ways:
| Fix | How | Cost |
|---|---|---|
| Atomic instruction | atomic_fetch_add(&count, 1) in C, AtomicInteger.incrementAndGet() in Java, compiled to lock add / lock xadd on x86. The CPU holds the cache line for the whole read-add-write. | One instruction; works for one variable only |
| Mutex | lock(m) before and unlock(m) after the three instructions. A thread that finds m held sleeps until it is released. | Protects any number of variables; waiting threads sleep |
Run the same order with each fix (Demo: same order with an atomic add, Demo: same order with a mutex): count ends at 2 every time. The code between lock and unlock is the critical section. How a mutex sleeps and wakes is on Mutex with a Futex; a lock-free retry loop is on Compare-and-Swap; the simplest lock is the Test-and-Set Spinlock.
Data races in real languages
In C and C++, two threads touching the same plain variable at once, at least one writing, is a data race and is undefined behaviour: the compiler may keep count in a register for the whole loop or merge increments. Java defines what happens, but the lost update still happens. volatile does not help: it makes each load and store go to memory, but they are still three separate steps. Tools such as ThreadSanitizer (-fsanitize=thread) and Go's -race flag find these bugs at run time.
See also Concurrency vs Parallelism, where the same counter race happens on several cores.
What the page leaves out
Each thread runs count++ only once, on two CPUs, and every instruction is visible to the other CPU as soon as it runs. Real CPUs have store buffers and caches, and compilers reorder code, so other orders are possible too (memory models, fences). Other race shapes are left out: check-then-act (if (!exists) create()), read-copy races on structures, and races between processes on files.
See where this page fits on the Concurrency mind map.