Guide

Three primitives cover most blocking synchronization. A mutex lets one thread into a section at a time. A semaphore counts a pool of identical resources. A condition variable lets a thread sleep until some state becomes true. Each one answers a different question, and mixing them up causes most misuse.

Walking through it: the mutex

A mutex (mutual exclusion lock) has two operations, lock and unlock. Only one thread holds it at a time. Any other thread that calls lock blocks until the holder unlocks. Use it to protect a critical section, the code that reads and writes shared state together.

The race from the race conditions page disappears with a lock:

Python
import threading

counter = 0
lock = threading.Lock()

def work():
    global counter
    for _ in range(100_000):
        with lock:          # only one thread in this block at a time
            counter += 1

ts = [threading.Thread(target=work) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print("with lock:", counter)

Output:

with lock: 400000

The with block releases the lock even when an exception is raised. Prefer that form to manual lock/unlock pairs, which leak the lock on an error path.

Semaphores

A semaphore holds a count. acquire decrements it and blocks at zero. release increments it. A counting semaphore limits concurrency to N, such as 10 open database connections or 5 parallel downloads. A binary semaphore with a count of 1 looks like a mutex, but it has no owner. Any thread can release it, which makes it easy to misuse.

Use a mutex when the same thread must release what it took, and a semaphore when you're rationing a pool.

Condition variables

A condition variable is for waiting until something is true, such as "the queue is not empty". The waiting thread releases the lock and sleeps, and wakes up when another thread signals. Because wakeups can be spurious, always check the condition in a loop:

lock.acquire()
while not queue:            # loop, not if: wakeups can be spurious
    cond.wait()             # releases lock while sleeping, reacquires on wake
item = queue.pop(0)
lock.release()

The producer does the same under the lock, then calls notify. The pattern is a mutex with a waiting room. The condition variable is never a substitute for a lock, since it only works together with one.

Note

Most languages ship a higher-level queue, such as Python's queue.Queue or Go channels. Use them before writing a condition-variable queue by hand.

Common mistakes

  • Using if instead of while around wait, so a spurious wakeup proceeds with a false condition.
  • Calling wait without holding the lock.
  • Using a binary semaphore where a mutex's ownership rule is what you need.
  • Locking across a slow call, which makes every other thread wait for the network.

Check yourself

  • Why is a condition variable always paired with a lock? Answer: checking the condition and sleeping must be atomic, or a signal can arrive between the check and the sleep and be lost.
  • Why limit database connections with a counting semaphore instead of a mutex? Answer: a mutex allows one at a time. You want N in parallel, which is a counting semaphore.

Key takeaways

  • A mutex gives exclusive access, and the holder must unlock it.
  • A counting semaphore limits how many can use a pool at once.
  • A condition variable waits for a state change, always inside a while loop.
  • Use the language's with form and higher-level queues where you can.

Further learning

Next, see atomics and memory ordering for synchronization that doesn't block at all.