ยง30.2โ€“30.4The Producer/Consumer (Bounded Buffer) Problem โ€ฆ Summary

Part II OSTEP pp. 355โ€“364 ยท ~12 min read

  • producer/consumer problem
  • mesa semantics
  • hoare semantics
  • covering condition
  • broadcast

Locks and CVs are enough to solve real synchronization problems โ€” but getting the signaling right is subtle. The canonical proving ground is the producer/consumer problem (a.k.a. the bounded-buffer problem, first posed by Dijkstra).

30.2 The Producer/Consumer (Bounded Buffer) Problem

One or more producers generate items and place them in a shared buffer; one or more consumers take items out. Itโ€™s everywhere: a multithreaded web server (producers enqueue HTTP requests, consumer threads process them); a UNIX pipe grep foo file.txt | wc -l (grep is the producer, wc the consumer, an in-kernel bounded buffer between them).

Start with a single-slot buffer and its put/get (Version 1):

Figure 30.6: put / get (single slot)

int buffer;
int count = 0; // initially empty

void put(int value) {
    assert(count == 0);
    count = 1;
    buffer = value;
}
int get() {
    assert(count == 1);
    count = 0;
    return buffer;
}

Figure 30.7: the threads (Version 1)

void *producer(void *arg) {
    int loops = (int) arg;
    for (int i = 0; i < loops; i++)
        put(i);
}
void *consumer(void *arg) {
    while (1) {
        int tmp = get();
        printf("%d\n", tmp);
    }
}

Put only into an empty buffer, get only from a full one. A plain lock around put/get gives mutual exclusion but canโ€™t make a thread wait for the right condition โ€” so we need condition variables.

Broken attempt #1: one CV, and an if

cond_t cond; mutex_t mutex;

void *producer(void *arg) {
    for (int i = 0; i < loops; i++) {
        Pthread_mutex_lock(&mutex);            // p1
        if (count == 1)                        // p2
            Pthread_cond_wait(&cond, &mutex);  // p3
        put(i);                                // p4
        Pthread_cond_signal(&cond);            // p5
        Pthread_mutex_unlock(&mutex);          // p6
    }
}
void *consumer(void *arg) {
    for (int i = 0; i < loops; i++) {
        Pthread_mutex_lock(&mutex);            // c1
        if (count == 0)                        // c2
            Pthread_cond_wait(&cond, &mutex);  // c3
        int tmp = get();                       // c4
        Pthread_cond_signal(&cond);            // c5
        Pthread_mutex_unlock(&mutex);          // c6
        printf("%d\n", tmp);
    }
}

With one producer and one consumer this works. Add a second consumer and it shatters. Step through the disaster:

Figure 30.9: broken with a single CV and an `if`. Tc2 steals the value between the signal and Tc1 actually running.
t =
Tc1
Tc2
Tp
buffer
count 0
tick 1 / 8 ยท one scheduling step per tickrunningready (awoken, not running)sleeping (on CV)

Consumer Tc1 finds the buffer empty and โ€” using an `if` โ€” waits (sleeps).

The root cause: after the producer woke Tc1 but before Tc1 ran, another thread changed the buffer. A signal only makes a thread runnable โ€” itโ€™s a hint that the state changed, not a guarantee it still holds. This is Mesa semantics , the contrast being Hoare semantics :

What does a signal mean? Two answers โ€” and every real system picks the first.
a signal meansโ€ฆguarantee when the waiter runsused by
Mesa semanticsa HINT that the state may have changednone โ€” re-check the conditionvirtually every real system
Hoare semanticsan immediate handoff of lock + CPU to a waiterthe signalled state still holdsrare (Hoare, theory)
Dotted-underlined cells have explanations โ€” click one.

Fix #1: while, not if

Because a woken thread must re-verify reality, change every if before a wait into a while (p2 and c2). Now when Tc1 wakes it re-checks count; if the buffer is empty again, it just goes back to sleep.

while (count == 1)                     // p2 โ€” was: if
    Pthread_cond_wait(&cond, &mutex);  // p3
// ... and in the consumer:
while (count == 0)                     // c2 โ€” was: if
    Pthread_cond_wait(&cond, &mutex);  // c3

That kills the first bug โ€” but a second one remains, and itโ€™s about having only one condition variable:

Figure 30.11: `while` fixed the first bug, but with ONE condition variable a consumer can wake a consumer โ€” and everyone ends up asleep.
t =
Tc1
Tc2
Tp
buffer
count 0
tick 1 / 10 ยท one scheduling step per tickrunningready (awoken, not running)sleeping (on CV)

Consumer Tc1 finds empty, waits.

Fix #2: two condition variables

The trace shows a consumer waking another consumer while the producer sleeps. Signaling must be directed: a consumer should only wake producers, and vice-versa. Use two CVs โ€” empty and fill:

Producer

while (count == 1)
    Pthread_cond_wait(&empty, &mutex);
put(i);
Pthread_cond_signal(&fill);

Consumer

while (count == 0)
    Pthread_cond_wait(&fill, &mutex);
int tmp = get();
Pthread_cond_signal(&empty);

Two condition variables route wakeups correctly: a producer can never wake a producer, nor a consumer a consumer.

Producerwaits full โ†’ refillsConsumerwaits empty โ†’ drainsemptyfillwait โ†—โ†˜ signalsignal โ†˜โ†– wait

Tip: Use While (Not If) For Conditions

Using a while loop around a condition check is always correct; an if only might be, depending on signaling semantics. while also handles spurious wakeups , where some thread packages wake more than one waiter from a single signal. So: always while, and be happy.

The correct, general solution

Finally, generalize the single slot to a ring buffer of MAX entries โ€” more concurrency, fewer context switches. Producers sleep only when all slots are full (count == MAX); consumers only when all are empty (count == 0):

Figure 30.13: the ring buffer

int buffer[MAX];
int fill_ptr = 0, use_ptr = 0, count = 0;

void put(int value) {
    buffer[fill_ptr] = value;
    fill_ptr = (fill_ptr + 1) % MAX;
    count++;
}
int get() {
    int tmp = buffer[use_ptr];
    use_ptr = (use_ptr + 1) % MAX;
    count--;
    return tmp;
}

Figure 30.14: correct sync

// producer:
while (count == MAX)
    Pthread_cond_wait(&empty, &mutex);
put(i);
Pthread_cond_signal(&fill);

// consumer:
while (count == 0)
    Pthread_cond_wait(&fill, &mutex);
int tmp = get();
Pthread_cond_signal(&empty);

The ring buffer: fill_ptr (where the producer writes next) chases use_ptr (where the consumer reads next), both wrapping mod MAX; count tracks how many slots are full.

ยท[0]ยท[1]item[2]item[3]item[4]ยท[5]ยท[6]โ–ผ fill_ptrโ–ผ use_ptrcount = 3 full slots ยท wraps around mod MAX

And there we have it: a correct, concurrent producer/consumer. Time to sit back and drink a cold one.

30.3 Covering Conditions

One more wrinkle, from Lampson and Redellโ€™s Mesa work. Consider a multithreaded memory allocator:

int bytesLeft = MAX_HEAP_SIZE;
cond_t c; mutex_t m;

void *allocate(int size) {
    Pthread_mutex_lock(&m);
    while (bytesLeft < size)
        Pthread_cond_wait(&c, &m);
    void *ptr = ...; // carve out size bytes
    bytesLeft -= size;
    Pthread_mutex_unlock(&m);
    return ptr;
}
void free(void *ptr, int size) {
    Pthread_mutex_lock(&m);
    bytesLeft += size;
    Pthread_cond_signal(&c); // but WHOM to wake??
    Pthread_mutex_unlock(&m);
}

The signaler canโ€™t know which waiter its freed bytes can satisfy. Walk the failure โ€” and the fix:

Covering conditions (Fig 30.15): a single signal can wake the wrong waiter โ€” broadcast wakes them all and lets each re-check.
heap freebytesLeft = 0Ta โ€” allocate(100)sleeping (need > free)Tb โ€” allocate(10)not yet waiting

1Ta wants 100 bytes โ€” but 0 are free

bytesLeft is 0. Thread Ta calls allocate(100); since 0 < 100, it waits on the condition and sleeps.

step 1 / 5

Lampson and Redellโ€™s fix is to replace signal with broadcast (pthread_cond_broadcast), which wakes all waiters; each re-checks its own condition, and the ones that still canโ€™t proceed simply sleep again. They call this a covering condition โ€” it conservatively covers every thread that might need waking. The cost is performance: threads woken needlessly. As a rule, if your program only works when you turn a signal into a broadcast (and you donโ€™t think it should need to), you probably have a bug โ€” but for cases like this allocator, broadcast is the cleanest tool.

30.4 Summary

Condition variables โ€” a second synchronization primitive beyond locks โ€” let threads sleep until program state is as desired, cleanly solving the producer/consumer problem and covering conditions. The load-bearing rules: hold the lock, use a state variable, always re-check with while, and signal in a directed way (or broadcast when you canโ€™t).

Check yourself: producer/consumer and covering conditions

1.In the single-CV, `if`-based solution (Fig 30.8), why does adding a second consumer break it?

2.Under Mesa semantics, what does receiving a signal guarantee about the condition you were waiting on?

3.Changing `if` to `while` fixes the first bug. What second bug remains with a single condition variable?

4.How do the two condition variables empty and fill route wakeups correctly?

5.In the memory allocator, why replace signal() with broadcast() (a covering condition)?

6.In the correct MAX-slot ring buffer, when does a producer go to sleep?

6 questions