Condition Variables
Imagine a bounded buffer shared between a producer and a consumer. The consumer must sleep when the buffer is empty. Using only Locks and Mutual Exclusion, a thread faces a brutal problem. It checks "is the buffer empty?" and then wants to sleep. Between releasing the lock and going to sleep, trouble can strike. The producer might fill the buffer and signal. That signal reaches nobody, since no one is listening yet. This is the classic lost-wakeup problem. It is the reason condition variables exist. A condition variable (CV) is a synchronization primitive. It lets a thread block efficiently until some predicate over shared state becomes true. It does this while atomically coordinating with a lock, so no wakeup is ever lost. Rather than spinning in a loop burning CPU cycles waiting for a flag to flip, a thread calls `wait` on a condition variable. The operating system scheduler then puts it to sleep. It wakes only when another thread calls `signal` or `broadcast`. This turns busy-waiting into event-driven waiting. That distinction matters enormously in real systems. Database connection pools, thread pools, I/O completion queues, and network servers all depend on it. Threads sleep until work arrives, rather than polling. The subtlety of condition variables is that they are not enough on their own. They are always paired with a lock. Correct usage requires a specific discipline. Check the condition in a `while` loop rather than an `if` statement. Always hold the lock when calling `wait` or `signal`. Understand the difference between signaling one waiter versus broadcasting to all of them. Get any of these details wrong, and the program deadlocks. Or it produces subtle, timing-dependent bugs. These bugs vanish under a debugger and reappear in production. This article builds the mechanism from first principles. It explains why locks alone cannot solve the sleep/wake problem. It covers the exact semantics of `wait`/`signal`/`broadcast`. It shows why the while-loop predicate check is mandatory, rather than a matter of style. It also shows how to reason formally about correctness using invariants. It closes by connecting condition variables to the higher-level abstraction of Monitors. It also links to the canonical Producer-Consumer Problem. That problem motivates almost every real use of this primitive.
- A condition variable lets a thread sleep until a predicate on shared state becomes true, without busy-waiting.
- CVs are always used together with a lock: the lock protects the shared state, the CV manages the sleeping/waking.
- `wait(cv, lock)` atomically releases the lock and sleeps; it reacquires the lock before returning.
- Always re-check the condition in a `while` loop after `wait` returns — never assume it holds (spurious/stolen wakeups).
- `signal` wakes at most one waiter; `broadcast` wakes all waiters — use broadcast when in doubt about which waiter can proceed.
- Getting this pattern right underlies the design of Monitors and solves the Producer-Consumer Problem cleanly.
A condition variable is a queue of sleeping threads tied to a lock, and it is only ever safe to use with a `while`-loop re-check of the condition, because signals can be spurious, stolen, or simply outdated by the time the waiter wakes.
The idea
A condition variable is an object supporting three operations — `wait`, `signal` (sometimes called `notify`), and `broadcast` (sometimes called `notify_all`) — that always operate in conjunction with an associated mutex. The condition variable itself holds no data; it is conceptually a queue of threads waiting for some condition — a boolean predicate over variables protected by the lock — to become true. Threads never wait on a raw boolean value; they wait on the CV, while the boolean predicate is checked by inspecting ordinary shared variables that the lock protects. The essential operation is `wait(cv, mutex)`. A thread calls this while holding `mutex`, having just checked that the condition it needs is false. The call atomically releases `mutex` and puts the calling thread to sleep on `cv`'s queue. "Atomically" here is the crux: if release-then-sleep were two separate steps, another thread could acquire the lock, make the condition true, and call `signal` in the gap between the release and the sleep — and that signal would be lost forever, because no thread was yet on the queue to receive it. The CV implementation guarantees this cannot happen: by the time `wait` has released the lock, the calling thread is already registered as a waiter. When some other thread changes shared state such that the condition might now hold, it calls `signal(cv)` (wake one waiter) or `broadcast(cv)` (wake all waiters), typically just before or after releasing the lock. A woken thread does not resume running immediately with the lock in hand and the condition guaranteed true; it merely becomes eligible to compete for the lock again. Once it reacquires the lock, `wait` returns, and — critically — the thread must re-check the condition, because in the interval between being woken and reacquiring the lock, some other thread may have grabbed the lock first and changed the state again (a "stolen wakeup"), or the underlying implementation may permit spurious wakeups (returning from `wait` with no signal at all, permitted by POSIX for implementation efficiency on multiprocessors). This is why the mandatory idiom is: ``` while (!condition) { wait(cv, mutex) } ``` never ``` if (!condition) { wait(cv, mutex) } ``` The `while` loop is not a defensive style choice — it is a correctness requirement rooted in the exact atomicity guarantees the CV offers and does not offer.
How it works
The mechanism rests on the interaction between a lock (guarding a piece of shared state and the predicate over it) and a CV (a wait queue with `wait`/`signal`/`broadcast`). Here is the discipline, stated as an algorithm.
Invariant maintained by the pattern: whenever a thread is not holding the lock and not asleep inside `wait`, all reads/writes to the shared state it cares about have gone through critical sections protected by that same lock. The CV never touches the shared state itself — it only ever puts a thread to sleep and wakes it up, always in the context of holding (or about to hold, or having just released) the lock. ```pseudocode
# Shared state, protected by `lock` shared_state # e.g., buffer contents, count, flag lock # a mutex cv # a condition variable tied to `lock`
# --- Consumer / Waiter side --- procedure ConsumeWhenReady(): acquire(lock) while not ConditionHolds(shared_state): wait(cv, lock) # atomically: release(lock); sleep on cv
# on wakeup: reacquire(lock) before returning DoWorkAssumingConditionTrue(shared_state) release(lock)
# --- Producer / Signaler side --- procedure ProduceAndNotify(): acquire(lock) ModifyState(shared_state) # makes ConditionHolds possibly true if ConditionHolds(shared_state): signal(cv) # or broadcast(cv) — wake waiter(s) release(lock) ``` Step-by-step semantics of `wait(cv, lock)`, which is the operation demanding the most care: 1. The calling thread must already hold `lock` (a precondition — calling `wait` without the lock is undefined behavior / a programming error in virtually every real implementation). 2. `wait` atomically: (a) adds the calling thread to `cv`'s internal waiting-thread queue, and (b) releases `lock`. No other thread can observe the calling thread as "not yet queued but also not holding the lock" — that gap is exactly what atomicity forbids. 3. The thread is descheduled by the OS (it consumes no CPU while asleep — this is the entire point versus spin-waiting). 4. Some other thread later calls `signal(cv)` or `broadcast(cv)` while holding `lock`. This moves one (for `signal`) or all (for `broadcast`) waiting threads from "asleep" to "runnable," but does not hand them the lock directly nor guarantee they run next. 5. When the OS scheduler eventually runs a woken thread, `wait` reacquires `lock` (blocking if necessary, since the signaler or some other thread may hold it) before returning control to the caller. 6. Control returns to the `while` loop, which re-checks the condition — and only exits the loop, proceeding to use `shared_state`, once the condition is verified true while holding the lock. Why `signal` alone is not enough without the lock discipline: signaling changes the runnable set, but the thread that calls `signal` typically still holds the lock at that moment (in the common "signal-and-continue" semantics used by POSIX and most real systems), so the woken thread cannot actually proceed until the signaler releases the lock. This ordering guarantee — that the woken thread only resumes its critical section after reacquiring the lock — is what prevents two threads from ever believing the condition is true simultaneously when it isn't. Two wake primitives, two use cases:
- `signal` wakes at most one waiting thread. Appropriate when any one waiter can make progress and waking others would be wasted work (e.g., a single-slot mailbox where only one consumer needs the item).
- `broadcast` wakes every waiting thread. Necessary when different waiters are waiting for different sub-conditions on the same CV, or when a single signal cannot correctly guarantee that the "right" waiter (of possibly several eligible ones) proceeds — each woken thread re-checks its own predicate and either proceeds or goes back to sleep. Broadcast is always safe (never a correctness bug) — it can only ever cost extra wakeups. Signal, if misused (waking one thread when the correct behavior requires waking a specific different one), can cause deadlock: threads that could have been woken remain permanently asleep.
Complexity & correctness
Time complexity. In a well-implemented CV, `wait`, `signal`, and `broadcast` are all amortized per call for the queue manipulation itself (enqueue/dequeue a thread control block, typically a doubly linked list or similar), plus the unavoidable cost of an OS context switch to actually deschedule/reschedule a thread — a constant but comparatively large cost (microseconds), independent of the number of other threads in the system. `broadcast` costs where is the number of currently waiting threads, since each must individually be marked runnable; this is the honest cost, not , though it is still linear and typically small. Compare this to a busy-wait/polling alternative: ```pseudocode while not ConditionHolds(shared_state): release(lock); acquire(lock) # or: spin without releasing ``` This costs per poll but an unbounded number of polls — the thread consumes CPU cycles proportional to the wall-clock wait time divided by the polling interval, wasting cycles that could run other threads. On a single-core machine spinning while holding the lock is not merely wasteful but can deadlock the system entirely (no other thread can run to make the condition true). CVs asymptotically dominate polling whenever the wait time is not tiny relative to a context switch, which is essentially always true in real systems.
Space complexity. where is the number of threads currently waiting on the CV — each waiting thread occupies one slot in the CV's internal queue (in practice, the thread's own kernel stack/control block, referenced by the queue, so marginal cost per waiter is small and constant).
Correctness argument. The central claim to prove is: no thread ever proceeds past its `while` loop believing the condition holds when it does not, and no wakeup is ever permanently lost. *No false proceed: the `while` loop only exits when `ConditionHolds(shared_state)` is evaluated as true, and this evaluation happens strictly after `wait` has reacquired `lock`. Since all writes to `shared_state` occur only while `lock` is held (the mutual-exclusion discipline inherited from ordinary locking), and the reading thread now holds `lock` too, the read is not racing any writer — it observes a definite, consistent value at the moment of the check. Mutual exclusion, proven for <a class="wiki-link" href="/wiki/cs-os-locks-and-mutual-exclusion" data-track="wiki_article.body.wiki_link">Locks and Mutual Exclusion</a> independently, is exactly what makes this check trustworthy. No stale cached view, no partial write, no race: the property being checked is the actual current state. No lost wakeup: this reduces to the atomicity of `wait`'s release-and-sleep step. Suppose for contradiction a signal is lost — meaning some thread T calls `signal` (or `broadcast`) while the intended waiter W is conceptually waiting but the signal reaches an empty queue and does nothing, after which W goes to sleep and is never woken. For this to happen, W must call `wait`'s internal enqueue operation after T's signal has already scanned the queue and found it empty. But W's `wait` call enqueues itself and releases the lock as one atomic action; T cannot be executing `signal` concurrently with that atomic action, because `signal` requires holding the same `lock`, and W still holds `lock` until the atomic release-and-enqueue completes. Hence T's `signal` (which must acquire `lock` first) is strictly ordered either entirely before W enqueues (in which case T's write to `shared_state` happens first, and W's subsequent `while`-check on re-entering will see the true condition next time it wakes on some later signal or even without needing to sleep) or strictly after W has already enqueued (in which case T's `signal` finds W in the queue and wakes it). There is no interleaving in which the signal is dispatched to an empty queue while W's wait-enqueue is logically pending — the lock forbids the race. This is precisely the guarantee that fails for a naive hand-rolled sleep/wake flag without this atomicity, producing the classic lost-wakeup bug. Termination / no permanent starvation for `broadcast` designs:* since broadcast wakes every waiter, any waiter whose condition has become true will, upon reacquiring the lock (in some order, eventually, assuming a fair scheduler and fair lock), observe it true and proceed; waiters whose condition remains false simply go back to sleep at negligible cost, preserving correctness while very slightly bounding efficiency (extra wakeups do wasted work per broadcast, which is the honest tradeoff against `signal`'s ambiguity risk).
Tradeoffs. `signal` is cheaper per call than `broadcast` but requires the programmer to prove that waking any single waiter is always sufficient and correct — subtle when multiple distinct conditions share one CV, a common source of real bugs. `broadcast` is always safe but pays wakeup cost even when only one waiter can actually proceed, causing a "thundering herd" if is large and only one thread's predicate is actually satisfiable — mitigated in practice by giving each logically distinct condition its own CV rather than multiplexing many predicates onto one.
Worked example
Trace a bounded buffer of capacity 2 shared by one producer P and one consumer C, using two condition variables — `notFull` and `notEmpty` — both tied to the same `lock`. `count` tracks items currently in the buffer. ```pseudocode count = 0; CAPACITY = 2 lock; notFull; notEmpty # CVs share `lock` procedure Produce(item): acquire(lock) while count == CAPACITY: wait(notFull, lock) buffer.enqueue(item); count += 1 signal(notEmpty) release(lock) procedure Consume(): acquire(lock) while count == 0: wait(notEmpty, lock) item = buffer.dequeue(); count -= 1 signal(notFull) release(lock) return item ```
| Step | Thread | Action | `count` | Lock held by | Notes |
|---|---|---|---|---|---|
| 1 | C | `acquire(lock)` | 0 | C | consumer runs first, buffer empty |
| 2 | C | checks `count==0` → true, calls `wait(notEmpty, lock)` | 0 | — | atomically releases lock, sleeps on `notEmpty` |
| 3 | P | `acquire(lock)` | 0 | P | lock free, P proceeds |
| 4 | P | `count==CAPACITY`? false, skips loop | 0 | P | |
| 5 | P | enqueues item, `count=1` | 1 | P | |
| 6 | P | `signal(notEmpty)` | 1 | P | moves C from asleep → runnable; P still holds lock |
| 7 | P | `release(lock)` | 1 | — | now C can actually acquire it |
| 8 | C | (was runnable) `wait` reacquires `lock` | 1 | C | resumes inside the `while` loop, re-checks |
| 9 | C | `count==0`? false now → exits loop | 1 | C | correctness: check happens under lock, sees true state |
| 10 | C | dequeues item, `count=0` | 0 | C | |
| 11 | C | `signal(notFull)`, `release(lock)` | 0 | — | wakes any waiting producer (none here) |
Now trace the case that demonstrates why `while`, not `if`, is required. Suppose two consumers C1 and C2 both wait on `notEmpty`, and a single producer signals only once.
| Step | Thread | Action | `count` | Notes |
|---|---|---|---|---|
| 1 | C1 | waits on `notEmpty` (count=0) | 0 | asleep |
| 2 | C2 | waits on `notEmpty` (count=0) | 0 | asleep |
| 3 | P | produces one item, `count=1`, `signal(notEmpty)` | 1 | wakes exactly one of {C1, C2} — say C1 |
| 4 | C1 | reacquires lock, `while count==0` is false, proceeds, dequeues, `count=0` | 0 | correct |
| 5 | C2 | eventually gets CPU time, reacquires lock (it was already runnable from a stale wake, or is still asleep) | 0 | if `if` had been used instead of `while`, C2 would wrongly proceed to dequeue from an empty buffer |
With `while`, C2 — if it happens to wake spuriously, or was mistakenly signaled twice, or wakes up after C1 already consumed the one item — re-checks `count==0`, finds it still true, and correctly goes back to sleep instead of dequeuing from an empty buffer (which would be a serious bug, e.g., an underflow or reading garbage). This is exactly the scenario the invariant argument in the previous section predicts must be handled, and it is why production code (POSIX `pthread_cond_wait`, Java's `Condition.await`, C++'s `std::condition_variable::wait`) all document that spurious wakeups are possible and the `while` idiom is mandatory, not optional style.
Common pitfalls & traps
| Pitfall | Why it breaks | Fix |
|---|---|---|
| Using `if` instead of `while` around `wait` | Spurious/stolen wakeups leave the thread proceeding on a false condition | Always re-check condition in a `while` loop after `wait` returns |
| Calling `wait`/`signal` without holding the lock | Breaks the atomicity guarantee that prevents lost wakeups; undefined behavior in POSIX | Acquire the lock before any CV operation, hold it through `wait`'s call |
| Using `signal` when multiple distinct predicates share one CV | Wakes a thread whose specific condition is still false, while the thread that could proceed stays asleep — livelock/deadlock | Use `broadcast`, or give each distinct predicate its own CV |
| Forgetting to release the lock after the critical section | Every other thread — including CV waiters trying to reacquire — blocks forever | Ensure every code path (including exceptions) releases the lock |
| Signaling before mutating shared state | Woken waiter may reacquire lock and see the old (unchanged) condition, sleep again, and miss the real update if it arrives with no further signal | Mutate state, then signal, all while holding the lock |
| Assuming `signal` immediately runs the woken thread | The signaler usually keeps the lock until it releases; the woken thread only becomes runnable, not running | Never assume ordering between signal and the woken thread's resumption beyond "eventually, after lock is free" |
Pioneers & history
The condition variable as a formal primitive emerged from the broader effort to structure concurrent programs safely, moving beyond raw semaphores toward higher-level, more error-resistant abstractions — the Monitors concept, of which condition variables are the core waiting mechanism.
Origins · 1 minC. A. R. Hoare and how this was discovered
Hoare, already renowned for Quicksort and axiomatic program verification, published the formal semantics of monitors in 1974, giving condition variables their first rigorous mathematical treatment: precise rules for `wait` and `signal` and a definition of what it means for a monitor program to be correct with respect to its invariant.
Where this matters
Condition variables underpin the sleeping/waking logic inside almost every concurrent system component built above raw locks. Operating system kernels use them (or kernel-level equivalents) to implement wait queues for I/O completion, process termination (`wait()` on a child process conceptually blocks until a condition — child exited — becomes true), and blocking system calls. Language runtimes expose them directly: POSIX `pthread_cond_t`, Java's `Object.wait/notify` and `java.util.concurrent.locks.Condition`, C++'s `std::condition_variable`, Python's `threading.Condition`, and Go's channels (which provide similar semantics through a different interface) all reduce, at the implementation level, to this wait/signal/broadcast pattern paired with a lock. Thread pools use condition variables so idle worker threads sleep on an empty task queue rather than spinning, waking precisely when a task is submitted — a foundational pattern in web servers, database connection pools, and job schedulers. Producer-consumer pipelines throughout data processing systems (message queues, streaming frameworks, buffered I/O layers) use the exact bounded-buffer pattern traced above. Higher-level concurrency abstractions such as Monitors, barriers, read-write locks, and thread-safe blocking collections (e.g., Java's `BlockingQueue`) are all built internally from condition variables, making this primitive one of the load-bearing walls of concurrent systems design — quietly present under almost every "a thread waits until X happens" behavior in modern software.
Ask the tutor: How would the bounded-buffer producer-consumer example change if we used a single condition variable instead of two separate ones (`notFull` and `notEmpty`)?
Can a condition variable be used without any lock at all?
No. A condition variable's correctness guarantees — specifically, that no wakeup is lost — depend entirely on the atomic release-and-sleep behavior of `wait`, which requires the lock to define what "atomic" means with respect to other threads. Without a lock, there is no way to guarantee that a `signal` call and a `wait` call are properly ordered: a thread could check the condition, find it false, and be interrupted before it manages to register as a waiter, at which point a concurrent signal finds nothing to wake. Every real CV implementation (POSIX, Java, C++, Python) requires the lock to be held when `wait` is called and enforces or strongly documents this requirement, sometimes raising a runtime error if violated.
What exactly is a spurious wakeup, and why is it allowed?
A spurious wakeup is when `wait` returns even though no thread ever called `signal` or `broadcast` on that condition variable. POSIX explicitly permits this because it lets implementations be more efficient on multiprocessor systems — for instance, batching wakeups, or using hardware/OS mechanisms that occasionally return early rather than paying the cost of perfectly precise signaling. Because this behavior is permitted by the standard, any portable, correct program must tolerate it, which is exactly why the mandatory idiom is a `while` loop re-checking the actual condition rather than assuming that returning from `wait` means the condition is true.
Why does the producer call signal after modifying the shared state, not before?
If the producer signaled before enqueuing the item, a woken consumer could reacquire the lock, check the condition, find the buffer still empty (because the enqueue hasn't happened yet), and go back to sleep — potentially missing the eventual real update if no further signal ever arrives. Signaling after the mutation guarantees that when the consumer wakes and checks, the state genuinely reflects the change that triggered the signal, keeping the `while`-loop check meaningful rather than racing against pending writes.
Is broadcast ever strictly necessary, or is it just a safety net?
Broadcast is necessary whenever a single condition variable is shared by waiters with different predicates, or when the exact identity of which waiter can proceed cannot be determined by the signaler. For example, if some threads wait for "buffer not full" and others (mistakenly sharing the same CV) wait for "buffer not empty," a single `signal` might wake the wrong kind of waiter, which then re-checks its own predicate, finds it false, and sleeps again — while the thread that could have proceeded remains asleep indefinitely. The safe, general fix is either to use `broadcast`, or better, to use separate CVs per distinct predicate so that `signal` is provably sufficient.
How do condition variables relate to monitors?
A monitor is a language-level or library-level construct that bundles a lock, the shared data it protects, and one or more condition variables together with the procedures that operate on that data, so that mutual exclusion is automatic (entering any monitor procedure implicitly acquires the lock). Condition variables are the mechanism inside a monitor that lets a procedure block mid-execution until a predicate holds, then resume safely. Understanding condition variables directly, as this article does, is the prerequisite for understanding how Monitors achieve structured, less error-prone concurrent programming compared to raw locks and CVs used ad hoc.
What is the "thundering herd" problem with broadcast, and how is it mitigated?
When `broadcast` wakes many waiting threads but only one (or a few) can actually make progress, all the woken threads compete to reacquire the lock, each re-checks its condition, and most immediately go back to sleep — wasted context switches and lock contention proportional to the number of waiters. This is mitigated by using separate condition variables for logically distinct predicates (so a broadcast only wakes threads whose condition might plausibly now hold), or in performance-critical systems, using `signal` when it is provably safe to wake exactly one thread, reserving `broadcast` for cases where waking one is not enough.
Does calling signal or broadcast when no thread is waiting cause an error?
No — signaling an empty condition variable (no threads currently in `wait`) is a well-defined no-op in every standard implementation; it simply has no effect and does not raise an error or block the caller. This is actually essential to correct usage: a producer cannot generally know in advance whether any consumer happens to be waiting, so signaling unconditionally after every state change that could satisfy some waiter is the standard, safe pattern.