bigonotes
CS ConceptsOS Fundamentals

Race Conditions and Mutexes

Why unsynchronized read-modify-write corrupts shared data, and how a mutex makes a critical section run one thread at a time.

3 min readLast updated OS FundamentalsListen · Watch on NotebookLM

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 1000.Thread1isanATMwithdrawalof1000. Thread 1 is an ATM withdrawal 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 700,butitshouldbe700, but it should be 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#

  1. Shared data: multiple threads access the same variable or object.
  2. At least one write: if everyone only reads, nothing can go wrong.
  3. 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#

  1. 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, RLock enforces this; a plain Lock does not check who releases it.
  2. 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.
  3. Non-recursive by default: if a thread holding a plain mutex calls lock() again, it deadlocks on itself. Reentrant locks (Python's threading.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.

Found a mistake? Tell me on LinkedIn ↗

Further reading#