AnyLearn
All lessons
Programmingadvanced

Locks and Synchronization

Mutexes, condition variables, semaphores, and reader-writer locks — the primitives that make concurrent code correct. Understand the four Coffman deadlock conditions, how lock ordering prevents them, and why contention turns a performance win into a bottleneck.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

The mutex: your first and most important primitive

A mutex (mutual exclusion lock) has two operations: lock() and unlock(). At most one thread holds the mutex at a time; every other caller blocks until it is released. The region between lock and unlock is the critical section.

Correct mutex usage in Python:

import threading

_lock = threading.Lock()
_counter = 0

def safe_increment():
    global _counter
    with _lock:          # acquires on entry, releases on exit — even on exception
        _counter += 1

Key rules:

  • Always use the same lock for a given piece of shared data. One unprotected access defeats the mutex entirely.
  • Prefer RAII / context managers. Manual lock() / unlock() leaks on exceptions. Python's with, C++'s std::lock_guard, Go's defer mu.Unlock() all enforce this.
  • Keep critical sections short. Every nanosecond inside the lock is a nanosecond other threads are blocked.

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. The mutex: your first and most important primitive

A mutex (mutual exclusion lock) has two operations: lock() and unlock(). At most one thread holds the mutex at a time; every other caller blocks until it is released. The region between lock and unlock is the critical section.

Correct mutex usage in Python:

import threading

_lock = threading.Lock()
_counter = 0

def safe_increment():
    global _counter
    with _lock:          # acquires on entry, releases on exit — even on exception
        _counter += 1

Key rules:

  • Always use the same lock for a given piece of shared data. One unprotected access defeats the mutex entirely.
  • Prefer RAII / context managers. Manual lock() / unlock() leaks on exceptions. Python's with, C++'s std::lock_guard, Go's defer mu.Unlock() all enforce this.
  • Keep critical sections short. Every nanosecond inside the lock is a nanosecond other threads are blocked.

2. Deadlock: four conditions, one disaster

A deadlock is a cycle of threads, each waiting for a resource held by the next. Coffman (1971) identified four necessary and sufficient conditions — break any one and deadlock is impossible:

ConditionWhat it means
Mutual exclusionResources can't be shared (one holder at a time)
Hold and waitA thread holds one resource while requesting another
No preemptionResources are only released voluntarily
Circular waitA cycle: A waits for B, B waits for A

In practice, mutual exclusion and no preemption are inherent to mutexes — you can't easily remove them. Circular wait is the one you attack: establish a global lock ordering and always acquire locks in that order. If every thread locks mutex A before mutex B, the A→B→A cycle is impossible.

3. A deadlock example and the fix

Classic two-thread, two-lock deadlock:

import threading, time

lock_a = threading.Lock()
lock_b = threading.Lock()

def thread1():
    with lock_a:
        time.sleep(0.01)  # yield to let thread2 grab lock_b
        with lock_b:      # blocks forever — thread2 holds lock_b
            print("T1 done")

def thread2():
    with lock_b:
        time.sleep(0.01)
        with lock_a:      # blocks forever — thread1 holds lock_a
            print("T2 done")

threading.Thread(target=thread1).start()
threading.Thread(target=thread2).start()
# Neither thread ever prints. Program hangs.

Fix — consistent lock ordering: both threads acquire lock_a then lock_b. If thread2 changes its first acquisition to lock_a, the cycle vanishes. threading.Lock() also supports acquire(timeout=...) to detect deadlocks and threading.RLock() for re-entrant acquisition in the same thread.

4. Condition variables: waiting for a predicate

A condition variable pairs with a mutex to let a thread sleep until a condition holds. The canonical pattern:

import threading

_lock = threading.Lock()
_cond = threading.Condition(_lock)
_queue = []

def producer(item):
    with _cond:
        _queue.append(item)
        _cond.notify()         # wake one waiting consumer

def consumer():
    with _cond:
        while not _queue:      # MUST be a while loop, not if
            _cond.wait()       # atomically releases lock, sleeps, reacquires on wake
        return _queue.pop(0)

The while loop is mandatory, not optional. Spurious wakeups — where wait() returns without notify() being called — are permitted by POSIX and by Python's implementation. Without the loop, the consumer may proceed with an empty queue. Always re-check the predicate after waking.

5. Semaphores: counting resources

A semaphore generalizes a mutex to count up to NN concurrent holders. Two operations:

  • P() / acquire() — decrement; block if count is already 0.
  • V() / release() — increment; wake a blocked waiter if any.

A binary semaphore (N=1N=1) is equivalent to a mutex. A counting semaphore with N=5N=5 allows up to 5 threads to hold it simultaneously — useful for bounding concurrency on a shared resource pool (DB connections, HTTP clients):

import threading

_pool = threading.Semaphore(5)  # at most 5 concurrent DB connections

def run_query(sql):
    with _pool:                  # blocks if 5 connections already in use
        conn = db.connect()
        result = conn.execute(sql)
        conn.close()
        return result

Gotcha: unlike mutexes, semaphores have no concept of ownership. Any thread can call release(), even one that never called acquire(). This makes them powerful for signaling but dangerous if misused — a stray release() breaks the bound.

6. Reader-writer locks: exploiting read concurrency

Most data is read far more often than written. A plain mutex serializes all readers too, wasting concurrency. A reader-writer lock (rwlock) allows:

  • Multiple concurrent readers (no writer active)
  • Exactly one writer, with all readers excluded
import java.util.concurrent.locks.*;

ReadWriteLock rwl = new ReentrantReadWriteLock();
Map<String, String> cache = new HashMap<>();

void put(String k, String v) {
    rwl.writeLock().lock();
    try { cache.put(k, v); }
    finally { rwl.writeLock().unlock(); }
}

String get(String k) {
    rwl.readLock().lock();
    try { return cache.get(k); }
    finally { rwl.readLock().unlock(); }
}

Read-heavy workloads can see 10× lower latency with rwlocks. However, if writes are frequent, write-lock contention turns the rwlock into a bottleneck. Measure before adopting — the implementation overhead is higher than a plain mutex.

7. Deadlock cycle: visualized

Thread A holds Lock 1 and wants Lock 2; Thread B holds Lock 2 and wants Lock 1. Neither can proceed.

flowchart LR
  A["Thread A"] -->|holds| L1["Lock 1"]
  A -->|waiting for| L2["Lock 2"]
  B["Thread B"] -->|holds| L2
  B -->|waiting for| L1

8. Lock contention and convoying

When many threads compete for the same lock, most spend most of their time blocked — this is contention. A particularly nasty form is lock convoying: a long-running thread holds a lock; other threads queue behind it; when it releases, the next thread in the OS scheduling queue acquires it, but that thread may immediately be preempted before doing useful work, while the rest of the convoy still waits. Throughput collapses.

Strategies to reduce contention:

  • Fine-grained locking: one lock per data structure element instead of one global lock. More complexity, less contention.
  • Lock-free data structures: compare-and-swap instead of mutual exclusion (covered in the next lesson).
  • Sharding: partition data into NN independent buckets, each with its own lock. Reduces contention by N×N\times for uniformly distributed accesses.
  • Reduce critical section duration: do expensive computation outside the lock; only touch shared state inside.

Contention is measurable. Linux perf, Java's JFR, and Go's pprof mutexprofile all expose lock wait time directly.

9. When to use which primitive

Choosing wrong costs correctness or performance:

ScenarioBest primitive
Exclusive access to shared dataMutex
Wait until a condition is trueCondition variable + mutex
Limit concurrency on a resource poolCounting semaphore
One-time signal (e.g., task complete)Binary semaphore or condition variable
Read-heavy, write-rare shared mapReader-writer lock
Single word update with no ABA concernAtomic operation (next lesson)

Common mistakes:

  • Using a semaphore as a mutex (no ownership, any thread can release).
  • Polling a condition variable with sleep() instead of wait() — wastes CPU and delays response.
  • Using notify() when you should use notifyAll() — if multiple threads wait on different predicates and you only wake one, you may wake the wrong one, leaving the other stuck forever.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Thread A holds lock_a and is waiting for lock_b. Thread B holds lock_b and is waiting for lock_a. Which Coffman condition, if broken, would directly prevent this deadlock?
    • Mutual exclusion — allow shared lock ownership
    • No preemption — forcibly take lock_b from Thread B
    • Circular wait — enforce a global lock ordering so both threads acquire lock_a before lock_b
    • Hold and wait — require threads to acquire all locks atomically at the start
  2. Why must the condition variable wait loop use `while (!condition)` instead of `if (!condition)`?
    • Because `if` would cause a deadlock if the mutex is not reacquired.
    • Because spurious wakeups are permitted — the thread may wake without the condition being true.
    • Because `notify()` does not release the mutex, so the condition cannot change between check and wait.
    • Because `while` is required by the Java memory model to establish happens-before.
  3. A counting semaphore is initialized with value 3. Thread A, B, C, and D all call acquire(). What happens?
    • All four proceed; the semaphore value becomes -1.
    • A, B, and C proceed; D blocks until one of them calls release().
    • Only A proceeds; B, C, and D block because semaphores are exclusive.
    • All four proceed but the semaphore records the overflow and raises an error on the next acquire().
  4. Does the following Go code deadlock? ```go var mu sync.Mutex mu.Lock() mu.Lock() // second lock on the same goroutine ```
    • No — Go's sync.Mutex detects recursive locking and returns an error.
    • No — the second Lock() is a no-op if the same goroutine holds it.
    • Yes — Go's sync.Mutex is not reentrant; the goroutine blocks waiting for itself.
    • Yes — but only if GOMAXPROCS is set to 1.
  5. A cache has 95% reads and 5% writes. Switching from a mutex to a reader-writer lock is most likely to help when:
    • Reads are long-running and many concurrent readers contend on the mutex.
    • Writes are frequent and need to complete without blocking readers.
    • The cache is accessed by a single goroutine at a time.
    • The cache stores immutable data that never changes after initialization.

Related lessons