Race Conditions and Mutexes Explained: The Lost Update
7 min readBytePatterns
Why count += 1 loses updates when two threads share it, every interleaving that causes it, and how a mutex removes those schedules instead of speeding code up.
Two threads each add one to a shared counter that starts at zero. Most of the time the counter ends at 2. Occasionally it ends at 1, with no exception, no warning and no way to reproduce it on demand. That is a race condition, and it is less mysterious than it looks: a short list of possible orderings, and some of them are wrong. A mutex fixes it by making the wrong orderings impossible.
The problem it solves
count += 1 reads like one action, but a processor performs it as three: read the current value into a register or local, add one, write the result back. A thread can be paused between any two of those steps while another thread runs.
A race condition is any bug where the result depends on how the steps of concurrent threads happen to interleave. The version here — two read-modify-write sequences overlapping so that one write erases the other — is called a lost update. It is the same bug as two people editing a shared document offline and the second upload overwriting the first.
The intuition
Give each thread two steps: read (copy the shared value into a private variable) and write (store private + 1). Thread A's steps must happen in order, and so must B's, but the scheduler may interleave them any way it likes. With two steps each there are exactly six orderings.
The outcome depends on one question: did the second thread read before or after the first thread wrote? If A writes before B reads, B sees 1 and writes 2. If both read before either writes, both see 0 and both write 1. Four of the six orderings do the second thing.
A mutex (mutual-exclusion lock) is a token only one thread can hold. A thread takes it before the read and returns it after the write, so no other thread can start its read in between. The mutex does not speed anything up or change the arithmetic. It deletes the four bad orderings from the list of things the scheduler is allowed to do.
Watch it run
The animation plays the lesson's schedule on a two-lane timeline. Thread A reads 0, the scheduler switches, and thread B reads the same 0. A writes 1, then B writes 1 as well, computed from its stale copy — A's increment is gone. Then the same two threads run in the lucky order, and the same code produces 2.
Race Conditions
Step 1 of 12
One counter, two threads, each running count += 1. The schedule decides the answer.
The same interactive animation as the lesson — step through it with the controls.
The code
Real threads make races hard to study, because the schedule changes on every run. So the model below makes the schedule an input: a string like "ABAB" says which thread takes its next step. It enumerates every ordering with and without a lock:
from collections import Counter
from itertools import permutations
def run(schedule, locked):
"""Each letter is one step of that thread, alternating read and write:
count += 1 split into its two halves. With locked=True a thread holds
the lock from its read to its write; a schedule that makes another
thread read in between is impossible, so it returns None."""
count, local, holder, steps = 0, {}, None, Counter()
for t in schedule:
if steps[t] % 2 == 0: # read into a private copy
if locked and holder not in (None, t):
return None # would block: never happens
holder = t if locked else None
local[t] = count
else: # add one and write it back
count = local[t] + 1
holder = None
steps[t] += 1
return count
orders = sorted(set(permutations("AABB"))) # every interleaving
print(len(orders)) # 6
print(Counter(run(o, locked=False) for o in orders)) # Counter({1: 4, 2: 2})
print(Counter(run(o, locked=True) for o in orders)) # Counter({None: 4, 2: 2})
print(run("ABAB", locked=False)) # 1 -- the lesson's schedule
Without the lock, four of six orderings lose an update. With it, those same four are impossible — None marks a schedule in which a thread would have had to read while another held the lock — and the two that remain both give 2.
For more threads and more increments, enumerating every ordering gets large quickly, so the next check samples them. A random scheduler picks any thread that is allowed to run, and the result is compared with the brute-force answer: run the threads one at a time, which gives threads × increments:
import random
def random_schedule(threads, reps, locked): # a scheduler that picks at random
left = {t: 2 * reps for t in threads}
holder, out = None, []
while any(left.values()):
ready = [t for t in threads if left[t] and
not (locked and holder not in (None, t))]
t = random.choice(ready)
if locked:
holder = t if left[t] % 2 == 0 else None # read takes, write frees
out.append(t)
left[t] -= 1
return out
random.seed(8)
ok, runs_with_loss = True, 0
for _ in range(2000):
threads = "ABCD"[: random.randint(2, 4)]
reps = random.randint(1, 5)
serial = len(threads) * reps # brute force: one at a time
ok &= run(random_schedule(threads, reps, True), locked=True) == serial
free = run(random_schedule(threads, reps, False), locked=False)
ok &= free <= serial
runs_with_loss += free < serial
print(ok, runs_with_loss) # True 1880
Every locked run matched the serial answer. Of 2,000 unlocked runs, 1,880 lost at least one update. Real code rarely looks that bad, because a real scheduler switches threads far less often than a uniformly random one — which is exactly why the bug hides in testing and appears under load.
Finally, the real thing with Python's threading.Lock:
import threading
counter = 0
lock = threading.Lock()
def bump(times):
global counter
for _ in range(times):
with lock: # read-modify-write, one at a time
counter += 1
workers = [threading.Thread(target=bump, args=(50_000,)) for _ in range(4)]
for w in workers: w.start()
for w in workers: w.join()
print(counter) # 200000
Remove the lock and the result may or may not come out short on a given run, interpreter and machine. That uncertainty is the point: a race is a property of the allowed schedules, not of the runs you happened to see.
The complexity
A lock adds a small cost to every acquire and release, and a much larger one when threads contend: waiting threads do no useful work, so the critical section runs serially. Keep it short — only the read-modify-write of shared state — and never do slow input or output while holding a lock. When the shared state is a single number, an atomic increment or a compare-and-swap loop does the same job without blocking.
Where it goes wrong
- Locking only the write. The read must be inside the same critical section, or two threads still read the same stale value.
- Different locks for the same data. A mutex protects data only if every access to that data goes through the same lock.
- Check-then-act.
if key not in cache: cache[key] = load()is a race for the same reason: the check and the act are two steps. - Acquire without release. An exception between
acquire()andrelease()leaves the lock held forever.with lock:releases it on every path. - Two locks, two orders. Thread A holds lock 1 and waits for lock 2 while B holds 2 and waits for 1. That is a deadlock; always take locks in one global order.
How to say it in an interview
"count += 1 is a read, an add and a write, and another thread can run between them. If two threads both read before either writes, one update is lost. That's a race condition: the result depends on the interleaving. A mutex makes the whole read-modify-write a critical section, so only one thread can be between its read and its write at a time. The bad interleavings become impossible. The cost is serialisation, so I keep the critical section minimal, and for a single counter I'd consider an atomic operation instead."