Anatomy of a race condition
“Two threads each increment a shared counter 1,000 times and the final value is less than 2,000. Explain exactly what happened at the instruction level, and give three different fixes with their costs.”
What this tests
- That
count++is a load, an add and a store, and where the interleaving happens - That a single core with preemption is enough; multi-core adds visibility and reordering
- Fix options: mutex, atomic, per-thread sharding, avoiding sharing
- Race condition vs data race, and the check-then-act pattern
Answers by level
Read the beginner answer first and notice what is missing.
count++ compiles to three steps: load count into a register, add one, store the register back. Thread A loads 41. The scheduler preempts A — a timer interrupt, on a single core — and runs B, which loads 41, adds, stores 42. A resumes, adds to *its* 41, stores 42. One increment is lost. Over 2,000 increments with frequent switches or on two cores running truly simultaneously, dozens or hundreds vanish. No core count is required; only interleaving and shared state (Race Conditions).
On multiple cores there is a second problem even without preemption: each core has its own cache and store buffer, and the compiler may keep count in a register for the whole loop. Without synchronization there is no guarantee a store by one core is ever *seen* by the other, or seen in order. In C++ this is a data race and undefined behaviour; in Java, Go and C# the memory model says what you get, and it is not what you want (A Taxonomy of Concurrency Bugs).
Fixes, in increasing specialisation. A mutex around the increment makes the three steps a critical section; correct, general, ~20 ns uncontended, but a serialisation point under contention (Mutexes). An atomic fetch_add makes the hardware do read-modify-write as one indivisible instruction (lock xadd on x86); no lock, no blocking, but the cache line still ping-pongs between cores, so it does not scale either (Atomic Operations). Sharding: give each thread its own counter and sum on read — no shared writes at all, the pattern behind per-CPU counters in the kernel and striped counters in libraries. Or restructure so only one thread owns the state and others send it messages.
Two distinctions the interviewer is listening for. A data race (unsynchronised conflicting accesses) is not the same as a race condition (an outcome that depends on timing) — if (!map.contains(k)) map.put(k, v) with a thread-safe map has no data race and is still a race. And JavaScript on one thread has no data races on JS values but has logical races across awaits; CPython’s GIL does not make += 1 atomic because it is several bytecodes.
Green flags · Red flags
- Breaks
count++into load/add/store and shows the interleaving - Says one core with preemption suffices; multi-core adds visibility
- Offers mutex, atomic and sharding with their costs
- Distinguishes data race from race condition
- Mentions cache-line contention or false sharing
- Believes the race needs two cores
- Thinks
volatileor the GIL makes the increment atomic - Only knows "use a lock"
Follow-up questions
if absent then put safe?Scenario
total += 1 inside a std::thread pool in C++. Explain the discrepancy and choose a fix appropriate for a hot path on 32 cores.