ยง30.1Definition and Routines

Part II OSTEP pp. 351โ€“354 ยท ~7 min read

Locks give mutual exclusion , but not waiting. Yet threads constantly need to wait for something: a parent waiting for a child to finish (a join() ), a consumer waiting for data to arrive. How should that wait be built?

void *child(void *arg) {
    printf("child\n");
    // XXX how to indicate we are done?
    return NULL;
}
int main(int argc, char *argv[]) {
    printf("parent: begin\n");
    pthread_t c;
    Pthread_create(&c, NULL, child, NULL);
    // XXX how to wait for child?
    printf("parent: end\n");
}

The desired output is parent: begin / child / parent: end. The obvious hack is a shared flag the parent spins on:

volatile int done = 0;
void *child(void *arg) { printf("child\n"); done = 1; return NULL; }
// parent:
while (done == 0)
    ; // spin  โ† works, but burns the CPU the whole wait

It works, but the parent wastes CPU spinning. Weโ€™d rather put the parent to sleep until the condition comes true, and wake it precisely then.

The Crux: How To Wait For A Condition

How should a thread wait for a condition to become true โ€” without the gross inefficiency (and occasional incorrectness) of spinning?

30.1 Definition and Routines

The answer is the condition variable (CV): an explicit queue threads put themselves on when some state isnโ€™t as desired (by waiting), and that another thread pulls from when it changes that state (by signaling). The idea is Dijkstraโ€™s (โ€œprivate semaphoresโ€); Hoare named it while inventing monitors. A CV pthread_cond_t c; has two operations (weโ€™ll call them wait() and signal()):

pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m);  // sleep
pthread_cond_signal(pthread_cond_t *c);                    // wake one

Notice wait() takes a mutex. The rule that makes CVs work:

  • wait() assumes the lock is held when called;
  • it atomically releases the lock and puts the caller to sleep;
  • when woken, it re-acquires the lock before returning.

That atomic โ€œrelease-and-sleepโ€ is what prevents a whole class of races. The correct join uses it, with a state variable done:

The signaler โ€” thr_exit()

void thr_exit() {
    Pthread_mutex_lock(&m);
    done = 1;
    Pthread_cond_signal(&c);
    Pthread_mutex_unlock(&m);
}

The waiter โ€” thr_join()

void thr_join() {
    Pthread_mutex_lock(&m);
    while (done == 0)
        Pthread_cond_wait(&c, &m);
    Pthread_mutex_unlock(&m);
}

Step through the handoff (the parent-first case; the reverse case is easy โ€” if the child runs first, it sets done=1, signals nobody, and the parentโ€™s while (done==0) is simply false, so it never waits):

Figure 30.3: the join done right โ€” the state variable done plus the held lock make wait/signal race-free.
Parentthr_join()
Childthr_exit()
OS
Parent reaches the join first
Child runs, sets state, signals
Parent wakes and finishes
step 1 / 12 ยท time flows downward

The lock is held around the whole check-and-wait โ€” that is what closes the race.

The same lifecycle as a state machine โ€” drive it and watch a thread move between running, sleeping on the CV, and merely ready:

A thread's life around a condition variable โ€” drive it: click a highlighted transition.
wait(c, m)signal / broadcastscheduledpreemptedRunningReadySleeping
drive it: click a highlighted transition from running

Why both the state variable AND the lock are essential

Two tempting simplifications each break. Drop the state variable (Figure 30.4): if the child runs first and signals when no one is asleep, the signal is lost; the parent later calls wait() and sleeps forever. The variable done is what lets the parent discover the child already finished.

Drop the lock (Figure 30.5): now the check and the sleep arenโ€™t atomic. Replay the deadly interleaving:

Figure 30.5, replayed: drop the lock and the check-then-wait is no longer atomic โ€” one bad interleaving sleeps the parent forever.
t =
Parent
Child
shared state
done = 0
tick 1 / 6 ยท one scheduling step per tickrunningready (preempted)signal โ†’ lost!sleepingasleep foreverdone

The parent (holding NO lock) checks done โ€” it is 0 โ€” and decides it must wait. But it has not called wait() yet.

Tip: Hold The Lock While Signaling (and Always While Waiting)

Holding the lock when you call signal() is not strictly required in every case, but itโ€™s simplest and sometimes mandatory โ€” so just do it. Holding the lock when you call wait() is not optional: waitโ€™s very semantics assume the lock is held, release it on sleep, and re-acquire it on wake. Generalization: hold the lock when calling signal or wait, and youโ€™ll always be in good shape.

With the mechanism understood, the next section stress-tests it on the classic producer/consumer problem โ€” where a single condition variable turns out not to be enough.

Check yourself: condition variables

1.pthread_cond_wait() takes a mutex. What does it do with it?

2.In Figure 30.4 the state variable done is removed. What goes wrong?

3.Figure 30.5 removes the lock around the check-and-wait. Trace the bug.

4.A thread calls signal() and wakes a sleeper. What state is the woken thread in immediately after?

5.Using the correct Figure 30.3 code, the child happens to run to completion BEFORE the parent reaches thr_join(). What happens?

5 questions