Two threads reach for the same counter, step on each other, and an update quietly vanishes — a lock makes them take turns.
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.
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.
| Approach | Correct? | Contention cost | Deadlock risk |
|---|---|---|---|
| No synchronization | No — lost updates | None | None |
| Mutex / lock | Yes | Threads block and wait | Yes, if locks are taken in different orders |
| Atomic / CAS | Yes, for one variable | Low; retries under heavy contention | None |
| Lock-free structure | Yes | Low, but hard to design | None, 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.
with lock: or a try/finally so release always happens.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.
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?