Lecture 13 - Condition Variables
Instructor: Mike Swift & Tej Chajed Topics: Condition variables, wait/signal, thread join, producer/consumer problem, concurrency bugs
Learning Outcomes
- Understand the purpose of condition variables
- Use condition variables to solve waiting problems
- Recall guidelines for using CVs (or at least be aware of them)
What Are Condition Variables?
Condition Variable: A queue of waiting threads. Allows one thread to wait for a signal from another thread before proceeding.
- Thread B waits for a signal on a CV before running:
wait(CV, ...) - Thread A sends a signal to the CV when it is time for B to run:
signal(CV, ...)
The wait() Operation
wait(cond_t *cv, mutex_t *lock)
- Requires the lock is held when
wait()is called - Puts the caller to sleep and releases the lock (atomically)
- When awoken, reacquires the lock before returning
Lecture Note
The sequence inside
wait()is:unlock(l)→ sleeps →lock(l). The caller holds the lock before callingwait(), and holds it again afterwait()returns.
The signal() Operation
signal(cond_t *cv)
- Wakes a single waiting thread (if >= 1 thread is waiting)
- If there is no waiting thread, just returns, doing nothing
Key Insight
Signals do not get remembered for future waits. If a condition is signaled and no thread is
wait()ing, the signal is lost. It makes no difference.
Why Do Condition Variables Take a Lock?
- Synchronization is around shared state
- Locks are needed to prevent race conditions around shared state
- The CV must release the lock atomically with sleeping, otherwise there is a window where a signal could be missed
Implementing Join with Condition Variables
The thread_join() / thread_exit() pattern demonstrates how to correctly use CVs. The lecture builds up through several broken attempts.
Attempt: No Locks
// Parent:
void thread_join() {
if (done == 0) // x
Cond_wait(&c, &m); // y
}
// Child:
void thread_exit() {
done = 1; // a
Cond_signal(&c); // b
}
Problem: If the child runs first (a, b), the signal is sent before the parent calls wait(). Then the parent checks done == 0 (false after child set it to 1), but if the schedule is: child does a, then parent does x (seeing done == 0), then child does b (signal is lost), parent does y — parent waits forever.
Lecture Note
doneis 0 before waiting, 1 afterthread_exit. The annotation shows the problematic interleaving where Y waits forever.
Attempt 1: Lock Around Wait/Signal (No State Check)
// Parent:
void thread_join() {
Mutex_lock(&m); // x
Cond_wait(&c, &m); // y
Mutex_unlock(&m); // z
}
// Child:
void thread_exit() {
Mutex_lock(&m); // a
Cond_signal(&c); // b
Mutex_unlock(&m); // c
}
Works when parent runs first (x, y, then child a, b, c, then parent z). But breaks when child runs first: child does a, b (signal is ignored — nobody waiting), c; then parent does x, y — parent waits forever.
Attempt 2: State Check Without Lock in Child
// Parent:
void thread_join() {
Mutex_lock(&m); // w
if (done == 0) // x
Cond_wait(&c, &m); // y
Mutex_unlock(&m); // z
}
// Child:
void thread_exit() {
done = 1; // a
Cond_signal(&c); // b
}
This fixes the case where the child runs first (parent sees done == 1, skips wait). But breaks with interleaving: parent does w, x (sees done == 0), then child does a, b (signal lost — parent hasn’t called wait() yet), then parent does y — parent sleeps forever.
Correct Implementation
// Parent:
void thread_join() {
Mutex_lock(&m); // w
if (done == 0) // x
Cond_wait(&c, &m); // y
Mutex_unlock(&m); // z
}
// Child:
void thread_exit() {
Mutex_lock(&m); // a
done = 1; // b
Cond_signal(&c); // c
Mutex_unlock(&m); // d
}
The mutex ensures no race between checking/modifying state and calling wait/signal. Both threads hold the lock when interacting with the shared done variable and the condition variable.
Lecture Note
cis acond_t(condition variable),mis amutex_t. Use the mutex to ensure no race between interacting with state and wait/signal.
Rules of Thumb for Condition Variables
Rule 1: Keep State in Addition to CVs
- CVs are used to signal threads when state changes
- If state is already as needed, the thread does not wait for a signal
- Without state, you cannot know whether to wait or proceed
Rule 2: Always Do Wait/Signal with Lock Held
- The lock protects the shared state that the CV is associated with
- Without the lock, there is a race between checking state and calling wait
Rule 3: Recheck Assumptions After Waking
- Whenever a lock is acquired, recheck assumptions about state
- Another thread could grab the lock in between the signal and the wakeup from wait
- Some libraries also have “spurious wakeups” (may wake multiple waiting threads at signal or at any time)
- This is why you use
while()instead ofif()to check the condition
Rule 4: One CV Per Condition
- Have a separate condition variable for each condition you want to wait on
- Signal the CV when that condition becomes true
- Avoids waking up wrong threads unnecessarily
- Ensures waking up the right threads
Lecture Note
Multiple condition variables can still share a single mutex.
Producer/Consumer Problem
Motivation: UNIX Pipes
A pipe may have many writers (producers) and readers (consumers). Internally, there is a finite-sized buffer (circular buffer with start and end pointers).
- Writers add data to the buffer
- Writers have to wait if buffer is full
- Readers remove data from the buffer
- Readers have to wait if buffer is empty
Lecture Note
The pipe buffer works like a circular queue. Writers advance
end, readers advancestart. Whenstart == end, the buffer is empty. When the buffer wraps around and fills, writers must wait. Example:cat | grep—catis the producer,grepis the consumer.
Implementation requirements:
- Reads/writes to buffer require locking
- When buffers are full, writers must wait
- When buffers are empty, readers must wait
The General Problem
- Producers generate data (like pipe writers)
- Consumers grab data and process it (like pipe readers)
- Producer/consumer problems are frequent in systems (e.g., web servers)
- General strategy: use condition variables to make producers wait when buffers are full and consumers wait when there is nothing to consume
Single Producer, Single Consumer (One CV — Works)
Start with the easy case: 1 producer thread, 1 consumer thread, 1 shared buffer slot (max = 1). numfull tracks the number of filled buffers.
void *producer(void *arg) {
for (int i = 0; i < loops; i++) {
mutex_lock(&m);
while (numfull == max)
cond_wait(&cond, &m);
do_fill(i);
cond_signal(&cond);
mutex_unlock(&m);
}
}
void *consumer(void *arg) {
while (1) {
mutex_lock(&m);
while (numfull == 0)
cond_wait(&cond, &m);
int tmp = do_get();
cond_signal(&cond);
mutex_unlock(&m);
printf("%d\n", tmp);
}
}
Lecture Note
numfullstarts at 0,mis initially free, andcondqueue is empty. The consumer sees the buffer is empty, sleeps. The producer acquires the lock, fills the buffer (numfull becomes 1), signals the consumer, unlocks. The consumer wakes, acquires the lock, gets the data, signals the producer, and the cycle repeats.
Two Consumers Problem (Why while Not if)
With 2 consumers and 1 producer using a single CV and if instead of while:
// BROKEN with 2 consumers — uses if instead of while
void *producer(void *arg) {
for (int i = 0; i < loops; i++) {
Mutex_lock(&m); // p1
if (numfull == max) // p2 <-- BUG: should be while
Cond_wait(&cond, &m); // p3
do_fill(i); // p4
Cond_signal(&cond); // p5
Mutex_unlock(&m); // p6
}
}
void *consumer(void *arg) {
while (1) {
Mutex_lock(&m); // c1
if (numfull == 0) // c2 <-- BUG: should be while
Cond_wait(&cond, &m); // c3
int tmp = do_get(); // c4
Cond_signal(&cond); // c5
Mutex_unlock(&m); // c6
printf("%d\n", tmp); // c7
}
}
Problematic schedule:
- Consumer1 waits because
numfull == 0 - Consumer2 waits because
numfull == 0 - Producer fills buffer, signals — wakes Consumer1
- Before Consumer1 runs, Consumer2 grabs the lock, consumes the item, sets
numfull = 0 - Consumer1 resumes after
wait()butnumfullis now 0 — reads bad data
Lecture Note
The condition may have changed before the woken thread actually runs. Use
while()to retest the condition after waking up.
Another problem with a single CV: a consumer might signal another consumer instead of the producer, leading to all threads sleeping.
Correct Solution: Two CVs and While Loop
void *producer(void *arg) {
for (int i = 0; i < loops; i++) {
Mutex_lock(&m); // p1
while (numfull == max) // p2
Cond_wait(&empty, &m); // p3
do_fill(i); // p4
Cond_signal(&fill); // p5
Mutex_unlock(&m); // p6
}
}
void *consumer(void *arg) {
while (1) {
Mutex_lock(&m);
while (numfull == 0)
Cond_wait(&fill, &m);
int tmp = do_get();
Cond_signal(&empty);
Mutex_unlock(&m);
}
}
Key properties:
- No concurrent access to shared state (mutex protects it)
- Every time the lock is acquired, assumptions are reevaluated (
whileloop) - A consumer will get to run after every
do_fill()(signaled onfill) - A producer will get to run after every
do_get()(signaled onempty) - Producers wait on
empty, consumers wait onfill— signals always wake the right type of thread
Concurrency Bug Patterns
A study by Lu et al. [ASPLOS 2008] examined over 500K bug reports across four major projects (MySQL, Apache, Mozilla, OpenOffice) and identified common types of concurrency bugs:
- Atomicity bugs — most common non-deadlock bug
- Order bugs — less common but still significant
- Deadlock bugs — very common, especially in Mozilla
Fixing Atomicity Bugs with Locks
// BUGGY: Thread 1 and Thread 2 have no synchronization
// Thread 1:
if (thd->proc_info) {
...
fputs(thd->proc_info, ...);
...
}
// Thread 2:
thd->proc_info = NULL;
Thread 2 can set proc_info to NULL between Thread 1’s check and use, causing a crash. Fix: wrap both in a mutex for mutual exclusion.
// Thread 1:
pthread_mutex_lock(&lock);
if (thd->proc_info) {
...
fputs(thd->proc_info, ...);
...
}
pthread_mutex_unlock(&lock);
// Thread 2:
pthread_mutex_lock(&lock);
thd->proc_info = NULL;
pthread_mutex_unlock(&lock);
Fixing Ordering Bugs with Condition Variables
// BUGGY: Thread 2 may run before Thread 1 finishes init
// Thread 1:
void init() {
...
mThread = PR_CreateThread(mMain, ...);
mtInit = 1;
...
}
// Thread 2:
void mMain(...) {
...
mState = mThread->State; // may crash if init not done
...
}
Fix: use a condition variable so Thread 2 waits until initialization is complete.
// Thread 1:
void init() {
...
mThread = PR_CreateThread(mMain, ...);
pthread_mutex_lock(&mtLock);
mtInit = 1;
pthread_cond_signal(&mtCond);
pthread_mutex_unlock(&mtLock);
...
}
// Thread 2:
void mMain(...) {
...
pthread_mutex_lock(&mtLock);
while (mtInit == 0)
Cond_wait(&mtCond, &mtLock);
pthread_mutex_unlock(&mtLock);
mState = mThread->State;
...
}
Summary
- Condition variables are a synchronization primitive for threads to wait for something to be true
- Rules of thumb for using CVs:
- Keep state in addition to CVs
- Always do wait/signal with lock held
- Whenever a thread wakes from waiting, recheck state (use
while, notif) - Have a condition variable per condition you want to wait on (can still share a mutex)
- Key use case: producer-consumer queue
- Use locks to fix atomicity bugs, condition variables to fix ordering bugs
Related
- Navigation: ← Lecture 12 · Lecture 14 →
- Exam: Midterm 2
- Course: CS 537 Map of Content