# Lecture 13 - Condition Variables

Source: https://wisconsin.twango.dev/sp26-cs537/lectures/lecture-13

# Lecture 13 - Condition Variables

**Instructor:** Mike Swift & Tej Chajed
**Topics:** Condition variables, wait/signal, thread join, producer/consumer problem, concurrency bugs

> [!abstract]- 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

> [!note] Lecture Note
> The sequence inside `wait()` is: `unlock(l)` -> sleeps -> `lock(l)`. The caller holds the lock before calling `wait()`, and holds it again after `wait()` 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

> [!tip] 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

```c
// 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**.

> [!note] Lecture Note
> `done` is 0 before waiting, 1 after `thread_exit`. The annotation shows the problematic interleaving where Y waits forever.

### Attempt 1: Lock Around Wait/Signal (No State Check)

```c
// 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

```c
// 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

```c
// 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.

> [!note] Lecture Note
> `c` is a `cond_t` (condition variable), `m` is a `mutex_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 of `if()` 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

> [!note] 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**

> [!note] Lecture Note
> The pipe buffer works like a circular queue. Writers advance `end`, readers advance `start`. When `start == end`, the buffer is empty. When the buffer wraps around and fills, writers must wait. Example: `cat | grep` — `cat` is the producer, `grep` is 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.

```c
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);
    }
}
```

> [!note] Lecture Note
> `numfull` starts at 0, `m` is initially free, and `cond` queue 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`:

```c
// 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:**

1. Consumer1 waits because `numfull == 0`
2. Consumer2 waits because `numfull == 0`
3. Producer fills buffer, signals — wakes Consumer1
4. Before Consumer1 runs, Consumer2 grabs the lock, consumes the item, sets `numfull = 0`
5. Consumer1 resumes after `wait()` but `numfull` is now 0 — reads bad data

> [!note] 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

```c
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** (`while` loop)
- A consumer will get to run after every `do_fill()` (signaled on `fill`)
- A producer will get to run after every `do_get()` (signaled on `empty`)
- Producers wait on `empty`, consumers wait on `fill` — 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

```c
// 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.

```c
// 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

```c
// 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.

```c
// 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:
  1. Keep state in addition to CVs
  2. Always do wait/signal with lock held
  3. Whenever a thread wakes from waiting, recheck state (use `while`, not `if`)
  4. 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 12]] · [[lecture-14|Lecture 14 →]]
- **Exam:** [[exams/midterm-2/index|Midterm 2]]
- **Course:** [[README|CS 537 Map of Content]]
