COMP 3150Operating Systems

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 called wait(s), down(s), or acquire(s)
If s > 0, decrement s and return immediately. If s == 0, block the calling thread until s becomes positive, then decrement s and 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 called signal(s), up(s), or release(s)
Increment s by 1. If one or more threads are blocked in P on s, 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.

Semaphore operations P and V A diagram showing a semaphore as an integer variable with two atomic operations. P (wait) decrements the value if it is positive, or blocks the calling thread if the value is zero. V (signal) increments the value and wakes one blocked thread if any are waiting. A queue of blocked threads is shown below the semaphore, and the diagrams illustrate both the case where a P finds the semaphore positive and the case where it finds it zero. THE SEMAPHORE ABSTRACTION semaphore s integer value, s ≥ 0 P(s) / wait(s) if s > 0: s = s − 1; return if s == 0: block until s > 0 V(s) / signal(s) s = s + 1 wake one blocked thread if any queue of blocked threads T1 T2 T3 … Two cases for P s > 0: decrement s, return immediately. The caller takes a unit of the resource. s == 0: block the caller and add it to the queue. A later V wakes one of the blocked threads.
Figure 6.4 — The semaphore abstraction. A semaphore is an integer with two atomic operations. P decrements if the value is positive, or blocks the caller if the value is zero. V increments the value and wakes one blocked thread if any are waiting. The value of the semaphore represents the number of available units of a resource, or the number of pending signals.

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.

The bounded-buffer producer-consumer problem A diagram showing a circular buffer of five slots between a producer and a consumer. The producer places items into the buffer at the in index; the 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 producer waits on empty, acquires mutex, inserts, releases mutex, and posts full. The consumer waits on full, acquires mutex, removes, releases mutex, and posts empty. THE BOUNDED-BUFFER PRODUCER–CONSUMER PROBLEM Producer generate items Consumer process items buffer of N = 5 slots (circular) item item item empty empty ↑ in ↑ out three semaphores coordinate the interaction sem_t empty counts free slots initial value = N = 5 sem_t full counts filled slots initial value = 0 sem_t mutex protects the buffer initial value = 1 Producer sem_wait(&empty) → sem_wait(&mutex) → insert item → sem_post(&mutex) → sem_post(&full) Consumer sem_wait(&full) → sem_wait(&mutex) → remove item → sem_post(&mutex) → sem_post(&empty) The order of the waits matters. Waiting on mutex before empty (or full) causes deadlock: the producer would hold the lock while waiting for a free slot, and the consumer would be unable to acquire the lock to free one. Always wait on the counting semaphore first, then on the mutex.
Figure 6.4b — The bounded-buffer producer–consumer problem. A producer inserts items at the 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.

Comparing synchronization primitives
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—initial3010
1Psem_wait(empty)2010
2Psem_wait(mutex)2000
3Pinsert item, in++2001
4Psem_post(mutex)2011
5Psem_post(full)2111
6Csem_wait(full)2011
7Csem_wait(mutex)2001
8Cremove item, out++2000
9Csem_post(mutex)2010
10Csem_post(empty)3010
11Psem_wait(empty)2010
12Psem_wait(mutex)2000
13Pinsert item, in++2001
14Psem_post(mutex)2011
15Psem_post(full)2111

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—initial110idle
1R1wait(mutex)100—
2R1read_count++101—
3R1count == 1: wait(wrt)001reader holds wrt
4R1post(mutex)011reading
5R2wait(mutex)001—
6R2read_count++002—
7R2count != 1: no wrt002—
8R2post(mutex)012two readers
9Wwait(wrt)012writer blocks
10R1wait(mutex)002—
11R1read_count--001—
12R1count != 0: no post001—
13R1post(mutex)011—
14R2wait(mutex)001—
15R2read_count--000—
16R2count == 0: post(wrt)100writer may proceed
17R2post(mutex)110—
18Wacquires wrt, writes010writer holds wrt
19Wpost(wrt)110idle

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.

  1. 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, and mutex — 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.
    #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;
    }
    Compile with gcc -O2 -pthread -o pc pc.c and run. The program should produce no error output, confirming that the items are consumed in order.
  2. 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 mutex semaphore and run again. What happens? Do you see corrupted data, lost items, or both? Explain the result.
  3. Introduce the reverse-order deadlock. Modify the producer to wait on mutex before empty, and the consumer to wait on mutex before full. Run the program with a small buffer and many items. What happens? Use gdb to attach to the hung process and inspect the state of the threads.
    gdb -p $(pgrep pc)
    (gdb) thread apply all bt
    (gdb) quit
    The backtraces should show both threads blocked in sem_wait, one on the counting semaphore and one on the mutex. Restore the correct order.
  4. 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 mutex semaphore become the bottleneck? What does this suggest about the buffer size and the contention on the lock?
  5. 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.
    sem_t wrt, mutex;
    int read_count = 0;
    int shared = 0;
    Then modify the program to remove the mutex that protects read_count. Run it under ThreadSanitizer. What does the tool report?
  6. 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?
  7. 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.
  8. 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.
    sem_t *s = sem_open("/my_sync", O_CREAT, 0644, 0);
    /* ... */
    sem_post(s);
    sem_close(s);
    sem_unlink("/my_sync");
    This is the mechanism used to synchronize unrelated processes; the unnamed semaphore cannot be shared across processes without a shared memory region.

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
A semaphore is an integer variable that can be accessed only through two atomic operations. P (also 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
A semaphore is used for signalling by initializing it to 0 and having one thread call V to signal an event and another call P to wait for it. If the waiting thread reaches P before the signalling thread reaches V, it blocks; when V is called, it is woken. If the signalling thread reaches V first, the value becomes 1, and the subsequent P succeeds immediately without blocking — the signal is remembered. The property a semaphore has that a lock does not is this memory of pending signals. A lock has no memory: a release that happens before an acquire has no effect, and an acquire that happens before a release blocks until the release. A semaphore, by contrast, accumulates its signals in its value. This is what makes the producer–consumer and reader–writer solutions possible: the producer can post 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
The three semaphores are 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
In the reader–writer problem, a shared data structure is accessed by readers, which only read it, and writers, which modify it. Multiple readers may read simultaneously; a writer must have exclusive access. The reader-preferring solution uses a binary semaphore 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
A mutex is designed for mutual exclusion: it has two operations, acquire and release, it is owned by the thread that holds it, and only that thread may release it. A condition variable is designed for waiting on a condition within a critical section: it is used together with a mutex, and it has no memory — a signal that arrives before a wait is lost. A semaphore is a more general primitive: it can enforce mutual exclusion (a binary semaphore), signal between threads (a semaphore initialized to 0), or count resources (a counting semaphore). It has no ownership — any thread may signal — and it does remember pending signals in its value. The guidance is to use a mutex for mutual exclusion, a condition variable for waiting on a condition, and a semaphore for the problems it solves best: one-shot signalling, counting resources, and the classic producer–consumer and reader–writer problems. The danger of using a semaphore for mutual exclusion is that the lack of ownership removes a safety check. With a mutex, a thread that releases a lock it does not hold is a programming error that the implementation can detect (with an error-checking mutex). With a semaphore, any thread can signal, and an accidental extra signal silently corrupts the protocol — the value becomes larger than intended, and eventually the invariant it was supposed to protect is violated. The ownership semantics of a mutex catch errors that a semaphore will allow.

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.

  1. 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); …
    At the last step, the producer blocks. On which semaphore? What must happen for it to proceed?
  2. 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.
  3. Demonstrate the reverse-order deadlock. Modify the producer to wait on mutex before empty, and the consumer to wait on mutex before full. Run with a small buffer and verify that the program hangs. Use gdb to 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.
  4. 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_count variable.
  5. 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?
  6. 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.
  7. 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.
  8. 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), and mutex (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 wrt and a counting semaphore mutex protecting a read_count. The first reader acquires wrt, 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.
👤