COMP 3150Operating Systems

01 Introduction

Lesson 6.1 established the problem: when two threads share state and at least one of them writes, the outcome can depend on the interleaving chosen by the scheduler. The lost-update and check-then-act examples showed that correct-looking code can produce incorrect results under concurrency, and that the bug is in the failure to make a sequence of operations atomic, not in the operations themselves.

This lesson introduces the abstraction that fixes the problem. A critical section is a region of code that must be executed by at most one thread at a time. The operations within the critical section are still non-atomic at the machine level — the scheduler can still preempt between them — but the synchronization protocol surrounding the critical section ensures that no other thread can be executing a conflicting critical section at the same time. The effect is to make the entire sequence appear atomic to every other thread.

The critical-section abstraction is the foundation of every synchronization mechanism in the rest of the unit. Locks, semaphores, monitors, and condition variables are all, in different ways, tools for identifying and protecting critical sections. Understanding the requirements — mutual exclusion, progress, and bounded waiting — is therefore prerequisite to understanding why those mechanisms are designed as they are, and why some designs that look correct are not.

This lesson covers the structure of a solution to the critical-section problem, the three properties such a solution must have, Peterson's software solution for two processes, the bakery algorithm for n processes, and the hardware primitives — test-and-set and compare-and-swap — that make practical solutions possible. It closes with a discussion of why purely software solutions, while intellectually satisfying, are not used in practice on modern hardware.

Prerequisites

This lesson builds directly on Lesson 6.1. The race conditions introduced there — the lost update and the check-then-act pattern — are the motivation for everything in this lesson. If the distinction between a data race and a race condition is unfamiliar, review sections 4.4 and 4.5 of Lesson 6.1 before continuing. The POSIX threads API from Lesson 4.5 is used in the interactive activity.

02 Learning objectives

By the end of this lesson you will be able to

  • Define the critical-section problem and identify critical sections in concurrent code.
  • State and apply the three requirements of a correct solution: mutual exclusion, progress, and bounded waiting.
  • Explain Peterson's software solution and prove informally that it satisfies the three requirements.
  • Describe the hardware primitives test-and-set and compare-and-swap and explain how they enable synchronization.
  • Explain why software-only solutions are not used in practice on modern hardware.

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

03 Required reading

  • Operating System Concepts — Chapter 6, Sections 6.1–6.3, Background, The Critical-Section Problem, and Peterson's Solution. The formal definitions and the classic two-process solution.
  • OSTEP — Chapter 28, Locks, through the section on test-and-set and compare-and-swap. OSTEP's treatment is the clearest available explanation of how hardware primitives enable locks, and it builds up from the failed software attempts to the working primitives.
  • Tanenbaum & Bos — Modern Operating Systems, Chapter 2, Section 2.3, through the subsections on Mutual Exclusion with Busy Waiting and Sleep and Wakeup. The discussion of Dekker's and Peterson's algorithms.
  • Silberschatz et al. — Chapter 6, Section 6.4, Synchronization Hardware, and Section 6.5, Mutex Locks. The hardware support for synchronization and the spinlock.
  • Linux man pages: man 7 pthreads, man 3 pthread_mutex_init, man 3 pthread_mutex_lock, man 3 pthread_spin_lock. The POSIX interfaces to the mechanisms discussed here.

Reading strategy

Read the Silberschatz section on the critical-section problem first for the formal definitions and Peterson's solution. Then read OSTEP Chapter 28 for the hardware primitives — its staged presentation, building from the failed software attempts to the working hardware solution, is the clearest way to understand why the primitives are what they are. Pay particular attention to the discussion of why software-only solutions fail on modern hardware; this is the honest treatment that many introductory texts omit.

04 Core content

4.1 The critical-section abstraction

Consider a program in which several threads access a shared data structure. Some of the accesses are safe: a thread that only reads the structure cannot corrupt it, and multiple readers can proceed concurrently. Other accesses are not safe: a thread that modifies the structure, or that makes a decision based on a value in the structure and then acts on that decision, can produce incorrect results if another thread modifies the structure in between.

The unsafe accesses are grouped into critical sections: regions of code that must be executed by at most one thread at a time. The threads that contain critical sections are structured as follows:

do {
    /* entry section: request permission to enter */
    ...
    /* critical section: access shared state */
    ...
    /* exit section: release permission */
    ...
    /* remainder section: do work that does not touch shared state */
    ...
} while (1);

The entry section contains the protocol by which a thread requests permission to enter the critical section. The exit section contains the protocol by which it releases permission. The remainder section contains the code that does not touch shared state and can therefore run concurrently with any other thread.

The critical section itself is unremarkable: it is ordinary code that reads and writes shared state. What makes it critical is the guarantee provided by the entry and exit sections: no other thread will be executing a critical section on the same shared state at the same time. The operations inside the critical section are still not atomic at the machine level, but the protocol makes them appear atomic to every other thread.

Structure of a solution to the critical-section problem Two process timelines are shown. Each process cycles through entry section, critical section, exit section, and remainder section. The critical sections of the two processes do not overlap: while one process is in its critical section, the other is either in its entry section, waiting, or in its remainder section. A vertical dashed line marks the boundary of the critical section, showing that at most one process is inside at any time. STRUCTURE OF A CRITICAL-SECTION SOLUTION P0 entry critical section exit remainder section P1 entry wait critical section exit remainder section P0 is inside P1 enters only after P0 leaves t0 t1 t2 t3 t4 entry section critical section exit section remainder The entry and exit sections are the synchronization protocol. The critical section is ordinary code that touches shared state. The protocol guarantees that at most one thread is inside a critical section on the same shared state at any time. The operations inside the critical section are still non-atomic — but they appear atomic to every other thread.
Figure 6.2 — The structure of a critical-section solution. Each thread cycles through entry, critical, exit, and remainder sections. The protocol in the entry and exit sections ensures that at most one thread is inside a critical section on the same shared state at any time.

4.2 The three requirements

A correct solution to the critical-section problem must satisfy three properties. They are stated here for the general case of n processes, though many of the classical solutions assume n = 2.

The three requirements of a critical-section solution
Requirement Statement What it prevents
Mutual exclusion If a process is executing in its critical section, no other process may be executing in its critical section on the same shared state. Corruption of shared state by concurrent access. This is the defining requirement.
Progress If no process is in its critical section and some processes wish to enter, only those processes that are not in their remainder sections may participate in the decision of which will enter next, and the decision cannot be postponed indefinitely. Deadlock: a situation in which no process can enter because the protocol itself blocks everyone.
Bounded waiting There is a bound on the number of times other processes may enter their critical sections after a process has made a request to enter and before that request is granted. Starvation: a situation in which a process waits indefinitely while others repeatedly enter.

Mutual exclusion is the essential property; a solution that violates it does not solve the problem. Progress and bounded waiting are the properties that distinguish a useful solution from one that is correct only in the sense that it never allows two threads into the critical section at once — but may prevent any of them from ever getting in, or may starve one of them indefinitely.

Mutual exclusion is not enough

A solution that satisfies mutual exclusion but violates progress is a deadlock: no process can ever enter the critical section, so the program makes no progress. A solution that satisfies mutual exclusion and progress but violates bounded waiting is a starvation: some process never enters while others repeatedly do. Both are failures, and both are easy to produce accidentally when designing a synchronization protocol by hand. This is why practical systems use well-tested library primitives rather than ad-hoc protocols.

4.3 Peterson's solution

The classical software solution to the two-process critical-section problem is due to Gary Peterson (1981). It uses two shared variables:

int flag[2] = {0, 0};   /* flag[i] = 1 means Pi wants to enter */
int turn = 0;           /* whose turn it is to enter */

Each process i executes the following protocol, where j = 1 − i is the other process:

do {
    flag[i] = 1;                    /* I want to enter */
    turn = j;                       /* but you can go first */
    while (flag[j] && turn == j)    /* wait while it is your turn */
        ;                           /* and you want to enter */
    /* critical section */
    flag[i] = 0;                    /* I am leaving */
    /* remainder section */
} while (1);

The protocol is remarkably compact. Each process announces its intention by setting its flag, then politely offers the turn to the other process, then waits only if the other process also wants to enter and it is the other process's turn.

4.4 Why Peterson's solution works

The correctness of the protocol can be argued informally for each of the three requirements.

Mutual exclusion. Suppose both processes are in their critical sections at the same time. For P0 to be in its critical section, the condition flag[1] && turn == 1 must have been false when P0 last checked it. That means either flag[1] == 0 or turn == 0. But P1 has set flag[1] = 1 before entering, so flag[1] == 0 is impossible while P1 is in its critical section. Therefore turn == 0 when P0 entered. But P1 set turn = 0 before entering, and turn can only have one value. If turn == 0, then P1's wait condition flag[0] && turn == 1 is true (since flag[0] == 1 while P0 is in its critical section), so P1 is still waiting. Contradiction. Hence both cannot be in their critical sections at once.

Progress. Suppose no process is in its critical section and at least one wants to enter. If only one process wants to enter, its wait condition is false (because the other process's flag is 0), and it enters immediately. If both want to enter, both have set their flags to 1, and the value of turn determines which one waits: the process whose turn it is not enters, and the other waits. In either case, at least one process enters, so the decision cannot be postponed indefinitely.

Bounded waiting. Suppose P0 wants to enter and P1 is in its critical section. P0 sets flag[0] = 1 and turn = 1, then waits. When P1 exits, it sets flag[1] = 0. P0's wait condition becomes false, and P0 enters. If P1 tries to re-enter immediately, it sets flag[1] = 1 and turn = 0. Now turn == 0, so P1 waits, and P0 enters. P0 therefore enters after at most one entry by P1 — the bound is one.

4.5 The bakery algorithm

Peterson's solution generalizes to two processes. The classical solution for n processes is Lamport's bakery algorithm (1974). It is modelled on the way customers take numbered tickets at a bakery: each process that wants to enter takes a number, and the process with the smallest number enters first. Ties are broken by process identifier.

int choosing[N] = {0};
int number[N]   = {0};

/* Process i */
choosing[i] = 1;
number[i] = 1 + max(number[0], ..., number[N-1]);
choosing[i] = 0;

for (int j = 0; j < N; j++) {
    while (choosing[j])
        ;
    while (number[j] != 0 &&
           (number[j] < number[i] ||
            (number[j] == number[i] && j < i)))
        ;
}
/* critical section */
number[i] = 0;
/* remainder section */

The bakery algorithm satisfies all three requirements, and unlike Peterson's solution it does not assume anything about the relative speeds of the processes. Its cost is that each process must read the numbers of all the others, which is O(n) work per entry. The algorithm is historically important because it was the first correct solution for n processes, but it is not used in practice for the same reasons as Peterson's solution: it assumes sequential consistency, which modern hardware does not provide.

4.6 The limits of software-only solutions

Peterson's solution and the bakery algorithm are correct on a machine that provides sequential consistency: a memory model in which all memory operations appear to execute in program order, and all threads see the same order of operations. Early multiprocessors provided this model, and the algorithms were designed for that setting.

Modern hardware does not provide sequential consistency. Two features of modern processors violate it:

  • Compiler reordering. The compiler may reorder independent memory accesses for optimisation. In Peterson's protocol, the compiler is permitted to hoist the read of flag[j] out of the loop, since it does not know that another thread may modify it. The resulting code does not implement the protocol.
  • Processor reordering. The processor may execute memory accesses out of order, or in a different order than the program specifies, as long as the result appears correct to a single thread. On a multiprocessor, another core may observe the reordered accesses.

The consequence is that Peterson's solution, as written in plain C without memory barriers or atomic operations, does not work on a modern multi-core machine. It must be implemented with atomic variables and memory fences to prevent the compiler and the processor from reordering the accesses. The fundamental problem is that the solution relies on a memory model the hardware does not provide.

Why you should not write your own synchronization

This is the practical lesson of this section. The classical software solutions are intellectually satisfying and historically important, but they are not safe to use on modern hardware without careful implementation of memory barriers and atomic operations. Even when the algorithm is correct in principle, the translation to machine code can introduce subtle reorderings that break it. In practice, synchronization is implemented using hardware primitives — test-and-set and compare-and-swap — and packaged in well-tested library routines. Do not write your own lock; use pthread_mutex_lock.

4.7 Hardware support: test-and-set

The hardware primitives that make practical synchronization possible are atomic read-modify-write instructions. These instructions perform a read, a modification, and a write as a single indivisible operation, immune to interruption by the scheduler and to reordering by the processor.

The simplest of these is test-and-set. It takes a pointer to a memory location, returns the old value, and sets the location to 1 — all atomically:

int test_and_set(int *target) {
    int old = *target;
    *target = 1;
    return old;
    /* both operations execute atomically */
}

The instruction is provided by the processor; on x86 it is xchg used with a lock prefix, and on ARM it is ldrex/strex. The C standard library exposes it through the atomic operations in <stdatomic.h>.

Test-and-set can be used to implement a spinlock:

int lock = 0;

void acquire(int *l) {
    while (test_and_set(l))
        ;   /* spin until the lock was free */
}

void release(int *l) {
    *l = 0;
}

The acquire function repeatedly calls test_and_set. If the lock was free (value 0), the function returns 0 and the caller enters the critical section; the lock is now 1. If the lock was held (value 1), the function returns 1 and the caller loops. Because test_and_set is atomic, only one thread can observe the lock as free and proceed.

The spinlock satisfies mutual exclusion, and it satisfies progress in the weak sense that if the lock is free, some thread will acquire it. It does not satisfy bounded waiting: a thread that is spinning may lose the race to acquire the lock every time it is released, and may therefore spin indefinitely. In practice, the fairness of a spinlock depends on the hardware's cache-coherence protocol and the scheduler, and it is not guaranteed.

4.8 Hardware support: compare-and-swap

A more general primitive is compare-and-swap (CAS). It takes a pointer to a memory location, an expected value, and a new value. It compares the current value of the location with the expected value; if they match, it stores the new value; and it returns the old value — all atomically:

int compare_and_swap(int *value, int expected, int new) {
    int old = *value;
    if (old == expected)
        *value = new;
    return old;
}

Compare-and-swap is more powerful than test-and-set because it can be used to implement lock-free data structures. A thread can read a value, compute a new value based on it, and then attempt to install the new value with CAS. If another thread modified the value in the meantime, the CAS fails, and the thread retries with the new value. This is the basis of lock-free stacks, queues, and other concurrent data structures.

The C standard library exposes compare-and-swap as atomic_compare_exchange_weak and atomic_compare_exchange_strong in <stdatomic.h>. The weak form may fail spuriously, so it must be used in a loop; the strong form fails only if the comparison fails.

4.9 Disabling interrupts

A different approach to mutual exclusion, available only in the kernel, is to disable interrupts around the critical section. On a uniprocessor, a thread that disables interrupts cannot be preempted, so no other thread can run until it re-enables them. The critical section is therefore atomic by construction.

This approach is simple and effective on a uniprocessor, and it is used in kernel code where the cost of a lock is too high. But it has serious limitations:

  • It does not work on a multiprocessor. Disabling interrupts on one core does not prevent threads on other cores from running. The critical section is protected only against preemption on the local core, not against concurrent access from other cores.
  • It is dangerous. A thread that disables interrupts and then fails to re-enable them — because of a bug or an exception — can hang the system. The critical section must be short, and the code must be carefully written to restore the interrupt state.
  • It is a privileged operation. User-space code cannot disable interrupts; only kernel code can.

For these reasons, interrupt disabling is used only in specific kernel contexts, and it is usually combined with a lock when the kernel must protect against both local preemption and concurrent access from other cores.

4.10 Critical sections and the race conditions of Lesson 6.1

It is worth connecting this lesson back to the race conditions of Lesson 6.1. The critical-section abstraction addresses the third of the three conditions for a race: non-atomic access. By making a sequence of operations appear atomic to other threads, it removes the possibility that the scheduler's interleaving will produce an incorrect result.

For the counter example, the critical section is the increment:

lock(&l);
counter++;
unlock(&l);

For the check-then-act example, the critical section is the check and the act together:

lock(&l);
if (a->balance >= amount) {
    a->balance -= amount;
    result = 1;
} else {
    result = 0;
}
unlock(&l);

In both cases, the operations inside the critical section are the same operations as before. What has changed is that the entry and exit sections now guarantee that no other thread can execute a conflicting critical section concurrently. The critical section abstraction therefore addresses the race condition without requiring the programmer to reason about individual interleavings.

4.11 Common misconceptions

"A critical section makes the code atomic."

The operations inside the critical section are still non-atomic at the machine level. What the protocol does is make them appear atomic to other threads by preventing concurrent execution.

"Mutual exclusion is all that matters."

A solution that satisfies mutual exclusion but violates progress is a deadlock; one that violates bounded waiting is a starvation. All three properties are required for a usable solution.

"Peterson's solution is used in practice."

It is not. It assumes sequential consistency, which modern hardware does not provide. It must be implemented with memory barriers and atomic operations, and even then it is not worth using when library primitives are available.

"Test-and-set is a lock."

Test-and-set is a hardware instruction; a lock is a higher-level abstraction built from it. The instruction performs an atomic read-modify-write; the lock uses it to implement acquire and release semantics.

"A spinlock is always the fastest choice."

Spinning wastes CPU cycles while the lock is held. A spinlock is appropriate when the critical section is short and the lock is expected to be held briefly; for longer critical sections, a blocking lock is better.

"Disabling interrupts prevents all races."

It prevents preemption on the local core only. On a multiprocessor, threads on other cores can still access the shared state concurrently. Interrupt disabling alone is not sufficient for multiprocessor systems.

"Any critical section is fine as long as it is protected."

A critical section that is too long serializes the program and destroys parallelism. A critical section that is too short may not protect the actual invariant. Getting the boundary right is a design skill.

"A lock protects the variables inside it."

A lock protects a region of code, not a set of variables. If some code accesses the same variables without taking the lock, the protection is void. Every access to the shared state must be inside a critical section protected by the same lock.

05 Worked example

The following example traces Peterson's solution through an interleaving that exercises the protocol's logic, and implements a spinlock using test-and-set.

Tracing Peterson's solution

Two processes, P0 and P1, execute Peterson's protocol. Initially flag[0] = flag[1] = 0 and turn = 0. The trace below shows the values of the shared variables and the state of each process at each step.

Step P0 P1 flag[0] flag[1] turn
0remainderremainder000
1flag[0] = 1remainder100
2turn = 1remainder101
3test: flag[1]==0 → false
turn==1 → true
waits
remainder101
4waitingflag[1] = 1111
5waitingturn = 0110
6waitingtest: flag[0]==1 → true
turn==0 → false
enters CS
110
7test: flag[1]==1 → true
turn==1 → false
enters CS
in CS110
8in CSflag[1] = 0100
9flag[0] = 0remainder000

At step 3, P0 has set its flag and offered the turn to P1. It tests its wait condition: flag[1] is 0, so the condition flag[j] && turn == j is false, and P0 would enter. But before P0 actually enters, P1 sets its flag and sets turn = 0. At step 6, P1 tests its condition: flag[0] is 1 but turn == 0, so the condition is false, and P1 enters the critical section. At step 7, P0 re-tests its condition: flag[1] is 1 but turn == 0, so the condition is false, and P0 enters. Both are now in their critical sections — which violates mutual exclusion.

Wait — is this a counterexample? No. The trace above is subtly wrong, and it is worth working through why. At step 3, P0's test is the condition of the while loop, evaluated each iteration. If P0 evaluates the condition and finds it false, it exits the loop and enters the critical section immediately, without giving P1 a chance to run. The trace above has P0 evaluate the condition, then pause, then allow P1 to run — but that interleaving is not possible, because P0 does not yield between evaluating the condition and entering the critical section. The mutual-exclusion proof from section 4.4 depends on this: once P0 sees turn == 0, it enters, and P1's subsequent assignment turn = 0 cannot change what P0 has already decided.

The correct trace is:

Step P0 P1 flag[0] flag[1] turn
0remainderremainder000
1flag[0] = 1remainder100
2turn = 1remainder101
3test: flag[1]==0 → false
enters CS
remainder101
4in CSflag[1] = 1111
5in CSturn = 0110
6in CStest: flag[0]==1 → true
turn==0 → false
enters CS
110

At step 6, P1 finds turn == 0, which means it is P0's turn, so P1 waits. The mutual-exclusion property holds. The subtle point is that the test in the while loop is not an "atomic test and enter" — the process must not be interrupted between evaluating the condition and entering the critical section, or the property breaks. This is one reason the algorithm requires sequential consistency: on a machine with weak memory ordering, the test and the entry could be reordered or another core could observe a stale value.

Implementing a spinlock with test-and-set

The test-and-set instruction provides a much simpler solution. The following implementation uses the C11 atomic operations to guarantee the atomicity of the read-modify-write:

#include <stdatomic.h>
#include <pthread.h>
#include <stdio.h>

typedef struct {
    atomic_int locked;
} spinlock_t;

void spin_lock(spinlock_t *s) {
    while (atomic_exchange(&s->locked, 1))
        ;   /* spin until we observe 0 */
}

void spin_unlock(spinlock_t *s) {
    atomic_store(&s->locked, 0);
}

/* Usage */
spinlock_t lock = { 0 };
int counter = 0;

void *worker(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        spin_lock(&lock);
        counter++;
        spin_unlock(&lock);
    }
    return NULL;
}

The atomic_exchange function performs the same operation as test-and-set: it returns the old value and stores the new value, atomically. If the lock was free, the function returns 0, the loop exits, and the caller holds the lock. If the lock was held, the function returns 1 and the caller loops.

This implementation satisfies mutual exclusion. It does not guarantee bounded waiting: a thread that is spinning may lose the race for the lock every time, and may spin indefinitely on a heavily contended lock. In practice, real spinlock implementations add a backoff step — the thread pauses or yields between attempts — to reduce contention and improve fairness.

The performance of the spinlock depends on the workload. For a short critical section, spinning is cheaper than blocking and waking up. For a long critical section, spinning wastes CPU cycles that could be spent on other work; a blocking lock is better. This is the trade-off examined in the next lesson, which presents the full lock abstraction and its implementations.

Measuring the effect

Running the racy counter from Lesson 6.1 produces an inconsistent value; running the protected version produces 2,000,000 reliably. The cost of the protection is the time spent acquiring and releasing the lock on every increment. On a lightly contended lock, the overhead is tens of nanoseconds per operation; on a heavily contended one, it can be hundreds of nanoseconds or more, because the cache line holding the lock bounces between cores.

Version Result (2 threads, 1M each) Time (relative)
Racy1,000,000–1,800,0001.0×
Atomic increment2,000,0003–5×
Spinlock2,000,00010–30×
Blocking mutex2,000,00015–50×

The numbers are illustrative: the actual overhead depends on the machine, the contention level, and the length of the critical section. The important point is that correctness has a cost, and the cost increases with contention. A program that is correct but slow is a very different problem from a program that is fast but wrong, and the designer must choose where to place the boundary.

06 Interactive activity

Activity · implement and test critical-section protocols

This activity lets you implement a spinlock from a hardware primitive, verify that it fixes the race condition from Lesson 6.1, and measure the cost of the protection. Work through the steps in your course VM.

  1. Implement a spinlock. Write a spinlock using the C11 atomic operations, as shown in the worked example. Compile and run the racy counter program from Lesson 6.1 with the spinlock protecting the increment. Verify that the result is always 2,000,000.
    #include <stdatomic.h>
    #include <pthread.h>
    #include <stdio.h>
    
    typedef struct { atomic_int locked; } spinlock_t;
    spinlock_t lock = { 0 };
    int counter = 0;
    
    void spin_lock(spinlock_t *s) {
        while (atomic_exchange(&s->locked, 1))
            ;
    }
    void spin_unlock(spinlock_t *s) {
        atomic_store(&s->locked, 0);
    }
    
    void *worker(void *arg) {
        for (int i = 0; i < 1000000; i++) {
            spin_lock(&lock);
            counter++;
            spin_unlock(&lock);
        }
        return NULL;
    }
    Compile with gcc -O2 -pthread -o spin spin.c and run it several times. Confirm the result is consistent.
  2. Verify with ThreadSanitizer. Compile with -fsanitize=thread and run. Does ThreadSanitizer report a race? Why or why not?
    gcc -O1 -g -fsanitize=thread -pthread -o spin_tsan spin.c
    ./spin_tsan
    ThreadSanitizer understands the C11 atomic operations and should not report a race on the counter, because the lock serializes the accesses. This is a good illustration of how the tool models synchronization.
  3. Measure the cost of the spinlock. Time the racy version, the atomic-increment version, and the spinlock version, each with two threads doing one million increments. Use clock_gettime with CLOCK_MONOTONIC around the whole computation.
    double now(void) {
        struct timespec ts;
        clock_gettime(CLOCK_MONOTONIC, &ts);
        return ts.tv_sec + ts.tv_nsec / 1e9;
    }
    Record the times and compute the ratios. Which version is fastest? Which is correct? What is the cost of correctness for this workload?
  4. Vary the contention. Increase the number of threads from 2 to 4 to 8 and re-measure. How does the time change for each version? Does the cost of the spinlock grow linearly with the number of threads, or faster? Explain your observation in terms of cache-line contention.
    for n in 2 4 8; do
        echo "threads = $n"
        ./spin $n
    done
  5. Compare a spinlock and a mutex. Replace the spinlock with a POSIX mutex and re-measure. Which is faster for this workload? Try changing the critical section to include a short sleep or a longer computation; which is faster now?
    #include <pthread.h>
    pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
    
    void lock(void)   { pthread_mutex_lock(&m); }
    void unlock(void) { pthread_mutex_unlock(&m); }
    The spinlock is faster when the critical section is short and the lock is held briefly; the mutex is faster when the critical section is long or the lock is heavily contended. This is the trade-off that the next lesson examines in detail.
  6. Implement test-and-set by hand (conceptually). Write a function int test_and_set(int *target) that performs the read-modify-write without using atomic operations, and use it in the spinlock. Compile and run. Does the program still produce the correct result? If it does, is that evidence that the implementation is correct?
    int test_and_set(int *target) {
        int old = *target;
        *target = 1;
        return old;
    }
    The non-atomic version may appear to work in a lightly loaded test, but it is incorrect and will fail under contention. This is the same lesson as Lesson 6.1: the absence of an observed failure is not evidence of correctness. Run under ThreadSanitizer to see the race reported.
  7. Observe the futility of spinning. Write a program where two threads contend for a spinlock, and one of them holds the lock for a long time (for example, with a short sleep inside the critical section). Measure the CPU time consumed by the spinning thread using getrusage or clock. How much CPU time is wasted? What would happen if the critical section were even longer? This is the motivation for blocking locks.

Reflect: the spinlock satisfies mutual exclusion, but it does not guarantee bounded waiting, and it wastes CPU time when the lock is held for a long period. A blocking lock avoids the wasted CPU but adds the cost of a context switch. In a system with many threads and a long critical section, which would you choose? How would you decide? What information would you need about the workload?

07 Knowledge check

Check your understanding

Question 1

Define the critical-section problem. What are the four sections of a process that contains a critical section, and what is the role of each?

Show answer
The critical-section problem is to design a protocol by which processes can cooperate on shared state such that at most one process is executing the code that accesses that state at any time. The four sections are: (1) the entry section, which contains the protocol by which a process requests permission to enter the critical section; (2) the critical section, which contains the code that accesses the shared state; (3) the exit section, which contains the protocol by which the process releases permission; and (4) the remainder section, which contains the code that does not touch the shared state and can therefore run concurrently with other processes. The entry and exit sections together provide the synchronization that makes the operations in the critical section appear atomic to other processes.

Question 2

State the three requirements of a correct solution to the critical-section problem. Give an example of a protocol that satisfies mutual exclusion but violates progress, and one that satisfies mutual exclusion and progress but violates bounded waiting.

Show answer
The three requirements are:
  1. Mutual exclusion: if a process is executing in its critical section, no other process may be executing in its critical section on the same shared state.
  2. Progress: if no process is in its critical section and some processes wish to enter, only those processes that are not in their remainder sections may participate in the decision of which will enter next, and the decision cannot be postponed indefinitely.
  3. Bounded waiting: there is a bound on the number of times other processes may enter their critical sections after a process has made a request to enter and before that request is granted.
An example of a protocol that satisfies mutual exclusion but violates progress is one in which each process sets a flag and then waits for the other process's flag to be clear before entering. If both set their flags simultaneously, each waits for the other, and neither enters — a deadlock. An example of a protocol that satisfies mutual exclusion and progress but violates bounded waiting is a simple spinlock using test-and-set: a process that is spinning may lose the race to acquire the lock every time it is released, and may therefore spin indefinitely while another process repeatedly enters and exits.

Question 3

Write Peterson's solution for two processes and explain informally why it satisfies mutual exclusion, progress, and bounded waiting.

Show answer
Peterson's solution for process i (with j = 1 − i) is:
do {
    flag[i] = 1;                    /* I want to enter */
    turn = j;                       /* but you can go first */
    while (flag[j] && turn == j)
        ;
    /* critical section */
    flag[i] = 0;
    /* remainder section */
} while (1);
Mutual exclusion: suppose both processes are in their critical sections. For P0 to be in its critical section, its wait condition flag[1] && turn == 1 must have been false, so either flag[1] == 0 or turn == 0. But P1 has set flag[1] = 1, so flag[1] == 0 is impossible. Therefore turn == 0 when P0 entered. But P1 set turn = 0 before entering, so P1's wait condition flag[0] && turn == 1 is true, and P1 is waiting. Contradiction. Progress: if only one process wants to enter, its wait condition is false because the other's flag is 0, and it enters. If both want to enter, both have set their flags, and the value of turn determines which one enters and which waits. In either case, at least one process enters. Bounded waiting: suppose P0 wants to enter and P1 is in its critical section. P0 sets its flag and turn = 1 and waits. When P1 exits, it sets flag[1] = 0, and P0 enters. If P1 tries to re-enter immediately, it sets turn = 0 and waits. So P0 enters after at most one entry by P1.

Question 4

What is test-and-set? Write a spinlock using it, and explain why the spinlock satisfies mutual exclusion but not bounded waiting.

Show answer
Test-and-set is a hardware instruction that atomically reads a memory location, stores the value 1 into it, and returns the old value. It is the primitive used to implement the simplest spinlock:
int test_and_set(int *target) {
    int old = *target;
    *target = 1;
    return old;
}

void acquire(int *l) {
    while (test_and_set(l))
        ;
}

void release(int *l) {
    *l = 0;
}
Mutual exclusion holds because test_and_set is atomic: only one thread can observe the lock as free (value 0) and proceed to acquire it. All other threads observe the lock as held (value 1) and continue spinning. Bounded waiting does not hold because there is no mechanism to ensure that a thread that has been spinning will acquire the lock before a thread that releases it and immediately re-acquires it. The classic failure is a thread that releases the lock and re-acquires it before a waiting thread has a chance to run; the waiting thread continues to spin, and the cycle repeats. There is no bound on the number of times the releasing thread can win the race.

Question 5

Why is Peterson's solution not used in practice on modern hardware? What feature of modern hardware breaks it, and what is the alternative?

Show answer
Peterson's solution assumes sequential consistency: a memory model in which all memory operations execute in program order and all threads see the same order of operations. Modern hardware does not provide this model. Two features break the solution. First, the compiler may reorder independent memory accesses for optimisation, moving the read of flag[j] out of the while loop, since it does not know that another thread may modify it. The resulting code does not implement the protocol. Second, the processor may execute memory accesses out of order, and other cores may observe those reordered accesses. A core may see turn = j before it sees flag[i] = 1, or vice versa, even though the program specifies the opposite order. The result is that the protocol can allow both processes into their critical sections at once. The alternative is to use hardware atomic primitives — test-and-set and compare-and-swap — which are implemented by the processor with the necessary ordering guarantees, and to build synchronization from them. These primitives are what the C11 atomic operations and the POSIX threading library use internally. Peterson's solution can be made to work with explicit memory barriers and atomic variables, but there is no reason to do so when library primitives are available and well tested.

08 Unit discussion

Discussion prompt

Peterson's solution is a beautiful piece of work: a complete solution to the critical-section problem, using only ordinary memory reads and writes, proved correct for two processes and generalizable to n. It was published in 1981 and remains a standard topic in operating-systems courses. And yet it is not used in any production system, because the hardware it was designed for no longer exists.

This raises a general question about the relationship between theory and practice in computer science. The theory of synchronization — Peterson's algorithm, the bakery algorithm, the formal semantics of mutual exclusion — is elegant and correct. The practice — C11 atomics, POSIX mutexes, hardware cache-coherence protocols — is messy and hardware-specific. What is the value of the theory if it cannot be used directly? Is it merely educational, or does it inform the design of the practical systems?

Consider also the direction of the influence. The hardware primitives — test-and-set, compare-and-swap — were designed after the software solutions, as a response to the difficulty of implementing software synchronization on real hardware. Did the theory guide the design of the hardware? Or did the hardware constraints determine what the theory could be?

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

  1. Trace Peterson's solution. Two processes execute Peterson's protocol. Construct three interleavings: (a) one in which P0 enters first, (b) one in which P1 enters first, and (c) one in which both set their flags before either tests its condition. For each, trace the values of the shared variables and the state of each process at each step. Confirm that mutual exclusion holds in all three.
  2. Write a proof of progress. Write a careful proof that Peterson's solution satisfies progress. Your proof should cover the case in which only one process wants to enter and the case in which both want to enter. State any assumptions you make about the memory model.
  3. Implement a spinlock and test it. Implement the spinlock from the worked example, compile it with -fsanitize=thread, and run it. Confirm that no race is reported. Then implement a non-atomic version of test_and_set that performs the read and the write as separate operations, use it in the spinlock, and run under ThreadSanitizer again. What does the tool report? Explain the difference.
  4. Measure the cost of protection. Time the following four versions of the counter program, each with two threads doing one million increments: (a) the racy version, (b) the version using atomic_fetch_add, (c) the version using the spinlock, and (d) the version using a POSIX mutex. Run each five times and report the mean and standard deviation. Which is fastest? Which is correct? What is the cost of correctness?
  5. Design a bounded-waiting spinlock. The simple spinlock does not satisfy bounded waiting. Design a spinlock that does, using a ticket-based scheme: each thread takes a ticket on arrival and waits until its ticket is called. Implement it with atomic_fetch_add and atomic_load, and verify that it satisfies all three requirements. Run it under ThreadSanitizer.
    typedef struct {
        atomic_int next_ticket;
        atomic_int now_serving;
    } ticket_lock_t;
  6. Compare with the bakery algorithm. The ticket lock is a simplified version of the bakery algorithm. Read the description of the bakery algorithm in Silberschatz and identify the differences. What does the bakery algorithm do that the ticket lock does not? Why is the additional complexity necessary in the general case?
  7. Read man 3 pthread_mutex_init. Read the man page in full. What mutex types does POSIX define? What is the difference between a normal mutex, an error-checking mutex, a recursive mutex, and a default mutex? Which of these might deadlock if a thread tries to lock the same mutex twice? How does this connect to the critical-section problem?
  8. Reflect. In your own words, explain why a critical section makes a sequence of operations appear atomic to other threads even though the operations themselves are not atomic at the machine level. Then describe a situation in which protecting a critical section with a lock would be incorrect — for example, because the lock is not held on every path, or because two different locks are used for the same shared state.

10 Summary

  • A critical section is a region of code that must be executed by at most one thread at a time. The operations inside it are still non-atomic, but the synchronization protocol makes them appear atomic to other threads.
  • The structure of a solution is: entry section (request permission), critical section (access shared state), exit section (release permission), and remainder section (work that does not touch shared state).
  • A correct solution must satisfy three requirements: mutual exclusion (at most one thread in the critical section), progress (some thread enters if the critical section is free), and bounded waiting (no thread waits indefinitely).
  • Peterson's solution is a software solution for two processes. It uses a flag per process and a turn variable, and it satisfies all three requirements on a sequentially consistent machine.
  • Lamport's bakery algorithm generalizes the idea to n processes using a ticket-based scheme. It is correct but requires O(n) work per entry.
  • Software-only solutions are not used in practice because modern hardware does not provide sequential consistency. Compiler and processor reordering can break the protocols even when the algorithm is correct in principle.
  • Test-and-set and compare-and-swap are hardware atomic read-modify-write instructions. They are the primitives from which all practical synchronization is built.
  • A spinlock is implemented with test-and-set. It satisfies mutual exclusion but not bounded waiting, and it wastes CPU time when the lock is held for a long period.
  • Disabling interrupts provides mutual exclusion on a uniprocessor but is not sufficient on a multiprocessor, and it is a privileged operation.
  • The critical-section abstraction addresses the third condition for a race condition from Lesson 6.1 — non-atomic access — by making a sequence of operations appear atomic to other threads.

Looking ahead

This lesson introduced the critical-section abstraction and the hardware primitives that make it implementable. Lesson 6.3 presents the lock: the standard library abstraction for protecting a critical section. It examines the difference between spinlocks and blocking locks, the various implementation strategies (test-and-set, ticket locks, queue locks, futex-based locks), the cost of contention, and the design of the POSIX mutex. The lock is the tool you will use in every concurrent program in the rest of the course.

11 References

  • Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 6, Sections 6.1–6.5.
  • Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C. Operating Systems: Three Easy Pieces, Chapter 28. Available online at ostep.org.
  • Tanenbaum, A. S., & Bos, H. Modern Operating Systems, 4th ed., Chapter 2, Section 2.3.
  • Peterson, G. L. Myths About the Mutual Exclusion Problem. Information Processing Letters, 1981. The original paper on Peterson's solution.
  • Lamport, L. A New Solution of Dijkstra's Concurrent Programming Problem. Communications of the ACM, 1974. The bakery algorithm.
  • Herlihy, M., & Shavit, N. The Art of Multiprocessor Programming, 2nd ed. The definitive treatment of the hardware primitives and their use in lock-free data structures.
  • ISO/IEC 9899:2011, Programming Languages — C, Section 7.17 (atomics).
  • Linux man-pages: man 7 pthreads, man 3 pthread_mutex_init, man 3 pthread_mutex_lock, man 3 pthread_spin_lock.
👤