CMP-320 · Lesson 6 of 12 · 33 min
Synchronization II: semaphores, condition variables, monitors and classic problems
Locks give mutual exclusion: only one thread at a time in a critical section. Many concurrency problems need more than that. A consumer thread must wait until a producer has put something in a queue; a pool of database connections must admit at most ten users at once; all the threads in a simulation must finish step 41 before any starts step 42. These are problems of ordering and waiting, not just exclusion. This lesson covers the tools operating systems and libraries provide for them: semaphores, condition variables, monitors and barriers, and applies them to the classic problems every systems programmer should recognize.
Lesson 5 covered races, locks and atomic operations; Lesson 7 covers deadlock, the main hazard of combining several locks. CMPUT-274 met Python's Queue, Event and Lock objects; here we use the POSIX primitives that underlie such libraries.
Semaphores
Edsger Dijkstra introduced the semaphore in the 1960s. A semaphore is an integer counter with two atomic operations. P, also called wait or down (from the Dutch 'proberen', to try), decrements the counter if it is positive and otherwise blocks until it can. V, also called signal, post or up ('verhogen', to raise), increments the counter and wakes one waiting thread if there is one. A binary semaphore, initialized to 1, behaves like a lock. A counting semaphore initialized to n admits up to n threads at once, which models a pool of n identical resources: database connections, license seats or download slots.
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <unistd.h>
#define WORKERS 10
static sem_t slots; /* counting semaphore: 3 permits */
static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static int inside = 0, peak = 0;
static void *download(void *arg) {
(void)arg;
sem_wait(&slots); /* P: take a permit or sleep */
pthread_mutex_lock(&m);
if (++inside > peak) peak = inside;
pthread_mutex_unlock(&m);
usleep(20000); /* the limited resource is in use */
pthread_mutex_lock(&m); inside--; pthread_mutex_unlock(&m);
sem_post(&slots); /* V: return the permit */
return NULL;
}
int main(void) {
sem_init(&slots, 0, 3);
pthread_t t[WORKERS];
for (int i = 0; i < WORKERS; i++) pthread_create(&t[i], NULL, download, NULL);
for (int i = 0; i < WORKERS; i++) pthread_join(t[i], NULL);
printf("%d downloads ran; at most %d ran at the same time (limit 3)\n", WORKERS, peak);
sem_destroy(&slots);
return 0;
}$ gcc -O2 -Wall -g -pthread -fsanitize=address,undefined toy.c -o toy $ ./toy 10 downloads ran; at most 3 ran at the same time (limit 3)
Semaphores are also useful for signaling between threads. A semaphore initialized to 0 lets one thread wait for an event that another thread will announce: the waiter calls P and sleeps; the announcer calls V. Unlike a lock, a semaphore has no owner, so the thread that calls V need not be the one that called P. This flexibility is also its weakness. Semaphores are easy to misuse: a forgotten V leaks a permit forever, an extra V adds a permit that should not exist, and nothing in the code makes the intended pattern obvious. POSIX provides unnamed semaphores (sem_init) for threads and named semaphores (sem_open) for separate processes.
Condition variables
A condition variable lets a thread wait, while holding a lock, until some condition on shared data becomes true. It always works together with a mutex. The waiting thread locks the mutex, checks the condition and, if it is false, calls pthread_cond_wait, which atomically releases the mutex and puts the thread to sleep; when woken, the thread reacquires the mutex before returning. A thread that changes the shared data calls pthread_cond_signal to wake one waiter or pthread_cond_broadcast to wake all of them.
Two rules make condition variables work. First, always test the condition in a while loop, never an if: a woken thread must re-check because another thread may have run first and changed the state, and POSIX allows spurious wake-ups with no signal at all. Second, change the shared state and signal while holding the mutex (or at least change it under the mutex), so that no waiter can test the condition between the change and the signal and miss the wake-up. Condition variables are memoryless: a signal sent when nobody is waiting is lost, which is why the condition itself, not the signal, carries the information.
The bounded-buffer (producer-consumer) problem
In the bounded-buffer problem, producer threads put items into a buffer of fixed size and consumer threads take them out. Producers must wait when the buffer is full and consumers when it is empty, and the buffer's indexes must be updated under mutual exclusion. The pattern is everywhere: a pipe between two processes, a print spooler, a web server's queue of incoming connections, a logging thread draining messages from application threads, and the kernel's queue of disk requests.
#include <pthread.h>
#include <stdio.h>
#define CAP 4 /* bounded buffer of 4 slots */
#define ITEMS 10
static int buf[CAP], head = 0, tail = 0, count = 0;
static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
static pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;
static int max_seen = 0;
static void *producer(void *arg) {
(void)arg;
for (int i = 1; i <= ITEMS; i++) {
pthread_mutex_lock(&m);
while (count == CAP) /* always re-check in a loop */
pthread_cond_wait(¬_full, &m); /* releases m while sleeping */
buf[tail] = i * i; tail = (tail + 1) % CAP; count++;
if (count > max_seen) max_seen = count;
pthread_cond_signal(¬_empty);
pthread_mutex_unlock(&m);
}
return NULL;
}
static void *consumer(void *arg) {
(void)arg;
for (int i = 1; i <= ITEMS; i++) {
pthread_mutex_lock(&m);
while (count == 0)
pthread_cond_wait(¬_empty, &m);
int v = buf[head]; head = (head + 1) % CAP; count--;
pthread_cond_signal(¬_full);
pthread_mutex_unlock(&m);
printf("consumed %3d\n", v); /* print outside the lock */
}
return NULL;
}
int main(void) {
pthread_t p, c;
pthread_create(&c, NULL, consumer, NULL);
pthread_create(&p, NULL, producer, NULL);
pthread_join(p, NULL); pthread_join(c, NULL);
printf("buffer never held more than %d of %d slots\n", max_seen, CAP);
return 0;
}$ gcc -O2 -Wall -g -pthread -fsanitize=thread toy.c -o toy $ ./toy consumed 1 consumed 4 consumed 9 consumed 16 consumed 25 consumed 36 consumed 49 consumed 64 consumed 81 consumed 100 buffer never held more than 4 of 4 slots
The output shows every item consumed in order and the buffer never overfilling. Because the consumer prints outside the lock, slow output does not hold up the producer. The same problem can be solved with three semaphores: one counting empty slots, initialized to the capacity; one counting full slots, initialized to 0; and a binary semaphore for mutual exclusion. Getting the order of operations right matters there: if a producer takes the mutex before waiting for an empty slot, it can sleep while holding the mutex and block the consumer forever, a deadlock.
Monitors
A monitor packages shared data, the operations on it and the synchronization into one unit, so that the language or library guarantees that only one thread is active inside the monitor at a time and provides condition variables for waiting. Tony Hoare and Per Brinch Hansen proposed monitors in the 1970s to make concurrent programs easier to get right than raw semaphores. Java builds monitors into every object: synchronized methods lock the object, and wait, notify and notifyAll are its condition variable. C# has lock and Monitor, Python has threading.Condition, and Go's channels offer a different style based on passing messages rather than sharing memory.
A subtle design choice is what happens when a thread signals. In Hoare semantics, the woken thread runs immediately and the signaler waits, so the condition is guaranteed true when the waiter resumes. In Mesa semantics, used by Java, POSIX and nearly every real system, the signaler keeps running and the woken thread merely becomes ready, so by the time it runs the condition may be false again. That is the deeper reason for the while loop around every wait.
Readers and writers
Many shared data structures are read far more often than written: a routing table, a configuration object, a cache. A plain mutex makes readers wait for each other unnecessarily. A reader-writer lock allows any number of readers at once, or one writer alone. The design question is fairness. With reader preference, new readers may enter while a writer waits, so a steady stream of readers can starve writers. With writer preference, a waiting writer blocks new readers, which bounds the writer's wait but can delay readers. POSIX provides pthread_rwlock_t, and Linux's kernel adds seqlocks and RCU, which let readers proceed without any locking at all.
# Readers-writers on a timeline: (name, arrival ms, duration ms). Compare the two fairness policies.
arrivals = [("R1", 0, 4), ("R2", 1, 4), ("W1", 2, 3), ("R3", 3, 4), ("R4", 4, 4), ("R5", 6, 4), ("R6", 8, 4)]
def simulate(prefer_writers):
waiting, active, start = [], {}, {}
for t in range(60):
for n, a, d in arrivals:
if a == t: waiting.append((n, d))
for n in [n for n, end in active.items() if end == t]: del active[n]
writer_active = any(n[0] == "W" for n in active)
writer_waiting = any(n[0] == "W" for n, _ in waiting)
for n, d in list(waiting):
if n[0] == "W" and not active:
active[n] = t + d; start[n] = t; waiting.remove((n, d)); break
if n[0] == "R" and not writer_active and not (prefer_writers and writer_waiting):
active[n] = t + d; start[n] = t; waiting.remove((n, d))
return start
for pref in (False, True):
start = simulate(pref)
order = " ".join(f"{n}@{start[n]}" for n in sorted(start, key=lambda n: (start[n], n)))
w_wait = start["W1"] - 2
print(f"{'writer' if pref else 'reader'} preference: {order}")
print(f" W1 waited {w_wait} ms")reader preference: R1@0 R2@1 R3@3 R4@4 R5@6 R6@8 W1@12 W1 waited 10 ms writer preference: R1@0 R2@1 W1@5 R3@8 R4@8 R5@8 R6@8 W1 waited 3 ms
Barriers
A barrier makes a group of threads wait until all of them reach the same point. It is the natural tool for computations that proceed in phases, such as a physics simulation in which every thread must finish updating its part of the grid before any thread reads its neighbors' values for the next step. POSIX provides pthread_barrier_t; OpenMP and MPI have their own. The cost of a barrier is that every thread waits for the slowest, so balancing work between threads matters.
#define _POSIX_C_SOURCE 200809L /* barriers are an optional POSIX feature */
#include <pthread.h>
#include <stdio.h>
#define T 3
static pthread_barrier_t bar;
static int phase_done[T][2];
static void *worker(void *arg) {
int id = *(int *)arg;
phase_done[id][0] = 1; /* phase 1 work */
pthread_barrier_wait(&bar); /* nobody starts phase 2 until all finish phase 1 */
int all = 1;
for (int k = 0; k < T; k++) all &= phase_done[k][0];
phase_done[id][1] = all; /* phase 2 can rely on every phase 1 result */
return NULL;
}
int main(void) {
pthread_t t[T]; int ids[T];
pthread_barrier_init(&bar, NULL, T);
for (int i = 0; i < T; i++) { ids[i] = i; pthread_create(&t[i], NULL, worker, &ids[i]); }
for (int i = 0; i < T; i++) pthread_join(t[i], NULL);
for (int i = 0; i < T; i++)
printf("worker %d saw every phase-1 result before phase 2: %s\n", i, phase_done[i][1] ? "yes" : "no");
pthread_barrier_destroy(&bar);
return 0;
}$ gcc -O2 -Wall -g -pthread -fsanitize=thread toy.c -o toy $ ./toy worker 0 saw every phase-1 result before phase 2: yes worker 1 saw every phase-1 result before phase 2: yes worker 2 saw every phase-1 result before phase 2: yes
Message passing as an alternative
All of the tools so far coordinate threads that share memory. Another school of thought says: do not communicate by sharing memory; share memory by communicating. In the message-passing style, threads or processes own their data privately and send each other messages through channels or queues. Go's channels, Erlang's mailboxes, the actor model in Akka, and Unix pipes from Lesson 2 all follow this idea. The bounded buffer reappears inside the channel implementation, but application code no longer touches locks directly, which removes whole classes of bugs. Message passing also extends naturally across machines, the subject of CMPUT-450.
Worked examples
Worked problem
Why the while loop matters
A consumer is written with if (count == 0) pthread_cond_wait(¬_empty, &m); followed by taking an item. Two consumers and one producer share the buffer. Show how this fails.
Show solution
- The buffer is empty; consumers C1 and C2 both wait.
- The producer adds one item and signals; C1 is woken but must reacquire the mutex.
- Before C1 runs, C2 is woken (by a spurious wake-up or a broadcast) and takes the only item.
- C1 now returns from wait, skips the test because of the if, and reads from an empty buffer, corrupting head and count. A while loop would re-check and wait again.
Answer: With if, a woken consumer may proceed when the buffer is empty again; use while.
Worked problem
A connection pool with a semaphore
A web application may open at most 20 database connections, but runs 200 worker threads. Design the synchronization.
Show solution
- Initialize a counting semaphore to 20.
- Before using the database, a worker calls sem_wait, sleeping if all 20 permits are taken.
- It takes a connection from a list protected by a mutex, uses it, returns it under the mutex and then calls sem_post.
- Release the permit in every path, including errors, for example with a cleanup block, or permits will leak.
Answer: A counting semaphore set to 20 bounds concurrency; a mutex protects the list of connections.
Worked problem
Choosing a reader-writer policy
A stock-price service reads prices thousands of times per second and updates each price a few times per second. Updates must appear within 10 ms. Which reader-writer policy fits?
Show solution
- With reader preference, the constant stream of readers could keep the writer waiting indefinitely, breaking the 10 ms requirement.
- Writer preference bounds the writer's wait: new readers queue behind a waiting writer.
- An even better fit is RCU or copy-on-write: readers use the current snapshot without locks, and the writer publishes a new snapshot atomically.
Answer: Writer preference, or better, RCU-style snapshots so readers never block the writer.
Common mistakes
Mistakeif (!condition) pthread_cond_wait(...) is enough.
InsteadAlways wait in a while loop; wake-ups can be spurious and the condition can change before the waiter runs.
MistakeA signal on a condition variable is remembered until someone waits.
InsteadCondition variables are memoryless; a signal with no waiter is lost, so the condition must be stored in shared data.
MistakeA semaphore and a mutex are interchangeable.
InsteadA mutex has an owner and protects a critical section; a semaphore has no owner and counts permits or events.
MistakeIn the semaphore bounded buffer, the order of P operations does not matter.
InsteadTaking the mutex before waiting for a slot can deadlock; wait for a slot first, then take the mutex.
MistakeReader-writer locks are always faster than mutexes.
InsteadThey have more overhead and can starve writers; they help only when reads greatly outnumber writes and critical sections are long enough.
Summary and key terms
- Semaphores count permits: P waits for a permit, V returns one; binary semaphores act like locks and counting semaphores limit concurrency.
- Condition variables let a thread sleep until a condition on shared data holds; always wait in a while loop with the mutex held.
- The bounded-buffer problem coordinates producers and consumers with a mutex and two conditions, or three semaphores.
- Monitors bundle data, operations and synchronization; real systems use Mesa semantics, so waiters must re-check.
- Reader-writer locks let readers share, with a policy choice between reader and writer preference.
- Barriers synchronize phases of parallel computation.
- Message passing avoids shared-memory synchronization in application code.
- Semaphore
- An integer counter with atomic wait (P) and signal (V) operations.
- Counting semaphore
- A semaphore initialized to n that admits up to n threads.
- Condition variable
- A queue of threads waiting, with a mutex, for a condition to become true.
- Spurious wake-up
- A return from a condition wait without a matching signal.
- Monitor
- A construct combining shared data, operations and synchronization so one thread is active at a time.
- Mesa semantics
- Signaling makes a waiter ready but the signaler continues, so the waiter must re-check.
- Bounded buffer
- A fixed-size queue shared by producers and consumers.
- Reader-writer lock
- A lock allowing many readers or one writer.
- Barrier
- A point all threads must reach before any may continue.
- Message passing
- Coordination by sending messages instead of sharing memory.
Practice problems
Work each problem on paper first, then reveal the answer to check.
What do the semaphore operations P and V do?
Show answer
P decrements the count or waits if it is zero; V increments it and wakes a waiter.
How would you use a semaphore to make one thread wait for another's event?
Show answer
Initialize it to 0; the waiter calls P and the announcer calls V.
What does pthread_cond_wait do with the mutex?
Show answer
Atomically releases it while sleeping and reacquires it before returning.
Why must a condition be tested in a while loop?
Show answer
Wake-ups can be spurious, and another thread may change the condition before the waiter runs.
In the bounded-buffer program, on which condition does the producer wait?
Show answer
not_full, while count equals the capacity.
Why does the consumer print outside the lock?
Show answer
Slow output while holding the lock would block the producer unnecessarily.
What is the difference between Hoare and Mesa monitor semantics?
Show answer
In Hoare semantics the woken thread runs at once; in Mesa semantics the signaler continues and the waiter must re-check.
Which reader-writer policy can starve writers?
Show answer
Reader preference.
When is a barrier the right tool?
Show answer
When threads work in phases and must all finish one phase before any starts the next.
What does the counting semaphore program show about peak concurrency?
Show answer
At most three threads used the resource at once, matching the three permits.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.