Race conditions and how a lock fixes them

Two threads reach for the same counter, step on each other, and an update quietly vanishes — a lock makes them take turns.

The idea

Imagine two cashiers updating one shared total on a whiteboard. Each one reads the number, adds their sale in their head, then writes the new number back. If they both read 0 at the same instant and both write 1, one sale silently disappears. The whiteboard says 1 when it should say 2.

That is a race condition: the result depends on the exact timing of how operations interleave. A single balance += 1 is really three steps — read, add, write — and a second thread can slip in between them. A lock (mutex) puts a gate around that critical section so only one thread is inside at a time, turning read-modify-write back into one indivisible move.

Pick a scenario, then press play or step through it.

How it works

The unsynchronized version below looks atomic but is not — balance += 1 compiles to read, add, write, and a second thread can interleave between those steps. Wrapping the read-modify-write in a lock means a thread must acquire the lock before entering and release it after, so the other thread waits its turn instead of clobbering the value.

import threading

balance = 0

# UNSYNCHRONIZED — racy.
# balance += 1 is really: tmp = balance; tmp = tmp + 1; balance = tmp
# Two threads can both read the same old value and lose an update.
def deposit_racy():
    global balance
    for _ in range(100_000):
        balance += 1          # <-- read, add, write can interleave

# FIXED — a lock serializes the critical section.
lock = threading.Lock()

def deposit_safe():
    global balance
    for _ in range(100_000):
        with lock:            # acquire; auto-releases at block end, even on error
            balance += 1      # only one thread is ever inside here

# With two threads each running deposit_safe(), the final
# balance is always exactly 200_000. The racy version drifts lower.

Trade-offs

ApproachCorrect?Contention costDeadlock risk
No synchronizationNo — lost updatesNoneNone
Mutex / lockYesThreads block and waitYes, if locks are taken in different orders
Atomic / CASYes, for one variableLow; retries under heavy contentionNone
Lock-free structureYesLow, but hard to designNone, but can livelock

A lock is the simplest correct fix. Reach for an atomic counter when you only need one shared number, and a lock-free structure only when profiling proves the lock is your bottleneck.

Watch out for

Worked example

Walk the without lock scenario in the visual. Both threads run balance += 1 on a shared balance that starts at 0. Thread A reads 0 into its register. Before A writes back, the scheduler switches to thread B, which also reads 0. Now both threads hold a local tmp = 0 and both compute 0 + 1 = 1. A writes 1 to the shared balance; then B writes 1 too. Two deposits happened, but the balance is 1 — one update was lost.

Now switch to with lock. Thread A acquires the lock and enters the critical section; thread B tries to acquire it, finds it held, and blocks. A reads 0, computes 1, writes 1, and releases. Only then does B wake up, acquire the lock, read the now-current 1, compute 2, and write 2. Because the reads and writes can no longer interleave, the balance ends at exactly 2.

Check yourself

1. Two threads each run balance += 1 with no lock, starting from 0. What final values are possible?

2. You wrap the counter update in with lock: but one other function still does balance += 1 without the lock. Is the race gone?