Condition variables and semaphores
How threads wait for each other without burning CPU. Condition variables, monitors, wait and notify, spurious and lost wakeups, semaphores, latches and barriers, with demos and code in five languages.
- Read first
- Locks and atomics
A lock makes a thread wait for another thread to finish with some state. Just as often, a
thread has to wait for something to happen: an item to arrive in a queue, a result to be
computed, a connection to free up. You could wait with a loop that keeps checking
(while (queue.isEmpty()) {}). But that burns a whole CPU core doing nothing. And without a lock,
it reads stale data.
This page covers the two tools for waiting properly:
- A condition variable lets a thread sleep until another thread says the state it’s waiting on has changed.
- A semaphore is a counter of permits. Threads take permits and give them back, and wait when there are none.
Together, they build every other waiting tool: blocking queues, latches, barriers, futures and connection pools.
Condition variables
Section titled “Condition variables”A condition variable has three operations, all called while holding its lock:
wait(): release the lock and go to sleep, as one atomic step. When woken, retake the lock, then return. Because the release and the sleep are one step, no notification can slip in between them and be missed.signal()(Javanotify, C#Pulse): wake one waiting thread, if any.signalAll()(JavanotifyAll, C#PulseAll, Pythonnotify_all): wake all of them.
A woken thread doesn’t run right away. It has to get the lock back first, and the thread that signaled still holds it. Only when the signaler unlocks does a woken thread compete for the lock. Other threads that arrived in the meantime may compete too.
- Color key
- State
A monitor is that whole package: one lock, the state it guards, and one or more condition
variables over that state. Every Java object is a monitor (synchronized, wait, notify). C#’s
Monitor class is the same idea.
Stage 1: wait for a result
Section titled “Stage 1: wait for a result”Several threads need a result that another thread computes once. Each waiter checks whether the result is there. If not, it sleeps on the condition. The producer sets the result, and wakes all of them.
import java.util.ArrayList;import java.util.List;
// Several threads wait for a result that another thread will produce once.public class Main { public static void main(String[] args) throws Exception { ResultSlot<Integer> slot = new ResultSlot<>(); List<Thread> waiters = new ArrayList<>(); int[] seen = new int[3]; for (int i = 0; i < 3; i++) { final int me = i; Thread t = new Thread(() -> { try { seen[me] = slot.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); t.start(); waiters.add(t); } Thread.sleep(50); // the waiters are asleep now, using no CPU slot.set(42); // one call wakes all three for (Thread t : waiters) t.join(); for (int v : seen) if (v != 42) throw new AssertionError("a waiter missed the result");
// A get with a deadline on a slot nobody fills returns empty instead of hanging. Integer none = new ResultSlot<Integer>().get(50); if (none != null) throw new AssertionError(); System.out.println("all waiters got 42; the timed get gave up"); // all waiters got 42; the timed get gave up }}
// A one-shot result: set once, read by any number of threads that block until it's there.// This is a monitor: one lock, the state it guards, and waiting on a condition of that state.class ResultSlot<T> { private final Object lock = new Object(); // private, so nobody else can notify on it private T value; // guarded by lock private boolean done; // guarded by lock
void set(T v) { synchronized (lock) { if (done) throw new IllegalStateException("already set"); value = v; done = true; // notifyAll, not notify: every waiter is waiting for this same event, and notify() // would wake one and leave the rest asleep forever. lock.notifyAll(); } }
T get() throws InterruptedException { synchronized (lock) { // while, never if: a thread can wake without the condition being true (a spurious // wakeup, or another thread changed the state before this one got the lock back). while (!done) lock.wait(); // releases the lock while asleep, retakes it before returning return value; } }
// Waits at most timeoutMs. Each wakeup recomputes the time left, so a spurious wakeup // doesn't restart the full timeout. T get(long timeoutMs) throws InterruptedException { long deadline = System.nanoTime() + timeoutMs * 1_000_000; synchronized (lock) { while (!done) { long leftMs = (deadline - System.nanoTime()) / 1_000_000; if (leftMs <= 0) return null; lock.wait(leftMs); } return value; } }}import threadingimport timefrom typing import Generic, Optional, TypeVar
T = TypeVar("T")
# A one-shot result: set once, read by any number of threads that block until it's there.## A monitor: one lock, the state it guards, and a condition variable to wait on.class ResultSlot(Generic[T]): def __init__(self) -> None: self._cond = threading.Condition() # owns its own lock self._value: Optional[T] = None # guarded by _cond self._done = False # guarded by _cond
def set(self, value: T) -> None: with self._cond: if self._done: raise RuntimeError("already set") self._value = value self._done = True # notify_all, not notify: every waiter is waiting for this same event. self._cond.notify_all()
def get(self, timeout: Optional[float] = None) -> Optional[T]: with self._cond: # wait_for is the while loop written for you: it re-checks the predicate after every # wakeup (spurious or stolen) and tracks the time left against one deadline. if not self._cond.wait_for(lambda: self._done, timeout): return None # timed out return self._value
if __name__ == "__main__": slot: ResultSlot[int] = ResultSlot() seen: list[Optional[int]] = [] waiters = [threading.Thread(target=lambda: seen.append(slot.get())) for _ in range(3)] for t in waiters: t.start() time.sleep(0.05) # the waiters are asleep now, using no CPU slot.set(42) # one call wakes all three for t in waiters: t.join() assert seen == [42, 42, 42], seen
# A get with a deadline on a slot nobody fills returns None instead of hanging. assert ResultSlot[int]().get(timeout=0.05) is None print("all waiters got 42; the timed get gave up")using System;using System.Diagnostics;using System.Threading;
// Several threads wait for a result that another thread will produce once.static class Program{ static void Main() { var slot = new ResultSlot<int>(); var seen = new int[3]; var waiters = new Thread[3]; for (int i = 0; i < 3; i++) { int me = i; waiters[i] = new Thread(() => seen[me] = slot.Get()); waiters[i].Start(); } Thread.Sleep(50); // the waiters are asleep now, using no CPU slot.Set(42); // one call wakes all three foreach (var t in waiters) t.Join(); foreach (var v in seen) if (v != 42) throw new InvalidOperationException("a waiter missed the result");
// A get with a deadline on a slot nobody fills gives up instead of hanging. if (new ResultSlot<int>().TryGet(TimeSpan.FromMilliseconds(50), out _)) throw new InvalidOperationException(); Console.WriteLine("all waiters got 42; the timed get gave up"); // all waiters got 42; the timed get gave up }}
// A one-shot result: set once, read by any number of threads that block until it's there.// A monitor: one lock, the state it guards, and waiting on a condition of that state.class ResultSlot<T>{ private readonly object _gate = new(); // private, so nobody else can pulse it private T? _value; // guarded by _gate private bool _done; // guarded by _gate
public void Set(T value) { lock (_gate) { if (_done) throw new InvalidOperationException("already set"); _value = value; _done = true; // PulseAll, not Pulse: every waiter is waiting for this same event. Monitor.PulseAll(_gate); } }
public T Get() { lock (_gate) { // while, never if: re-check after every wakeup. Wait releases the lock while asleep // and retakes it before returning. while (!_done) Monitor.Wait(_gate); return _value!; } }
// Waits at most `timeout`. Each wakeup recomputes the time left from one deadline. public bool TryGet(TimeSpan timeout, out T? value) { var clock = Stopwatch.StartNew(); lock (_gate) { while (!_done) { var left = timeout - clock.Elapsed; if (left <= TimeSpan.Zero) { value = default; return false; } Monitor.Wait(_gate, left); } value = _value; return true; } }}package main
import ( "fmt" "sync" "time")
// ResultSlot is a one-shot result: set once, read by any number of goroutines that block until// it's there. Go has sync.Cond, but the idiomatic tool is closing a channel: a receive on a// closed channel returns at once, for every receiver, forever. That's notifyAll with no lost// wakeups and no while loop to forget.type ResultSlot[T any] struct { once sync.Once done chan struct{} value T // written once, before done is closed}
func NewResultSlot[T any]() *ResultSlot[T] { return &ResultSlot[T]{done: make(chan struct{})}}
// Set stores the value and wakes every waiter. sync.Once makes a second Set a no-op instead// of a panic from closing a closed channel.func (s *ResultSlot[T]) Set(v T) { s.once.Do(func() { s.value = v close(s.done) // the close happens-after the write, so receivers see the value })}
// Get blocks until Set, or until the timeout. ok is false on timeout.func (s *ResultSlot[T]) Get(timeout time.Duration) (v T, ok bool) { select { case <-s.done: return s.value, true case <-time.After(timeout): return v, false }}
func main() { slot := NewResultSlot[int]() seen := make([]int, 3) var wg sync.WaitGroup for i := range 3 { wg.Add(1) go func() { defer wg.Done() seen[i], _ = slot.Get(time.Second) // each goroutine writes only its own slot }() } time.Sleep(50 * time.Millisecond) // the waiters are blocked now, using no CPU slot.Set(42) // one close wakes all three wg.Wait() for _, v := range seen { if v != 42 { panic("a waiter missed the result") } } if _, ok := NewResultSlot[int]().Get(50 * time.Millisecond); ok { panic("expected a timeout") } fmt.Println("all waiters got 42; the timed get gave up")}// In single-threaded async code, "wait until something happens" is a promise: callers await// it, and resolving it wakes every one of them. No lock, no while loop, no lost wakeups.class ResultSlot<T> { #resolve!: (v: T) => void; #done = false; readonly #promise = new Promise<T>((resolve) => (this.#resolve = resolve));
set(value: T): void { if (this.#done) throw new Error("already set"); this.#done = true; this.#resolve(value); // every pending and future get() sees this value }
// Resolves to the value, or to undefined if the timeout comes first. get(timeoutMs?: number): Promise<T | undefined> { if (timeoutMs === undefined) return this.#promise; let timer!: ReturnType<typeof setTimeout>; const timeout = new Promise<undefined>((resolve) => (timer = setTimeout(resolve, timeoutMs))); // Clear the timer either way, so a finished wait doesn't keep the process alive. return Promise.race([this.#promise, timeout]).finally(() => clearTimeout(timer)); }}
async function main(): Promise<void> { const slot = new ResultSlot<number>(); const waiters = [slot.get(), slot.get(), slot.get()]; setTimeout(() => slot.set(42), 50); const seen = await Promise.all(waiters); if (seen.some((v) => v !== 42)) throw new Error("a waiter missed the result");
if ((await new ResultSlot<number>().get(50)) !== undefined) throw new Error("expected a timeout"); console.log("all waiters got 42; the timed get gave up");}
main();What each version shows:
- Java and C#: the classic monitor.
while (!done) lock.wait(), withnotifyAllbecause every waiter is waiting for the same event. The timed version computes the time left from one deadline on each pass. So a spurious wakeup doesn’t restart the whole timeout. - Python:
Condition.wait_for(predicate, timeout)writes thewhileloop and the deadline for you. Prefer it to a barewait(). - Go: Go has
sync.Cond, but idiomatic Go closes a channel instead. A receive on a closed channel returns right away, for every receiver, forever. It’s a broadcast that can’t be missed, with no loop to forget.selectwithtime.Aftergives the timeout. - TypeScript: in single-threaded async code, “wait until something happens” is just a promise. Resolving it wakes every caller that awaits it.
That code already contains the two rules people get wrong. Here they are, one at a time.
Stage 2: why it’s always while, never if
Section titled “Stage 2: why it’s always while, never if”Between being signaled and getting the lock back, a woken thread is not running. Anything can happen in that time. In the stepper below, two consumers (A, B) each take one item, and a producer (C) makes two. Press Play the stolen wakeup:
- A takes the lock, sees zero items, and waits (which releases the lock).
- C produces an item and signals. A wakes up, but C still holds the lock.
- Before A gets the lock back, B arrives, sees one item, and takes it.
- A finally gets the lock. With
if, it doesn’t check again and takes an item that isn’t there.
- lock(m)
- if (items == 0)
- notEmpty.wait(m)
- items = items - 1 // take one
- unlock(m)
ready
- lock(m)
- if (items == 0)
- notEmpty.wait(m)
- items = items - 1 // take one
- unlock(m)
ready
- lock(m)
- items = items + 1
- notEmpty.signal()
- unlock(m)
- if (made < 2) repeat
ready
Two consumers (A, B) each take one item; the producer (C) makes two. Press Play for the order that breaks "if", or use the Wake button to fire a spurious wakeup at a sleeping consumer.
Switch to while, and play the same order. A re-checks, sees zero, and goes back to sleep until the
second item.
The Spurious wakeup button shows the other reason. POSIX, Java and C# all allow a waiting thread
to wake with no signal at all. It’s a detail of how the OS parks threads. With while, a spurious
wakeup is harmless. With if, it’s the same bug.
Stage 3: signal or signalAll?
Section titled “Stage 3: signal or signalAll?”signal() wakes one waiter. That’s efficient when every waiter is waiting for the same thing, and
any one of them can use the event. For example, one item arrived, so one consumer should wake.
It goes wrong when waiters wait for different things on the same condition variable. Say producers wait for space, and consumers wait for items. The one wakeup can go to a thread that can’t use it. That thread re-checks and goes back to sleep. And the thread that could have used it never hears about it. The producer-consumer page shows that exact stall, step by step.
The rules that follow:
- With one condition variable, and waiters for different conditions, use
signalAll. - Better: give each condition its own condition variable (
notFullandnotEmpty). Thensignalthe one that matches what changed. Java’sReentrantLock.newCondition()and Go’ssync.Condallow several conditions on one lock. Java’s built-inwait/notifyallows only one per object. That’s whynotifyAllis the default there. - Signal after changing the state, while holding the lock (or right after releasing it). A signal sent when nobody is waiting is lost. That’s fine only because waiters check the state before sleeping, under the same lock.
Why does wait() have to be called while holding the lock?
Whether you understand the lost wakeup. Say a thread checked the condition without the lock. The other thread could change the state and signal in the gap, before the first thread sleeps. That signal goes to nobody. Holding the lock across the check closes the gap, because wait() releases it atomically as it sleeps. The red flag is answering “because the API requires it”.
Semaphores
Section titled “Semaphores”A semaphore holds a number of permits. acquire() takes one, and blocks while there are none.
release() returns one, and wakes a waiter. Two things set it apart from a lock:
- It counts. A semaphore with 10 permits lets 10 threads in at once. It bounds concurrency, where a mutex forbids it.
- It has no owner. Any thread can release a permit, even one that never acquired it. So a semaphore is a signaling tool between threads, as well as a limiter.
Here are three request threads, and a database that accepts two connections. Play A, B, C in turn,
with no limit, and the peak reaches three. Then switch to a semaphore with two permits. The third
request waits on acquire until a permit comes back.
- open++ // connection in use
- runQuery()
- open--
ready
- open++ // connection in use
- runQuery()
- open--
ready
- open++ // connection in use
- runQuery()
- open--
ready
The database accepts 2 connections; three request threads want one each. Play A, B, C in turn.
Here is the same thing in code, with ten requests and three permits. This is how connection pools, rate limiters for outbound calls, and “at most 4 concurrent uploads” are built.
import java.util.concurrent.Semaphore;import java.util.concurrent.atomic.AtomicInteger;
// Ten threads want the database; it allows three connections at once.public class Main { // Three permits. acquire() takes one or blocks until one is returned; release() returns one. // Unlike a lock, a permit has no owner: any thread may release it. static final Semaphore connections = new Semaphore(3); static final AtomicInteger inUse = new AtomicInteger(); static final AtomicInteger peak = new AtomicInteger();
static void query() throws InterruptedException { connections.acquire(); try { int now = inUse.incrementAndGet(); peak.accumulateAndGet(now, Math::max); Thread.sleep(20); // the query inUse.decrementAndGet(); } finally { connections.release(); // in finally: a failed query must still return its permit } }
public static void main(String[] args) throws InterruptedException { Thread[] requests = new Thread[10]; for (int i = 0; i < requests.length; i++) { requests[i] = new Thread(() -> { try { query(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); requests[i].start(); } for (Thread t : requests) t.join(); if (peak.get() > 3) throw new AssertionError("limit broken: " + peak.get()); System.out.println("peak connections = " + peak.get()); // peak connections = 3 }}import threadingimport time
# Ten threads want the database; it allows three connections at once. BoundedSemaphore raises if# released more times than acquired, which catches a double release instead of silently# raising the limit to four.connections = threading.BoundedSemaphore(3)_stats_lock = threading.Lock()in_use = 0 # guarded by _stats_lockpeak = 0 # guarded by _stats_lock
def query() -> None: global in_use, peak # `with` acquires a permit (blocking while none is free) and returns it however the block # exits, so a failed query can't leak a permit. with connections: with _stats_lock: in_use += 1 peak = max(peak, in_use) time.sleep(0.02) # the query with _stats_lock: in_use -= 1
if __name__ == "__main__": requests = [threading.Thread(target=query) for _ in range(10)] for t in requests: t.start() for t in requests: t.join() assert peak <= 3, peak print("peak connections =", peak) # peak connections = 3using System;using System.Linq;using System.Threading;using System.Threading.Tasks;
// Ten requests want the database; it allows three connections at once.static class Program{ // SemaphoreSlim has WaitAsync, so a request waiting for a permit doesn't hold a thread. // Unlike a lock, a permit has no owner: any task may release it. static readonly SemaphoreSlim Connections = new(3); static int _inUse, _peak;
static async Task QueryAsync() { await Connections.WaitAsync(); try { int now = Interlocked.Increment(ref _inUse); // Raise the recorded peak with a CAS loop, retrying if another task raised it first. int seen; while (now > (seen = Volatile.Read(ref _peak)) && Interlocked.CompareExchange(ref _peak, now, seen) != seen) { } await Task.Delay(20); // the query Interlocked.Decrement(ref _inUse); } finally { Connections.Release(); // in finally: a failed query must still return its permit } }
static async Task Main() { await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => QueryAsync())); if (_peak > 3) throw new InvalidOperationException($"limit broken: {_peak}"); Console.WriteLine($"peak connections = {_peak}"); // peak connections = 3 }}package main
import ( "fmt" "sync" "sync/atomic" "time")
// Ten goroutines want the database; it allows three connections at once. A buffered channel of// capacity 3 is Go's idiomatic semaphore: a send takes a slot (blocking when all 3 are full) and// a receive gives one back. golang.org/x/sync/semaphore adds weighted permits and contexts.var connections = make(chan struct{}, 3)
var inUse, peak atomic.Int64
func query() { connections <- struct{}{} // acquire defer func() { <-connections }() // release, on every return path now := inUse.Add(1) for { p := peak.Load() if now <= p || peak.CompareAndSwap(p, now) { break } } time.Sleep(20 * time.Millisecond) // the query inUse.Add(-1)}
func main() { var wg sync.WaitGroup for range 10 { wg.Add(1) go func() { defer wg.Done() query() }() } wg.Wait() if peak.Load() > 3 { panic(fmt.Sprint("limit broken: ", peak.Load())) } fmt.Println("peak connections =", peak.Load()) // peak connections = 3}// Ten requests want the database at once; it allows three connections. In Node nothing blocks a// thread, so a semaphore is a counter plus a queue of callers waiting for a permit.class Semaphore { #permits: number; #waiting: Array<() => void> = [];
constructor(permits: number) { this.#permits = permits; }
async acquire(): Promise<void> { if (this.#permits > 0) { this.#permits--; return; } // No permit: park until release() hands one over directly, in arrival order. await new Promise<void>((resolve) => this.#waiting.push(resolve)); }
release(): void { const next = this.#waiting.shift(); if (next) next(); // pass the permit straight to the next waiter else this.#permits++; }}
const connections = new Semaphore(3);let inUse = 0;let peak = 0;
async function query(): Promise<void> { await connections.acquire(); try { peak = Math.max(peak, ++inUse); await new Promise((resolve) => setTimeout(resolve, 20)); // the query inUse--; } finally { connections.release(); // in finally: a failed query must still return its permit }}
async function main(): Promise<void> { await Promise.all(Array.from({ length: 10 }, () => query())); if (peak > 3) throw new Error(`limit broken: ${peak}`); console.log("peak connections =", peak); // peak connections = 3}
main();- Java:
Semaphore(3), withrelease()infinally, so a failed query still returns its permit.tryAcquire(100, MILLISECONDS)turns waiting into shedding load: fail fast, instead of queuing forever. - Python:
BoundedSemaphoreraises on an extra release. That catches a bug that would otherwise silently raise the limit to four. - C#:
SemaphoreSlim.WaitAsync(), so a request waiting for a permit doesn’t hold a thread. It’s also the standard way to get a lock that works acrossawait, sincelockcan’t contain one. - Go: a buffered channel of capacity 3 is the idiomatic semaphore. Sending takes a slot, and receiving gives it back.
- TypeScript: a counter and a queue of resolvers. It’s the same shape as
p-limit, which you’d use in practice.
Binary semaphore vs mutex
Section titled “Binary semaphore vs mutex”A semaphore with one permit looks like a lock. In an interview, “what’s the difference?” is a standard question. The answer is ownership.
- A mutex is held by a thread. Only that thread may unlock it, and it can be reentrant. Some implementations raise the holder’s priority when a higher-priority thread waits. This is called priority inheritance.
- A binary semaphore is just a count of 1, and any thread can release it. When you mean mutual exclusion, that’s a bug magnet, because anyone can let a second thread in. But it’s exactly right when one thread signals another: “the data is ready, go”.
Latches, barriers and wait groups
Section titled “Latches, barriers and wait groups”Three more waiting tools are built from the two above, and each answers one common need:
| Tool | What it does | Java | C# | Go | Python |
|---|---|---|---|---|---|
| Latch / wait group | Wait until N things have finished. One use, counts down to zero. | CountDownLatch |
CountdownEvent |
sync.WaitGroup |
join() each thread, or futures |
| Barrier | N threads each wait until all N arrive, then all continue. Reusable. | CyclicBarrier |
Barrier |
Build it from a sync.Cond |
threading.Barrier |
| Future / promise | Wait for one result that another thread will produce. | CompletableFuture |
Task<T> |
A channel | concurrent.futures.Future |
The result slot in stage 1 is a hand-built future. Use the library’s version in real code. It handles exceptions, cancellation and timeouts, which a hand-built one usually forgets. The barrier is the tool that held two threads inside the race window on the race conditions page. Tests are its most common use.
The consumer waits on the condition in a while loop, because a woken thread can find the queue empty again. The producer changes the state under the lock, and then signals. Producers and consumers wait for different things. So I’d use two conditions, or signalAll with one.
Each language, honestly
Section titled “Each language, honestly”| Language | Condition variable | Semaphore | Notes |
|---|---|---|---|
| Java | wait/notifyAll on any object; lock.newCondition() for several |
Semaphore |
Virtual threads park cheaply in all of these |
| Python | threading.Condition, with wait_for |
Semaphore, BoundedSemaphore |
asyncio has its own Condition and Semaphore for coroutines |
| C# | Monitor.Wait/PulseAll |
SemaphoreSlim (with WaitAsync), Semaphore (cross-process) |
lock can’t contain await; use SemaphoreSlim(1, 1) as an async lock |
| Go | sync.Cond, rarely; channels instead |
A buffered channel; x/sync/semaphore |
Closing a channel is the broadcast; select adds timeouts and cancellation |
| TypeScript | A promise; resolve it to wake every awaiter | A counter plus a queue of resolvers | Atomics.wait and Atomics.notify between workers |
How waiting fails
Section titled “How waiting fails”| Failure | What happens | Prevention |
|---|---|---|
if instead of while |
A woken thread acts on a condition that is no longer true | Always while, or wait_for / a predicate overload |
| Lost wakeup | The signal comes before the wait (check without the lock), the thread sleeps forever | Check and wait under the same lock |
| Wrong thread woken | signal wakes a waiter for a different condition; everyone ends up asleep |
signalAll, or one condition variable per condition |
| Leaked permit | An exception skips release(); the limit shrinks until everything blocks |
finally, with, defer |
| Wait with no timeout | A crashed producer leaves consumers asleep forever | Timed waits, and a deadline per request |
Interview questions
Section titled “Interview questions”Interviewer asksWhat is a spurious wakeup, and how do you handle it?
A thread waiting on a condition variable can return from wait() without anyone signaling. POSIX,
Java and C# all permit it, because it lets the OS park and unpark threads more efficiently. You
handle it the same way as a stolen wakeup. Wait in a while loop that re-checks the condition.
Then a wakeup with nothing to do just goes back to sleep.
Interviewer asksWhat's the difference between wait() and sleep()?
wait() releases the lock it’s called on, and sleeps until signaled (or until a timeout). It’s
for waiting on a condition another thread will change. sleep() pauses for a fixed time, and
releases nothing. So calling it while holding a lock makes every other thread wait too. Polling
with sleep() in a loop is a sign that a condition variable or a blocking queue was needed.
Interviewer asksSemaphore vs mutex: when do you use each?
Use a mutex for mutual exclusion over shared state: one thread at a time. It’s owned by the thread that locked it, and unlocked by the same thread. Use a semaphore to bound how many threads do something at once (a pool of N connections). Or use it to signal between threads, since any thread can release a permit. A binary semaphore used as a mutex works, until some code path releases it from the wrong thread.
Interviewer asksHow would you implement a CountDownLatch?
A count and a condition variable, under one lock. countDown() takes the lock and decrements. If
the count reached zero, it calls signalAll. await() takes the lock, and waits in
while (count > 0). Once the count is zero, it stays zero, so late callers return right away. In
Go, sync.WaitGroup is exactly this.
Go deeperWhy some languages split the lock from the condition2 min
Java’s built-in monitor gives each object one lock and one wait set. That forces notifyAll
whenever threads wait for different reasons. And every notifyAll wakes threads that will only
re-check and sleep again. This is called the “thundering herd”. With 200 waiters, one item wakes 200
threads, to hand the item to one of them.
ReentrantLock with several Condition objects, POSIX pthread_cond_t, Go’s sync.Cond and
Python’s Condition(lock) all let you attach several wait sets to one lock. Producers wait on
notFull, and consumers on notEmpty. Each event wakes only a thread that can use it. That’s why
java.util.concurrent.ArrayBlockingQueue is built on one lock with two conditions.
| Idea | Remember |
|---|---|
| Condition variable | Sleep until another thread changes state; always used with its lock |
wait() |
Releases the lock and sleeps in one step; retakes the lock before returning |
while, not if |
Stolen and spurious wakeups mean the condition may be false again |
signal vs signalAll |
signal only when any waiter can use the event; otherwise signalAll or two conditions |
| Monitor | Lock + guarded state + condition variables, as one unit |
| Semaphore | N permits, no owner; bounds concurrency or signals between threads |
| Latch, barrier, future | Wait for N to finish, for N to arrive, for one result |
Further reading
Section titled “Further reading”- Brian Goetz et al., Java Concurrency in Practice, chapter 14 (building custom synchronizers).
- Allen B. Downey, The Little Book of Semaphores, free, and full of puzzles built from nothing but semaphores.
- Next: Deadlock, what happens when threads wait for each other in a circle.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.