COMP 3150Operating Systems

01 Introduction

Lessons 6.3 and 6.4 presented two synchronization primitives — the lock and the semaphore — that are provided by the operating system or its libraries. Both are powerful and both are error-prone. A lock must be acquired before every access to the shared state and released after every access, with no path through the code that forgets either. A semaphore must be signalled exactly as many times as it is waited on, in the right order, with no path that forgets or over-signals. In both cases, the correctness of the program depends on the programmer's discipline in following a protocol that the compiler cannot check.

The monitor is a different approach. It is a synchronization abstraction provided by the programming language rather than the operating system. A monitor is a module — like a class in an object-oriented language — that contains a shared data structure, the procedures that operate on it, and the synchronization that protects it. The compiler enforces the mutual exclusion: a monitor procedure is executed by at most one thread at a time, and the programmer never writes an acquire or a release at all.

The monitor was introduced by Per Brinch Hansen in 1973 and developed by Tony Hoare in 1974. It was the first widely adopted high-level synchronization abstraction, and it influenced the design of Java's synchronized methods, C#'s lock statement, and the synchronized blocks of many other languages. It is the primitive that most modern programmers actually use, even if they do not call it by that name.

This lesson presents the monitor abstraction. It defines the structure of a monitor, explains how mutual exclusion is enforced, and introduces the condition variable — the mechanism a monitor uses to wait for a condition to become true. It presents the producer–consumer problem solved with a monitor, compares the two semantics that a monitor's signal operation can have (Hoare and Mesa), and explains why C and POSIX do not provide monitors directly. The condition variable itself is the subject of Lesson 6.6, which follows this one; here we introduce it enough to write correct monitor code.

Prerequisites

This lesson builds directly on Lessons 6.1–6.4. 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 implementation technique; and the semaphore of Lesson 6.4 provides the comparison point — a monitor solves the same problems as a semaphore, with a different programming model. If the difference between a mutex and a semaphore is unclear, review the comparison table in section 4.10 of Lesson 6.4 before continuing.

02 Learning objectives

By the end of this lesson you will be able to

  • Define a monitor and describe the mutual exclusion that the language enforces automatically.
  • Distinguish the monitor's implicit synchronization from the explicit lock and semaphore protocols.
  • Use condition variables with a monitor to solve the bounded-buffer producer–consumer problem.
  • Compare Hoare semantics (signal-and-wait) with Mesa semantics (signal-and-continue) and explain the consequences of each.
  • Describe how modern languages implement monitors and why C and POSIX do not provide them directly.

This lesson contributes to U6.3, U6.4, and U6.5, and to course learning outcomes CLO2, CLO3, and CLO8.

03 Required reading

  • Operating System Concepts — Chapter 6, Section 6.7, Monitors. The standard treatment, including the producer–consumer solution with condition variables and the discussion of Hoare versus Mesa semantics.
  • Tanenbaum & Bos — Modern Operating Systems, Chapter 2, Section 2.3.7, Monitors. A complementary treatment that places the monitor in the historical context of the 1970s and includes the dining philosophers solution.
  • OSTEP — Chapter 30, Condition Variables. OSTEP treats the condition variable as the primitive and builds the monitor-style solutions around it. Read this as a preview of Lesson 6.6 and for the producer–consumer solutions using condition variables.
  • Hoare, C. A. R. — Monitors: An Operating System Structuring Concept. Communications of the ACM, 1974. The original paper on monitors and condition variables. Read the first three sections; the rest is more technical.
  • Brinch Hansen, P. — Operating System Principles, Chapter 3. The original presentation of the monitor concept from a design perspective.
  • Java Language Specification — Section 17.1, Synchronization, and Section 8.4.3, synchronized Methods. The Java implementation of monitor semantics.

Reading strategy

Read the Silberschatz section first for the formal definition and the structure of a monitor. Then read the Hoare paper for the design rationale — it is short and surprisingly readable, and it explains why the condition variable was designed the way it was. Skim OSTEP Chapter 30 for the condition-variable solutions that you will examine in more detail in Lesson 6.6. Pay particular attention to the Hoare-versus-Mesa discussion; it is the most important design decision in the monitor abstraction, and it is the one most often misunderstood.

04 Core content

4.1 The monitor abstraction

A monitor is a programming-language construct that packages three things into a single unit:

  • Shared data. The state that the monitor protects — a buffer, a counter, a queue, any data structure that requires synchronization.
  • Procedures. The operations that read and modify the shared data. These are the only operations that external code can invoke.
  • Initialization. Code that initializes the shared data when the monitor is created.

The essential property of a monitor is that at most one thread can be executing a monitor procedure at a time. The compiler or the runtime enforces this; the programmer does not write any code to make it happen. When a thread calls a monitor procedure, the runtime automatically acquires a lock on the monitor before the procedure body runs, and releases it when the procedure returns. If another thread is already inside a monitor procedure, the calling thread blocks until the monitor is free.

Structure of a monitor A diagram showing a monitor as a box containing shared data, a set of monitor procedures, and an initialization section. Threads enter through the monitor entry queue, and the runtime guarantees that only one thread is inside the monitor at a time. Inside the monitor are condition variables, each with its own wait queue. A thread that waits on a condition variable leaves the monitor and joins the wait queue; a thread that signals a condition variable moves a waiting thread back into the monitor. STRUCTURE OF A MONITOR monitor entry queue T1 T2 T3 threads waiting to enter MONITOR shared data buffer, indices, counters, … monitor procedures insert(item) · remove() · … executed by at most one thread at a time condition variable c c.wait() · c.signal() · c.broadcast() T4 T5 … waiters on c enter wait signal running thread in a monitor procedure The runtime acquires the monitor's lock on procedure entry and releases it on return. The programmer writes no lock code. A thread that calls c.wait() leaves the monitor and joins c's wait queue. A thread that calls c.signal() wakes one waiter from c's queue. A monitor may have several condition variables, each with its own wait queue, for different conditions on the same shared data. Mutual exclusion on the monitor's data is automatic. The condition variables handle waiting for conditions.
Figure 6.5 — The structure of a monitor. A monitor packages shared data, monitor procedures, and condition variables into a single unit. The runtime enforces mutual exclusion on the procedures automatically. A thread that must wait for a condition calls wait on a condition variable, which suspends it and releases the monitor; a thread that makes the condition true calls signal, which wakes one of the waiters.

4.2 The difference from locks and semaphores

The essential difference between a monitor and the lower-level primitives is where the synchronization is enforced. With a lock or a semaphore, the synchronization is the programmer's responsibility: the programmer writes the acquire and release, or the wait and signal, and is responsible for every path through the code.

With a monitor, the synchronization is part of the abstraction. The compiler or runtime inserts the acquire and release around every monitor procedure, automatically and correctly. The programmer writes only the shared data and the operations on it.

Monitors compared with locks and semaphores
Aspect Lock Semaphore Monitor
Who enforces mutual exclusion? The programmer, by writing acquire and release The programmer, by writing wait and post The language runtime, automatically
Can the compiler check it? No No Yes — mutual exclusion is structural
What if the programmer forgets? Silent race or deadlock Silent race or deadlock Impossible — the runtime inserts it
Ownership semantics Owned by the holder No ownership Owned by the entering thread
Waiting for a condition Not directly supported Via a separate semaphore Via a condition variable
Provided by OS / library OS / library Programming language

The monitor's structural guarantee is a significant advantage: a whole class of errors — forgetting to release a lock, acquiring a lock twice, acquiring the wrong lock — becomes impossible by construction. This is why languages that provide monitors are widely regarded as easier to use for concurrent programming than C and C++.

4.3 Condition variables

Mutual exclusion alone is not enough to solve the problems of the previous two lessons. The producer–consumer problem requires a producer to wait until a slot is free, and a consumer to wait until an item is available. Both waits are for a condition that another thread will make true — not merely for the monitor to be free.

A monitor provides condition variables for this purpose. A condition variable c is an object with three operations:

c.wait()
Release the monitor, suspend the calling thread, and add it to the wait queue for c. When the thread is woken (by a signal or broadcast), it re-acquires the monitor and returns from wait. The release-and-reacquire is automatic; the programmer writes only c.wait().
c.signal()
Wake one thread waiting on c, if any. If no thread is waiting, the signal has no effect — it is lost, unlike a semaphore's post, which would increment the value.
c.broadcast() (also c.notify_all())
Wake all threads waiting on c. Used when the condition that has become true may allow more than one waiter to proceed.

The central difference between a condition variable and a semaphore is that a condition variable has no memory. A signal that arrives when no thread is waiting has no effect. This is because the condition variable is always used inside a monitor, where the condition is a property of the shared data: if the condition is true, a thread that later enters the monitor will see it directly, without needing a remembered signal. The signal is only a wakeup, not a value.

A monitor may have any number of condition variables, each associated with a different condition on the shared data. In the producer–consumer solution, for example, we use one condition variable for "buffer is not full" and another for "buffer is not empty".

4.4 Producer–consumer with a monitor

The bounded-buffer producer–consumer problem, solved with a monitor, is the classic example. The monitor holds the buffer and its indices, and provides two procedures, insert and remove:

monitor BoundedBuffer {
    /* shared data */
    int  buffer[N];
    int  in = 0, out = 0, count = 0;

    /* condition variables */
    condition not_full;
    condition not_empty;

    procedure insert(int item) {
        while (count == N)
            not_full.wait();
        buffer[in] = item;
        in = (in + 1) % N;
        count++;
        not_empty.signal();
    }

    procedure remove() {
        while (count == 0)
            not_empty.wait();
        int item = buffer[out];
        out = (out + 1) % N;
        count--;
        not_full.signal();
        return item;
    }
}

A producer calls insert(item) and a consumer calls remove(). Neither calls any synchronization operation directly: the runtime acquires the monitor's lock on entry to the procedure and releases it on exit. The wait calls handle the cases where the buffer is full or empty; the signal calls notify a waiting thread that the condition has changed.

Compare this to the semaphore solution from Lesson 6.4. The monitor version is shorter, clearer, and has no explicit locking. The synchronization is expressed entirely in terms of the conditions the procedures must satisfy — count < N to insert, count > 0 to remove — and the wait-loop for each condition is local to the procedure that checks it. The semaphore version required the programmer to reason about three separate semaphore values and the order of the waits.

Always wait in a loop

The wait is written inside a while loop, not an if. This is essential. When a waiting thread is woken by a signal, the condition it was waiting for may no longer hold — another thread may have run first and consumed the item, or the scheduler may have woken it spuriously. Re-checking the condition after the wait ensures that the thread proceeds only when the condition is actually true. A wait inside an if is one of the most common bugs in monitor-style code.

4.5 Hoare versus Mesa semantics

When a thread calls c.signal(), one of the waiters on c is woken. But there are two possible semantics for what happens next, and the choice between them has substantial consequences for how monitor code must be written.

Hoare semantics (signal-and-wait)
The signalling thread immediately gives up the monitor to the woken thread, which runs as soon as the signaller suspends. The signaller is placed in a special queue and resumes when the woken thread leaves the monitor or waits again. The woken thread can be sure that the condition it was waiting for is still true when it wakes, because no other thread has had a chance to change it.
Mesa semantics (signal-and-continue)
The signalling thread continues executing, and the woken thread is moved to the monitor entry queue, where it must compete with new arrivals for the monitor. By the time the woken thread actually runs, other threads may have entered the monitor and changed the shared data. The condition it was waiting for may no longer hold.
Hoare versus Mesa signal semantics Two timelines compare Hoare and Mesa signal semantics for three threads inside a monitor. In Hoare semantics, the signaller immediately gives up the monitor to the woken thread, which runs before any other thread. In Mesa semantics, the signaller continues running, and the woken thread competes with other threads for the monitor, so the condition it was waiting for may no longer hold when it runs. HOARE VERSUS MESA SIGNAL SEMANTICS Hoare (signal-and-wait) S (signaller) running suspended resumes W (woken) waiting on c runs immediately O (other) cannot enter — signaller and woken thread hold the monitor The condition is guaranteed to hold when W runs. No re-check is required. Mesa (signal-and-continue) S (signaller) continues running W (woken) waiting on c entry queue eventually runs O (other) entry queue runs may change state The condition may no longer hold when W runs. The wait must always be in a while loop. Java, C#, and most modern monitors use Mesa semantics. Hoare semantics is pedagogically important but rare in practice.
Figure 6.5b — Hoare versus Mesa signal semantics. Under Hoare semantics, the signaller immediately yields the monitor to the woken thread, so the condition is guaranteed to hold when the woken thread runs. Under Mesa semantics, the signaller continues and the woken thread competes for the monitor, so other threads may change the state before the woken thread runs. The wait must always be inside a while loop under Mesa semantics.

Hoare semantics is simpler to reason about: a thread that wakes from wait knows that the condition it was waiting for is true, so an if would suffice. But Hoare semantics is harder to implement — the signaller must be suspended and resumed with the correct priority — and it leads to more context switches, because every signal that wakes a thread causes the signaller to be suspended. For these reasons, virtually every real implementation of monitors uses Mesa semantics.

The consequence is that every wait must be inside a while loop. Under Mesa semantics, the woken thread must re-check the condition after it re-acquires the monitor, because another thread may have consumed the item or filled the slot in the meantime. This is why the producer–consumer code above uses while (count == N) rather than if (count == N). The while loop is correct under both semantics; the if is correct only under Hoare.

4.6 signal versus broadcast

A condition variable has two wakeup operations. The choice between them depends on how many waiters can proceed when the condition becomes true.

signal
Wake exactly one waiter. Appropriate when the condition that has become true allows exactly one waiter to make progress. In the producer–consumer solution, one inserted item allows one consumer to remove it, so a signal is correct and more efficient than a broadcast.
broadcast
Wake all waiters, each of which must re-check its condition. Appropriate when the condition that has become true may allow more than one waiter to proceed, or when the set of waiters is heterogeneous and the signaller does not know which one can proceed. The classic example is a change to a global state that different waiters are waiting on different parts of.

A broadcast is always correct — it wakes more threads than necessary, and each re-checks its condition and waits again if the condition is still false. But it has a cost: it wakes threads that will immediately go back to sleep, causing useless context switches and contention on the monitor. On a system with many waiters, the difference between signal and broadcast can be substantial.

The general guidance is to use signal when the condition is specific and only one waiter can proceed, and broadcast when the condition is general or the waiters are heterogeneous. The dining philosophers problem, for example, uses broadcast because a philosopher putting down a fork may allow either of two neighbours to pick up a fork — and the signaller does not know which of them will succeed.

4.7 Monitors in modern languages

The monitor concept as Hoare and Brinch Hansen described it — a language construct with a compiler that enforces mutual exclusion — is rare in its pure form. But the ideas appear in every modern language that supports concurrency.

  • Java. Every Java object has a monitor associated with it. A method declared synchronized is executed as a monitor procedure: the JVM acquires the object's monitor on entry and releases it on exit. A synchronized block acquires the monitor for the duration of the block. Java provides wait(), notify(), and notifyAll() on every object, corresponding to wait, signal, and broadcast. Java uses Mesa semantics.
  • C#. The lock statement acquires a monitor on a specified object for the duration of a block. The Monitor class provides Wait, Pulse, and PulseAll. C# uses Mesa semantics.
  • Python. The threading module provides Lock, RLock, and Condition. A Condition is a condition variable, used together with a lock in the same pattern as a monitor. Python's GIL limits the concurrency of pure-Python code, but the abstractions are the same.
  • Ruby. The Monitor class in the standard library provides a reentrant mutex and condition variables, used in the monitor pattern.

In each case, the language provides the monitor's key guarantees: structural mutual exclusion for the protected region, and condition variables for waiting on a condition. The programmer does not write acquire and release around every critical section; the language inserts them.

4.8 Monitors in C and POSIX

C and POSIX do not provide monitors. There is no language-level construct that automatically acquires a lock on entry to a function and releases it on exit. The closest thing is the combination of a mutex and one or more condition variables, used together by convention:

/* The "monitor" is a struct containing a mutex and a condvar. */
typedef struct {
    pthread_mutex_t mutex;
    pthread_cond_t  not_full;
    pthread_cond_t  not_empty;
    int buffer[N];
    int in, out, count;
} buffer_t;

/* Monitor procedure — the caller must lock the mutex first. */
void insert(buffer_t *b, int item) {
    pthread_mutex_lock(&b->mutex);
    while (b->count == N)
        pthread_cond_wait(&b->not_full, &b->mutex);
    b->buffer[b->in] = item;
    b->in = (b->in + 1) % N;
    b->count++;
    pthread_cond_signal(&b->not_empty);
    pthread_mutex_unlock(&b->mutex);
}

This is not a monitor in the language sense — the programmer still writes the lock and unlock, and the compiler does not check that every path does so correctly. But the discipline is the same: the mutex protects the shared data, and the condition variables handle waiting for conditions. The pthread_cond_wait function takes the mutex as an argument, because it must atomically release the mutex and begin waiting — if the release and the wait were not atomic, another thread could signal between them and the signal would be lost.

The lesson for C programmers is that the monitor pattern is available in C, but only as a convention the programmer must follow. Nothing prevents a programmer from accessing the shared data without holding the mutex, or from calling pthread_cond_wait without the mutex, or from forgetting to release the mutex on some path. The structural guarantee of a true monitor is absent, and the correctness of the program depends entirely on the programmer's discipline. This is why C is considered harder for concurrent programming than Java or C#, and why the same patterns appear in C code with more ceremony and more opportunities for error.

4.9 The dining philosophers with monitors

The dining philosophers problem is a second classic. Five philosophers sit at a round table, each alternating between thinking and eating. Between each pair of adjacent philosophers is a single fork, so there are five forks for five philosophers. To eat, a philosopher must pick up both forks adjacent to them.

The monitor solution uses a single monitor for the table, with a state variable for each philosopher and a condition variable per philosopher:

monitor Table {
    enum { THINKING, HUNGRY, EATING } state[5];
    condition self[5];

    procedure pickup(int i) {
        state[i] = HUNGRY;
        test(i);
        while (state[i] != EATING)
            self[i].wait();
    }

    procedure putdown(int i) {
        state[i] = THINKING;
        test((i + 4) % 5);   /* left neighbour */
        test((i + 1) % 5);   /* right neighbour */
    }

    procedure test(int i) {
        if (state[(i + 4) % 5] != EATING &&
            state[i] == HUNGRY &&
            state[(i + 1) % 5] != EATING) {
            state[i] = EATING;
            self[i].signal();
        }
    }
}

A philosopher calls pickup(i) before eating and putdown(i) after. The test procedure checks whether the philosopher can eat (both neighbours are not eating) and, if so, sets the state to EATING and signals the philosopher's condition variable. The pickup procedure sets the state to HUNGRY, runs the test, and waits if it cannot eat yet.

The solution avoids deadlock: the monitor serializes the state changes, and a philosopher is allowed to eat only when both neighbours are not eating. It also avoids starvation in a weak sense — a philosopher who is hungry and whose neighbours eventually put down their forks will be signalled by the test calls in the neighbours' putdown procedures. The problem is examined in more detail in Unit 7, where deadlock is the subject.

4.10 Common pitfalls

wait inside an if instead of a while

Under Mesa semantics, the condition may not hold when the woken thread runs. The wait must always be inside a while loop that re-checks the condition.

Signalling the wrong condition variable

A monitor may have several condition variables, one per condition. Signalling the wrong one wakes a thread that is waiting for a different condition, which will re-check and go back to sleep — or, worse, will proceed on an invalid assumption.

Forgetting to signal after changing the state

A condition variable has no memory. If a thread changes the state so that a condition becomes true and does not signal, the waiting thread sleeps forever.

Using broadcast when signal suffices

A broadcast wakes every waiter, even those that will immediately go back to sleep. Use signal when only one waiter can proceed, and reserve broadcast for the cases where the condition is general.

Holding the monitor while doing long work

The monitor is held for the duration of a monitor procedure. A procedure that does a long computation, or that blocks on I/O, serializes the entire monitor for that duration. Keep the procedures short.

Calling wait without the monitor held

In C, calling pthread_cond_wait without holding the mutex is undefined behaviour. In Java, calling wait() without synchronizing on the object throws IllegalMonitorStateException.

Assuming Hoare semantics

Code that is correct under Hoare semantics may be incorrect under Mesa semantics, because the condition may change between the signal and the wakeup. Write code that is correct under Mesa, since that is what real languages provide.

Mixing monitor and lock patterns

A monitor's mutual exclusion is provided by the runtime; adding an explicit lock inside a monitor procedure that also acquires the same lock is a deadlock. Do not mix the two patterns.

05 Worked example

The following example traces the monitor-based producer–consumer solution through a sequence of operations, and shows the difference between Hoare and Mesa semantics by tracing the same sequence under both.

Producer–consumer with a monitor

The monitor has a buffer of N = 3 slots, a count, and two condition variables: not_full and not_empty. Initially the buffer is empty (count = 0), and the wait queues are empty.

We trace the operations of one producer P and two consumers C1 and C2, under Mesa semantics, with the interleaving shown.

Step Thread Operation count not_full queue not_empty queue
0—initial0emptyempty
1C1remove(): count == 0, not_empty.wait()0emptyC1
2C2remove(): count == 0, not_empty.wait()0emptyC1, C2
3Pinsert(1): count < 3, insert, count++1emptyC1, C2
4Pnot_empty.signal() — C1 woken1emptyC2
5Preturn from insert1emptyC2
6Pinsert(2): count < 3, insert, count++2emptyC2
7Pnot_empty.signal() — C2 woken2emptyempty
8Preturn from insert2emptyempty
9C1re-acquires monitor, re-checks count == 0 → false2emptyempty
10C1remove(): item = 1, count--1emptyempty
11C1not_full.signal() — no waiters1emptyempty
12C1return 11emptyempty
13C2re-acquires monitor, re-checks count == 0 → false1emptyempty
14C2remove(): item = 2, count--0emptyempty
15C2not_full.signal() — no waiters0emptyempty
16C2return 20emptyempty

The trace illustrates the essential points. At step 4, the producer signals not_empty, which wakes C1. Under Mesa semantics, the producer continues (steps 5–8), inserting a second item before C1 runs. When C1 finally re-acquires the monitor at step 9, it re-checks the condition count == 0, which is now false (count = 2), so it proceeds. If the code had used an if instead of a while, C1 would have run correctly here — but only by luck. With a different interleaving — say, if C2 had run first and consumed both items — C1's assumption would be wrong.

The same trace under Hoare semantics

Under Hoare semantics, the trace is different. At step 4, when the producer signals not_empty, the producer is immediately suspended and C1 runs immediately, before the producer inserts the second item. C1 removes the item, runs to completion, and returns. The producer then resumes at step 5 and inserts the second item.

Step Thread Operation count not_full queue not_empty queue
0—initial0emptyempty
1C1remove(): count == 0, not_empty.wait()0emptyC1
2C2remove(): count == 0, not_empty.wait()0emptyC1, C2
3Pinsert(1): count < 3, insert, count++1emptyC1, C2
4Pnot_empty.signal() — P suspends1emptyC2
5C1runs immediately; count == 0 → false1emptyC2
6C1remove(): item = 1, count--0emptyC2
7C1not_full.signal() — no waiters0emptyC2
8C1return 10emptyC2
9Presumes; returns from insert0emptyC2
10Pinsert(2): count < 3, insert, count++1emptyC2
11Pnot_empty.signal() — P suspends1emptyempty
12C2runs immediately; count == 0 → false1emptyempty
13C2remove(): item = 2, count--0emptyempty
14C2not_full.signal() — no waiters0emptyempty
15C2return 20emptyempty
16Presumes; returns from insert0emptyempty

The two traces produce the same final state (both items consumed, count = 0), but the intermediate states are different, and the Hoare version performs more context switches because the signaller is suspended and resumed twice. Under Hoare semantics, C1 and C2 can rely on the condition being true when they wake; under Mesa semantics, they must re-check. The while loop is correct under both, which is why it is the recommended form regardless of the language's semantics.

Correctness argument for the monitor solution

Three properties must hold. Mutual exclusion: the runtime guarantees that at most one thread is inside a monitor procedure at a time, so the buffer's data and indices are never accessed concurrently. No insertion into a full buffer: the insert procedure waits while count == N, so a producer proceeds only when there is a free slot. No removal from an empty buffer: the remove procedure waits while count == 0, so a consumer proceeds only when an item is available. Additionally, the signal calls ensure that waiting threads are woken when the condition they are waiting for becomes true: a producer signals not_empty after inserting, and a consumer signals not_full after removing. Since every change to count is accompanied by the appropriate signal, no waiter is left sleeping when the condition becomes true.

The dining philosophers trace

A trace of the dining philosophers monitor for a single philosopher's transition is instructive. Suppose all five philosophers are thinking, and philosopher 0 calls pickup(0). The monitor sets state[0] = HUNGRY, then runs test(0). Neighbours 4 and 1 are thinking, so the test succeeds: state[0] = EATING and self[0].signal() is called. Since philosopher 0 is the only thread currently in the monitor (having entered pickup), there is no one waiting on self[0], so the signal has no effect. The wait loop in pickup is then checked: state[0] == EATING, so the loop exits and philosopher 0 begins to eat.

Now suppose philosopher 1 calls pickup(1) while philosopher 0 is eating. The monitor sets state[1] = HUNGRY and runs test(1). Philosopher 0 is eating, so the test fails: philosopher 1 cannot eat. The wait loop blocks on self[1].wait(), adding philosopher 1 to the wait queue for self[1]. When philosopher 0 later calls putdown(0), the monitor sets state[0] = THINKING and calls test(4) and test(1). The latter succeeds, because philosopher 0 is no longer eating and philosopher 2 is thinking; so state[1] = EATING and self[1].signal() wakes philosopher 1. Philosopher 1 re-checks its loop condition, sees state[1] == EATING, and proceeds to eat.

This example illustrates why the monitor is a good fit for the dining philosophers. The shared state — the state of each philosopher — is protected by the monitor's mutual exclusion, and the condition "philosopher i can eat" is expressed directly as a test procedure and a condition variable. The solution avoids the deadlock that a naive fork-picking solution would produce, because a philosopher is allowed to eat only when both neighbours are not eating, and the state is only modified inside the monitor.

06 Interactive activity

Activity · implement a monitor-style solution in C and in Java

C does not provide monitors directly, but the monitor pattern can be implemented with a mutex and one or more condition variables. Java provides monitors as a language construct. This activity lets you implement the producer–consumer solution in both languages and compare the two programming models. Work through the steps in your course VM.

  1. Implement the producer–consumer in C using the monitor pattern. Write a C program with a buffer structure containing a mutex, two condition variables, and the buffer's data and indices. The insert and remove functions acquire the mutex on entry, wait on the appropriate condition variable, and release the mutex on exit.
    typedef struct {
        pthread_mutex_t mutex;
        pthread_cond_t  not_full;
        pthread_cond_t  not_empty;
        int buffer[N];
        int in, out, count;
    } buffer_t;
    
    void buffer_init(buffer_t *b) {
        pthread_mutex_init(&b->mutex, NULL);
        pthread_cond_init(&b->not_full, NULL);
        pthread_cond_init(&b->not_empty, NULL);
        b->in = b->out = b->count = 0;
    }
    
    void buffer_insert(buffer_t *b, int item) {
        pthread_mutex_lock(&b->mutex);
        while (b->count == N)
            pthread_cond_wait(&b->not_full, &b->mutex);
        b->buffer[b->in] = item;
        b->in = (b->in + 1) % N;
        b->count++;
        pthread_cond_signal(&b->not_empty);
        pthread_mutex_unlock(&b->mutex);
    }
    
    int buffer_remove(buffer_t *b) {
        pthread_mutex_lock(&b->mutex);
        while (b->count == 0)
            pthread_cond_wait(&b->not_empty, &b->mutex);
        int item = b->buffer[b->out];
        b->out = (b->out + 1) % N;
        b->count--;
        pthread_cond_signal(&b->not_full);
        pthread_mutex_unlock(&b->mutex);
        return item;
    }
    Create producer and consumer threads that use these functions, and verify that the items arrive in order. Compile with gcc -O2 -pthread -o pc pc.c.
  2. Break the while loop. Change the while loops in buffer_insert and buffer_remove to if statements. Run the program with several producers and consumers. Do the errors appear? Run it many times and note whether the failure is consistent or intermittent. Restore the while loops.
  3. Replace signal with broadcast. Change pthread_cond_signal to pthread_cond_broadcast in both functions. Run the program. Does it still work? Time it and compare to the signal version. Does the broadcast slow it down? By how much? Explain the difference.
  4. Implement the same solution in Java. Write a Java class with a shared buffer, a synchronized insert method, a synchronized remove method, and calls to wait(), notify(), and notifyAll().
    class BoundedBuffer {
        private final int[] buffer = new int[N];
        private int in = 0, out = 0, count = 0;
    
        public synchronized void insert(int item)
                throws InterruptedException {
            while (count == N)
                wait();
            buffer[in] = item;
            in = (in + 1) % N;
            count++;
            notifyAll();
        }
    
        public synchronized int remove()
                throws InterruptedException {
            while (count == 0)
                wait();
            int item = buffer[out];
            out = (out + 1) % N;
            count--;
            notifyAll();
            return item;
        }
    }
    Compile with javac and run. Verify that the program works and that the items arrive in order.
  5. Compare the two implementations. Write a short table comparing the C and Java versions: how many lines of synchronization code does each require? What does the programmer have to remember in each? What errors can the compiler catch in each? Which is easier to get right? Which is easier to read?
  6. Demonstrate the Java monitor. Use the javap tool to disassemble a class with a synchronized method and observe the monitorenter and monitorexit bytecode instructions that the compiler inserted.
    javac MyClass.java
    javap -c MyClass
    These instructions are the JVM's implementation of the monitor's mutual exclusion. The programmer never writes them; the compiler inserts them at every synchronized boundary. This is the structural guarantee that distinguishes a monitor from a lock.
  7. Implement the dining philosophers with a monitor in C. Adapt the monitor pattern to the dining philosophers problem. Use a single mutex for the table and one condition variable per philosopher. Verify that all five philosophers eventually eat and that no two adjacent philosophers eat simultaneously.
    typedef struct {
        pthread_mutex_t mutex;
        pthread_cond_t  self[5];
        int state[5];       /* 0=THINKING, 1=HUNGRY, 2=EATING */
    } table_t;
    Run for 30 seconds with each philosopher eating and thinking repeatedly. Count the meals per philosopher and verify that the counts are roughly equal. If one philosopher starves, examine the order of the test calls in putdown.
  8. Observe the cost of a condition variable. Write a small benchmark that acquires a mutex, waits on a condition variable, and is signalled by another thread. Measure the round-trip time for one million such handoffs. Compare to the spinlock handoff measured in Lesson 6.2. How much does the monitor pattern cost compared to a raw lock handoff?

Reflect: the Java version of the producer–consumer requires no explicit lock or unlock, because the synchronized keyword makes the monitor's mutual exclusion automatic. The C version requires the programmer to lock and unlock the mutex on every path. What does this tell you about the difference between a language-level abstraction and a library abstraction? Would you use a monitor-style pattern in C for a production system, or would you prefer to use something else? What would you need to make the C version as safe as the Java version?

07 Knowledge check

Check your understanding

Question 1

Define a monitor. What three things does it package together, and what is enforced automatically by the language? How does this differ from a lock or a semaphore?

Show answer
A monitor is a language-level synchronization construct that packages three things into a single unit: (1) the shared data that the monitor protects; (2) the monitor procedures that operate on the data; and (3) the initialization code that sets up the shared data. The essential property is that at most one thread can be executing a monitor procedure at a time. The language runtime enforces this automatically: when a thread calls a monitor procedure, the runtime acquires a lock on the monitor before the procedure body runs and releases it when the procedure returns. The programmer writes no lock, no acquire, no release. This is the fundamental difference from a lock or a semaphore. With a lock or a semaphore, the synchronization is the programmer's responsibility — the programmer writes the acquire and release, or the wait and post, on every path through the code, and the compiler cannot check that they are correct. With a monitor, the synchronization is structural: the compiler or runtime inserts it automatically, and forgetting it is impossible. A whole class of errors — forgetting to release, releasing twice, acquiring the wrong lock — is eliminated by the abstraction itself.

Question 2

What is a condition variable? What are its three operations, and how does it differ from a semaphore? Why must wait always be called inside a while loop?

Show answer
A condition variable is an object used inside a monitor to allow a thread to wait for a condition to become true. It has three operations: wait, which releases the monitor, suspends the calling thread, and adds it to the condition variable's wait queue; signal, which wakes one thread waiting on the condition variable if any; and broadcast, which wakes all of them. A condition variable differs from a semaphore in the most important respect: it has no memory. A semaphore's post increments a value, and a later wait finds the value and does not block. A condition variable's signal has no effect if no thread is waiting — it is lost. This is because the condition variable is always used inside a monitor, where the condition it represents is a property of the shared data. If the condition is true, a thread that later enters the monitor will see it directly, without needing a remembered signal. The signal is a wakeup, not a value. The wait must always be called inside a while loop because of Mesa semantics: when a waiting thread is woken, other threads may have entered the monitor in the meantime and changed the shared data. The condition the thread was waiting for may no longer hold. Re-checking the condition after the wait ensures that the thread proceeds only when the condition is actually true. There is also the possibility of spurious wakeups, where the runtime wakes a thread without a signal; the loop handles those as well. A wait inside an if is correct only under Hoare semantics, which real implementations do not provide.

Question 3

Compare Hoare semantics (signal-and-wait) with Mesa semantics (signal-and-continue). What are the consequences of each for the programmer, and which is more common in practice?

Show answer
Under Hoare semantics, when a thread calls signal, it immediately gives up the monitor to the woken thread, which runs as soon as the signaller suspends. The signaller is placed in a special queue and resumes when the woken thread leaves the monitor or waits again. The woken thread can be sure that the condition it was waiting for is still true when it wakes, because no other thread has had a chance to change it. Under Mesa semantics, the signalling thread continues executing, and the woken thread is moved to the monitor entry queue, where it must compete with new arrivals for the monitor. By the time the woken thread actually runs, other threads may have entered the monitor and changed the shared data, so the condition may no longer hold. The consequence for the programmer is that under Hoare semantics an if suffices to test the condition before waiting, while under Mesa semantics a while loop is required — the condition must be re-checked after every wakeup. Since Mesa semantics is what nearly every real implementation provides (Java, C#, the POSIX condition variable, and the condition variables in most other libraries), the while form is the correct one to write in practice. Hoare semantics is pedagogically important for understanding the concept, and it is the semantics Hoare described in his original paper, but it is harder to implement and leads to more context switches, because every signal that wakes a thread causes the signaller to be suspended and resumed.

Question 4

Write the monitor-based producer–consumer solution. Explain how it differs from the semaphore-based solution of Lesson 6.4, and identify the advantages and disadvantages of each.

Show answer
The monitor solution defines a monitor BoundedBuffer with the buffer's data, the indices, a count, and two condition variables not_full and not_empty. The insert procedure waits while count == N, inserts the item, increments the count, and signals not_empty. The remove procedure waits while count == 0, removes the item, decrements the count, and signals not_full. The producer calls insert; the consumer calls remove. The differences from the semaphore solution: (1) The monitor solution has no explicit locking. The programmer writes no acquire, no release, no wait on a mutex, no post on a mutex. The mutual exclusion is provided automatically by the runtime. (2) The monitor solution uses condition variables, which are tied to specific conditions (count == N and count == 0) rather than to counts of resources. The condition is expressed directly in the code, rather than being encoded in the initial value of a semaphore. (3) The monitor solution has no fixed order of acquisition. There is no way to write the operations in an order that deadlocks, because the monitor is acquired automatically and the conditions are checked before waiting. The semaphore solution required the programmer to get the order of the two waits right, and reversing them deadlocked. The advantages of the monitor solution are readability and safety: it is shorter, clearer, and harder to get wrong. The disadvantage is that it requires language support. C and POSIX do not provide monitors; the closest equivalent is the mutex-plus-condition-variable pattern, which requires the programmer to write the lock and unlock explicitly and therefore loses the structural guarantee. In a language that provides monitors (Java, C#), the monitor solution is preferable; in C, the semaphore solution is often more convenient because it is fully expressed in the library rather than relying on a language feature that does not exist.

Question 5

What is the difference between signal and broadcast on a condition variable? When should each be used? What is the cost of always using broadcast?

Show answer
signal wakes exactly one waiter on the condition variable; if no thread is waiting, the signal is lost. broadcast wakes all waiters; each of them must re-check its condition and go back to sleep if the condition is still false. signal is appropriate when the condition that has become true allows exactly one waiter to proceed. In the producer–consumer solution, one inserted item allows exactly one consumer to remove it, so a signal on not_empty is correct and more efficient than a broadcast. broadcast is appropriate when the condition that has become true may allow more than one waiter to proceed, or when the waiters are heterogeneous — waiting on different aspects of the same condition — and the signaller cannot know which one can proceed. The dining philosophers problem is an example: when a philosopher puts down their forks, both neighbours may be able to eat, and the signaller does not know which of them will succeed. A broadcast wakes both, and each re-checks its own condition. The cost of always using broadcast is that it wakes threads that will immediately go back to sleep. Each of those wakeups causes a context switch and contention on the monitor. On a system with many waiters, the wasted wakeups can be a substantial fraction of the total CPU time. The cost is acceptable when the number of waiters is small or when the wakeup rate is low; it is significant when the number of waiters is large or the wakeup rate is high. The general rule is to use signal when the condition is specific and to reserve broadcast for the cases where the condition is general and more than one waiter may proceed.

08 Unit discussion

Discussion prompt

The monitor was a response to the difficulty of using semaphores correctly. Its key insight was that synchronization should be enforced by the language, not by the programmer's discipline: if the compiler can see that a region of code is mutually exclusive, it can insert the lock and unlock automatically, and a whole class of errors becomes impossible by construction.

Forty years later, the same idea is still being reinvented in new forms. Rust enforces data-race freedom at compile time through ownership and borrowing. Go encourages communication over shared memory and provides goroutines and channels. Erlang and Elixir use actor models with no shared state at all. Swift has actor types. Each of these is an attempt to move the synchronization discipline from the programmer into the language, and each has a different theory of what kind of error is most worth preventing.

What is the right abstraction? Should the language enforce mutual exclusion automatically, at the cost of some performance and some flexibility? Should it enforce stronger properties — no data races at all, as Rust does — at the cost of a more complex type system? Or should it step back entirely, as Erlang does, and give the programmer a fundamentally different model of concurrency in which shared state does not arise? Consider the languages you know: which one has the best concurrency story, and why?

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.6. Aim to spend 90–120 minutes on it.

  1. Trace the monitor producer–consumer. Using the monitor code from section 4.4 with N = 3, trace the following sequence of operations under Mesa semantics: C1.remove(), C2.remove(), P.insert(1), P.insert(2), P.insert(3), C1.run, P.insert(4). Show the value of count and the contents of the not_full and not_empty queues after each operation. At which points does a thread block? At which points is a thread woken?
  2. Write the producer–consumer in Java. Write a Java program with a single producer and a single consumer sharing a bounded buffer. Use the monitor pattern with synchronized methods and wait/notifyAll. Test with a buffer of 5 slots and 100,000 items, and verify that the items arrive in order. Then change notifyAll to notify and run again. Does the program still work? Why or why not?
  3. Write the producer–consumer in C. Write the same program in C using the monitor pattern with pthread_mutex_t and pthread_cond_t. Compile with -O2 -pthread. Test with a buffer of 5 slots and 100,000 items. Compare the number of lines of synchronization code to the Java version.
  4. Break the while loop. In the C version, change the while loops to if statements. Run with three producers and three consumers. Run the program ten times and record how many runs produce an error. Then restore the while loops and run again. Does the error disappear entirely? Why is it intermittent?
  5. Compare signal and broadcast. Modify the C program to use pthread_cond_broadcast instead of pthread_cond_signal. Run with five producers and five consumers on a small buffer. Time the program for 100,000 items with signal and with broadcast. Which is faster? By how much? Explain the difference in terms of the number of useless wakeups.
  6. Implement the dining philosophers with a monitor. Write the dining philosophers monitor from section 4.9 in C using the mutex-and-condition-variable pattern. Run for 30 seconds with five philosophers, each alternating between eating and thinking for random durations. Count the number of meals per philosopher. Are the counts roughly equal? Which philosopher eats most, and why?
  7. Examine Java bytecode for synchronized. Compile a Java class with a synchronized method and disassemble it with javap -c. Identify the monitorenter and monitorexit instructions that the compiler inserted. Write a short paragraph explaining what these instructions do and why they are the implementation of the monitor's mutual exclusion.
  8. Reflect. In your own words, explain what it means to say that a monitor provides mutual exclusion as a structural guarantee rather than a conventional one. Give one example of an error that is possible with a mutex and impossible with a monitor, and one advantage that a mutex has over a monitor in languages that provide both.

10 Summary

  • A monitor is a language-level construct that packages shared data, the procedures that operate on it, and the synchronization that protects it into a single unit. It was introduced by Brinch Hansen (1973) and Hoare (1974).
  • The essential property of a monitor is that at most one thread is executing a monitor procedure at a time. The runtime enforces this automatically; the programmer writes no lock or unlock.
  • This structural guarantee distinguishes the monitor from the lock and the semaphore, where the synchronization is the programmer's responsibility and the compiler cannot check it.
  • A condition variable allows a thread to wait for a condition inside a monitor. Its operations are wait (release the monitor and suspend), signal (wake one waiter), and broadcast (wake all waiters).
  • A condition variable has no memory: a signal that arrives when no thread is waiting has no effect. This is different from a semaphore, whose post increments a value that a later wait can find.
  • Mesa semantics (signal-and-continue) is what nearly every real implementation provides. Under Mesa semantics, the signaller continues and the woken thread competes for the monitor, so the condition may no longer hold when the woken thread runs.
  • Hoare semantics (signal-and-wait) is the semantics Hoare described in his original paper: the signaller immediately yields the monitor to the woken thread. It is simpler to reason about but harder to implement and causes more context switches.
  • The consequence of Mesa semantics is that every wait must be inside a while loop that re-checks the condition. A wait inside an if is correct only under Hoare semantics and is a common bug.
  • The monitor solution to the bounded-buffer producer–consumer problem uses one condition variable for "buffer not full" and one for "buffer not empty". It is shorter and clearer than the semaphore solution, and it cannot deadlock from a reversed acquisition order.
  • Modern languages implement monitors in various forms: Java's synchronized methods and wait/notify, C#'s lock statement and Monitor class, and the condition variable libraries of Python and Ruby. C and POSIX do not provide monitors directly; the closest equivalent is the mutex-plus-condition-variable pattern, which requires the programmer to write the synchronization explicitly.

Looking ahead

This lesson introduced the condition variable as the monitor's mechanism for waiting on a condition. Lesson 6.6 examines the condition variable in detail as a primitive in its own right. It presents the POSIX condition variable (pthread_cond_t), its use together with a mutex, the reasons for the loop-around-wait pattern, the difference between signal and broadcast with concrete examples, and the classic applications — the producer–consumer problem, the reader–writer problem, and the barrier. The condition variable is the most subtle primitive in the unit, and it is the one most often misused. Lesson 6.6 will give you the tools to use it correctly.

11 References

  • Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 6, Section 6.7.
  • Tanenbaum, A. S., & Bos, H. Modern Operating Systems, 4th ed., Chapter 2, Section 2.3.7.
  • Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C. Operating Systems: Three Easy Pieces, Chapter 30. Available online at ostep.org.
  • Hoare, C. A. R. Monitors: An Operating System Structuring Concept. Communications of the ACM, 1974. The original paper on monitors and condition variables.
  • Brinch Hansen, P. Operating System Principles. Prentice-Hall, 1973. The original presentation of the monitor concept.
  • Lampson, B. W., & Redell, D. D. Experience with Processes and Monitors in Mesa. Communications of the ACM, 1980. The paper that introduced signal-and-continue semantics.
  • Lea, D. Concurrent Programming in Java: Design Principles and Patterns, 2nd ed. Addison-Wesley, 1999. The Java memory model and the implementation of monitors in the JVM.
  • Linux man-pages: man 3 pthread_cond_init, man 3 pthread_cond_wait, man 3 pthread_cond_signal, man 3 pthread_cond_broadcast, man 3 pthread_cond_destroy.
👤