Module 2 · Unit 6 · Lesson 4 of 7
Semaphores
A synchronization primitive more general than a lock. A semaphore can enforce mutual exclusion, but it can also signal between threads, count available resources, and order operations that must happen in a particular sequence. Dijkstra introduced it in 1965, and it remains the basis of the classic solutions to the producer–consumer and reader–writer problems.
01 Introduction
Lesson 6.3 presented the lock: a synchronization primitive with two operations, acquire and release, whose only purpose is to guarantee mutual exclusion. A lock is the right tool when the problem is to protect a critical section, but it is not general enough for every synchronization need. Consider two problems that a lock cannot solve cleanly.
- Signalling. A thread must wait until another thread has completed some action — for example, a consumer must wait until a producer has placed an item in a buffer. A lock can express the mutual exclusion on the buffer, but it cannot express the condition "wait until the buffer is non-empty". A busy-wait loop under a lock would deadlock, because the consumer would hold the lock while waiting for the producer, and the producer would be unable to acquire the lock to make the buffer non-empty.
- Counting. A pool of resources has a fixed number of units available — say, ten database connections. A thread must wait if no unit is free and proceed when one is. A lock can express "at most one thread at a time", but not "at most ten threads at a time".
Both problems are solved by the semaphore, introduced by Edsger Dijkstra in 1965. A semaphore is an integer variable with two atomic operations — traditionally called P and V, from the Dutch words proberen (to test) and verhogen (to increment). The value of the semaphore can be any non-negative integer, and it typically represents the number of available units of a resource, or the number of threads waiting for a condition to become true.
This lesson presents the semaphore in its full generality. It defines the two operations and their semantics, shows how a binary semaphore (one restricted to the values 0 and 1) is equivalent to a lock, and then shows how a counting semaphore solves problems that a lock cannot. The central example is the bounded-buffer producer–consumer problem, which uses three semaphores to coordinate an arbitrary number of producers and consumers through a fixed-size buffer. The lesson also covers the reader–writer problem, the POSIX semaphore interface, and the classic pitfalls — forgetting to signal, signalling too many times, and the deadlocks that arise from acquiring semaphores in the wrong order.
Prerequisites
This lesson builds directly on Lessons 6.1–6.3. The race conditions of Lesson 6.1 motivate the problem; the critical-section abstraction of Lesson 6.2 provides the framework; the lock of Lesson 6.3 provides the basic tool that the binary semaphore generalizes. If the difference between a spinlock and a blocking lock is unclear, review section 4.2 of Lesson 6.3 before continuing.
02 Learning objectives
By the end of this lesson you will be able to
- Define a semaphore and describe the semantics of the P and V operations.
- Distinguish binary from counting semaphores and explain how each is used.
- Use semaphores to solve the bounded-buffer producer–consumer problem.
- Use semaphores to solve the reader–writer problem.
- Identify the classic pitfalls of semaphore programming and explain how to avoid them.
This lesson contributes to U6.3 and U6.4, and to course learning outcomes CLO2, CLO3, and CLO8.
03 Required reading
- Operating System Concepts — Chapter 6, Sections 6.6–6.8, Semaphores, Classic Problems of Synchronization, and Monitors (the first part). The standard treatment of the semaphore, the producer–consumer problem, and the reader–writer problem.
- OSTEP — Chapter 31, Semaphores. The clearest available treatment of the semaphore as a general synchronization primitive. OSTEP's use of the semaphore to solve the producer–consumer and reader–writer problems is the basis for the worked example in this lesson.
- Tanenbaum & Bos — Modern Operating Systems, Chapter 2, Section 2.3.4, Semaphores, and Section 2.3.5, Mutexes. A complementary treatment that places semaphores in the historical context of the 1960s.
- Dijkstra, E. W. — Cooperating Sequential Processes. In Programming Languages, ed. F. Genuys, Academic Press, 1968. The original paper introducing the semaphore and the P and V operations.
-
Linux man pages:
man 7 sem_overview,man 3 sem_init,man 3 sem_wait,man 3 sem_post,man 3 sem_destroy,man 3 sem_open,man 3 sem_close,man 3 sem_unlink,man 3 sem_getvalue.
Reading strategy
Read OSTEP Chapter 31 first. Its treatment of the
semaphore as a general primitive — first for mutual
exclusion, then for ordering, then for counting — is the
clearest way to understand why a semaphore is more
general than a lock. Then read the Silberschatz section
on the classic problems for the formal solutions to the
producer–consumer and reader–writer problems. Keep
man 7 sem_overview open while you work
through the interactive activity; it is the authoritative
reference for the POSIX semaphore interface.
04 Core content
4.1 The semaphore abstraction
A semaphore is an integer variable that, apart from initialization, can be accessed only through two atomic operations:
P(s)— also calledwait(s),down(s), oracquire(s)-
If
s > 0, decrementsand return immediately. Ifs == 0, block the calling thread untilsbecomes positive, then decrementsand return. The operation is atomic: no other thread can observe an intermediate state or interleave its own P or V with this one. V(s)— also calledsignal(s),up(s), orrelease(s)-
Increment
sby 1. If one or more threads are blocked in P ons, wake one of them. The operation is atomic.
The value of a semaphore has a natural interpretation that depends on how it is used. In a counting semaphore, the value is the number of units of a resource currently available: a thread calls P to take a unit (blocking if none is available) and V to return a unit. The value ranges from 0 to some initial maximum, and it never goes negative. In a binary semaphore, the value is restricted to 0 or 1, and the semaphore acts as a lock: P is acquire and V is release.
4.2 Busy-waiting and blocking implementations
The P and V operations can be implemented in two ways. The busy-waiting implementation uses the spinlock machinery from Lesson 6.3:
void P(sem_t *s) {
while (s->value <= 0)
; /* spin */
s->value--;
}
void V(sem_t *s) {
s->value++;
}
This implementation is simple but has the same drawbacks as the spinlock: it wastes CPU cycles, and the P operation must be atomic (typically implemented with a lock inside the semaphore), which means it is not as simple as it appears. In practice, this implementation is used only in kernel code where spinning is appropriate.
The blocking implementation is what the POSIX semaphore provides. The semaphore has a value and a queue of blocked threads. P decrements the value if it is positive; otherwise, it adds the caller to the queue and blocks. V increments the value and, if the queue is non-empty, removes one thread from it and makes it ready. The waiting thread consumes no CPU while it is blocked.
A well-implemented blocking semaphore uses a hybrid design similar to the futex: the uncontended fast path is handled in user space, and the kernel is involved only when the semaphore must block a thread or wake one. This is the design of the POSIX semaphore in glibc.
4.3 Binary semaphores and mutual exclusion
A binary semaphore is a semaphore whose value is restricted to 0 or 1. It is initialized to 1, and it is used to enforce mutual exclusion:
sem_t mutex;
sem_init(&mutex, 0, 1); /* initial value 1 */
/* Critical section */
sem_wait(&mutex); /* P: take the lock */
counter++;
sem_post(&mutex); /* V: release the lock */
The first thread to call sem_wait finds the
value 1, decrements it to 0, and enters the critical
section. A second thread calling sem_wait
finds the value 0 and blocks. When the first thread calls
sem_post, the value becomes 1 and the second
thread is woken. This is exactly the behaviour of a lock;
a binary semaphore and a mutex are functionally equivalent
for mutual exclusion.
There is one difference worth noting. A lock is owned by the thread that holds it: only the holding thread can release a mutex. A binary semaphore has no ownership: any thread can call V on it, regardless of which thread called P. This makes semaphores more flexible — they can be used for signalling, as we will see — but it also makes them more error-prone: a thread that accidentally signals a semaphore it did not wait on can corrupt the protocol.
4.4 Using a semaphore for signalling
The second use of the semaphore is signalling: one thread waits for another to complete an action. A semaphore is initialized to 0 and used as a one-shot signal:
sem_t done;
sem_init(&done, 0, 0);
/* Thread A — the worker */
do_work();
sem_post(&done); /* signal completion */
/* Thread B — the waiter */
sem_wait(&done); /* wait for the signal */
use_result();
If thread B reaches sem_wait before thread A
reaches sem_post, B blocks. When A posts, B is
woken. If B reaches sem_wait after A has
already posted, the semaphore's value is 1, so B does not
block; it decrements the value to 0 and proceeds. The order
of the two operations does not matter: the signal is
remembered.
This is the key property that a lock cannot provide. With a lock, the waiter must acquire before the releaser releases, or the release has no effect. With a semaphore used for signalling, the post is remembered as an increment to the value, and the wait consumes it. This is the basis of the producer–consumer and reader–writer solutions.
4.5 The bounded-buffer producer–consumer problem
The producer–consumer problem (also called
the bounded-buffer problem) is the
canonical synchronization problem. One or more
producers generate items and place them in a
shared buffer of fixed size; one or more consumers
remove items from the buffer and process them. The buffer
is a circular array of N slots with two indices,
in and out.
Three synchronization problems must be solved:
- A producer must not insert into a full buffer. It must wait until a slot is free.
- A consumer must not remove from an empty buffer. It must wait until an item is available.
- The buffer's indices and contents must be accessed mutually exclusively. Two producers must not both write to the same slot, and a consumer must not read a slot while a producer is writing it.
The classical solution uses three semaphores:
sem_t empty; /* counts empty slots, initialized to N */
sem_t full; /* counts filled slots, initialized to 0 */
sem_t mutex; /* protects the buffer structure, initialized to 1 */
The producer's loop is:
do {
item = produce();
sem_wait(&empty); /* wait for a free slot */
sem_wait(&mutex); /* enter the critical section */
buffer[in] = item;
in = (in + 1) % N;
sem_post(&mutex); /* leave the critical section */
sem_post(&full); /* announce a new item */
} while (1);
The consumer's loop is the mirror image:
do {
sem_wait(&full); /* wait for an item */
sem_wait(&mutex); /* enter the critical section */
item = buffer[out];
out = (out + 1) % N;
sem_post(&mutex); /* leave the critical section */
sem_post(&empty); /* announce a free slot */
consume(item);
} while (1);
The empty and full semaphores
perform the signalling: a producer waits on
empty until a slot is free, and posts to
full when it has produced an item; a consumer
does the opposite. The mutex semaphore
performs the mutual exclusion on the buffer structure.
in
index; a consumer removes them at the
out index. Three semaphores coordinate the
interaction: empty counts free slots,
full counts filled slots, and
mutex protects the buffer structure. The
order of the waits is critical: the counting semaphore
must be waited on before the mutex, or the protocol
deadlocks.
4.6 Why the order of the waits matters
The producer–consumer solution is a standard example in every operating-systems textbook, and the most common mistake is to reverse the order of the two waits in the producer:
/* WRONG */
sem_wait(&mutex); /* enter critical section first */
sem_wait(&empty); /* then wait for a free slot */
...
This version deadlocks. Suppose the buffer is full and a
producer reaches sem_wait(&mutex). It
acquires the mutex and then calls
sem_wait(&empty), which blocks because
there are no free slots. The producer now holds the mutex
while it is blocked. A consumer arrives, calls
sem_wait(&full) — which succeeds, because
there are filled slots — and then calls
sem_wait(&mutex), which blocks because the
producer holds it. The consumer cannot remove an item, so
the producer cannot proceed. Both are blocked, and the
system makes no progress.
The correct order — wait on the counting semaphore first, then on the mutex — avoids the deadlock because the producer does not hold the mutex while it is waiting for a slot. The general rule is: acquire semaphores in order of decreasing scope. The counting semaphores are the outermost resource; the mutex is the innermost. Wait for the outermost first.
4.7 The reader–writer problem
The reader–writer problem is a second classic synchronization problem. A shared data structure is accessed by two kinds of threads: readers, which only read it, and writers, which modify it. Multiple readers can access the structure simultaneously, because reading does not modify it; a writer must have exclusive access, because it changes the structure while others may be reading it.
The synchronization requirements are:
- Any number of readers may read simultaneously.
- At most one writer may write at a time.
- A writer must not write while any reader is reading.
- A reader must not read while a writer is writing.
A semaphore solution uses a binary semaphore
wrt (for "write") that is held by a writer and
by the readers collectively, and a counting semaphore
mutex that protects a reader count:
sem_t wrt; /* initialized to 1 */
sem_t mutex; /* initialized to 1, protects read_count */
int read_count = 0;
/* Writer */
sem_wait(&wrt);
/* write the data structure */
sem_post(&wrt);
/* Reader */
sem_wait(&mutex);
read_count++;
if (read_count == 1)
sem_wait(&wrt); /* first reader locks out writers */
sem_post(&mutex);
/* read the data structure */
sem_wait(&mutex);
read_count--;
if (read_count == 0)
sem_post(&wrt); /* last reader lets writers in */
sem_post(&mutex);
The first reader acquires wrt, which blocks
any writer. Subsequent readers increment the count and
proceed without touching wrt. The last reader
releases wrt, allowing a writer to proceed.
The mutex semaphore protects the count so that
the increments and decrements are atomic.
This solution is reader-preferring: a writer that arrives while readers are active must wait until all current readers have finished. If new readers keep arriving, the writer can wait indefinitely — a starvation problem. A writer-preferring variant gives writers priority: once a writer is waiting, new readers must wait until the writer has completed. A third variant gives neither side priority and relies on the scheduler for fairness.
4.8 POSIX semaphores
POSIX defines two kinds of semaphore: unnamed semaphores, which live in memory and are shared between threads of the same process (or between processes via a shared memory region), and named semaphores, which are identified by a name in the file system and can be shared between unrelated processes.
The unnamed interface is:
#include <semaphore.h>
sem_t s;
sem_init(&s, pshared, value);
/* pshared = 0: shared between threads of one process */
/* pshared = 1: shared between processes (via shared memory) */
/* value: the initial value of the semaphore */
sem_wait(&s); /* P */
sem_post(&s); /* V */
sem_getvalue(&s, &val); /* read the current value (best-effort) */
sem_destroy(&s); /* release the semaphore */
The named interface uses the file system:
sem_t *s = sem_open("/my_sem", O_CREAT, 0644, 1);
sem_wait(s);
sem_post(s);
sem_close(s);
sem_unlink("/my_sem"); /* remove the name */
Named semaphores are useful for synchronizing unrelated processes, for example a client and a server, or two command-line tools that share a resource. Unnamed semaphores are the right choice for threads within a single process, and they are slightly faster because they do not involve the file system.
The POSIX implementation in glibc uses a futex-like design:
the value of the semaphore is stored in user space, and the
kernel is involved only when a thread must block or be
woken. An uncontended sem_wait or
sem_post is a small number of atomic
instructions; a contended one enters the kernel.
4.9 Common pitfalls
Semaphores are flexible but unforgiving. Because they have no ownership semantics and no type checking, mistakes produce subtle bugs that are hard to diagnose. The following are the most common.
Forgetting to signal
A thread that waits on a semaphore but never signals it leaves every subsequent waiter blocked forever. This is the semaphore analogue of forgetting to release a lock.
Signalling too many times
An extra sem_post increments the semaphore
beyond its intended value. A semaphore meant to count
free slots may report more slots than exist, allowing
producers to overwrite items the consumers have not yet
removed.
Reversing the order of the waits
As shown in section 4.6, waiting on the mutex before the counting semaphore deadlocks the producer–consumer solution. Always wait on the counting semaphore first.
Using a semaphore where a lock is intended
A binary semaphore is functionally equivalent to a lock for mutual exclusion, but it has no ownership. Any thread can post a semaphore, which is a source of bugs if the wrong thread does so. Use a mutex when ownership matters.
Using a lock where a semaphore is intended
A lock cannot express signalling: a wait for a condition that another thread will make true. Attempting to build producer–consumer or reader–writer with a lock alone leads to deadlock or busy-waiting.
Destroying a semaphore with waiters
Calling sem_destroy on a semaphore with
blocked waiters is undefined behaviour. Ensure that all
threads have completed before destroying the semaphore.
Reinitializing a semaphore in use
Calling sem_init on a semaphore that
already has waiters corrupts the state. Initialize once,
before any thread uses the semaphore.
Assuming the wakeup order is FIFO
POSIX does not specify the order in which blocked threads are woken. A semaphore is not a fair queue; a thread that has been waiting a long time may be woken after one that arrived later.
4.10 Semaphores versus locks and condition variables
It is worth placing the semaphore in the context of the other primitives in this unit.
| Primitive | Purpose | Ownership | Can be used for signalling? |
|---|---|---|---|
| Spinlock | Mutual exclusion for very short critical sections | Implicit — the holder releases | No |
| Mutex (lock) | Mutual exclusion for general critical sections | Yes — only the holder may release | No |
| Semaphore | Mutual exclusion, signalling, or resource counting | No — any thread may signal | Yes |
| Condition variable | Waiting for a condition within a critical section | No — wait and signal are not paired | Yes, but always used with a mutex |
The semaphore is the most general of these primitives. It can do everything a mutex can (a binary semaphore used for mutual exclusion), and it can do things a mutex cannot (signalling, counting). Its flexibility is also its weakness: the same flexibility that allows a semaphore to solve many problems makes it easy to misuse. A mutex or a condition variable is more constrained, which makes the correct usage easier to see.
The general guidance in modern concurrent programming is: use a mutex for mutual exclusion, a condition variable for waiting on a condition, and a semaphore for the specific problems it solves best — signalling a one-shot event, counting the units of a resource, and the classic producer–consumer and reader–writer problems. Do not use a semaphore for mutual exclusion when a mutex is available: the ownership semantics of the mutex catch errors that a semaphore will silently allow.
4.11 Common misconceptions
"A semaphore is just a lock."
A binary semaphore is equivalent to a lock for mutual exclusion, but a counting semaphore is a different abstraction. The semaphore can signal between threads, which a lock cannot.
"The value of a semaphore is the number of waiters."
The value of a counting semaphore is the number of available units of a resource. The number of waiters is a separate quantity that is not exposed by the standard interface.
"P and V must be called by the same thread."
Unlike a mutex, a semaphore has no ownership. One thread may call P and a different thread may call V. This is the basis of signalling.
"A semaphore can be signalled by an interrupt handler."
In general, sem_post is not async-signal-safe
and must not be called from a signal handler. The
correct primitive for signalling from an interrupt
handler or signal handler is sem_post
only if the implementation guarantees it; typically a
different mechanism is used.
"More semaphores means better synchronization."
Each semaphore is a source of potential deadlock if the acquisition order is not consistent. Use the minimum number of semaphores that solves the problem, and document the acquisition order.
"The buffer in producer–consumer needs only two semaphores."
With a single producer and a single consumer, the
in and out indices are
private, so the mutex is unnecessary. With multiple
producers or consumers, it is required.
05 Worked example
The following example traces the bounded-buffer producer–consumer solution through a sequence of operations, showing the values of all three semaphores and the state of the buffer at each step.
Producer–consumer with a three-slot buffer
The buffer has N = 3 slots. The semaphores are
initialized as empty = 3,
full = 0, and mutex = 1. The
buffer starts empty. We trace the operations of one
producer P and one consumer C.
| Step | Thread | Operation | empty | full | mutex | buffered |
|---|---|---|---|---|---|---|
| 0 | — | initial | 3 | 0 | 1 | 0 |
| 1 | P | sem_wait(empty) | 2 | 0 | 1 | 0 |
| 2 | P | sem_wait(mutex) | 2 | 0 | 0 | 0 |
| 3 | P | insert item, in++ | 2 | 0 | 0 | 1 |
| 4 | P | sem_post(mutex) | 2 | 0 | 1 | 1 |
| 5 | P | sem_post(full) | 2 | 1 | 1 | 1 |
| 6 | C | sem_wait(full) | 2 | 0 | 1 | 1 |
| 7 | C | sem_wait(mutex) | 2 | 0 | 0 | 1 |
| 8 | C | remove item, out++ | 2 | 0 | 0 | 0 |
| 9 | C | sem_post(mutex) | 2 | 0 | 1 | 0 |
| 10 | C | sem_post(empty) | 3 | 0 | 1 | 0 |
| 11 | P | sem_wait(empty) | 2 | 0 | 1 | 0 |
| 12 | P | sem_wait(mutex) | 2 | 0 | 0 | 0 |
| 13 | P | insert item, in++ | 2 | 0 | 0 | 1 |
| 14 | P | sem_post(mutex) | 2 | 0 | 1 | 1 |
| 15 | P | sem_post(full) | 2 | 1 | 1 | 1 |
At each step, the invariant
empty + full = N holds. The producer
decrements empty and increments
full; the consumer does the opposite. The
mutex semaphore is held only briefly, for
the actual update of the buffer. The buffer's contents
change only while mutex = 0.
The case of a full buffer
Suppose the buffer is full: empty = 0,
full = 3. A producer calls
sem_wait(&empty). Since the value is 0,
the producer blocks and is added to the queue for
empty. It does not hold the mutex, so the
consumer is free to proceed. When the consumer removes an
item, it calls sem_post(&empty), which
increments empty to 1 and wakes the
producer. The producer then calls
sem_wait(&mutex), acquires the mutex,
inserts its item, and proceeds.
Compare this to the incorrect order, where the producer
would hold the mutex while blocked on empty.
The consumer would be unable to acquire the mutex to
remove an item, and the system would deadlock. This is
the scenario from section 4.6.
The case of multiple producers and consumers
The solution above assumes a single producer and a single
consumer. With multiple producers or multiple consumers,
the mutex semaphore is essential. Without
it, two producers might both read in,
both write to the same slot, and both increment
in; one of the two inserted items would be
lost, and the buffer would contain a corrupted slot. The
same applies to consumers.
The mutual exclusion provided by mutex
applies only to the buffer structure — the indices
in and out and the slots they
point to. The empty and full
semaphores handle the producer–consumer coordination;
they are not a substitute for the mutex.
Reader–writer trace
The reader–writer solution can be traced in a similar way.
Initially wrt = 1, mutex = 1,
and read_count = 0. The trace below shows
two readers and one writer.
| Step | Thread | Operation | wrt | mutex | read_count | State |
|---|---|---|---|---|---|---|
| 0 | — | initial | 1 | 1 | 0 | idle |
| 1 | R1 | wait(mutex) | 1 | 0 | 0 | — |
| 2 | R1 | read_count++ | 1 | 0 | 1 | — |
| 3 | R1 | count == 1: wait(wrt) | 0 | 0 | 1 | reader holds wrt |
| 4 | R1 | post(mutex) | 0 | 1 | 1 | reading |
| 5 | R2 | wait(mutex) | 0 | 0 | 1 | — |
| 6 | R2 | read_count++ | 0 | 0 | 2 | — |
| 7 | R2 | count != 1: no wrt | 0 | 0 | 2 | — |
| 8 | R2 | post(mutex) | 0 | 1 | 2 | two readers |
| 9 | W | wait(wrt) | 0 | 1 | 2 | writer blocks |
| 10 | R1 | wait(mutex) | 0 | 0 | 2 | — |
| 11 | R1 | read_count-- | 0 | 0 | 1 | — |
| 12 | R1 | count != 0: no post | 0 | 0 | 1 | — |
| 13 | R1 | post(mutex) | 0 | 1 | 1 | — |
| 14 | R2 | wait(mutex) | 0 | 0 | 1 | — |
| 15 | R2 | read_count-- | 0 | 0 | 0 | — |
| 16 | R2 | count == 0: post(wrt) | 1 | 0 | 0 | writer may proceed |
| 17 | R2 | post(mutex) | 1 | 1 | 0 | — |
| 18 | W | acquires wrt, writes | 0 | 1 | 0 | writer holds wrt |
| 19 | W | post(wrt) | 1 | 1 | 0 | idle |
The trace illustrates the reader-preferring behaviour.
When R1 and R2 are both reading, the writer W blocks on
wrt. The writer can proceed only after R2 —
the last reader — releases wrt. If new
readers keep arriving, the writer waits indefinitely;
this is the starvation problem mentioned in section 4.7.
Correctness argument for the producer–consumer solution
Three properties must hold. Mutual
exclusion: the buffer is accessed only while
mutex is held; since mutex is
a binary semaphore, at most one thread holds it at a time.
No overwrite of a full buffer: a producer
decrements empty before writing. The value
of empty is the number of free slots, so a
producer can proceed only if a slot is free. When the
buffer is full, empty == 0, and the producer
blocks. No read from an empty buffer: a
consumer decrements full before reading. The
value of full is the number of filled slots,
so a consumer can proceed only if an item is available.
When the buffer is empty, full == 0, and the
consumer blocks. Together these three properties ensure
that the buffer never overflows, never underflows, and
is never accessed concurrently by two threads that would
corrupt it.
06 Interactive activity
Activity · implement the classic semaphore problems
This activity lets you implement the bounded-buffer producer–consumer problem and the reader–writer problem with POSIX semaphores, verify their correctness, and observe the pitfalls. Work through the steps in your course VM.
-
Implement a bounded buffer with semaphores.
Write a program with one producer thread and one
consumer thread sharing a buffer of five slots. Use
three semaphores —
empty,full, andmutex— as in the worked example. The producer generates the integers 0 to 99 and inserts them; the consumer removes them and verifies that they arrive in order.
Compile with#include <semaphore.h> #define N 5 sem_t empty, full, mutex; int buffer[N]; int in = 0, out = 0; void *producer(void *arg) { for (int i = 0; i < 100; i++) { sem_wait(&empty); sem_wait(&mutex); buffer[in] = i; in = (in + 1) % N; sem_post(&mutex); sem_post(&full); } return NULL; } void *consumer(void *arg) { for (int i = 0; i < 100; i++) { sem_wait(&full); sem_wait(&mutex); int item = buffer[out]; out = (out + 1) % N; sem_post(&mutex); sem_post(&empty); if (item != i) fprintf(stderr, "out of order: got %d, expected %d\n", item, i); } return NULL; }gcc -O2 -pthread -o pc pc.cand run. The program should produce no error output, confirming that the items are consumed in order. -
Increase the number of producers and consumers.
Modify the program to use multiple producer and
consumer threads. Verify that the program still works.
Then remove the
mutexsemaphore and run again. What happens? Do you see corrupted data, lost items, or both? Explain the result. -
Introduce the reverse-order deadlock.
Modify the producer to wait on
mutexbeforeempty, and the consumer to wait onmutexbeforefull. Run the program with a small buffer and many items. What happens? Usegdbto attach to the hung process and inspect the state of the threads.
The backtraces should show both threads blocked ingdb -p $(pgrep pc) (gdb) thread apply all bt (gdb) quitsem_wait, one on the counting semaphore and one on the mutex. Restore the correct order. -
Measure the throughput.
Time the program with one producer and one consumer,
then with two of each, four of each, and eight of each.
Record the elapsed time for 100,000 items. How does the
throughput scale with the number of threads? At what
point does the
mutexsemaphore become the bottleneck? What does this suggest about the buffer size and the contention on the lock? -
Implement the reader–writer problem.
Write a program with several reader threads and several
writer threads sharing a counter. Use the
semaphore-based solution from section 4.7. Readers
read the counter and verify that it is non-negative;
writers increment it. Run for a few seconds and verify
that no reader ever sees a negative value and that no
two writers corrupt the counter.
Then modify the program to remove thesem_t wrt, mutex; int read_count = 0; int shared = 0;mutexthat protectsread_count. Run it under ThreadSanitizer. What does the tool report? - Observe writer starvation. In the reader–writer program, run many reader threads and a small number of writers, with the readers looping continuously. Time how long a writer must wait to acquire the lock. Increase the number of readers and observe the increase in the writer's waiting time. What does this demonstrate about the reader-preferring variant? How would you modify the solution to give writers priority?
- Compare with a mutex-only solution. Replace the reader–writer semaphore solution with a single mutex that protects all accesses. Measure the throughput with a read-heavy workload (many readers, few writers) in both versions. Which is faster? Then measure with a write-heavy workload. Which is faster now? Explain the crossover.
-
Use a named semaphore.
Write two small programs that synchronize with each
other through a named semaphore. The first program
creates the semaphore with
sem_open, does some work, and posts. The second opens the same semaphore, waits, and reports that the work is done. Run them from two terminals.
This is the mechanism used to synchronize unrelated processes; the unnamed semaphore cannot be shared across processes without a shared memory region.sem_t *s = sem_open("/my_sync", O_CREAT, 0644, 0); /* ... */ sem_post(s); sem_close(s); sem_unlink("/my_sync");
Reflect: the producer–consumer and reader–writer problems are over fifty years old, but they still appear — in different forms — in every concurrent program. A thread pool with a work queue is a producer–consumer; a cache with concurrent readers and occasional updates is a reader–writer. The semaphore solution is one of several; a condition variable solution (the subject of Lesson 6.6) is often cleaner. Which approach would you choose for a modern web server that accepts requests from many clients and processes them with a pool of worker threads? What are the trade-offs?
07 Knowledge check
Check your understanding
Question 1
Define a semaphore. What are the semantics of the P and V operations? How does a binary semaphore differ from a counting semaphore?
Show answer
wait, down, or
acquire) decrements the value if it is
positive, or blocks the calling thread if the value
is zero. V (also
signal, up, or
release) increments the value and wakes
one blocked thread if any are waiting. A
binary semaphore is restricted to
the values 0 and 1 and is used for mutual exclusion,
exactly like a lock: initialized to 1, P acquires and
V releases. A counting semaphore can
take any non-negative value and is used to count
available units of a resource: initialized to the
number of units, P takes a unit (blocking if none is
available) and V returns a unit. The value of the
semaphore is always non-negative; the number of
blocked threads is separate and is not exposed by the
standard interface.
Question 2
Explain how a semaphore can be used for signalling. What property does a semaphore have that a lock does not, and why is it necessary?
Show answer
full even if no consumer is
currently waiting, and a consumer that arrives later
will find the signal. Without this property, the
producer would have to wait for a consumer to be
ready before producing, which defeats the purpose of
the buffer.
Question 3
Write the producer and consumer loops of the bounded-buffer solution using three semaphores. Explain the role of each semaphore. What happens if the order of the two waits in the producer is reversed?
Show answer
empty (counts
free slots, initialized to N), full
(counts filled slots, initialized to 0), and
mutex (protects the buffer structure,
initialized to 1). The producer loop:
sem_wait(&empty);
sem_wait(&mutex);
buffer[in] = item;
in = (in + 1) % N;
sem_post(&mutex);
sem_post(&full);
The consumer loop:
sem_wait(&full);
sem_wait(&mutex);
item = buffer[out];
out = (out + 1) % N;
sem_post(&mutex);
sem_post(&empty);
The empty and full
semaphores perform the signalling between producer
and consumer; the mutex semaphore
performs the mutual exclusion on the buffer's
indices and slots. If the order of the two waits in
the producer is reversed — waiting on
mutex before empty — the
solution deadlocks. Suppose the buffer is full and a
producer reaches the reverse-ordered code. It
acquires the mutex and then blocks on
empty, because there are no free slots.
A consumer arrives, calls sem_wait(&full)
(succeeds, since there are filled slots), and then
calls sem_wait(&mutex) — which blocks,
because the producer holds it. The consumer cannot
remove an item, so the producer cannot proceed, and
the system makes no progress. The general rule is to
wait on the counting semaphore first (the outer
resource), then on the mutex (the inner resource).
Question 4
Describe the reader–writer problem and the reader-preferring semaphore solution. What is the starvation problem in this solution, and how would you modify it to give writers priority?
Show answer
wrt and a counting
semaphore mutex protecting a
read_count. A writer acquires
wrt for the duration of its update. The
first reader to arrive acquires wrt,
blocking any writer; subsequent readers increment
read_count and proceed without touching
wrt; the last reader to leave releases
wrt, allowing a waiting writer to
proceed. The starvation problem is
that a writer that arrives while readers are active
must wait until all current readers have finished.
If new readers keep arriving, the writer can wait
indefinitely, because each new reader increments
read_count and keeps
wrt held. To give writers priority, add
a second semaphore, say turnstile,
acquired by readers at entry and released at exit:
once a writer arrives and acquires the turnstile,
new readers block behind it, and the current readers
drain. The writer then proceeds. This gives writers
precedence over subsequent readers, at the cost of
potentially starving readers if writers arrive
continuously. A third variant gives neither side
priority and relies on the scheduler for fairness.
Question 5
Compare semaphores with mutexes and condition variables. When should each be used? What is the danger of using a semaphore for mutual exclusion?
Show answer
08 Unit discussion
Discussion prompt
Semaphores are one of the oldest synchronization primitives — Dijkstra introduced them in 1965 — and they are still in use today. But the field has moved on. Some languages (Java, C#) prefer monitors and synchronized methods, which are language-level constructs the compiler can check. Others (Go, Erlang) prefer message passing over shared memory, on the principle that "do not communicate by sharing memory; share memory by communicating". Still others (Rust) use the type system to enforce synchronization discipline at compile time.
Semaphores are low-level: they are untyped, unowned, and unchecked. The compiler cannot tell whether you have forgotten a post, signalled too many times, or acquired two semaphores in the wrong order. Every one of these errors is silent and produces a bug that may not manifest for hours or days. And yet semaphores remain in wide use, because they are simple, portable, and fast.
Is this the right trade-off? Should modern programming languages hide semaphores behind safer abstractions, or expose them for the flexibility they provide? What would a safer semaphore look like — a type that knows which resource it protects, or a discipline that the compiler can verify? Or is the low-level flexibility of semaphores essential for the performance-critical code where they are most needed?
Post your response to the Unit 6 discussion forum and reply to at least two classmates. Supports CLO2, CLO3, and CLO8.
09 Practice exercise
This ungraded exercise consolidates the lesson and prepares you for Lesson 6.5. Aim to spend 90–120 minutes on it.
-
Trace the producer–consumer.
Using a buffer of N = 4 slots with semaphores
initialized to
empty = 4,full = 0,mutex = 1, trace the following sequence of operations. Show the values of all three semaphores and the number of buffered items after each step.- P: wait(empty); wait(mutex); insert; post(mutex); post(full)
- P: wait(empty); wait(mutex); insert; post(mutex); post(full)
- C: wait(full); wait(mutex); remove; post(mutex); post(empty)
- P: wait(empty); wait(mutex); insert; post(mutex); post(full)
- P: wait(empty); wait(mutex); insert; post(mutex); post(full)
- P: wait(empty); wait(mutex); …
- Implement the producer–consumer in C. Write the program from the interactive activity with POSIX semaphores. Compile and run it with one producer and one consumer, and verify that the items arrive in order. Then run it with four producers and four consumers and verify that the total number of items produced equals the total number consumed.
-
Demonstrate the reverse-order deadlock.
Modify the producer to wait on
mutexbeforeempty, and the consumer to wait onmutexbeforefull. Run with a small buffer and verify that the program hangs. Usegdbto attach and inspect the state of the threads. Write a paragraph explaining which semaphore each thread is blocked on and why the system cannot make progress. -
Implement the reader–writer problem.
Write the reader-preferring solution from section 4.7
with POSIX semaphores. Test with three readers and two
writers running for five seconds. Use ThreadSanitizer
to verify that no data race occurs on the shared data
structure or on the
read_countvariable. - Design a writer-preferring variant. Modify the reader–writer solution to give writers priority, as described in the answer to question 4 of the knowledge check. Test with a workload where writers arrive frequently and verify that no writer waits indefinitely. Then test with a workload where readers arrive frequently. Which side is now at risk of starvation?
- Measure the throughput of the two variants. Time the reader-preferring and writer-preferring solutions on the same workload: ten readers and one writer, each performing one million operations. Which variant completes first? Which better matches the needs of the workload? Explain the result in terms of the priorities of the two variants.
- Use a named semaphore. Write two programs that synchronize through a named semaphore. The first creates the semaphore, sleeps for a second, and posts. The second opens the semaphore, waits, and reports the elapsed time. Run the two programs from separate terminals to demonstrate that named semaphores synchronize unrelated processes.
- Reflect. In your own words, explain why a semaphore can be used for signalling but a mutex cannot. Then describe a problem in your own experience — a thread pool, a message queue, a cache — that is an instance of the producer–consumer or reader–writer pattern. What would the semaphores be in that problem, and what would the values be at initialization?
10 Summary
-
A semaphore is an integer variable with
two atomic operations: P
(
wait) decrements the value or blocks, and V (signal) increments the value and wakes a blocked thread if any. - A binary semaphore (values 0 and 1) is equivalent to a lock for mutual exclusion. A counting semaphore (any non-negative value) counts the available units of a resource.
- A semaphore has no ownership: any thread may signal. This is what makes it useful for signalling between threads, and it is what makes it easy to misuse.
- The signalling use of a semaphore is what distinguishes it from a lock: a signal posted before a wait is remembered in the value, so the wait does not block. A lock has no such memory.
-
The bounded-buffer producer–consumer problem
is solved with three semaphores:
empty(free slots),full(filled slots), andmutex(buffer protection). The order of the waits is critical — the counting semaphore must be waited on before the mutex. -
Reversing the order of the waits in the producer or
consumer causes a deadlock: the producer
holds the mutex while blocked on
empty, and the consumer cannot acquire the mutex to free a slot. -
The reader–writer problem is solved with
a binary semaphore
wrtand a counting semaphoremutexprotecting aread_count. The first reader acquireswrt, the last releases it, and multiple readers proceed in between. - The reader-preferring variant can starve writers; a writer-preferring variant uses an additional turnstile semaphore to give writers priority, at the cost of potentially starving readers.
- POSIX defines both unnamed semaphores (for threads within a process, or processes sharing memory) and named semaphores (for unrelated processes). The implementation is a hybrid similar to the futex: fast in user space, kernel-involved only under contention.
- Semaphores are more general than mutexes but also more error-prone. The classic pitfalls are forgetting to signal, signalling too many times, and reversing the order of the waits. Use a mutex when mutual exclusion is all you need.
Looking ahead
This lesson presented the semaphore, the most general of
the classical synchronization primitives. Lesson 6.5
presents the monitor: a language-level
abstraction that packages a shared data structure, the
mutex that protects it, and the operations on it into a
single unit. A monitor enforces mutual exclusion
automatically — the programmer does not call
lock and unlock — and it
provides the foundation for the condition variables
examined in Lesson 6.6. The monitor is the primitive
that most modern languages use, because it is easier to
use correctly than a semaphore and its protection is
enforced by the language rather than by the programmer's
discipline.
11 References
- Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 6, Sections 6.6–6.8.
-
Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C.
Operating Systems: Three Easy Pieces, Chapter 31.
Available online at
ostep.org. - Tanenbaum, A. S., & Bos, H. Modern Operating Systems, 4th ed., Chapter 2, Sections 2.3.4–2.3.5.
- Dijkstra, E. W. Cooperating Sequential Processes. In Programming Languages, ed. F. Genuys, Academic Press, 1968. The original paper introducing the semaphore.
- Courtois, P. J., Heymans, F., & Parnas, D. L. Concurrent Control with Readers and Writers. Communications of the ACM, 1971. The original reader–writer paper, including the writer-preferring variant.
- Love, R. Linux Kernel Development, 3rd ed., Chapter 9, Kernel Synchronization Methods. The Linux kernel's completion variables and reader-writer semaphores.
-
Linux man-pages:
man 7 sem_overview,man 3 sem_init,man 3 sem_wait,man 3 sem_post,man 3 sem_destroy,man 3 sem_open,man 3 sem_close,man 3 sem_unlink,man 3 sem_getvalue.