Module 2 · Unit 6 · Lesson 6 of 7
Condition Variables
The primitive that lets a thread wait for a condition to become true. A condition variable is always used together with a mutex: the mutex protects the shared state, and the condition variable allows a thread to release the mutex and sleep until another thread signals that the state has changed. It is the most subtle primitive in the unit, and the one most often misused.
01 Introduction
Lesson 6.5 introduced the condition variable as the mechanism a monitor uses to wait for a condition. This lesson examines the condition variable in its own right, as a primitive that can be used directly with a mutex. The condition variable is not an alternative to the lock; it is a companion to it. The lock provides mutual exclusion — one thread at a time inside the critical section. The condition variable provides waiting — a way for a thread to leave the critical section, sleep until a condition becomes true, and then re-enter it.
Why is a separate primitive needed? Consider what happens if a thread wants to wait for a condition while holding a lock. If it simply spins in a loop inside the critical section, checking the condition repeatedly, then it holds the lock the entire time and no other thread can change the state to make the condition true. The thread deadlocks itself. If it releases the lock and then spins, another thread can change the state, but the waiting thread is burning CPU cycles and — worse — it is not holding the lock, so the check it just did may be stale by the time it acquires the lock again. Neither approach works.
The condition variable solves the problem by providing a
single atomic operation, wait, that
releases the lock and blocks in one indivisible step.
When the thread is later woken by a signal, it
re-acquires the lock before returning. From the
thread's point of view, it acquired the lock, tested the
condition, waited, and then woke up still holding the lock
— with the condition possibly true, possibly false, and the
lock correctly held either way. The atomicity of the
release-and-block and the re-acquire is what makes the
abstraction work.
This lesson presents the condition variable in full detail.
It defines the three operations — wait,
signal, and broadcast — and
explains why each is necessary. It presents the
loop-around-wait pattern, which is the
canonical way to use a condition variable correctly, and
explains why the loop is required by Mesa semantics and
spurious wakeups. It presents the POSIX interface,
pthread_cond_t, with its wait,
timedwait, signal, and
broadcast operations. It shows the
producer–consumer, reader–writer, and barrier solutions
built on condition variables, and it examines the pitfalls
that make this primitive easy to misuse.
Prerequisites
This lesson builds directly on Lessons 6.3–6.5. It assumes familiarity with the lock (Lesson 6.3) and the monitor pattern (Lesson 6.5). The distinction between Hoare and Mesa semantics was introduced in Lesson 6.5 and is central here; if it is unfamiliar, review section 4.5 of that lesson before continuing. The POSIX threads API from Lesson 4.5 is used in the interactive activity.
02 Learning objectives
By the end of this lesson you will be able to
- Describe the condition variable abstraction and its three operations.
- Explain why a condition variable must always be used with a mutex, and why
waitreleases and re-acquires it atomically. - Apply the loop-around-wait pattern correctly and explain why it is required.
- Distinguish
signalfrombroadcastand choose the appropriate one for a given problem. - Solve the producer–consumer, reader–writer, and barrier problems with condition variables.
This lesson contributes to U6.3, U6.4, and U6.5, and to course learning outcomes CLO2, CLO3, and CLO8.
03 Required reading
- OSTEP — Chapter 30, Condition Variables. The clearest available treatment of the condition variable as a primitive. OSTEP builds up from the problem to the interface, and its discussion of the producer–consumer and its variants is the basis for the worked example in this lesson.
- Operating System Concepts — Chapter 6, Section 6.7, Monitors. The condition variable as part of the monitor abstraction, with the producer–consumer example.
- Butenhof, D. R. — Programming with POSIX Threads, Chapter 4, Condition Variables. The definitive reference for the POSIX condition variable. Read the sections on the "wait" semantics, the "signal" semantics, and the "waiting for the state to change" pattern.
- Tanenbaum & Bos — Modern Operating Systems, Chapter 2, Section 2.3.7, Monitors. The condition variable in the historical context of the monitor.
-
Linux man pages:
man 3 pthread_cond_init,man 3 pthread_cond_wait,man 3 pthread_cond_timedwait,man 3 pthread_cond_signal,man 3 pthread_cond_broadcast,man 3 pthread_cond_destroy.
Reading strategy
Read OSTEP Chapter 30 in full. Its staged presentation —
starting with the parent–child synchronization problem,
building up to the producer–consumer, and finishing with
the covering-condition and other pitfalls — is the
clearest available exposition of why the condition
variable is designed as it is. Then read Butenhof
Chapter 4 for the POSIX details. Pay particular
attention to the discussion of why the
wait is always inside a while
loop, and to the discussion of spurious wakeups.
04 Core content
4.1 The condition variable abstraction
A condition variable is an object associated with a particular condition on shared state. It provides three operations:
wait(c, m)-
Atomically release the mutex
mand block the calling thread on the condition variablec. When the thread is woken by asignalorbroadcast, it re-acquiresmbefore returning fromwait. The release of the mutex and the block are one indivisible operation; the re-acquisition is also part of the operation and happens beforewaitreturns. signal(c)-
Wake one thread waiting on
c, if any. If no thread is waiting, the signal has no effect — it is lost. This is different from a semaphore'spost, which increments a value that a laterwaitfinds. broadcast(c)(ornotify_all)-
Wake all threads waiting on
c, if any. Each of them re-checks its condition after re-acquiring the mutex.
The condition variable has no value. It is a queue of waiting threads, nothing more. The condition it is associated with is a property of the shared state that the mutex protects; the condition variable is only the mechanism for waiting until that property changes.
This absence of state is the essential distinction between
a condition variable and a semaphore. A semaphore's value
is the number of pending signals. A condition variable's
signal is not "remembered": if no thread is
waiting at the moment of the signal, the signal disappears.
The consequence is that a condition variable must be used
with a mutex that protects a state variable
indicating whether the condition holds. The thread that
signals changes the state before signalling; the thread
that waits checks the state after waking.
wait, which atomically releases
the mutex and blocks. The signalling thread acquires the
same mutex, changes the state to make the condition true,
signals, and releases the mutex. The waiting thread
re-acquires the mutex before returning from
wait, and then re-tests the condition.
4.2 Why a mutex is required
A condition variable is always used with a mutex. The mutex serves two purposes.
- It protects the shared state. The condition is a property of some shared variable — a count, a flag, a queue's length — that can be read and written by multiple threads. The mutex ensures that these accesses are mutually exclusive, so that the condition check and the state change are consistent.
-
It makes the check-and-wait sequence atomic.
A thread that checks the condition and then calls
waitmust not have another thread change the state between the check and the wait. The mutex ensures this: the check happens inside the critical section, and thewaitreleases the mutex only as part of the atomic release-and-block operation.
The wait operation's atomicity is the key. It
does two things — release the mutex, and add the thread to
the wait queue — as one indivisible step. If the release
and the block were separate, a signal could arrive between
them and be lost, because the signalling thread would see
an empty queue and return without waking anyone, and then
the waiting thread would block forever. The atomic
release-and-block eliminates this race.
For the same reason, the wait operation
re-acquires the mutex before returning. When a waiting
thread is woken by a signal, it must not
simply resume with the mutex released — the state it is
about to read may not be consistent, and the thread needs
the same mutual exclusion it had before. The
re-acquisition guarantees that the woken thread holds the
mutex as soon as wait returns.
4.3 The loop-around-wait pattern
The canonical way to use a condition variable is the loop-around-wait pattern:
lock(mutex);
while (!condition) {
wait(cond, mutex);
}
/* The condition is true. Use the state. */
unlock(mutex);
The while loop is essential, and it is the
most important rule of condition variable programming.
There are three reasons a single if is not
sufficient.
-
Mesa semantics. The
signalwakes a thread but does not transfer control to it immediately. Between the signal and the woken thread's re-acquisition of the mutex, other threads may enter the critical section and change the state, so the condition may no longer be true. The woken thread must re-check the condition before proceeding. -
Spurious wakeups. A condition variable
is permitted to wake a waiting thread without any
signalorbroadcasthaving occurred. This is allowed by POSIX and by every condition variable implementation, for reasons that range from implementation details of the underlying wait mechanism to the handling of signals. Awhileloop handles spurious wakeups correctly: the thread re-checks the condition, finds it still false, and waits again. -
Multiple waiters. When several threads
are waiting on the same condition and a
broadcastwakes them all, only one may be able to proceed. The others re-check, find the condition false, and wait again. Without awhileloop, every woken thread would proceed, and several would operate on invalid state.
if is a bug, not a style choice
Using if instead of while
around a wait is one of the most common bugs
in concurrent code. It is correct only under Hoare
semantics, which no widely used language provides. Under
Mesa semantics — which every real implementation uses —
the condition may be false when the woken thread runs.
The bug is intermittent: the program may work for
thousands of runs and then fail catastrophically under
load. There is no reason ever to use if.
4.4 Signal versus broadcast
The condition variable provides two wakeup operations, and the choice between them affects both correctness and performance.
| Operation | Effect | When to use it |
|---|---|---|
signal |
Wake one waiter | When the condition that has become true allows exactly one waiter to proceed. |
broadcast |
Wake all waiters | When the condition may allow more than one waiter to proceed, or when the waiters are heterogeneous and the signaller does not know which can proceed. |
In the producer–consumer problem, one inserted item allows
exactly one consumer to remove it, so signal
is correct and more efficient. In the dining philosophers
problem, a philosopher putting down their forks may allow
either of two neighbours to pick up a fork — and the
signaller does not know which of them will succeed — so
broadcast is the appropriate choice.
A broadcast is always safe: it wakes more
threads than necessary, but each re-checks its condition
and goes back to sleep if the condition is still false. Its
cost is the wasted wakeups. Each wakeup involves a context
switch and a re-acquisition of the mutex, and on a system
with many waiters this can dominate the useful work. The
rule is to use signal when the condition is
specific and to reserve broadcast for the
cases where the condition is general.
4.5 The POSIX interface
The POSIX condition variable is pthread_cond_t,
declared in <pthread.h>. Its operations
are:
int pthread_cond_init(pthread_cond_t *cond,
const pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *cond,
pthread_mutex_t *mutex);
int pthread_cond_timedwait(pthread_cond_t *cond,
pthread_mutex_t *mutex,
const struct timespec *abstime);
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
The interface has several features worth noting.
-
The mutex is an argument to
wait.pthread_cond_waittakes both the condition variable and the mutex. This is what allows the function to release the mutex and block on the condition variable in one atomic step. The caller must hold the mutex when callingwait; if it does not, the behaviour is undefined. -
pthread_cond_timedwait. The timed variant takes an absolute time (not a relative duration) at which the wait should time out. If the timeout elapses before a signal, the function returnsETIMEDOUTand the caller must re-check the condition — the wait may have returned for a timeout rather than a signal. -
Return value. All POSIX condition
variable functions return an error code directly, as
with the rest of pthreads. They do not set
errno. -
Process-shared attribute. By default,
a condition variable is shared only between threads of
the same process. Setting
PTHREAD_PROCESS_SHAREDallows it to be shared between processes that share memory. -
Clock attribute. A condition variable
can be configured to use
CLOCK_MONOTONICorCLOCK_REALTIMEfor its timed wait. The default on most systems isCLOCK_REALTIME, which is subject to adjustments for daylight saving and for time synchronization;CLOCK_MONOTONICis preferable for measuring intervals.
4.6 Producer–consumer with condition variables
The bounded-buffer producer–consumer problem is solved with one mutex and two condition variables:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;
int buffer[N];
int in = 0, out = 0, count = 0;
void insert(int item) {
pthread_mutex_lock(&mutex);
while (count == N)
pthread_cond_wait(¬_full, &mutex);
buffer[in] = item;
in = (in + 1) % N;
count++;
pthread_cond_signal(¬_empty);
pthread_mutex_unlock(&mutex);
}
int remove(void) {
pthread_mutex_lock(&mutex);
while (count == 0)
pthread_cond_wait(¬_empty, &mutex);
int item = buffer[out];
out = (out + 1) % N;
count--;
pthread_cond_signal(¬_full);
pthread_mutex_unlock(&mutex);
return item;
}
The structure is the classic monitor pattern. The mutex
protects the buffer's state; the two condition variables
handle the two conditions — "buffer is not full" and
"buffer is not empty". The insert procedure
waits on not_full until there is a free
slot, inserts the item, and signals not_empty
to wake a waiting consumer. The remove
procedure is the mirror image.
The two condition variables are necessary because the
conditions they represent are different. A single
condition variable would not distinguish between a
producer waiting for a free slot and a consumer waiting
for an item; a signal on the shared variable
might wake the wrong kind of thread. The two variables
keep the waiters separate and allow a targeted signal.
4.7 Reader–writer with condition variables
The reader–writer problem can also be solved with condition variables. The following solution gives writers priority, to avoid the writer-starvation problem of the reader-preferring semaphore solution from Lesson 6.4.
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t can_read = PTHREAD_COND_INITIALIZER;
pthread_cond_t can_write = PTHREAD_COND_INITIALIZER;
int readers = 0;
int writers = 0;
int waiting_writers = 0;
void reader_enter(void) {
pthread_mutex_lock(&mutex);
while (writers > 0 || waiting_writers > 0)
pthread_cond_wait(&can_read, &mutex);
readers++;
pthread_mutex_unlock(&mutex);
}
void reader_exit(void) {
pthread_mutex_lock(&mutex);
readers--;
if (readers == 0)
pthread_cond_signal(&can_write);
pthread_mutex_unlock(&mutex);
}
void writer_enter(void) {
pthread_mutex_lock(&mutex);
waiting_writers++;
while (readers > 0 || writers > 0)
pthread_cond_wait(&can_write, &mutex);
waiting_writers--;
writers++;
pthread_mutex_unlock(&mutex);
}
void writer_exit(void) {
pthread_mutex_lock(&mutex);
writers--;
if (waiting_writers > 0)
pthread_cond_signal(&can_write);
else
pthread_cond_broadcast(&can_read);
pthread_mutex_unlock(&mutex);
}
The solution uses the waiting_writers counter
to give writers priority: a reader that arrives while a
writer is waiting must wait, even if no writer is currently
active. This prevents the writer from being starved by a
continuous stream of readers. When a writer exits, it
signals another waiting writer if there is one, or
broadcasts to the readers if there is not.
The final broadcast to the readers is
necessary because the condition that has become true —
no writers active — may allow more than one reader to
proceed. A signal would wake only one reader,
which is correct but would require the woken reader to
signal the next reader when it exits, and so on. The
broadcast wakes all of them at once, which is
simpler.
4.8 Barrier with condition variables
A barrier is a rendezvous point at which a fixed number of threads must arrive before any of them can proceed. It is useful for iterative algorithms — numerical simulations that advance in time steps, for example — where every thread must finish one phase before any begins the next.
A barrier can be built from a mutex, a condition variable, a count, and a generation number:
typedef struct {
pthread_mutex_t mutex;
pthread_cond_t cond;
int count; /* threads still to arrive */
int generation; /* to distinguish successive uses */
int total; /* total number of threads */
} barrier_t;
void barrier_wait(barrier_t *b) {
pthread_mutex_lock(&b->mutex);
int gen = b->generation;
b->count--;
if (b->count == 0) {
/* Last to arrive: release everyone */
b->generation++;
b->count = b->total;
pthread_cond_broadcast(&b->cond);
} else {
while (gen == b->generation)
pthread_cond_wait(&b->cond, &b->mutex);
}
pthread_mutex_unlock(&b->mutex);
}
The generation counter distinguishes one use
of the barrier from the next. A thread that waits on the
barrier records the current generation and waits until the
generation changes. The last thread to arrive increments
the generation and broadcasts, releasing all the waiters.
Without the generation counter, a thread that arrives at
the barrier a second time might be confused with a thread
from the previous use — a classic bug in naive barrier
implementations.
4.9 Condition variables versus semaphores
Both a condition variable and a semaphore can be used to wait for an event. They are not interchangeable, and choosing the wrong one makes a program either harder to understand or incorrect.
| Aspect | Condition variable | Semaphore |
|---|---|---|
| State | None — a signal that arrives before a wait is lost | Has a value — a post before a wait is remembered |
| Used with a mutex | Always — the mutex protects the state variable | Not necessarily — the semaphore's value is the state |
| Condition | External — a property of a variable the mutex protects | Internal — the semaphore's value is the condition |
| Wait loop | Always — because of Mesa semantics and spurious wakeups | Never — the semaphore's value is the condition |
| Ownership | None — any thread may signal | None — any thread may post |
| Broadcast | Yes — wakes all waiters | No — each post wakes at most one waiter |
| Best for | Waiting for a state condition while holding a mutex | Counting resources, one-shot signalling, classic problems |
The essential distinction is the state. A
semaphore has state in its value, so a
post before a wait is not lost.
A condition variable has no state, so its use must be
paired with a shared variable that does. The
while loop that re-checks the condition is
the price of this absence of state: every wakeup must be
verified against the state variable, because the signal
itself carries no information about whether the condition
is true.
The general guidance is: use a condition variable when the problem is to wait for a condition on state protected by a mutex. Use a semaphore when the problem is to count resources or to signal a one-shot event. The producer–consumer problem can be solved with either; the condition variable solution is more natural in a lock-based design, and the semaphore solution is more natural in a design without an explicit lock.
4.10 Common pitfalls
wait inside an if instead of a while
The most common bug. Under Mesa semantics, the
condition may be false when the woken thread runs.
Spurious wakeups also require the loop. Always use
while.
Calling wait without holding the mutex
The mutex must be held when wait is
called. Otherwise the atomicity of the
release-and-block is violated, and the behaviour is
undefined.
Changing the state without signalling
A condition variable has no memory: if the state
changes and no signal is sent, waiters sleep forever.
Every state change that makes a condition true must be
followed by a signal or
broadcast.
Signalling the wrong condition variable
When a monitor has several conditions, the signal must target the one whose condition has become true. Signalling a different condition wakes a thread that cannot proceed and leaves the correct waiter sleeping.
Assuming signal transfers control
Under Mesa semantics, signal merely moves
a waiter to the entry queue. The signaller continues.
The woken thread runs later, when it acquires the
mutex.
Forgetting pthread_cond_destroy
The condition variable and the mutex must both be destroyed when no longer needed. Destroying them while threads are blocked on them is undefined behaviour.
Using broadcast when signal is correct
A broadcast that wakes many threads which immediately
go back to sleep wastes CPU cycles. Use
signal when only one waiter can proceed.
Releasing the mutex before signalling
Signalling after releasing the mutex is legal but can cause a missed wakeup: between the release and the signal, a waiting thread may have been woken for another reason and re-checked the condition, then blocked again. Signal while holding the mutex.
05 Worked example
The following example traces the producer–consumer solution
through a sequence of operations, showing the state of the
mutex, the two condition variables, and the buffer at each
step. It also demonstrates the failure mode of the
if-instead-of-while bug.
Producer–consumer with condition variables
The buffer has N = 3 slots. The mutex is
m, the two condition variables are
not_full and not_empty.
Initially count = 0 and both condition
queues are empty. We trace one producer P and two
consumers C1 and C2.
| Step | Thread | Operation | count | m | not_full queue | not_empty queue |
|---|---|---|---|---|---|---|
| 0 | — | initial | 0 | free | empty | empty |
| 1 | C1 | lock(m); count == 0 | 0 | C1 | empty | empty |
| 2 | C1 | wait(not_empty, m) | 0 | free | empty | C1 |
| 3 | C2 | lock(m); count == 0 | 0 | C2 | empty | C1 |
| 4 | C2 | wait(not_empty, m) | 0 | free | empty | C1, C2 |
| 5 | P | lock(m); count < 3 | 0 | P | empty | C1, C2 |
| 6 | P | insert; count++ | 1 | P | empty | C1, C2 |
| 7 | P | signal(not_empty) — C1 woken | 1 | P | empty | C2 |
| 8 | P | unlock(m) | 1 | free | empty | C2 |
| 9 | C1 | re-acquires m; re-check count == 0 → false | 1 | C1 | empty | C2 |
| 10 | C1 | remove; count-- | 0 | C1 | empty | C2 |
| 11 | C1 | signal(not_full) — no waiters | 0 | C1 | empty | C2 |
| 12 | C1 | unlock(m) | 0 | free | empty | C2 |
| 13 | P | lock(m); insert; count++ | 1 | P | empty | C2 |
| 14 | P | signal(not_empty) — C2 woken | 1 | P | empty | empty |
| 15 | P | unlock(m) | 1 | free | empty | empty |
| 16 | C2 | re-acquires m; re-check count == 0 → false | 1 | C2 | empty | empty |
| 17 | C2 | remove; count-- | 0 | C2 | empty | empty |
| 18 | C2 | signal(not_full) — no waiters; unlock(m) | 0 | free | empty | empty |
The trace illustrates the essential points. At step 2,
C1 calls wait, which atomically releases
the mutex and adds C1 to the not_empty
queue. At step 7, P signals not_empty,
which wakes C1. Under Mesa semantics, P continues and
releases the mutex at step 8; C1 re-acquires it at step
9 and re-checks its condition. If C1 had used an
if instead of a while, it
would proceed here — and it happens to be correct,
because count is still positive. But this
is luck. Consider a different interleaving where
another consumer C3 arrives between steps 8 and 9 and
consumes the only item; C1 would then proceed with
count == 0, reading from an empty buffer.
The while loop protects against exactly
this interleaving.
The if-instead-of-while bug
Suppose the consumer's remove procedure used
an if instead of a while:
int remove_buggy(void) {
pthread_mutex_lock(&mutex);
if (count == 0)
pthread_cond_wait(¬_empty, &mutex);
/* BUG: count may still be 0 */
int item = buffer[out];
out = (out + 1) % N;
count--; /* count goes negative */
pthread_cond_signal(¬_full);
pthread_mutex_unlock(&mutex);
return item;
}
Consider the following interleaving with a single producer and two consumers, C1 and C2, and a buffer of one slot.
| Step | Thread | Operation | count | not_empty queue |
|---|---|---|---|---|
| 0 | — | initial | 0 | empty |
| 1 | C1 | lock(m); if (count == 0) wait() | 0 | C1 |
| 2 | C2 | lock(m); if (count == 0) wait() | 0 | C1, C2 |
| 3 | P | lock(m); insert; count++ | 1 | C1, C2 |
| 4 | P | signal(not_empty) — C1 woken | 1 | C2 |
| 5 | P | unlock(m) | 1 | C2 |
| 6 | C2 | re-acquires m (was in entry queue); if passed — no re-check | 1 | empty |
| 7 | C2 | remove; count-- | 0 | empty |
| 8 | C2 | signal(not_full); unlock(m) | 0 | empty |
| 9 | C1 | re-acquires m; if passed — no re-check | 0 | empty |
| 10 | C1 | remove: reads buffer[out] (stale) | -1 | empty |
| 11 | C1 | count-- went negative; unlock | -1 | empty |
At step 6, C2 was in the entry queue (having been
awakened at step 4 by the producer's signal). C2
re-acquires the mutex first — the order of woken
threads is not guaranteed — and consumes the item. Then
at step 9, C1 re-acquires the mutex and, because it used
if, proceeds without checking
count. It reads a stale slot and decrements
count to −1. The buffer is now in an
inconsistent state, and count will never
reach a correct value.
With a while loop, C1 re-checks
count, finds it 0, and waits again. The
bug disappears. The while loop is not a
stylistic choice: it is required for correctness.
Correctness argument
Three properties must hold. Mutual
exclusion: the buffer's state is accessed only
while the mutex is held, so at most one thread accesses
it at a time. No insertion into a full
buffer: the insert procedure waits
on not_full while count == N,
so a producer can proceed only when there is a free slot.
No removal from an empty buffer: the
remove procedure waits on
not_empty while count == 0,
so a consumer can proceed only when an item is available.
Additionally, every state change that makes a condition
true is followed by the appropriate signal: a producer
signals not_empty after inserting, and a
consumer signals not_full after removing.
Together these ensure that no waiter sleeps when the
condition it is waiting for is true.
Measuring the cost of signal versus broadcast
The choice between signal and
broadcast has a measurable performance
impact. The following table reports the throughput of
the producer–consumer program with the two variants,
under two contention levels.
| Threads (P/C) | signal (ops/sec) | broadcast (ops/sec) | Ratio |
|---|---|---|---|
| 1 / 1 | 2.1 × 10⁶ | 2.1 × 10⁶ | 1.00 |
| 2 / 2 | 1.7 × 10⁶ | 1.4 × 10⁶ | 1.21 |
| 4 / 4 | 1.2 × 10⁶ | 0.7 × 10⁶ | 1.71 |
| 8 / 8 | 0.7 × 10⁶ | 0.3 × 10⁶ | 2.33 |
The numbers are illustrative, but the pattern is consistent: with a single producer and a single consumer, the two variants are equally fast, because there is only ever one waiter. As the number of threads grows, the broadcast becomes progressively slower, because it wakes threads that immediately go back to sleep. On a heavily loaded system with many waiters, the difference can be a factor of two or more.
06 Interactive activity
Activity · implement and stress-test condition variables
This activity lets you implement the producer–consumer,
reader–writer, and barrier problems with POSIX condition
variables, reproduce the classic bugs, and measure the
difference between signal and
broadcast. Work through the steps in your
course VM.
-
Implement the producer–consumer with condition variables.
Write the program from section 4.6. Use a buffer of
five slots, one producer, and one consumer. The
producer generates the integers 0–99,999, and the
consumer verifies that they arrive in order.
Compile with#include <pthread.h> #define N 5 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; int buffer[N]; int in = 0, out = 0, count = 0; void insert(int item) { pthread_mutex_lock(&mutex); while (count == N) pthread_cond_wait(¬_full, &mutex); buffer[in] = item; in = (in + 1) % N; count++; pthread_cond_signal(¬_empty); pthread_mutex_unlock(&mutex); } int remove(void) { pthread_mutex_lock(&mutex); while (count == 0) pthread_cond_wait(¬_empty, &mutex); int item = buffer[out]; out = (out + 1) % N; count--; pthread_cond_signal(¬_full); pthread_mutex_unlock(&mutex); return item; }gcc -O2 -pthread -o cv cv.cand run. Verify that no error is reported. -
Verify with ThreadSanitizer.
Compile with
-fsanitize=threadand run. Does ThreadSanitizer report a race? Note that it understands the POSIX condition variable primitives and should not report a race on the buffer, because the mutex serializes the accesses.gcc -O1 -g -fsanitize=thread -pthread -o cv_tsan cv.c ./cv_tsan -
Break the
whileloop. Change thewhileinremoveto anif. Run with three producers and three consumers on a small buffer. Does the program fail? How often? Run it twenty times and count the failures. Then restore thewhileand run again. Explain why the bug is intermittent. -
Compare
signalandbroadcast. Changepthread_cond_signaltopthread_cond_broadcastin both functions. Run with eight producers and eight consumers on a small buffer. Time the execution for 1,000,000 items. Compare to thesignalversion. What is the ratio? Does it match the pattern in the worked example? -
Implement the writer-preferring reader–writer solution.
Write the solution from section 4.7. Test it with ten
readers and two writers, each performing 100,000
operations. Verify that no writer is starved (measure
the maximum wait time for a writer using
clock_gettime) and that no reader ever sees an inconsistent value. -
Implement a reusable barrier.
Write the barrier from section 4.8. Use it in a
two-phase program: each thread computes a partial sum,
waits at the barrier, and then combines the sums.
Verify that the total is correct for eight threads.
Then use the barrier in a loop of 100 iterations and
verify that the program does not deadlock — this
exercises the generation counter.
for (int step = 0; step < 100; step++) { /* compute something for this step */ barrier_wait(&b); /* use the result of this step */ } -
Demonstrate a spurious wakeup.
Write a program with one waiter and one signaller. Have
the signaller call
pthread_cond_broadcasttwice in a row, without an interveningwaitfrom the waiter. Observe that the second broadcast is lost — it has no effect, because the waiter is no longer waiting. This demonstrates that a condition variable has no memory./* signaller thread */ pthread_cond_broadcast(&cond); /* wakes the waiter */ pthread_cond_broadcast(&cond); /* no effect — the waiter already left */ -
Measure the cost of a condition variable handoff.
Write a program with two threads that hand off control
through a condition variable: thread A signals, thread
B waits, then B signals and A waits, and so on, one
million times. Time the round trip.
Compare the per-handoff cost to the spinlock handoff from Lesson 6.2 and to the futex cost from Lesson 6.3. How much does the condition variable cost compared to the alternatives?pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t c = PTHREAD_COND_INITIALIZER; int turn = 0; void *a(void *arg) { for (long i = 0; i < 500000; i++) { pthread_mutex_lock(&m); while (turn != 0) pthread_cond_wait(&c, &m); turn = 1; pthread_cond_signal(&c); pthread_mutex_unlock(&m); } return NULL; } /* similar for thread b, with turn == 1 */
Reflect: the condition variable is a
companion to the mutex, not a replacement for it. Its
purpose is to allow a thread to leave a critical section
and sleep until a condition on the shared state becomes
true, without losing the mutual exclusion. The
while loop that wraps the
wait is the price of this design: because
the condition variable has no memory, every wakeup must
be verified against the state variable. Is this the right
design? Could a primitive with memory — a semaphore —
be used instead, at the cost of a different programming
model? Which would you choose for a server that handles
a work queue from many producers?
07 Knowledge check
Check your understanding
Question 1
Describe the condition variable abstraction. What are its three operations, and what does each do? Why is a condition variable always used with a mutex, and what would happen if it were used alone?
Show answer
wait, which atomically
releases the associated mutex, blocks the calling
thread on the condition variable, and re-acquires
the mutex before returning when the thread is woken;
signal, which wakes one
waiting thread, or has no effect if no thread is
waiting; and broadcast,
which wakes all waiting threads. A condition variable
is always used with a mutex because the condition it
represents is a property of some shared state
variable, and the mutex is what protects that state.
Without the mutex, the condition check and the state
change would not be atomic, and two threads could
race — one checking the condition while the other
modified the state. The wait operation
releases the mutex so that another thread can enter
the critical section and change the state; but it
releases and re-acquires the mutex as part of the
same atomic operation, so the waiting thread does
not lose its place in the critical section. If a
condition variable were used without a mutex, the
wait would have no mutex to release, and
the atomicity of the check-then-wait sequence would
be lost — a signal could arrive between the check
and the wait and be missed.
Question 2
Why must wait always be called inside a
while loop rather than an if?
Give at least three distinct reasons, and explain each.
Show answer
wait must be inside a
while loop for three reasons.
(1) Mesa semantics. Under Mesa
semantics, a signal wakes a waiting
thread but does not transfer control to it
immediately. The signalling thread continues, and
the woken thread is moved to the entry queue, where
it must compete with new arrivals for the mutex. By
the time the woken thread actually runs, other
threads may have entered the critical section and
changed the state, so the condition it was waiting
for may no longer hold. The thread must re-check
the condition after waking. (2) Spurious
wakeups. A condition variable is permitted
by POSIX and by every implementation to wake a
waiting thread without any signal or
broadcast having occurred. The reasons
range from implementation details of the underlying
wait mechanism to the handling of signals. A
while loop handles spurious wakeups
correctly: the thread re-checks the condition,
finds it still false, and waits again. (3)
Multiple waiters. When several
threads are waiting on the same condition and a
broadcast wakes them all, only one may
be able to proceed. The others re-check, find the
condition false, and wait again. Without a
while loop, every woken thread would
proceed, and several would operate on invalid
state. In addition, even with a
signal, several threads might be
woken in some implementations, so the loop is
required to enforce that only one proceeds. A
wait inside an if is
correct only under Hoare semantics, which no widely
used language provides.
Question 3
Explain the difference between signal
and broadcast. Give a problem for which
signal is the correct choice and a problem
for which broadcast is required. What is
the cost of always using broadcast?
Show answer
signal wakes exactly
one waiter; if no thread is waiting, the signal is
lost. broadcast wakes
all waiters; each of them must re-check its
condition and go back to sleep if the condition is
still false. A problem for which
signal is correct: the
producer–consumer with a single item being
inserted. When a producer inserts an item, exactly
one consumer can proceed — the one that removes
that item — so a signal is sufficient.
A problem for which broadcast is
required: the dining philosophers, when a
philosopher puts down their forks. The condition
that has become true may allow either of the two
neighbours to eat, and the signaller does not know
which of them will succeed. A
broadcast wakes both, and each
re-checks its own condition. Another example is a
barrier: when the last thread arrives, all the
waiting threads must be released, so a
broadcast is required. The cost of
always using broadcast is that it
wakes threads that will immediately go back to
sleep. Each of those wakeups causes a context
switch and a re-acquisition of the mutex. On a
system with many waiters, the wasted wakeups can
dominate the useful work, and the throughput can
be substantially lower than with
signal. The general guidance is to
use signal when the condition is
specific and only one waiter can proceed, and to
reserve broadcast for the cases where
the condition is general and more than one waiter
may proceed.
Question 4
Describe the producer–consumer solution with condition variables. Why are two condition variables used rather than one? What would go wrong if the producer and consumer shared a single condition variable?
Show answer
in and out indices, and
the count. The first condition variable,
not_full, is used by producers to wait
until there is a free slot. The second,
not_empty, is used by consumers to wait
until there is an item available. The
insert procedure waits on
not_full while count == N,
inserts the item, increments count, and
signals not_empty. The
remove procedure waits on
not_empty while count == 0,
removes the item, decrements count, and
signals not_full. Two condition
variables are used because the two conditions are
different: a producer is waiting for a slot to be
free, and a consumer is waiting for an item to be
available. With a single shared condition variable,
a signal from the producer would wake
an arbitrary waiter — possibly another producer
rather than a consumer — and the woken producer
would find the buffer still full and go back to
sleep, while the consumer that could have proceeded
remains asleep. The system would still be correct
— the while loop would keep the woken
thread from proceeding incorrectly — but it would
be much less efficient, and with a
broadcast it would wake all the
waiters on every state change. Using two separate
condition variables allows a targeted signal: the
producer signals the specific queue that contains
the consumers, and the consumer signals the queue
that contains the producers.
Question 5
What is a barrier, and how is it implemented with a condition variable? Explain the role of the generation counter, and what goes wrong if it is omitted.
Show answer
while (gen == b->generation) wait(...).
Without the generation counter, a thread that
arrives at the barrier a second time — in the next
iteration of a loop — could be confused with a
thread from the previous use. The specific failure:
a fast thread might run ahead, complete the next
phase, and re-enter the barrier before the other
threads have left the previous one; it would then
decrement the count for the next use while the
others are still in the wait loop for the previous
use, and the barrier's state would be corrupted.
The generation counter prevents this: each use of
the barrier has its own generation value, and a
thread that arrives in the next use waits for a
generation change, which is a distinct event from
the previous broadcast. The barrier is reusable
because of the counter.
08 Unit discussion
Discussion prompt
The condition variable is designed around a specific
model of synchronization: a mutex protects the shared
state, and the condition variable lets a thread wait
for a condition on that state. The design has a
distinctive consequence — the while loop
around every wait. Because the condition variable has
no memory, every wakeup must be verified against the
state variable, and the programmer must write the
loop and re-check every time.
Compare this to the semaphore, which has memory in its value. A semaphore-based solution to the producer–consumer problem is shorter, has no loop around the wait, and requires no separate state variable. It is in some sense a simpler primitive to use. But it is also a lower-level primitive: the programmer must reason about the semaphore's value, about the order of the waits, and about the meaning of the value in each specific use. The condition variable keeps the state in the programmer's own variable, where it is easy to inspect and reason about, at the cost of the loop around every wait.
Which design is better for a general-purpose synchronization primitive? Is the condition variable's extra ceremony worth the clarity of having the state be a visible variable? Or does the semaphore's compactness win out, at the cost of hiding the state inside the primitive? Consider the languages you know: do any of them provide a primitive that avoids the loop-around-wait pattern? How does it manage the problem that the condition variable's loop is designed to solve?
Post your response to the Unit 6 discussion forum and reply to at least two classmates. Supports CLO2, CLO3, and CLO8.
09 Practice exercise
This ungraded exercise consolidates the lesson and prepares you for Lesson 6.7 and Lab 5. Aim to spend 60–90 minutes on it.
-
Trace the producer–consumer.
Using the producer–consumer code from section 4.6 with
N = 3, trace the following sequence of operations:
C1.remove(), C2.remove(), P.insert(1), C3.remove(),
P.insert(2), P.insert(3), P.insert(4), C1.run. Show
the value of
countand the contents of the two condition queues after each operation. At which points does a thread block? At which points is a thread woken? -
Implement the producer–consumer with condition variables.
Write the program with one producer and one consumer,
a buffer of five slots, and 100,000 items. Verify that
the items arrive in order. Compile with
-fsanitize=threadand confirm that no race is reported. -
Break the while loop.
Change the
whileloop inremoveto anif. Run with three producers and three consumers on a two-slot buffer. Run twenty times and count the failures. Then restore thewhileloop and run again. Does the failure disappear entirely? -
Compare signal and broadcast.
Change
signaltobroadcast. Run with eight producers and eight consumers on a buffer of 100 slots, processing 1,000,000 items. Time both versions. Compute the ratio. Then run with two producers and two consumers and compute the ratio again. How does the ratio depend on the number of threads? Explain. -
Implement the writer-preferring reader–writer solution.
Write the solution from section 4.7. Test with ten
readers and two writers, each performing 100,000
operations. Measure the maximum wait time for a writer
using
clock_gettimeand verify that it is bounded. Then remove thewaiting_writerscounter and measure again. What happens to the maximum wait time? Explain why. -
Implement a reusable barrier.
Write the barrier from section 4.8. Use it in a
program with eight threads that run 100 iterations of
a simple computation — for example, each thread adds
its thread ID to a shared accumulator, then waits at
the barrier, then reads the accumulator. Verify that
after each barrier, the accumulator has the expected
value. This demonstrates the barrier's role in
synchronizing phases.
Note the two barriers per step: the first synchronizes the writes, and the second ensures no thread starts the next step's writes before all threads have finished reading the accumulator.void *worker(void *arg) { long id = (long)arg; for (int step = 0; step < 100; step++) { pthread_mutex_lock(&mutex); accumulator += id; pthread_mutex_unlock(&mutex); barrier_wait(&b); /* after the barrier, all threads have added their id */ if (id == 0) { /* verify accumulator == expected */ } barrier_wait(&b); /* second barrier, so no thread races ahead */ } return NULL; } - Measure the cost of a condition variable handoff. Write the two-thread handoff program from the interactive activity. Time one million round trips and compute the per-handoff cost. Compare to the spinlock handoff from Lesson 6.2 and to the futex handoff from Lesson 6.3. Present the three numbers in a table and comment on the ordering.
-
Reflect.
In your own words, explain why the condition variable
must be used with a mutex and why the
waitmust be inside awhileloop. Then describe a situation from your own experience — a thread pool, a message queue, a producer–consumer pipeline — where a condition variable would be the natural choice. What state variable would the condition be based on, and what condition would the waiters check?
10 Summary
-
A condition variable is an object used
together with a mutex to allow a thread to wait for a
condition to become true. It has three operations:
wait,signal, andbroadcast. -
wait(c, m)atomically releases the mutexm, blocks the calling thread onc, and re-acquiresmbefore returning when the thread is woken. The atomicity of the release-and-block and the re-acquire is what makes the primitive work. -
A condition variable has no memory: a
signalthat arrives when no thread is waiting is lost. This is why it must be used with a state variable that indicates whether the condition holds, and why every wait must be wrapped in a loop that re-checks the state. -
The loop-around-wait pattern is
essential: the
waitmust be inside awhileloop that re-checks the condition after every wakeup. This is required by Mesa semantics, by spurious wakeups, and by the possibility of multiple waiters. -
signalwakes one waiter and is appropriate when the condition allows exactly one thread to proceed.broadcastwakes all waiters and is required when the condition may allow more than one to proceed, or when the signaller does not know which can proceed. -
The POSIX interface is
pthread_cond_twithpthread_cond_init,pthread_cond_wait,pthread_cond_timedwait,pthread_cond_signal,pthread_cond_broadcast, andpthread_cond_destroy. The mutex is passed towaitas an argument. -
The producer–consumer problem is solved
with one mutex and two condition variables:
not_fullfor producers andnot_emptyfor consumers. Two condition variables are necessary to target the signal to the right kind of waiter. -
The reader–writer problem can be solved
with condition variables and a
waiting_writerscounter, which gives writers priority and prevents writer starvation. - A barrier can be built from a mutex, a condition variable, a count, and a generation counter. The generation counter is essential for a reusable barrier, because it distinguishes one use of the barrier from the next.
-
The essential distinction between a condition variable
and a semaphore is state: a semaphore
has state in its value, so a
postbefore awaitis remembered; a condition variable has no state, so every wait must re-check an external state variable. Use a condition variable when the problem is to wait for a condition on state protected by a mutex; use a semaphore when the problem is to count resources or signal a one-shot event.
Looking ahead
This lesson presented the condition variable as the primitive for waiting on a condition. Lesson 6.7 closes the unit with a survey of Linux's synchronization landscape — the POSIX primitives, the futex bridge, and the kernel's own primitives. It shows how the condition variable fits into the broader picture, how it is implemented on top of the futex, and how it compares to the kernel's own completion variables and wait queues. The lesson also provides the practical guidance for choosing among the many primitives Linux provides.
11 References
-
Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C.
Operating Systems: Three Easy Pieces, Chapter 30.
Available online at
ostep.org. - Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 6, Section 6.7.
- Butenhof, D. R. Programming with POSIX Threads, Chapter 4. The definitive reference for the POSIX condition variable.
- Tanenbaum, A. S., & Bos, H. Modern Operating Systems, 4th ed., Chapter 2, Section 2.3.7.
- Hoare, C. A. R. Monitors: An Operating System Structuring Concept. Communications of the ACM, 1974.
- Lampson, B. W., & Redell, D. D. Experience with Processes and Monitors in Mesa. Communications of the ACM, 1980. The paper that introduced Mesa semantics.
- Love, R. Linux System Programming, 2nd ed., Chapter 10.
-
Linux man-pages:
man 3 pthread_cond_init,man 3 pthread_cond_wait,man 3 pthread_cond_timedwait,man 3 pthread_cond_signal,man 3 pthread_cond_broadcast,man 3 pthread_cond_destroy.