COMP 3150Operating Systems

01 Introduction

The previous six lessons of this unit introduced the classical synchronization abstractions: race conditions, critical sections, locks, semaphores, monitors, and condition variables. Those abstractions are general — they appear in every operating system and every concurrent language. This final lesson examines the specific mechanisms that Linux provides to implement them.

Linux has two distinct worlds of synchronization. The user-space world is the one most programmers work in: the POSIX threading library (pthread) provides mutexes, condition variables, read-write locks, spinlocks, and barriers, and the POSIX semaphore library provides semaphores. These primitives are standardized and portable. A program written against them runs on Linux, macOS, the BSDs, and any other POSIX-conformant system.

The kernel-space world is the one the kernel uses internally. The kernel cannot use the POSIX library — it does not have an underlying library to call — so it provides its own primitives: spinlocks, mutexes, reader-writer locks, seqlocks, RCU, completion variables, and atomic operations. These primitives have different names, different semantics, and different constraints from their user-space counterparts, because they operate in a context where sleeping is forbidden in some places and preemption is disabled in others.

Between the two worlds sits the futex — the fast userspace mutex mechanism introduced in Lesson 6.3. The futex is the bridge: the user-space POSIX mutex is implemented on top of it, and it uses the kernel only when a thread must actually block. Understanding the futex completes the picture of how user-space synchronization works on Linux.

This lesson closes the unit. It surveys both worlds, places each primitive in its appropriate context, and provides the practical guidance for choosing among them. It also closes with a bridge to Unit 7, which examines the problem that arises when multiple locks are held simultaneously: deadlock.

Prerequisites

This lesson builds on all of Unit 6. Familiarity with the lock (Lesson 6.3), the semaphore (Lesson 6.4), the monitor (Lesson 6.5), and the condition variable (Lesson 6.6) is assumed. The futex mechanism was introduced in Lesson 6.3 and is used here without re-derivation; if it is unfamiliar, review section 4.6 of that lesson before continuing.

02 Learning objectives

By the end of this lesson you will be able to

  • List the POSIX synchronization primitives and describe the appropriate use of each.
  • Explain how the futex bridges user space and kernel space.
  • Describe the kernel's synchronization primitives and the constraints of each.
  • Choose the appropriate primitive for a given synchronization problem.
  • Describe the trade-offs of fine-grained, per-CPU, and lock-free synchronization.

This lesson contributes to U6.5 and U6.6, and to course learning outcomes CLO2, CLO3, and CLO6.

03 Required reading

  • Silberschatz et al. — Chapter 6, Section 6.9, Synchronization Examples. The POSIX synchronization primitives and the Java synchronization primitives, side by side.
  • Love, R. — Linux Kernel Development, 3rd ed., Chapter 9, Kernel Synchronization Methods, and Chapter 10, Synchronization Methods, Part II. The authoritative treatment of the kernel's primitives. Read the chapters together; they cover atomics, spinlocks, mutexes, semaphores, completion variables, RCU, and the memory barriers.
  • Love, R. — Linux System Programming, 2nd ed., Chapter 10, Concurrency. The user-space view of the same topic.
  • Butenhof, D. R. — Programming with POSIX Threads, Chapters 7 and 8. The definitive reference for the POSIX primitives — mutexes, condition variables, read-write locks, spinlocks, and barriers.
  • Drepper, U. — Futexes Are Tricky. Red Hat, 2004. The futex interface and its pitfalls.
  • McKenney, P. E. — Is Parallel Programming Hard, And, If So, What Can You Do About It? The definitive reference for RCU and the parallel-programming techniques it enables.
  • Linux man pages: man 7 pthreads, man 7 sem_overview, man 2 futex, man 3 pthread_mutex_init, man 3 pthread_cond_init, man 3 pthread_rwlock_init, man 3 pthread_spin_lock, man 3 pthread_barrier_init, man 3 pthread_mutexattr_setprotocol, man 3 pthread_mutexattr_setpshared.

Reading strategy

Read the Silberschatz section first for the catalogue of POSIX primitives. Then read Love Chapter 9 for the kernel's primitives — the chapter is long but it is the most accessible introduction to kernel synchronization available. Pay particular attention to the sections on spinlocks and RCU: those are the two primitives that most distinguish kernel synchronization from user-space synchronization, and they are the ones most likely to appear in an exam. Keep the man pages open as a reference when working through the interactive activity.

04 Core content

4.1 The two worlds of Linux synchronization

Synchronization in Linux is divided between user space and kernel space. The division is not arbitrary; it follows from the different constraints of the two environments.

  • User space is where applications run. The synchronization primitives here must be portable across POSIX systems, must be safe to use from many threads, and must not require privileges. They are provided by the C library (glibc on Linux), which implements them using the kernel only when necessary.
  • Kernel space is where the kernel itself runs. The synchronization primitives here must work in contexts where the kernel cannot sleep (interrupt handlers, softirqs, some spinlock-protected regions), must be fast enough for the hot paths of the networking and file-system code, and must work on a wide range of hardware, including systems with hundreds of cores. They are provided by the kernel itself and cannot be called from user space.

The two worlds are linked by the futex, which is the only primitive that exists in both. A futex is a user-space integer that the kernel treats specially: user-space code manipulates it with atomic operations, and the kernel is involved only when a thread must wait or be woken. The user-space POSIX mutex is built on top of futexes; the futex is the mechanism by which the user-space world calls into the kernel world when it needs to.

The Linux synchronization landscape A layered diagram showing the synchronization primitives in Linux. The top layer is user space, with POSIX primitives: pthread mutex, condition variable, read-write lock, spinlock, barrier, and the POSIX semaphore. The middle layer is the futex, the bridge between user space and kernel space. The bottom layer is kernel space, with the kernel's own primitives: spinlock, mutex, rwlock, seqlock, RCU, completion, and atomic operations. Arrows show the futex calls down from the POSIX mutex and condition variable into the kernel, and the kernel's primitives are used only by the kernel itself. THE LINUX SYNCHRONIZATION LANDSCAPE USER SPACE BRIDGE KERNEL SPACE POSIX primitives (glibc) pthread_mutex_t lock / mutex pthread_cond_t condition variable pthread_rwlock_t read-write lock pthread_spinlock_t spinlock pthread_barrier_t barrier sem_t · POSIX semaphore counting and binary semaphores (semaphore.h) C11 atomics · <stdatomic.h> atomic_int, atomic_fetch_add, atomic_exchange, atomic_compare_exchange Portable across POSIX systems. Do not require privileges. Fast when uncontended; kernel is called only on contention. futex — fast userspace mutex a 32-bit integer in user memory that the kernel treats specially user space does the atomic work; kernel is entered only to wait or to wake Kernel primitives (linux/…) spinlock_t busy-wait, no sleep struct mutex can sleep rwlock_t readers-writers seqlock_t optimistic readers completion one-shot wait RCU · read-copy-update readers lock-free, writers make copies atomic_t, atomic_long_t, atomic64_t · bit operations single-instruction atomic operations on integers and bits Kernel-only. Spinlocks are used in contexts where sleeping is forbidden; mutexes where it is allowed. RCU is used for read-mostly data. system call
Figure 6.7 — The Linux synchronization landscape. User space provides the POSIX primitives and the C11 atomic operations. The futex is the bridge: user-space locks manipulate a futex in user memory with atomic operations and call into the kernel only when a thread must wait or be woken. Kernel space provides its own primitives with semantics tuned to the constraints of kernel code.

4.2 The POSIX user-space primitives

The POSIX threading library provides six categories of synchronization primitive, and the POSIX semaphore library provides a seventh. The table below summarises them and the appropriate use of each.

The POSIX synchronization primitives
Type Header Purpose Key operations
pthread_mutex_t <pthread.h> Mutual exclusion lock, trylock, timedlock, unlock
pthread_cond_t <pthread.h> Waiting for a condition wait, timedwait, signal, broadcast
pthread_rwlock_t <pthread.h> Reader-writer locking rdlock, wrlock, tryrdlock, trywrlock, unlock
pthread_spinlock_t <pthread.h> Very short critical sections lock, trylock, unlock
pthread_barrier_t <pthread.h> Rendezvous point for a fixed number of threads wait
sem_t <semaphore.h> Counting, signalling, or mutual exclusion wait, trywait, timedwait, post, getvalue
atomic_* <stdatomic.h> Lock-free operations on a single variable load, store, exchange, fetch_add, compare_exchange_*

Each of the mutex-family types can be configured with attributes that change its behaviour. The most important attributes are:

  • Type. A normal mutex (PTHREAD_MUTEX_NORMAL) does not check for errors: relocking a held mutex deadlocks the thread silently, and unlocking an unheld mutex is undefined. An error-checking mutex (PTHREAD_MUTEX_ERRORCHECK) returns EDEADLK on a relock and EPERM on an unlock of an unheld mutex — useful during development but slower in production. A recursive mutex (PTHREAD_MUTEX_RECURSIVE) allows a thread to relock a mutex it already holds, maintaining a lock count; each lock must be balanced by an unlock. A default mutex (PTHREAD_MUTEX_DEFAULT) has implementation-defined semantics for relock, but on Linux it behaves like a normal mutex.
  • Process-shared. By default, a mutex is shared only between threads of the same process. Setting PTHREAD_PROCESS_SHARED allows it to be shared between processes that map the same memory region — useful for process-level cooperation but with higher overhead on every operation.
  • Protocol. By default, a mutex does not implement any priority protocol. Setting PTHREAD_PRIO_INHERIT enables priority inheritance, so that a low-priority thread holding the mutex temporarily inherits the priority of a high-priority thread that blocks on it. Setting PTHREAD_PRIO_PROTECT enables the priority ceiling protocol. These are used in real-time systems to bound priority inversion (Lesson 5.4).
  • Robustness. A robust mutex (PTHREAD_MUTEX_ROBUST) detects when the thread holding it has died, so that another thread can take over. This matters for process-shared mutexes in shared memory, where a crashed process would otherwise leave the mutex permanently locked.

4.3 The futex bridge

The futex (fast userspace mutex) is the mechanism that makes user-space locking fast on Linux. It was introduced in Lesson 6.3 and is worth revisiting here in its role as the bridge between the two worlds.

The futex's essential design is a division of labour. The common case — acquiring or releasing an uncontended lock, or acquiring a lightly contended one after a short spin — is handled entirely in user space, by atomic operations on a 32-bit integer. The rare case — blocking a thread that cannot acquire the lock, or waking one — is handled by the kernel, via two futex system calls: FUTEX_WAIT and FUTEX_WAKE.

/* User-space fast path of an uncontended lock acquire */
if (atomic_exchange(&lock->state, 1) == 0)
    return;                 /* we got it — no system call */

/* Slow path: another thread holds the lock */
while (atomic_exchange(&lock->state, 2) != 0) {
    syscall(SYS_futex, &lock->state, FUTEX_WAIT, 2, NULL, NULL, 0);
    /* returns when the kernel wakes us */
}

The user-space POSIX mutex, condition variable, read-write lock, and semaphore are all built on top of futexes. When you call pthread_mutex_lock, the library attempts the atomic fast path and, if it fails, enters the kernel through the futex system call. The result is that the kernel is involved only when it must be: an uncontended mutex is acquired and released with a couple of atomic instructions and no system call at all.

The futex interface is not part of POSIX. It is a Linux-specific mechanism, and the futex man page describes it as "not intended for direct use from applications". The interface is complex and its contract is easy to violate — the kernel checks a futex value before sleeping, and the check-then-wait sequence is racy if not done carefully. The recommendation is always to use the POSIX primitives rather than raw futexes, and to use raw futexes only when implementing a runtime or a lock library.

4.4 The kernel's synchronization primitives

The kernel cannot use the POSIX library. It provides its own primitives, with semantics that reflect the constraints of kernel code. The most important of these are listed below.

spinlock_t
A spinlock. The kernel version of the spinlock from Lesson 6.3. It busy-waits until the lock is free, and it must be used only in contexts where sleeping is forbidden — interrupt handlers, softirqs, and any code that holds a spinlock with interrupts disabled. The critical section must be short, and it must not block or sleep. The kernel provides many variants, including spin_lock_irqsave (disables local interrupts and saves their previous state) and spin_lock_bh (disables bottom halves), to protect against interrupt handlers that might try to acquire the same lock.
struct mutex
A sleeping lock. The kernel's mutex is a blocking lock: when a thread cannot acquire it, the thread sleeps, and the scheduler runs another thread. It is the right choice for critical sections that may be long, for code that may need to sleep (allocate memory, wait for I/O), and for any context where sleeping is permitted. The mutex cannot be used in an interrupt handler, because interrupt handlers cannot sleep.
rwlock_t and struct rw_semaphore
Reader-writer locks. The rwlock_t is the spinning variant and is used in the same contexts as a spinlock; the rw_semaphore is the sleeping variant. Both allow multiple readers simultaneously but only one writer. They are used for data structures that are read much more often than they are written.
seqlock_t
A sequence lock, optimised for write-rarely, read-frequently data. Readers read the sequence number, read the data, then re-read the sequence number; if the number has changed, the read was concurrent with a write and must be retried. Readers never block, and readers do not block writers; the write path is very fast. The classic use is the system clock and other frequently-read, rarely-written values.
struct completion
A one-shot rendezvous. A completion variable lets one thread wait for another to finish some work. It is used throughout the kernel for the pattern "thread A does work, thread B waits for it to finish". The completion is initialised once, waited on once, and completed once. It is a lighter-weight mechanism than a full semaphore or mutex for this specific pattern.
struct rcu_head and the RCU API
Read-copy-update, the most distinctive of the kernel's synchronization mechanisms. RCU allows lock-free reads of a data structure that may be modified concurrently. Readers execute an rcu_read_lock/rcu_read_unlock pair around the read, but this does not actually lock anything — it merely marks the read as being in progress. Writers make a copy of the data structure, modify the copy, and atomically swap the new version in, then wait for all current readers to finish before freeing the old version. Readers never block; writers pay the cost of copying and of waiting for readers to quiesce. RCU is used throughout the kernel for read-mostly data structures: the routing table, the process table, the file descriptor table, and many others.
atomic_t, atomic_long_t, atomic64_t
Atomic integer types. The kernel provides functions for atomic increment, decrement, addition, subtraction, and compare-exchange on these types. They are used for reference counts, statistics counters, and any single-variable operation that must be atomic but does not warrant a full lock.
Memory barriers: smp_mb(), smp_rmb(), smp_wmb()
Full, read, and write memory barriers. These prevent the compiler and the CPU from reordering memory accesses across the barrier. They are needed when a synchronization protocol depends on the order of memory operations, and they are one of the most subtle aspects of kernel programming. A locking primitive that is used correctly provides the necessary barriers implicitly; barriers are needed only when building a primitive, not when using one.

4.5 Spinlock or mutex: the fundamental kernel choice

The most important decision in kernel synchronization is the choice between a spinlock and a mutex. The two are appropriate in different contexts, and choosing wrongly is a serious bug.

Spinlock versus mutex in the kernel
Aspect Spinlock Mutex
Wait behaviour Busy-waits (burns CPU) Sleeps (yields CPU)
Can be used in interrupt context? Yes No
Can the holder sleep? No — sleeping while holding a spinlock deadlocks the system Yes
Typical critical section A few instructions Anything up to a few milliseconds
Cost when contended Wasted CPU cycles on all waiters Context-switch cost, but no wasted CPU
When to prefer it Short critical section, or the holder cannot sleep Longer critical section, or the holder must sleep

The rule of thumb is: use a spinlock when the critical section is short and you are certain the holder will not sleep; use a mutex in every other case. Most kernel code that touches user data, allocates memory, or waits for I/O uses a mutex. Spinlocks are used in the hottest paths — the scheduler, the interrupt handlers, the networking stack — where the cost of a context switch would be prohibitive.

The cardinal rules of kernel locking

Three rules are absolute. (1) Never sleep while holding a spinlock. Sleeping while holding a spinlock deadlocks at least one CPU and often the whole system. Any function that might sleep — allocation with GFP_KERNEL, copy_to_user, mutex_lock, wait_for_completion — must not be called from a spinlock-protected region. (2) Always release locks in the reverse order from acquisition. Failing to do so is a deadlock waiting to happen. (3) Hold locks for the shortest time possible. A lock held for a long time serializes the system; a lock held for the shortest possible time is the goal.

4.6 Per-CPU variables

A theme that appears in both worlds — and especially in the kernel — is that the fastest way to synchronize is often to avoid sharing altogether. The kernel provides per-CPU variables: variables for which each CPU has its own copy, so that a thread running on a given CPU accesses only that CPU's copy and never contends with threads on other CPUs.

DEFINE_PER_CPU(int, my_counter);

/* On the current CPU, no lock needed */
this_cpu_inc(my_counter);

/* To read another CPU's value, use a per-CPU accessor */
int total = 0;
int cpu;
for_each_possible_cpu(cpu)
    total += per_cpu(my_counter, cpu);

Per-CPU variables are used for statistics counters, the scheduler's runqueues, and many other data structures where the natural scope of the data is per-CPU. The general principle — partition the state so that threads do not share it — is the same one we saw in Lesson 6.3 for the per-thread counter, and it is arguably the most important technique in scalable concurrent programming. A per-CPU variable requires no lock at all, because by construction no two threads can access the same copy concurrently.

The trade-off is that reading the aggregate value — the sum across all CPUs — is more expensive, because each CPU's copy must be read separately. Per-CPU variables are therefore appropriate when writes are frequent and reads are rare, or when the read can tolerate a slightly stale value.

4.7 RCU: read-copy-update

RCU is the most distinctive of the kernel's synchronization mechanisms, and it is worth understanding because it illustrates a general principle about read-mostly data structures.

The problem RCU solves is this: many kernel data structures are read extremely frequently and modified only rarely. The routing table, the process table, and the file descriptor table are examples. A conventional lock — even a reader-writer lock — imposes a cost on every read, because readers must acquire and release the lock. RCU removes that cost entirely by allowing readers to access the data without any lock at all.

The mechanism is as follows. Readers execute an rcu_read_lock that does not acquire any lock at all — it merely informs the kernel that the current thread is in an RCU read-side critical section. The reader then accesses the data structure normally and executes rcu_read_unlock. Writers do not modify the existing data structure; they make a copy, modify the copy, and atomically install the copy as the new version of the data structure. The old version is not freed immediately, because existing readers may still be reading it. Instead, the writer registers the old version with the kernel, which frees it only after all current RCU readers have finished (after a "grace period"). Readers never block; writers block briefly while installing the new version, and the freeing is deferred.

The result is a data structure that can be read without any synchronization overhead at all — the reader does not even touch shared state to enter or leave the critical section. This is a substantial advantage on a many-core system, where lock contention would otherwise dominate the cost of a read.

RCU is not a general-purpose replacement for locks. It requires a workload where reads dominate writes (typically by a factor of ten or more), where readers can tolerate seeing an old version of the data for a short period, and where the update operation can be expressed as "replace the whole structure" rather than "modify in place". Where these conditions hold, RCU is dramatically faster than any lock. Where they do not, a lock is the right choice.

4.8 Choosing the right primitive

The following decision procedure is a useful guide for choosing the right synchronization primitive in Linux.

  1. Can the operation be expressed as a single atomic instruction? If so, use an atomic operation — atomic_fetch_add in user space, this_cpu_inc or atomic_inc in the kernel. No lock needed.
  2. Can the state be partitioned? If so, give each thread or CPU its own copy — a per-thread counter, a per-CPU variable — and combine the values only when necessary. No lock needed in the common case.
  3. Is the critical section short and free of any operation that might block? In user space, use a spinlock or a mutex; in the kernel, use a spinlock. The choice between spinlock and mutex depends on the expected contention and on whether sleeping is allowed.
  4. Is the critical section long, or does it contain operations that may block? Use a mutex (user space or kernel). The mutex blocks rather than spinning, so it does not waste CPU.
  5. Is the data read much more often than it is written? Use a reader-writer lock (user space or kernel). If the read-mostly property is extremely strong and the workload is in the kernel, use RCU.
  6. Is the problem one of signalling rather than mutual exclusion? Use a condition variable (user space) or a completion (kernel). A one-shot wait is cheaper than a full semaphore or mutex.
  7. Is the problem a count of available resources? Use a semaphore. The counting semaphore is the right tool when multiple threads share a pool of N identical units.
  8. Is the problem specific to a monitor-style pattern? Use a mutex and one or more condition variables (user space) or the equivalent kernel pattern. The monitor is a programming discipline, not a single primitive.

The general guidance is to start with the simplest primitive that solves the problem, and to add complexity only when measurement shows that the simplest choice is too slow. Most synchronization needs are met by a mutex and, occasionally, a condition variable. The more elaborate primitives — reader-writer locks, RCU, per-CPU variables — are optimisation tools that should be reserved for the hot paths where they make a measurable difference.

4.9 Common misconceptions

"A spinlock is always faster than a mutex."

A spinlock is faster when the lock is held briefly and contention is low. Under contention, or with a long critical section, a mutex is faster, because the waiting threads do not waste CPU.

"The kernel's spinlock and the POSIX spinlock are the same."

They have similar names but different semantics. The kernel spinlock interacts with interrupt disabling, preemption control, and the kernel's own memory model; it cannot be used from user space, and the POSIX spinlock cannot be used in the kernel.

"RCU is a lock."

rcu_read_lock does not acquire a lock. It marks a critical section so the kernel knows not to free data the reader might still be using. Readers never block; RCU is a synchronization technique, not a lock.

"Per-CPU variables need a lock."

No. A per-CPU variable has one copy per CPU, and each CPU accesses only its own copy. The whole point is that no lock is needed. Reading the aggregate value requires care, but the individual accesses do not.

"A futex is a lock."

A futex is a mechanism for building locks. It is an integer in user memory plus two system calls. The lock is built on top of the futex by user-space code that manipulates the integer atomically.

"I can sleep while holding a kernel spinlock, if the critical section is short."

No. Sleeping while holding a spinlock is a system-hanging bug. The holder must not do anything that might block, even briefly. Any function that might sleep is forbidden in a spinlock-protected region.

"The POSIX mutex is slow because it needs a system call."

The POSIX mutex uses a futex, so the uncontended fast path involves no system call at all — just a couple of atomic instructions. The kernel is entered only when the lock is contended.

"More locks make a program more correct."

More locks make a program more complex and more prone to deadlock. The right number of locks is the smallest number that protects the shared state. Adding locks "just in case" is a common source of bugs.

05 Worked example

The following example implements the same synchronization problem — a bounded buffer — with three of the Linux primitives and compares their behaviour and performance.

Three ways to synchronize a bounded buffer

The workload: four producers and four consumers share a buffer of 100 slots. Each producer inserts 100,000 items; each consumer removes 100,000 items. The buffer is protected by one of three mechanisms, and the synchronization is measured on the total execution time and on the total number of system calls.

Version 1: pthread_mutex_t + pthread_cond_t

The classic monitor pattern from Lesson 6.5. A single mutex protects the buffer's structure, and two condition variables handle the "not full" and "not empty" conditions.

typedef struct {
    pthread_mutex_t mutex;
    pthread_cond_t  not_full;
    pthread_cond_t  not_empty;
    int buffer[100];
    int in, out, count;
} buffer_t;

void buffer_insert(buffer_t *b, int item) {
    pthread_mutex_lock(&b->mutex);
    while (b->count == 100)
        pthread_cond_wait(&b->not_full, &b->mutex);
    b->buffer[b->in] = item;
    b->in = (b->in + 1) % 100;
    b->count++;
    pthread_cond_signal(&b->not_empty);
    pthread_mutex_unlock(&b->mutex);
}

Under the workload, the mutex is contended heavily, and each contended acquisition enters the kernel via the futex. The total number of system calls depends on the contention rate: on a lightly loaded system, most acquisitions succeed on the fast path; on the loaded system of this workload, most acquisitions fall through to the slow path.

Version 2: sem_t (POSIX semaphore)

The semaphore solution from Lesson 6.4, using three semaphores. The empty and full semaphores count free and filled slots; the mutex semaphore protects the buffer structure.

sem_t empty, full, mutex;

void buffer_insert(int item) {
    sem_wait(&empty);
    sem_wait(&mutex);
    buffer[in] = item;
    in = (in + 1) % 100;
    sem_post(&mutex);
    sem_post(&full);
}

The semaphore version is functionally equivalent to the mutex-plus-condition-variable version, but the sem_wait and sem_post calls have a different internal path. The sem_post is a single atomic increment on the fast path, and it wakes a waiter only if one exists — which is cheaper than a pthread_cond_signal, because the signal is checked without a system call. The sem_wait is similar to the mutex's fast path.

Version 3: C11 atomics with a lock-free ring buffer

A single-producer, single-consumer (SPSC) ring buffer can be implemented without any lock at all, using only atomic variables. The producer and consumer each have their own index; they exchange data through the buffer using atomic loads and stores. The SPSC buffer is the simplest case: with a single producer and a single consumer, the producer only modifies in and reads out, and the consumer does the opposite. No lock is needed, because there is never a concurrent write to the same location.

typedef struct {
    int      buffer[100];
    atomic_int in;
    atomic_int out;
} spsc_buffer_t;

bool spsc_try_insert(spsc_buffer_t *b, int item) {
    int in  = atomic_load_explicit(&b->in,  memory_order_relaxed);
    int out = atomic_load_explicit(&b->out, memory_order_acquire);
    int next = (in + 1) % 100;
    if (next == out)
        return false;           /* buffer full */
    b->buffer[in] = item;
    atomic_store_explicit(&b->in, next, memory_order_release);
    return true;
}

bool spsc_try_remove(spsc_buffer_t *b, int *item) {
    int out = atomic_load_explicit(&b->out, memory_order_relaxed);
    int in  = atomic_load_explicit(&b->in,  memory_order_acquire);
    if (out == in)
        return false;           /* buffer empty */
    *item = b->buffer[out];
    atomic_store_explicit(&b->out, (out + 1) % 100,
                         memory_order_release);
    return true;
}

The memory ordering annotations are essential. The memory_order_acquire on the load of the other thread's index ensures that the writer's data writes are visible before its index store; the memory_order_release on the store of the writer's own index ensures the same on the writer's side. The memory_order_relaxed on the load of the writer's own index is safe because only that thread writes it. This is an example of a lock-free data structure, and it is one of the fastest possible inter-thread queues.

The SPSC ring buffer works for exactly one producer and one consumer. Adding a second producer or consumer requires a compare-and-swap loop to make the index update atomic across multiple writers, and the buffer becomes significantly more complex. With four producers and four consumers, as in our workload, a lock-free implementation would require a multi-producer multi-consumer queue, which is a substantial piece of engineering. For this reason, the lock-free version is not the right choice for the mixed workload; it is the right choice when the workload is truly single-producer, single-consumer.

Results

The following table reports the measured time and system call count for the three implementations on a four-core machine. The numbers are illustrative; the actual results depend on the hardware and on the system load.

Implementation Time (relative) System calls (relative) Notes
Mutex + condvar 1.0× 1.0× Correct, general. Standard pattern.
Semaphores 0.85× 0.7× Correct, general. Fewer wakeups than condvar.
SPSC lock-free (1 producer, 1 consumer) 0.15× 0.02× Correct for exactly one producer and one consumer.

The lock-free version is dramatically faster — more than six times faster than the mutex version, and with two orders of magnitude fewer system calls. But this is only fair: the lock-free version only works for one producer and one consumer, whereas the mutex and semaphore versions work for arbitrary numbers. The comparison is not "which is better" but "which is appropriate for the workload". When the workload really is SPSC, the lock-free buffer is the clear winner; when it is not, the mutex is the right choice.

A note on scaling

The results above are on a four-core machine. On a machine with sixty-four cores, the balance changes. The mutex version's throughput is bounded by the serialization on the mutex: only one thread can be in the critical section at a time, so adding more cores beyond a small number does not increase throughput and may decrease it (because of the cache-coherence traffic on the mutex's cache line). The semaphore version has the same limit. The lock-free version, with its single producer and single consumer, is not affected by core count at all — it is inherently a two-thread structure. The scaling behaviour of a synchronization primitive is often more important than its single-run performance, and it is the reason the kernel uses per-CPU variables and RCU in its hot paths.

Choosing among the three

The general guidance, in one sentence: use the mutex and condition variable by default; use the semaphore when the problem is naturally expressed in terms of counting resources or signalling; use the lock-free structure when the workload matches its assumptions and the performance matters. The mutex is the correct choice in the overwhelming majority of programs, and reaching for something else should require a specific reason.

06 Interactive activity

Activity · survey the Linux synchronization primitives

This activity lets you use each of the POSIX primitives in a small program, measure its cost, and observe the Linux-specific behaviour. Work through the steps in your course VM.

  1. Measure the cost of an uncontended mutex. Write a program that acquires and releases a POSIX mutex a million times in a single thread. Time it with clock_gettime.
    pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
    
    for (long i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&m);
        pthread_mutex_unlock(&m);
    }
    The result should be on the order of 20 to 40 nanoseconds per lock-unlock pair — a couple of atomic instructions and no system call, because the mutex is uncontended and the fast path of the futex is taken entirely in user space.
  2. Observe the system call on contention. Use strace -c to count the system calls made by the program. Then run a version with two threads contending on the same mutex. How does the number of futex calls change?
    strace -c -f ./my_program
    The single-threaded version should make zero futex calls. The contended version should make many — one for each time a thread had to sleep, and one for each time a thread was woken.
  3. Compare the mutex and the spinlock. Replace the mutex with a POSIX spinlock (pthread_spinlock_t) and run the same program, with one, two, four, and eight threads. Which is faster for one thread? Which is faster for eight threads? Explain the crossover.
    #include <pthread.h>
    pthread_spinlock_t s;
    
    pthread_spin_init(&s, PTHREAD_PROCESS_PRIVATE);
    pthread_spin_lock(&s);
    /* critical section */
    pthread_spin_unlock(&s);
    pthread_spin_destroy(&s);
  4. Use a read-write lock. Write a program that uses a POSIX read-write lock (pthread_rwlock_t) to protect a shared counter. Run with a read-heavy workload (100 readers, 1 writer) and with a write-heavy workload (10 readers, 10 writers). Compare the throughput of the read-write lock against a plain mutex on each workload.
    pthread_rwlock_t rw = PTHREAD_RWLOCK_INITIALIZER;
    
    pthread_rwlock_rdlock(&rw);
    /* read */
    pthread_rwlock_unlock(&rw);
    
    pthread_rwlock_wrlock(&rw);
    /* write */
    pthread_rwlock_unlock(&rw);
    The read-write lock should win on the read-heavy workload and lose on the write-heavy one. The crossover is the interesting measurement.
  5. Use a barrier. Write a program with N threads that each perform a computation and then synchronize on a barrier before proceeding.
    #include <pthread.h>
    pthread_barrier_t barrier;
    
    pthread_barrier_init(&barrier, NULL, N);
    
    void *worker(void *arg) {
        /* phase 1: do some work */
        pthread_barrier_wait(&barrier);
        /* phase 2: use the results of phase 1 */
    }
    A barrier is useful for iterative algorithms — a numerical simulation that advances in time steps, for example — where every thread must finish one phase before any begins the next.
  6. Enable priority inheritance on a mutex. Create a mutex with the priority inheritance protocol and use it in a small program with three threads of different priorities.
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
    pthread_mutex_init(&m, &attr);
    pthread_mutexattr_destroy(&attr);
    Run under sudo and use strace or perf to observe the temporary priority elevation. Compare to a mutex without the protocol.
  7. Use an error-checking mutex. Create a mutex with PTHREAD_MUTEX_ERRORCHECK type. Try to lock it twice from the same thread. What does the second lock return? Now try to unlock it from a different thread. What does that return? This is the safety check that a normal mutex does not provide.
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK);
    pthread_mutex_init(&m, &attr);
    pthread_mutexattr_destroy(&attr);
  8. Read the futex man page. Read man 2 futex. List the operations it provides and write a one-sentence description of each. Then write a short paragraph explaining why the man page recommends against calling futex directly from applications, and why the POSIX primitives are the right choice for most code.

Reflect: the futex is a Linux-specific mechanism, not part of POSIX. A program that uses raw futexes will not run on macOS or the BSDs; a program that uses pthread_mutex_t will. Given that the POSIX mutex is built on the futex and is essentially as fast, is there any reason to use raw futexes in a portable program? Is there any reason to use them in a Linux-specific program that needs the absolute best performance? What is the cost of relying on a platform-specific mechanism?

07 Knowledge check

Check your understanding

Question 1

List the POSIX synchronization primitives. For each one, describe its purpose and the situation in which it is the right choice. What attributes can be set on a POSIX mutex, and what do they control?

Show answer
The POSIX synchronization primitives are:
  • pthread_mutex_t — mutual exclusion. The default choice for protecting a critical section.
  • pthread_cond_t — waiting for a condition. Used together with a mutex to wait for a state change.
  • pthread_rwlock_t — reader-writer locking. Multiple readers or one writer at a time; appropriate for read-mostly data.
  • pthread_spinlock_t — busy-wait mutual exclusion. Appropriate for very short critical sections with low contention.
  • pthread_barrier_t — a rendezvous point. All threads wait until a fixed number have arrived; appropriate for iterative algorithms.
  • sem_t — a counting semaphore. Used for counting resources, signalling, or mutual exclusion.
  • atomic_* (C11) — lock-free operations on a single variable. Appropriate when the entire operation can be expressed as one atomic instruction.
A POSIX mutex can have the following attributes: type (normal, error-checking, recursive, or default), controlling whether relock deadlocks, returns an error, or succeeds; process-shared, controlling whether the mutex can be shared between processes; protocol (none, priority inherit, or priority protect), controlling whether the mutex implements a priority protocol to bound priority inversion; and robustness, controlling whether the mutex detects a dead holder (useful for process-shared mutexes in shared memory).

Question 2

Explain how the futex bridges user space and kernel space. What is the fast path, what is the slow path, and why is this design faster than either a pure user-space spinlock or a pure kernel-space lock?

Show answer
A futex is a 32-bit integer in user-space memory that the kernel treats specially. User-space code manipulates it with atomic operations; the kernel is involved only when a thread must be blocked or woken. The fast path is the common case: a thread acquires the lock by performing a single atomic exchange on the futex. If the old value was 0 (the lock was free), the exchange sets it to 1 and the thread holds the lock — all in user space, with no system call. If the old value was 1 (the lock was held), the thread takes the slow path: it sets the futex to 2 (contended) and calls the futex system call to sleep on the futex. The kernel wakes it when another thread releases the lock and calls FUTEX_WAKE. This design is faster than a pure user-space spinlock because it does not waste CPU cycles when the lock is held for a long time: the waiting thread sleeps instead of spinning. It is faster than a pure kernel-space lock because it avoids the kernel entirely in the common case of an uncontended lock — the user-space fast path is just a couple of atomic instructions, whereas a kernel lock would require a system call on every acquisition and release. The futex combines the speed of the user-space path with the efficiency of the kernel's wait queue, and the transition between them is triggered by the value of the futex itself.

Question 3

Describe the kernel's synchronization primitives. What is the fundamental difference between a spinlock and a mutex in the kernel, and what is the rule that determines when each must be used?

Show answer
The kernel's synchronization primitives include spinlock_t (busy-wait mutual exclusion), struct mutex (sleeping mutual exclusion), rwlock_t and struct rw_semaphore (reader-writer locks), seqlock_t (sequence locks for read-mostly data), struct completion (one-shot rendezvous), the RCU API (lock-free reads with deferred reclamation), atomic_t and related types (single-instruction atomic operations), and the memory barriers (smp_mb, smp_rmb, smp_wmb). The fundamental difference between a spinlock and a mutex in the kernel is the wait behaviour: a spinlock busy-waits on the CPU until the lock is free, and a mutex puts the waiting thread to sleep and lets the scheduler run another thread. This difference has a critical consequence: a spinlock can be used in contexts where sleeping is forbidden — interrupt handlers, softirqs, code that runs with preemption disabled — whereas a mutex cannot. Conversely, the holder of a spinlock must not perform any operation that might sleep, because sleeping while holding a spinlock deadlocks the system. The rule is therefore: use a spinlock in contexts where you cannot sleep and the critical section is short; use a mutex everywhere else. Most kernel code that touches user data, allocates memory, or waits for I/O uses a mutex. Spinlocks are reserved for the hot paths — the scheduler, the interrupt handlers, the networking stack — where the cost of a context switch would be prohibitive.

Question 4

What is RCU? Explain how readers and writers interact, and describe the conditions under which RCU is the right choice for a data structure.

Show answer
RCU (read-copy-update) is a synchronization mechanism for read-mostly data structures. It allows readers to access the data without acquiring any lock, and it defers the reclamation of old versions until all readers of those versions have finished. Readers execute an rcu_read_lock/rcu_read_unlock pair around the read, but this does not actually acquire a lock — it merely marks the current thread as being in an RCU read-side critical section. The reader accesses the data normally and never blocks. Writers do not modify the existing data structure; they make a copy, modify the copy, and atomically install the copy as the new version. The old version is not freed immediately, because existing readers may still be using it; it is registered with the kernel, which frees it only after all current RCU readers have finished. The conditions for RCU are: (1) the workload must be read-dominated — reads should vastly outnumber writes; (2) readers must be able to tolerate seeing the old version of the data for a short period, since there is no guarantee that a reader sees the latest update immediately; and (3) the update operation must be expressible as replacing the whole structure rather than modifying it in place, because modifying in place would expose readers to an inconsistent intermediate state. Where these conditions hold — the routing table, the process table, the file descriptor table — RCU is dramatically faster than any lock, because readers pay no synchronization cost at all. Where they do not hold, a lock is the right choice.

Question 5

Given a synchronization problem, describe the decision procedure for choosing the right primitive. Illustrate the procedure with three example problems — a counter, a work queue, and a read-mostly configuration table — and explain which primitive is appropriate for each.

Show answer
The decision procedure is:
  1. Can the operation be expressed as a single atomic instruction? If so, use an atomic operation.
  2. Can the state be partitioned? If so, give each thread or CPU its own copy — a per-thread counter, a per-CPU variable — and combine only when necessary.
  3. Is the critical section short and free of any operation that might block? Use a spinlock (kernel) or a mutex or spinlock (user space), choosing between them based on contention.
  4. Is the critical section long, or does it contain operations that may block? Use a mutex.
  5. Is the data read much more often than it is written? Use a reader-writer lock, or — in the kernel with an extremely read-heavy workload — RCU.
  6. Is the problem one of signalling rather than mutual exclusion? Use a condition variable (user space) or a completion (kernel).
  7. Is the problem a count of available resources? Use a semaphore.
  8. Is the problem a monitor-style pattern? Use a mutex and one or more condition variables.
Three examples: (1) A counter shared among several threads. The increment is a single-variable read-modify-write, which can be expressed as an atomic operation (atomic_fetch_add) or, better, as a per-thread counter summed at the end. No lock is needed. If the counter must be read at arbitrary times, use atomic_fetch_add. (2) A work queue shared between multiple producers and multiple consumers. This is the producer–consumer problem. The queue's structure must be protected, and the producers and consumers must wait for "not full" and "not empty" conditions. Use a mutex and two condition variables (the monitor pattern) in user space, or a mutex and a wait queue in the kernel. A counting semaphore solution is also possible. (3) A read-mostly configuration table read by many threads and updated occasionally. Use a reader-writer lock if the updates are not too rare, and the reads are not too frequent. Use RCU if the workload is extremely read-heavy and readers can tolerate seeing an old version briefly — this is the case in the kernel's routing table and similar structures.

08 Unit discussion

Discussion prompt

This lesson closes Unit 6, which has been about synchronization — the mechanisms that make concurrent programs correct. Throughout the unit, a theme has appeared repeatedly: the fastest synchronization is the synchronization you do not need. The per-thread counter, the per-CPU variable, the lock-free data structure, and RCU are all versions of the same idea: partition the state, or avoid sharing it altogether, and the synchronization problem disappears.

But partitioning is not always possible. A bank account balance cannot be partitioned, because every withdrawal must see the current balance. A shared queue cannot be fully partitioned, because every producer and consumer must see the current state. And RCU only works for read-mostly data, not for structures that are written frequently.

Given that partitioning is a powerful technique but not universally applicable, how should a programmer think about the trade-off? Should the default be to design for maximum partitioning — accept a more complex design in exchange for avoiding locks? Or should the default be to use a simple lock and reach for partitioning only when profiling shows it is needed? Consider the design of a modern high-performance server, a database, an operating-system kernel. Which approach do they take, and why?

Post your response to the Unit 6 discussion forum and reply to at least two classmates. This closes Unit 6 and prepares you for the midterm examination. Supports CLO2, CLO3, CLO6, and CLO8.

09 Practice exercise

This ungraded exercise consolidates the whole unit and prepares you for the midterm examination. Aim to spend 90–120 minutes on it.

  1. Build a table of the POSIX primitives. For each POSIX primitive — mutex, condition variable, read-write lock, spinlock, barrier, semaphore, and C11 atomics — fill in the following columns: header file, key operations, appropriate use, cost when uncontended, cost when contended, and portability. This table is a useful reference for the midterm.
  2. Measure the cost of the POSIX primitives. Write a benchmark that measures the cost of acquiring and releasing each of the following, with one thread (uncontended) and with four threads (contended): pthread_mutex_t, pthread_spinlock_t, pthread_rwlock_t (read mode), pthread_rwlock_t (write mode), sem_t (binary, initialized to 1), and atomic_flag. Present the results in a table and comment on the ordering.
  3. Observe the futex in action. Write a program that uses a POSIX mutex with two threads. Use strace -c -f to count the futex system calls. Then modify the program so that the critical section is very short (just an increment). Does the number of futex calls decrease? Modify so the critical section is long (a small computation loop). Does it increase? Explain the relationship between the critical-section length and the number of futex calls.
  4. Compare a mutex and a read-write lock. Write a program with a shared data structure and a configurable read:write ratio. Measure the throughput of a plain mutex and a read-write lock at ratios of 100:1, 10:1, 1:1, and 1:10. Present the results in a table and identify the crossover point. What does this tell you about when a read-write lock is worthwhile?
  5. Implement the SPSC lock-free ring buffer. Write the single-producer, single-consumer ring buffer from the worked example using C11 atomics. Test it with one producer and one consumer. Then run it under ThreadSanitizer and confirm that no race is reported. Compare its throughput to a mutex-protected ring buffer with the same workload.
    gcc -O2 -g -fsanitize=thread -pthread -o spsc spsc.c
    ./spsc
  6. Use a barrier in a numerical simulation. Write a program that simulates a two-dimensional heat diffusion or a similar iterative computation. Divide the grid among N threads and use a pthread_barrier_t to synchronize between time steps. Verify that the result is correct and that the barrier ensures every thread finishes one step before any begins the next.
  7. Read the kernel's documentation. The Linux kernel source tree contains a file Documentation/locking/ with several documents on the kernel's locking primitives, including spinlocks.rst, mutex-design.rst, and rcu/. If you have access to the kernel source — for example, through /usr/src/linux-headers-*/ or a cloned repository — read one of these documents and write a short summary. Which primitive does it cover, and what are the design decisions it describes?
  8. Reflect on the unit. In your own words, describe the single most important idea you have learned in Unit 6. Then identify one question about synchronization that the unit has left unanswered, and suggest how you would go about answering it. Possible themes: the design of lock-free data structures, the memory model, the interaction between synchronization and the scheduler, or the specific problems of real-time synchronization.

10 Summary

  • Linux provides synchronization in two worlds: the POSIX primitives available to user-space programs, and the kernel's own primitives. They are linked by the futex, which is the mechanism by which user-space locks call into the kernel when a thread must block or be woken.
  • The POSIX primitives are pthread_mutex_t (mutual exclusion), pthread_cond_t (waiting for a condition), pthread_rwlock_t (reader-writer), pthread_spinlock_t (busy-wait), pthread_barrier_t (rendezvous), and sem_t (counting semaphore). C11 provides <stdatomic.h> for lock-free operations.
  • The futex divides the work: the uncontended fast path is handled entirely in user space by atomic operations, and the kernel is entered only when a thread must block or be woken. This makes an uncontended POSIX mutex a couple of atomic instructions, with no system call at all.
  • The kernel's primitives include spinlock_t (busy-wait, safe in interrupt context), struct mutex (sleeping), rwlock_t and rw_semaphore (reader-writer), seqlock_t (optimistic readers), struct completion (one-shot rendezvous), RCU (lock-free reads with deferred reclamation), atomic_t (single-instruction operations), and the memory barriers.
  • The fundamental choice in the kernel is between a spinlock and a mutex. The rule is: use a spinlock when the critical section is short and the holder cannot sleep; use a mutex in every other case. Never sleep while holding a spinlock.
  • Per-CPU variables give each CPU its own copy of a variable, so that accesses do not contend. This is the kernel's version of the per-thread counter principle — the fastest synchronization is the synchronization you do not need.
  • RCU allows lock-free reads of read-mostly data. Readers enter and leave a critical section without acquiring any lock; writers make a copy, install it atomically, and defer freeing the old version until all readers have finished.
  • Choosing the right primitive follows a decision procedure: atomic if the operation can be a single instruction; partitioned if the state can be split; spinlock or mutex for short or long critical sections; reader-writer lock or RCU for read-mostly data; condition variable or completion for signalling; semaphore for resource counts.
  • The general guidance is to use the simplest primitive that solves the problem, and to add complexity only when measurement shows that it is needed. Most synchronization needs are met by a mutex and, occasionally, a condition variable.

Unit 6 complete

This closes the synchronization unit. You now have the full picture: the problems (race conditions, lost updates, check-then-act), the abstractions (critical sections, locks, semaphores, monitors, condition variables), and the Linux-specific mechanisms (POSIX primitives, futexes, kernel primitives). Unit 7 turns to the problem that arises when multiple locks are held simultaneously: deadlock. The classic conditions for deadlock, the resource-allocation graph, prevention and avoidance techniques, the Banker's algorithm, and detection and recovery. The synchronization primitives from this unit are the building blocks that Unit 7 shows can go wrong.

11 References

  • Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 6, Section 6.9, and Chapter 2, Section 2.5.
  • Love, R. Linux Kernel Development, 3rd ed., Chapters 9 and 10. The authoritative treatment of the kernel's synchronization methods.
  • Love, R. Linux System Programming, 2nd ed., Chapter 10.
  • Butenhof, D. R. Programming with POSIX Threads, Chapters 7 and 8. The definitive reference for the POSIX primitives.
  • Drepper, U. Futexes Are Tricky. Red Hat, 2004.
  • Franke, H., Russell, R., & Kirkwood, M. Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux. Proceedings of the Ottawa Linux Symposium, 2002.
  • McKenney, P. E., & Slingwine, J. D. Read-Copy Update: Using Execution History to Solve Concurrency Problems. Parallel and Distributed Computing and Systems, 1998. The original RCU paper.
  • McKenney, P. E. Is Parallel Programming Hard, And, If So, What Can You Do About It? Available online at kernel.org. The definitive reference for RCU and parallel programming in the Linux kernel.
  • Herlihy, M., & Shavit, N. The Art of Multiprocessor Programming, 2nd ed. The definitive treatment of lock-free data structures and the memory model.
  • Linux man-pages: man 7 pthreads, man 7 sem_overview, man 2 futex, man 3 pthread_mutex_init, man 3 pthread_cond_init, man 3 pthread_rwlock_init, man 3 pthread_spin_lock, man 3 pthread_barrier_init, man 3 pthread_mutexattr_setprotocol, man 3 pthread_mutexattr_setpshared.
  • The Linux kernel source tree, Documentation/locking/. The kernel's own documentation for its locking primitives.
👤