15. Classical Synchronization Problems I¶
Lecture 14 handed over a full toolbox — hardware instructions, mutex locks, semaphores, and monitors — but a toolbox is only useful once you've used it on something real. This lecture and the next apply semaphores to the handful of synchronization problems every operating systems course treats as a shared benchmark: compact enough to fit in a lecture, yet rich enough to expose exactly the subtleties — operation order, starvation, deadlock — that real kernel code has to get right. We start with two: the bounded-buffer (producer-consumer) problem, a direct model of I/O buffering between a device and the kernel, and the readers-writers problem, a direct model of concurrent access to a file, a table, or any shared record.
In This Lecture¶
- The bounded-buffer problem's setup, and why it needs three separate semaphores rather than one
- The full producer and consumer pseudocode, built from
wait()/signal() - Why the order of acquiring semaphores is not a stylistic choice — getting it backward deadlocks the system
- The readers-writers problem's setup, and the "first" readers-writers variant's reader-preference behavior (and the starvation it risks)
- The semaphore-based solution, using a protected
read_countalongside arw_mutex
The Bounded-Buffer (Producer-Consumer) Problem¶
A fixed-size buffer holds up to N items. One or more producer processes generate items
and insert them into the buffer; one or more consumer processes remove items and use
them. Three distinct things can go wrong if access isn't coordinated:
- A producer must not insert into a buffer that is already full.
- A consumer must not remove from a buffer that is already empty.
- Two processes manipulating the buffer's internal bookkeeping (its insertion/removal index) at the same instant can corrupt it — exactly Lecture 13's race condition, now with a circular buffer's index in place of a simple counter.
Those are three separate concerns, and the standard solution uses three separate semaphores, one per concern:
Three semaphores, three separate jobs
empty Counting semaphore, init N — counts free slots
full Counting semaphore, init 0 — counts filled slots
mutex Binary semaphore, init 1 — protects the buffer index itself
Producer and Consumer Pseudocode¶
semaphore empty = N; /* free slots */
semaphore full = 0; /* filled slots */
semaphore mutex = 1; /* buffer index lock */
/* Producer */
do {
/* produce an item into next_produced */
wait(empty);
wait(mutex);
/* add next_produced to the buffer */
signal(mutex);
signal(full);
} while (true);
/* Consumer */
do {
wait(full);
wait(mutex);
/* remove an item from the buffer into next_consumed */
signal(mutex);
signal(empty);
/* consume the item in next_consumed */
} while (true);
Each process waits on the counting semaphore first (empty for the producer, full for
the consumer) and only then acquires mutex to touch the buffer's shared index. On the way
out, it releases mutex first and signals the counting semaphore second.
Why the Order Matters¶
Correct order — the counting semaphore is never acquired while holding mutex
wait(empty) / wait(full) May block — but mutex is NOT held yet
wait(mutex) Enter the brief critical section
signal(mutex) → signal(full) / signal(empty) Release mutex first, then update the count
Swap the order and the system deadlocks
Suppose the producer instead acquired mutex before the counting semaphore:
Now imagine the buffer is completely full (empty == 0). The producer acquires mutex
successfully, then calls wait(empty) — which blocks, because there are no free slots.
Critically, the producer is now blocked while still holding mutex. The consumer,
which needs mutex to remove an item and eventually call signal(empty), can never
acquire it — mutex is held by a process that is never going to release it, because the
only thing that could unblock that process is the very signal(empty) the consumer is
now unable to reach. Both processes wait forever, each for something only the other
could have provided. This is a deadlock, caused entirely by acquiring the two semaphores
in the wrong order.
The correct version avoids this because a process that must block on empty or full
does so before ever touching mutex — so if the producer blocks, it is never holding
the lock the consumer needs to make progress and eventually wake it back up.
Hand-tracing the correct version confirms this: if the buffer is full, the producer blocks
on wait(empty) without mutex; the consumer can freely acquire mutex, remove an item,
release mutex, and call signal(empty), which wakes the waiting producer. The symmetric
case (empty buffer, consumer blocked on wait(full)) resolves the same way. Neither process
is ever stuck holding mutex while waiting on a counting semaphore that only the other
process can signal.
The Readers-Writers Problem¶
A shared data object — a file, a database record, an in-memory table — is accessed by two kinds of processes. Readers only read the data and never modify it; writers modify it (and may read it too). Multiple readers may safely access the object simultaneously, since reading in parallel causes no conflict between them. A writer, however, needs exclusive access: no other reader or writer may touch the object while a writer is writing.
Shared access for readers, exclusive access for a writer
Readers (any number) May all access the shared data at the same time
Writer (at most one) Needs the shared data completely to itself — no readers, no other writers
This lecture covers the first readers-writers problem: no reader should be kept waiting unless a writer has already obtained permission to use the shared object. In other words, readers are given priority — as long as at least one reader is active, every newly arriving reader is let in immediately, even if a writer is already waiting its turn.
Reader preference vs. writer preference — a genuine tradeoff, not a bug
The first readers-writers problem, solved below, favors readers. A second variant flips the priority: once a writer is waiting, no new reader is allowed to start, so that writers are never starved by an endless stream of readers. Neither variant is universally "correct" — each protects one class of process from starvation at the cost of risking starvation for the other. A real system picks based on which failure mode it can tolerate less.
The Semaphore-Based Solution¶
int read_count = 0; /* number of readers currently reading */
semaphore mutex = 1; /* protects read_count itself */
semaphore rw_mutex = 1; /* exclusive access for a writer; held by the
"first in / last out" reader on behalf of
every reader currently active */
/* Writer */
do {
wait(rw_mutex);
/* writing is performed */
signal(rw_mutex);
} while (true);
/* Reader */
do {
wait(mutex);
read_count++;
if (read_count == 1) {
wait(rw_mutex); /* first reader locks out writers */
}
signal(mutex);
/* reading is performed */
wait(mutex);
read_count--;
if (read_count == 0) {
signal(rw_mutex); /* last reader lets a waiting writer in */
}
signal(mutex);
} while (true);
A writer treats rw_mutex exactly like an ordinary mutual-exclusion lock — simple, by
design. Readers are the subtler half: read_count is itself shared data, incremented and
decremented by potentially many readers concurrently, so it needs its own protection —
mutex — or updating it would reproduce Lecture 13's race condition on read_count itself.
The key idea is that only the first reader to arrive (the one that pushes read_count
from 0 to 1) actually acquires rw_mutex, locking out writers on behalf of every reader
that follows. Every subsequent concurrent reader simply increments read_count and goes
straight to reading — it never touches rw_mutex at all, which is exactly what allows all
of them to read genuinely in parallel. Symmetrically, only the last reader to leave (the
one that brings read_count back down to 0) releases rw_mutex, re-opening the door for
a writer that may have been waiting.
Key Takeaways¶
- The bounded-buffer problem needs three semaphores doing three separate jobs:
emptyandfull(counting semaphores tracking free and filled slots) andmutex(a binary semaphore protecting the shared buffer index). - Order matters: a counting semaphore (
empty/full) must always be acquired beforemutex, never after — reversing the order lets a blocked process holdmutexforever, deadlocking the system. - The first readers-writers problem gives priority to readers: a reader is only ever made to wait if a writer already has the data, which can starve a writer under a steady stream of readers — the tradeoff a writer-preference variant exists to fix instead.
- The semaphore solution protects
read_countwith its ownmutex, and usesrw_mutexas the single lock that only the first arriving reader acquires and only the last departing reader releases — letting every reader in between run fully in parallel.
Lecture 16 closes out the classical problems with the Dining Philosophers Problem — a case specifically chosen because its most natural solution deadlocks, setting up the Deadlocks unit that follows.