A race condition happens when the result of a program depends on the timing of threads that share mutable data. It is the core problem that every other concurrency tool (atomics, spinlocks, semaphores) exists to solve. The simplest fix is a mutex, a lock that lets only one thread at a time into a block of code.
How a race happens#
The classic example is a bank account with a balance of 500, and Thread 2 is an online payment of $300:
| Step | Thread 1 (withdraw $500) | Thread 2 (pay $300) |
|---|---|---|
| 1 | Read balance: $1000 | Read balance: $1000 |
| 2 | Subtract 500: $500 | Subtract 300: $700 |
| 3 | Write balance: $500 | Write balance: $700 |
The final balance is 200.
Read-modify-write is not atomic. It is three separate steps, and another thread can slip in between any of them with stale data.
Three conditions, all required#
- Shared data: multiple threads access the same variable or object.
- At least one write: if everyone only reads, nothing can go wrong.
- No synchronization: nothing coordinates the access.
Remove any one and the race is gone. That gives you three strategies: don't share (thread-local data), don't write (immutable data), or synchronize (locks, atomics).
Mutex: one thread at a time#
A mutex (mutual exclusion lock) guards a critical section, the code between lock() and unlock(). Only one thread can be inside at once.
| Step | Thread 1 | Thread 2 |
|---|---|---|
| 1 | mutex.lock(): acquires |
|
| 2 | Read balance | mutex.lock(): blocked, waits |
| 3 | Subtract 500 | Waiting |
| 4 | Write balance | Waiting |
| 5 | mutex.unlock(): releases |
|
| 6 | mutex.lock(): acquires |
|
| 7 | Read balance ($500, correct) | |
| 8 | Subtract 300, write balance ($200) | |
| 9 | mutex.unlock() |
The final balance is $200.
The same idea in Python:
import threading
class Account:
def __init__(self, balance: int = 1000) -> None:
self._lock = threading.Lock()
self.balance = balance
def withdraw(self, amount: int) -> None:
# `with` releases the lock even if an exception is raised
with self._lock:
# Critical section: read-modify-write happens as one unit
self.balance -= amount
account = Account()
t1 = threading.Thread(target=account.withdraw, args=(500,))
t2 = threading.Thread(target=account.withdraw, args=(300,))
t1.start(); t2.start()
t1.join(); t2.join()
print(account.balance) # 200
Three key properties#
- Ownership: only the thread that locked the mutex can unlock it. This prevents accidental releases (and is the main difference from a semaphore). In Python,
RLockenforces this; a plainLockdoes not check who releases it. - Blocking: a thread that can't get the lock moves to the Waiting state and burns no CPU. The OS wakes it when the lock is released. A spinlock busy-waits instead.
- Non-recursive by default: if a thread holding a plain mutex calls
lock()again, it deadlocks on itself. Reentrant locks (Python'sthreading.RLock) let the owning thread re-acquire.
Fairness: who gets the lock next?#
If 10 threads wait on a mutex, the one that waited longest is not guaranteed to get it next. Most implementations make no fairness promise, so a thread could keep getting skipped. That is starvation (see Starvation vs Deadlock vs Livelock).
| Unfair (usual default) | Fair (opt-in) | |
|---|---|---|
| Ordering | Any waiter, or a newly arriving thread, may win | Strict FIFO: longest waiter goes next |
| Throughput | Higher | Lower: must maintain a queue and wake a specific thread |
| Starvation | Possible | Prevented |
Python's threading.Lock makes no fairness promise and has no fair mode.
Why unfair is the default: when the lock is released, a thread that is already running can grab it immediately with no context switch. A fair lock must wake the longest waiter (a context switch) even if another thread is ready right now.
To prevent starvation you can use a fair lock, or rely on priority aging in the scheduler, which raises a waiting thread's priority over time until it runs.
Key takeaways#
- A race needs shared data, at least one writer and no synchronization; remove any one to fix it.
- Read-modify-write (
x += 1) is several steps, so threads can interleave and lose updates. - A mutex makes a critical section mutually exclusive; waiters sleep instead of spinning.
- Mutexes have ownership: only the holder unlocks. Use
with lock:so the lock is always released. - Default mutexes are usually unfair for throughput; use a fair lock when starvation matters.