COMP 3150Operating Systems

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 wait releases and re-acquires it atomically.
  • Apply the loop-around-wait pattern correctly and explain why it is required.
  • Distinguish signal from broadcast and 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 m and block the calling thread on the condition variable c. When the thread is woken by a signal or broadcast, it re-acquires m before returning from wait. The release of the mutex and the block are one indivisible operation; the re-acquisition is also part of the operation and happens before wait returns.
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's post, which increments a value that a later wait finds.
broadcast(c) (or notify_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.

Condition variable wait and signal A diagram showing a condition variable used together with a mutex and a shared state variable. A waiting thread calls wait, which atomically releases the mutex, blocks on the condition variable, and re-acquires the mutex when signalled. A signalling thread holds the mutex, changes the state variable, calls signal, and releases the mutex. The state variable is what makes the protocol work: a signal that arrives before a wait is not lost, because the wait re-checks the state after waking. CONDITION VARIABLE — WAIT AND SIGNAL shared state variable · e.g. count, ready, done protected by the mutex. The condition variable is associated with a property of this variable. mutex m protects the shared state condition variable c queue of threads waiting for the condition Waiting thread 1. lock(m) acquire the mutex 2. while (!condition(state)) re-check the condition — the loop-around-wait pattern 3. wait(c, m) atomically: release m, block on c re-acquire m before returning 4. use the state; unlock(m) the condition is true and the mutex is held waiters on c T1 T2 T3 Signalling thread 1. lock(m) acquire the same mutex 2. change the state make the condition true then signal(c) or broadcast(c); unlock(m) wait signal The wait re-checks the condition after waking because the state may have changed again before the woken thread re-acquired the mutex.
Figure 6.6 — Condition variable used with a mutex. The waiting thread acquires the mutex, tests the condition, and calls 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 wait must 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 the wait releases 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 signal wakes 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 signal or broadcast having 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. A while loop 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 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.

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.

Signal versus broadcast
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_wait takes 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 calling wait; 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 returns ETIMEDOUT and 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_SHARED allows it to be shared between processes that share memory.
  • Clock attribute. A condition variable can be configured to use CLOCK_MONOTONIC or CLOCK_REALTIME for its timed wait. The default on most systems is CLOCK_REALTIME, which is subject to adjustments for daylight saving and for time synchronization; CLOCK_MONOTONIC is 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(&not_full, &mutex);
    buffer[in] = item;
    in = (in + 1) % N;
    count++;
    pthread_cond_signal(&not_empty);
    pthread_mutex_unlock(&mutex);
}

int remove(void) {
    pthread_mutex_lock(&mutex);
    while (count == 0)
        pthread_cond_wait(&not_empty, &mutex);
    int item = buffer[out];
    out = (out + 1) % N;
    count--;
    pthread_cond_signal(&not_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.

Condition variables compared with semaphores
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—initial0freeemptyempty
1C1lock(m); count == 00C1emptyempty
2C1wait(not_empty, m)0freeemptyC1
3C2lock(m); count == 00C2emptyC1
4C2wait(not_empty, m)0freeemptyC1, C2
5Plock(m); count < 30PemptyC1, C2
6Pinsert; count++1PemptyC1, C2
7Psignal(not_empty) — C1 woken1PemptyC2
8Punlock(m)1freeemptyC2
9C1re-acquires m; re-check count == 0 → false1C1emptyC2
10C1remove; count--0C1emptyC2
11C1signal(not_full) — no waiters0C1emptyC2
12C1unlock(m)0freeemptyC2
13Plock(m); insert; count++1PemptyC2
14Psignal(not_empty) — C2 woken1Pemptyempty
15Punlock(m)1freeemptyempty
16C2re-acquires m; re-check count == 0 → false1C2emptyempty
17C2remove; count--0C2emptyempty
18C2signal(not_full) — no waiters; unlock(m)0freeemptyempty

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(&not_empty, &mutex);
    /* BUG: count may still be 0 */
    int item = buffer[out];
    out = (out + 1) % N;
    count--;            /* count goes negative */
    pthread_cond_signal(&not_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—initial0empty
1C1lock(m); if (count == 0) wait()0C1
2C2lock(m); if (count == 0) wait()0C1, C2
3Plock(m); insert; count++1C1, C2
4Psignal(not_empty) — C1 woken1C2
5Punlock(m)1C2
6C2re-acquires m (was in entry queue); if passed — no re-check1empty
7C2remove; count--0empty
8C2signal(not_full); unlock(m)0empty
9C1re-acquires m; if passed — no re-check0empty
10C1remove: reads buffer[out] (stale)-1empty
11C1count-- went negative; unlock-1empty

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 / 12.1 × 10⁶2.1 × 10⁶1.00
2 / 21.7 × 10⁶1.4 × 10⁶1.21
4 / 41.2 × 10⁶0.7 × 10⁶1.71
8 / 80.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.

  1. 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.
    #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(&not_full, &mutex);
        buffer[in] = item;
        in = (in + 1) % N;
        count++;
        pthread_cond_signal(&not_empty);
        pthread_mutex_unlock(&mutex);
    }
    
    int remove(void) {
        pthread_mutex_lock(&mutex);
        while (count == 0)
            pthread_cond_wait(&not_empty, &mutex);
        int item = buffer[out];
        out = (out + 1) % N;
        count--;
        pthread_cond_signal(&not_full);
        pthread_mutex_unlock(&mutex);
        return item;
    }
    Compile with gcc -O2 -pthread -o cv cv.c and run. Verify that no error is reported.
  2. Verify with ThreadSanitizer. Compile with -fsanitize=thread and 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
  3. Break the while loop. Change the while in remove to an if. 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 the while and run again. Explain why the bug is intermittent.
  4. Compare signal and broadcast. Change pthread_cond_signal to pthread_cond_broadcast in both functions. Run with eight producers and eight consumers on a small buffer. Time the execution for 1,000,000 items. Compare to the signal version. What is the ratio? Does it match the pattern in the worked example?
  5. 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.
  6. 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 */
    }
  7. Demonstrate a spurious wakeup. Write a program with one waiter and one signaller. Have the signaller call pthread_cond_broadcast twice in a row, without an intervening wait from 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 */
    
  8. 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.
    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 */
    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?

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
A condition variable is an object that lets a thread wait for a condition to become true. It has three operations: 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
The 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
The producer–consumer solution uses one mutex and two condition variables. The mutex protects the buffer's state — the buffer array, the 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
A barrier is a rendezvous point at which a fixed number of threads must arrive before any of them can proceed. It is used for iterative algorithms — for example, a numerical simulation that advances in time steps, 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 of threads yet to arrive, and a generation number. When a thread reaches the barrier, it locks the mutex, decrements the count, and checks whether it is the last to arrive. If it is, it resets the count, increments the generation number, and broadcasts on the condition variable to release all the waiting threads. If it is not, it records the current generation number and waits on the condition variable until the generation changes. The generation counter distinguishes one use of the barrier from the next. A thread records the generation number before waiting, and the wait loop is 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.

  1. 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 count and the contents of the two condition queues after each operation. At which points does a thread block? At which points is a thread woken?
  2. 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=thread and confirm that no race is reported.
  3. Break the while loop. Change the while loop in remove to an if. Run with three producers and three consumers on a two-slot buffer. Run twenty times and count the failures. Then restore the while loop and run again. Does the failure disappear entirely?
  4. Compare signal and broadcast. Change signal to broadcast. 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.
  5. 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_gettime and verify that it is bounded. Then remove the waiting_writers counter and measure again. What happens to the maximum wait time? Explain why.
  6. 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.
    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;
    }
    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.
  7. 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.
  8. Reflect. In your own words, explain why the condition variable must be used with a mutex and why the wait must be inside a while loop. 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, and broadcast.
  • wait(c, m) atomically releases the mutex m, blocks the calling thread on c, and re-acquires m before 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 signal that 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 wait must be inside a while loop that re-checks the condition after every wakeup. This is required by Mesa semantics, by spurious wakeups, and by the possibility of multiple waiters.
  • signal wakes one waiter and is appropriate when the condition allows exactly one thread to proceed. broadcast wakes 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_t with pthread_cond_init, pthread_cond_wait, pthread_cond_timedwait, pthread_cond_signal, pthread_cond_broadcast, and pthread_cond_destroy. The mutex is passed to wait as an argument.
  • The producer–consumer problem is solved with one mutex and two condition variables: not_full for producers and not_empty for 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_writers counter, 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 post before a wait is 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.
👤