Module 2 · Unit 6 · Lesson 7 of 7
Synchronization in Linux
The mechanisms that Linux actually provides. Two distinct worlds: the POSIX primitives available to user-space programs, and the kernel's own synchronization machinery. Between them sits the futex, the bridge that lets a user-space lock be fast when uncontended and efficient when contended. This lesson examines both worlds and the criteria for choosing among the primitives in each.
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.
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.
| 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) returnsEDEADLKon a relock andEPERMon 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_SHAREDallows 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_INHERITenables priority inheritance, so that a low-priority thread holding the mutex temporarily inherits the priority of a high-priority thread that blocks on it. SettingPTHREAD_PRIO_PROTECTenables 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) andspin_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_tandstruct rw_semaphore-
Reader-writer locks. The
rwlock_tis the spinning variant and is used in the same contexts as a spinlock; therw_semaphoreis 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_headand 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_unlockpair 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.
| 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.
-
Can the operation be expressed as a single
atomic instruction? If so, use an atomic
operation —
atomic_fetch_addin user space,this_cpu_incoratomic_incin the kernel. No lock needed. - 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
-
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.
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.pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; for (long i = 0; i < 1000000; i++) { pthread_mutex_lock(&m); pthread_mutex_unlock(&m); } -
Observe the system call on contention.
Use
strace -cto count the system calls made by the program. Then run a version with two threads contending on the same mutex. How does the number offutexcalls change?
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.strace -c -f ./my_program -
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); -
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.
The read-write lock should win on the read-heavy workload and lose on the write-heavy one. The crossover is the interesting measurement.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); -
Use a barrier.
Write a program with N threads that each perform a
computation and then synchronize on a barrier before
proceeding.
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.#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 */ } -
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.
Run underpthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT); pthread_mutex_init(&m, &attr); pthread_mutexattr_destroy(&attr);sudoand usestraceorperfto observe the temporary priority elevation. Compare to a mutex without the protocol. -
Use an error-checking mutex.
Create a mutex with
PTHREAD_MUTEX_ERRORCHECKtype. 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); -
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
- 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.
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
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
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_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
- Can the operation be expressed as a single atomic instruction? If so, use an atomic operation.
- 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.
- 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.
- Is the critical section long, or does it contain operations that may block? Use a mutex.
- 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.
- Is the problem one of signalling rather than mutual exclusion? Use a condition variable (user space) or a completion (kernel).
- Is the problem a count of available resources? Use a semaphore.
- Is the problem a monitor-style pattern? Use a mutex and one or more condition variables.
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.
- 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.
-
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), andatomic_flag. Present the results in a table and comment on the ordering. -
Observe the futex in action.
Write a program that uses a POSIX mutex with two
threads. Use
strace -c -fto 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. - 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?
-
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 -
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_tto 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. -
Read the kernel's documentation.
The Linux kernel source tree contains a file
Documentation/locking/with several documents on the kernel's locking primitives, includingspinlocks.rst,mutex-design.rst, andrcu/. 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? - 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), andsem_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_tandrw_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.