Course outline · 0% complete

0/28 lessons0%

Course overview →

Race conditions

lesson 5-2 · ~12 min · 16/28

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:

  1. read the current value of counter into the thread
  2. add 1 to it
  3. 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.

StepThread AThread B
readsees 0sees 0
addcomputes 1computes 1
writestores 1stores 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.

thread Athread Bread: sees 0read: sees 0write 1counter ends at 1, and the expected answer was 2neither thread is wrong on its own, the interleaving is the bugwrite 1, overwriting A
Both threads read the same old value, so one of the two increments disappears.

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.

PropertyConsequence
they pass testslight test load rarely triggers the bad interleaving
they appear in productionreal traffic means real concurrency
they vanish when observedadding 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 eventsResult
check, decrement, check, decrementcorrect, the second check fails
check, check, decrement, decrementoversold

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.

IngredientRequired
shared datayes
at least one writeryes
unsynchronized accessyes

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.