Mutexes, Condition Variables, and Producer-Consumer
What This Concept Is
Two primitives solve nearly all classical synchronization problems.
- A mutex (
pthread_mutex_t) is a lock.pthread_mutex_lock(&m)blocks until the lock is free, then takes it.pthread_mutex_unlock(&m)releases it. While one thread holds the lock, no other thread can be inside a critical section that starts withlock(&m). - A condition variable (
pthread_cond_t) lets a thread wait for a predicate -- "the queue is non-empty," "the buffer has space" -- to become true, while holding a mutex.pthread_cond_wait(&cv, &m)atomically releasesmand blocks the thread; another thread that changes the predicate callspthread_cond_signal(&cv)(orbroadcast) to wake a waiter, which re-acquiresmbeforewaitreturns.
The canonical pattern for both is:
pthread_mutex_lock(&m);
while (!predicate) /* not 'if' -- see below */
pthread_cond_wait(&cv, &m);
/* ... use the protected state ... */
pthread_mutex_unlock(&m);
Why It Matters Here
The producer-consumer problem is the workhorse of systems code: one thread produces items (parsed log lines, network packets, rendered frames), another consumes them (writes to a database, sends over a socket, draws to the screen). A bounded buffer sits between them. Producers block when the buffer is full; consumers block when it is empty.
Solving this correctly teaches every piece of the vocabulary: shared state, critical section, race condition, deadlock, spurious wake-up, signalling loss.
Concrete Example
A bounded-queue producer-consumer with one mutex and two condition variables:
#include <pthread.h>
#include <stdio.h>
#define CAP 8
typedef struct {
int buf[CAP];
int head, tail, count; /* shared, protected by m */
pthread_mutex_t m;
pthread_cond_t not_full;
pthread_cond_t not_empty;
} Q;
static void q_init(Q *q) {
q->head = q->tail = q->count = 0;
pthread_mutex_init(&q->m, NULL);
pthread_cond_init(&q->not_full, NULL);
pthread_cond_init(&q->not_empty, NULL);
}
static void q_put(Q *q, int v) {
pthread_mutex_lock(&q->m);
while (q->count == CAP) /* LINE A: wait for space */
pthread_cond_wait(&q->not_full, &q->m);
q->buf[q->tail] = v;
q->tail = (q->tail + 1) % CAP;
q->count++;
pthread_cond_signal(&q->not_empty); /* LINE B: wake a consumer */
pthread_mutex_unlock(&q->m);
}
static int q_get(Q *q) {
pthread_mutex_lock(&q->m);
while (q->count == 0) /* LINE C: wait for item */
pthread_cond_wait(&q->not_empty, &q->m);
int v = q->buf[q->head];
q->head = (q->head + 1) % CAP;
q->count--;
pthread_cond_signal(&q->not_full); /* LINE D: wake a producer */
pthread_mutex_unlock(&q->m);
return v;
}
Which line prevents which race?
- The mutex (
lock/unlockaround the whole body) prevents two producers from simultaneously advancingtailand overwriting each other's write; and two consumers from both reading athead; and any producer/consumer pair from tearingcount. - The
whileon LINE A prevents the "wake up and act without re-checking" race:pthread_cond_waitcan return spuriously (POSIX allows it). If we wroteif, we might proceed withcount == CAPand overwrite a slot. - The
whileon LINE C similarly prevents reading from an empty buffer after a spurious wake-up or after another consumer emptied the last slot between the signal and the re-acquire. - LINE B prevents the lost-wakeup race on the consumer side: a consumer that went to sleep on
not_emptygets notified exactly because we made the predicate true under the same mutex. - LINE D prevents the symmetric lost-wakeup on the producer side.
Swapping any while for if, or signalling without holding the mutex, reintroduces the corresponding race. This is why the pattern is worth memorizing line by line.
Common Confusion / Misconception
"I held the mutex while reading, so I do not need it while writing." You need it for both. Mutexes protect sequences of operations, not individual memory locations. Any thread that reads shared state without the lock can observe a partially updated intermediate.
"cond_signal wakes every waiter; I'll use broadcast just in case." signal wakes one (which is what you usually want -- one slot became free, wake one producer). broadcast wakes all, which is only needed when the event they are waiting on is shared (e.g., "the shutdown flag was set"). Using broadcast unnecessarily produces the thundering herd problem: many threads wake up, compete for the lock, and go back to sleep.
"pthread_cond_wait returns only when signalled." It returns when signalled, when broadcast, when the process gets a signal it handles, or spuriously for no reason at all. Always re-check the predicate in a while loop.
How To Use It
Writing correct concurrent code:
- Pick a single lock per data structure (or per invariant). Note, in a comment, which fields it protects.
- Every read and write of a protected field happens with the lock held.
- When a thread must wait for a predicate, use
while (!predicate) cond_wait(&cv, &m). - When a thread changes state that might make a predicate true, signal the matching cv while holding the mutex.
- Release the lock as quickly as possible. Never block on I/O while holding it.
Check Yourself
- Why is
pthread_cond_waitalways placed inside awhileloop? - What specific race does the mutex prevent when two producers enqueue at the same time?
- Why is signalling inside the critical section, not after unlock, the safer pattern?
Mini Drill or Application
Do all four:
- Write, compile, and run the full producer-consumer above with one producer inserting
0..999and one consumer summing them. Verify the sum equals499500. - Scale to 4 producers and 4 consumers. Verify the total sum still matches
4 * 499500. - Replace the two
whiles withifs and run a stress test. Describe the first failure you see and explain which line caused it. - Remove LINE B (
cond_signal(not_empty)). Run the program. Explain why the consumer hangs even though the producer deposited items.