The lost update
Here is the most expensive small bug in software. Two threads both run counter += 1, which looks atomic and is not. Under the hood it is three steps, just like the CPU simulator from lesson 1-2:
- read the current value of
counterinto the thread - add 1 to it
- write the result back
The OS can pause a thread between any two steps, for reasons unit 6 explains. If thread B reads while thread A is paused between its read and its write, both read the same old value, both write back the same new value, and one increment vanishes.
| Step | Thread A | Thread B |
|---|---|---|
| read | sees 0 | sees 0 |
| add | computes 1 | computes 1 |
| write | stores 1 | stores 1 |
That is a race condition, where correctness depends on lucky timing. The block below forces the unlucky interleaving on purpose, so the lost update is visible deterministically rather than once in a thousand runs.
A lost update, re-enacted by hand
Two pretend threads each try counter += 1, but B reads before A writes back.
counter = 0 a_read = counter # A reads 0 b_read = counter # B reads 0 (A has not written back yet) counter = a_read + 1 # A writes 1 counter = b_read + 1 # B writes 1, stomping on A's update print("expected 2, got", counter)
Output
expected 2, got 1
Both reads happened before either write, so both threads computed 1 from the same stale value.
Each thread did nothing wrong on its own. The interleaving is the bug, which is what makes races so hard to find by reading one function at a time.
The real thing, with four unprotected threads
Four threads each add to a shared counter 100,000 times with no protection at all.
import threading counter = 0 def worker(): global counter for _ in range(100_000): counter += 1 threads = [threading.Thread(target=worker) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print("got:", counter, "(wanted 400000)")
There is no single expected output, because the result is genuinely unpredictable and differs from run to run. On some Python versions the total comes out below 400000, and on others the timing rarely bites.
That unpredictability is the point. A bug that appears only sometimes, under load, on some machines is the signature of a race condition, and they are famously hard to reproduce, which is why the previous block simulated one deterministically.
Why races are the worst kind of bug
Three properties combine into the worst possible debugging experience.
| Property | Consequence |
|---|---|
| they pass tests | light test load rarely triggers the bad interleaving |
| they appear in production | real traffic means real concurrency |
| they vanish when observed | adding prints changes the timing, a heisenbug |
Real-world victims include double-spent account balances, two users granted the same username, and inventory systems selling the last item twice.
The habit worth building is a question. Any time you read shared data, compute, and write back, ask what happens if someone else writes in between. If the answer is bad, that sequence needs protecting.
Selling two tickets for one seat
A site that checks seats_left > 0 and then runs seats_left -= 1 can sell two tickets when seats_left is 1, if two requests arrive at nearly the same instant.
Check-then-act is a race window. Both threads read 1, both pass the check, and both decrement, so seats_left ends at -1 and two customers hold one seat.
| Order of events | Result |
|---|---|
| check, decrement, check, decrement | correct, the second check fails |
| check, check, decrement, decrement | oversold |
The check and the update must happen as one uninterruptible unit, which is exactly what the next lesson builds. Note that a negative seats_left is often the first visible symptom, long after the second ticket was already emailed.
Naming the timing-dependent bug
A bug whose result depends on the unlucky timing or interleaving of concurrent operations is a race condition.
Two or more threads access shared data, at least one of them writes, and the outcome depends on who gets there first. The name is literal, since the threads are effectively racing to the shared data.
| Ingredient | Required |
|---|---|
| shared data | yes |
| at least one writer | yes |
| unsynchronized access | yes |
Remove any one ingredient and the race is gone, which is why read-only shared data is safe and why per-thread copies need no locking. The general fix is to make the read-modify-write sequence atomic with a lock, which is next.