Mutex, Semaphore, Condition Variable
A mutex enforces 'one at a time'. A semaphore counts permits, N threads at a time. A condition variable lets threads wait for a state change. They're the three primitives every higher-level concurrency tool is built on.
Diagram
Three different questions
These three primitives, mutex, semaphore, condition variable, are the bedrock of every higher-level concurrency tool. BlockingQueue, sync.Once, CompletableFuture, asyncio.Lock, all of them are built from these.
Each one answers a different question:
Mutex "Can a thread enter?" one at a time
Semaphore "Is there room for one more?" N at a time
Condition variable "Wait for a state change?" sleep until signaled
Mutex: a key on the bathroom door
Imagine a single-occupancy bathroom with a key on a hook outside. To go in, take the key. While that key is held, nobody else can enter. On leaving, put the key back. The next person waiting takes it.
That's a mutex. One key, one user at a time, and the same person who took it must put it back.
Mutex held by Thread A:
+--------------------------------+
| CRITICAL SECTION (locked) |
| Thread A is inside |
+--------------------------------+
Thread B -> acquire -> PARKED (waits)
Thread C -> acquire -> PARKED (waits)
Thread A -> release
v
wakes one waiter (B), gives them the lock
A mutex is a binary lock with ownership: the thread that acquires must be the thread that releases. Reach for one whenever an invariant spans multiple operations on shared state, a counter that needs to read-then-write, a linked list that needs to update two pointers, a balance that must stay non-negative.
The classic shape:
acquire()
// critical section, only one thread inside
release()
Use a mutex anywhere an invariant spans multiple operations on shared state, a counter that needs to read-then-write, a linked list that needs to update two pointers, a balance that must stay non-negative.
Always release in finally / defer / with
An exception or early return that skips release() is a permanent deadlock. Every language gives a syntactic way to make this safe: Java's try/finally, Python's with, Go's defer. Use them religiously.
Semaphore: a bowl of permits
Imagine a bowl with N tokens. To do a thing, take a token from the bowl. On finishing, put the token back. If the bowl is empty on arrival, wait until someone returns a token.
That's a semaphore. N concurrent operations allowed, no more.
Semaphore initialized with 3 permits:
+---------------+
| * * * | 3 permits available
+---------------+
Thread A acquires: * * o (2 left, A holds 1)
Thread B acquires: * o o (1 left)
Thread C acquires: o o o (0 left)
Thread D acquires: PARKED (no permits)
Thread A releases: * o o (1 back)
v
wakes Thread D, hands it the permit
Use it to bound concurrency:
- Connection pool: 10 permits = at most 10 concurrent connections.
- Rate limiter: refill the semaphore on a timer.
- Worker pool: N permits = N workers active.
Mutex vs binary semaphore A semaphore with N=1 looks like a mutex but isn't. A mutex has ownership, only the locker can unlock. A binary semaphore is just a counter capped at 1; any thread can release. Use a mutex for mutual exclusion; use a binary semaphore only when one thread genuinely "delivers" a permit to another.
Condition variable: a waiting room with a bell
Imagine a waiting room outside the bathroom. People go in there if the bathroom is occupied AND they want to wait for some specific condition (like "wait until there's a free stall AND the floor has been mopped"). They check the condition, and if it's not true, they sleep on a bench. Someone with the key to the bathroom rings a bell when the condition might have changed; everyone wakes up, re-checks, and either proceeds or goes back to sleep.
That's a condition variable. It always pairs with a mutex (the bathroom key). The mutex protects the state being checked; the condition variable lets a thread efficiently sleep until that state might be worth re-checking.
Thread A (waiter): Thread B (signaler):
acquire mutex acquire mutex
while not predicate: modify state
cond.wait(mutex) <---- parks here, cond.signal() --> wakes one waiter
releases mutex release mutex
re-acquires on wake
// predicate true; mutex held
do work
release mutex
The canonical shape:
acquire mutex
while not predicate:
wait(cv) // atomically: release mutex + park
// predicate is true; mutex is held
do work
release mutex
Always wait in a while-loop
Two reasons. Spurious wakeups: the OS/JVM is allowed to wake a thread without a signal, and it does. Stolen wakeups: between waking up and re-acquiring the mutex, another thread may have grabbed the resource. Re-checking the predicate is non-negotiable. Using if instead of while is the most common condition-variable bug.
When to reach for what
| Problem | Primitive |
|---|---|
| Two threads must not modify state simultaneously | Mutex |
| At most N concurrent operations | Counting semaphore |
| Wait until queue has an item / has space | Condition variable (or use a queue/channel) |
| One-shot "I'm done" signal | Channel close / threading.Event / CountDownLatch |
| Resource pool with timeouts | Semaphore.tryAcquire(timeout) |
Most code shouldn't use these directly
For a producer-consumer, use a BlockingQueue, queue.Queue, or buffered channel, they're built from these primitives, correctly. For caching with read-heavy access, use RWLock. Reach for raw mutex+condition only when no higher-level primitive fits.
The mental model that makes this stick
Think of it as three different questions:
- "Can a thread enter?" → mutex.
- "Is there room for one more?" → semaphore.
- "Wait until something changes?" → condition variable.
Every concurrent design is a combination of these three questions, asked at different points in the code.
Implementations
ReentrantLock is Java's general-purpose mutex. The try/finally ensures the lock is always released, even on exception. The same code with synchronized is more concise but lacks tryLock and timed acquisition.
1 ReentrantLock lock = new ReentrantLock();
2 int counter = 0;
3
4 void inc() {
5 lock.lock();
6 try {
7 counter++;
8 } finally {
9 lock.unlock(); // ALWAYS in finally
10 }
11 }A connection pool with at most 5 concurrent users. acquire() blocks if all permits are taken; release() returns one. Critical: every acquire needs a matching release in finally, otherwise permits leak and the whole pool eventually deadlocks.
1 Semaphore connections = new Semaphore(5);
2
3 void useConnection() throws InterruptedException {
4 connections.acquire(); // blocks if 5 are in use
5 try {
6 hitDatabase();
7 } finally {
8 connections.release(); // ALWAYS release
9 }
10 }The producer waits on notFull; the consumer waits on notEmpty. Each side signals the other's condition when it changes the state the other is waiting for. The while-loop is non-negotiable: spurious wakeups exist on every JVM.
1 ReentrantLock lock = new ReentrantLock();
2 Condition notFull = lock.newCondition();
3 Condition notEmpty = lock.newCondition();
4 Queue<T> queue = new ArrayDeque<>();
5 int capacity = 100;
6
7 void put(T item) throws InterruptedException {
8 lock.lock();
9 try {
10 while (queue.size() == capacity) notFull.await();
11 queue.add(item);
12 notEmpty.signal();
13 } finally { lock.unlock(); }
14 }
15
16 T take() throws InterruptedException {
17 lock.lock();
18 try {
19 while (queue.isEmpty()) notEmpty.await();
20 T item = queue.remove();
21 notFull.signal();
22 return item;
23 } finally { lock.unlock(); }
24 }Key points
- •Mutex = binary lock, mutual exclusion, ONE owner
- •Semaphore = counted permits, allows N concurrent holders
- •Condition variable = wait until a predicate becomes true, used WITH a mutex
- •Mutex 'protects' invariants; semaphore 'limits' concurrency; condition variable 'signals' state changes
- •Always wait on a condition variable in a while-loop, never if (spurious wakeups + stolen wakeups)
- •Counting semaphore (N>1) for resource pools; binary semaphore (N=1) is 'almost a mutex' but lacks ownership
Follow-up questions
▸Mutex vs binary semaphore, what's the difference?
▸Why pair a mutex AND a condition variable?
▸What's a spurious wakeup?
▸Difference between signal() and signalAll()?
▸Why does Go discourage condition variables?
Gotchas
- !Forgetting to release a lock on the error path → permanent deadlock. Always use defer/finally/with.
- !Using if instead of while around await/wait → spurious + stolen wakeups corrupt invariants
- !Locking and waiting on different lock instances, the code looks locked but isn't
- !Calling signal() without holding the lock, the wait/check race is back
- !Counting semaphore release without prior acquire → permit leak (raises in BoundedSemaphore)
- !Mutex copy in Go (passing a struct by value), go vet catches it
Common pitfalls
- Reaching for sync.Cond / Condition variable when a channel/Queue/BlockingQueue already does the job
- Building a custom bounded buffer instead of using BlockingQueue / queue.Queue / chan T
- Holding a mutex while making a network call, turns lock contention into a network outage
Practice problems
counter + cond.wait while counter == 0 + decrement on acquire
APIs worth memorising
- Java: ReentrantLock, ReentrantLock.newCondition, Semaphore, synchronized, Object.wait/notify
- Python: threading.Lock, threading.Semaphore, threading.BoundedSemaphore, threading.Condition
- Go: sync.Mutex, sync.Cond, buffered channel as semaphore, golang.org/x/sync/semaphore
Every concurrent runtime is built from these. Java's ConcurrentHashMap uses fine-grained locks. Go's runtime uses futexes (kernel mutexes) under the hood. Python's queue.Queue is a Lock + two Conditions. Database engines use semaphores to bound concurrent transactions.