Module 2 · Unit 6 · Lesson 3 of 7
Locks
The standard library abstraction for protecting a critical section. A lock provides two operations — acquire and release — and a guarantee that at most one thread holds it at a time. The implementation beneath that abstraction is anything but simple: spinlocks that waste CPU, blocking locks that pay for context switches, ticket locks that guarantee fairness, and the futex machinery that lets a single lock be fast when uncontended and efficient when contended.
01 Introduction
Lesson 6.2 introduced the critical-section abstraction and
the hardware primitives — test-and-set and compare-and-swap
— that make it implementable. This lesson presents the
lock: the standard library abstraction that
packages those primitives into a convenient interface for
the programmer. A lock has just two operations:
lock(), which blocks the caller until it
acquires the lock, and unlock(), which releases
it. Between them the caller is guaranteed to be the only
thread executing code protected by that lock.
The interface is simple. The implementation is not. A lock that simply spins on a test-and-set instruction — the naive approach from Lesson 6.2 — satisfies mutual exclusion but wastes CPU cycles while it waits, and it offers no fairness guarantee. A lock that blocks the caller on a wait queue avoids the wasted cycles but adds the cost of a context switch to every contended acquisition. Neither approach is right for every workload, and the design of a production lock must balance the two.
This lesson examines the lock abstraction from both sides. It presents the interface and its semantics, the two fundamental implementation strategies (spinning and blocking), and the refinements that make each strategy practical: ticket locks for fairness, queue-based locks for cache efficiency, and futexes for a hybrid that is fast in the common case and efficient in the contended case. It closes with the design considerations that determine how a lock performs — granularity, contention, and the choice between coarse-grained and fine-grained locking.
Locks are the tool you will use in every concurrent program in the rest of the course. Understanding what they guarantee, what they cost, and how they are implemented is essential to using them well.
Prerequisites
This lesson builds directly on Lessons 6.1 and 6.2. The race conditions introduced in Lesson 6.1 are the motivation; the critical-section abstraction and the hardware primitives of Lesson 6.2 are the foundation. If the difference between a spinlock and a blocking lock is not yet clear, review section 4.7 of Lesson 6.2 before continuing.
02 Learning objectives
By the end of this lesson you will be able to
- Describe the lock interface and explain the semantics of acquire and release.
- Compare spinlocks and blocking locks and explain when each is appropriate.
- Implement a spinlock, a ticket lock, and a queue-based lock using hardware primitives.
- Explain how futexes make a lock fast when uncontended and efficient when contended.
- Analyse the effect of lock granularity and contention on performance.
This lesson contributes to U6.3 and U6.5, and to course learning outcomes CLO2, CLO3, and CLO8.
03 Required reading
- OSTEP — Chapter 28, Locks. The complete chapter. OSTEP builds up the lock from the naive spinlock through ticket locks and queue-based locks, and finishes with the futex. This is the single most important reading for this lesson.
- Operating System Concepts — Chapter 6, Section 6.5, Mutex Locks. The standard treatment of the mutex and the spinlock.
- Tanenbaum & Bos — Modern Operating Systems, Chapter 2, Section 2.3.3, Mutual Exclusion with Busy Waiting and Sleep and Wakeup. The historical development of spinlocks and blocking locks.
- Silberschatz et al. — Chapter 6, Sections 6.5–6.7, Mutex Locks, Semaphores, and Monitors. Read section 6.5 for the lock itself; the later sections preview the next lessons.
-
Linux man pages:
man 7 pthreads,man 3 pthread_mutex_init,man 3 pthread_mutex_lock,man 3 pthread_mutex_unlock,man 3 pthread_mutexattr_settype,man 3 pthread_spin_lock,man 2 futex.
Reading strategy
Read OSTEP Chapter 28 in full. Its staged presentation —
building the lock from the naive spinlock, then fixing
each problem in turn — is the clearest way to understand
why production locks are as complex as they are. Then
read the Silberschatz section on mutex locks for the
formal terminology. Keep man 3 pthread_mutex_init
open while you work through the interactive activity;
the man page documents the mutex types and attributes
that you will use in the laboratory.
04 Core content
4.1 The lock abstraction
A lock (also called a mutex, for mutual exclusion) is an object with two operations:
lock_acquire(L)-
Block the caller until it holds the lock
L. When the function returns, the caller is the sole holder. If another thread already holds the lock, the caller waits until it is released. lock_release(L)-
Release the lock
L. The caller must currently hold the lock. If another thread is waiting, it may acquire the lock as a result of the release.
The programmer uses the lock to protect a critical section:
lock_acquire(&mutex);
/* critical section: access shared state */
counter++;
lock_release(&mutex);
The lock guarantees mutual exclusion: at most one thread holds the lock at any time, so at most one thread is executing the code between the acquire and the release. The programmer is responsible for making sure that every access to the shared state is protected by the same lock — a lock protects a region of code, not a set of variables.
A lock that satisfies only mutual exclusion is correct in the minimal sense, but a usable lock should also satisfy two additional properties:
- Progress. If the lock is free and some thread wants to acquire it, some thread acquires it. A lock that can be "stuck" — no holder, but no acquirer — is deadlocked.
- Bounded waiting. There is a bound on how many times a thread can be overtaken by other threads after it has requested the lock. Without this, a thread can starve while others repeatedly acquire and release.
These are the same three requirements introduced in Lesson 6.2 for the critical-section problem. A lock is simply the packaging of a critical-section solution into a reusable object.
4.2 Spinlocks
The simplest lock implementation is the spinlock: a thread that wants to acquire a held lock repeatedly tests the lock until it becomes free. The implementation uses the test-and-set primitive from Lesson 6.2:
typedef struct {
atomic_int flag; /* 0 = free, 1 = held */
} spinlock_t;
void spin_lock(spinlock_t *s) {
while (atomic_exchange(&s->flag, 1))
; /* spin until the old value was 0 */
}
void spin_unlock(spinlock_t *s) {
atomic_store(&s->flag, 0);
}
The acquire operation loops until
atomic_exchange returns 0, which means the
lock was free and the caller has now set it to 1. The
release operation stores 0 into the flag. The lock satisfies
mutual exclusion because the exchange is atomic: only one
thread can observe the flag as 0.
The spinlock is fast when the lock is uncontended: the acquire operation is a single atomic instruction plus a branch, taking tens of nanoseconds. It is also appropriate when the critical section is short, because the waiting thread does not pay the cost of a context switch. But it has two serious problems when the lock is heavily contended:
- It wastes CPU cycles. A spinning thread is using a processor core to do nothing useful. On a system with more threads than cores, this steals time from threads that could be doing real work.
- It causes cache-line bouncing. The atomic exchange invalidates the cache line holding the lock in every other core, forcing them to re-fetch it on the next iteration. The cache-coherence traffic can dominate the cost of the critical section itself.
A second problem is that the simple spinlock does not satisfy bounded waiting. A thread that is spinning can lose the race to acquire the lock every time it is released, and may starve. In practice the scheduler's preemption of the spinning thread is enough to break the tie on most systems, but there is no formal guarantee.
4.3 Ticket locks
The ticket lock is a small modification that adds bounded waiting. It uses two counters: a next ticket that a thread increments when it arrives, and a now serving counter that the lock advances on each release.
typedef struct {
atomic_int next_ticket;
atomic_int now_serving;
} ticket_lock_t;
void ticket_lock(ticket_lock_t *t) {
int my_ticket = atomic_fetch_add(&t->next_ticket, 1);
while (atomic_load(&t->now_serving) != my_ticket)
; /* spin until it is my turn */
}
void ticket_unlock(ticket_lock_t *t) {
atomic_fetch_add(&t->now_serving, 1);
}
When a thread arrives, it takes a ticket by atomically
incrementing next_ticket. It then spins until
now_serving equals its ticket. On release, the
lock increments now_serving, which allows the
thread holding the next ticket to proceed.
The ticket lock satisfies bounded waiting: a thread that takes ticket k will acquire the lock after exactly k − now_serving releases, regardless of how many other threads arrive in the meantime. It is fair in the strictest sense — first-come, first-served among waiting threads.
Its disadvantage is that it does not solve the cache-line
bouncing problem. Every thread spins on
now_serving, and every release invalidates the
cache line holding it. On a heavily contended lock, the
cache-coherence traffic is still a bottleneck.
4.4 Queue-based locks
A queue-based lock solves the cache-bouncing problem by giving each waiting thread its own memory location to spin on. The waiting threads are kept in a linked list (a queue), and each thread spins on a flag that is specific to its position in the queue. When the holder releases the lock, it sets the flag of the next thread in the queue, and only that thread's cache line is invalidated.
The best-known example is the MCS lock,
named after its inventors Mellor-Crummey and Scott. Each
thread has its own qnode structure containing a
locked flag and a next pointer.
The lock itself holds a pointer to the tail of the queue.
The MCS lock satisfies mutual exclusion, progress, and
bounded waiting (the queue order is FIFO), and it scales far
better than a simple spinlock under contention. Its
disadvantage is complexity: each thread must allocate a
qnode and pass it to the acquire and release
operations, which is awkward for an interface that wants to
look like lock(L) and unlock(L).
Production locks therefore often use a hybrid: a fast path
that spins a few times on a simple flag, falling back to a
queue-based path under heavy contention.
4.5 Blocking locks
A blocking lock (or sleeping lock) avoids the wasted cycles of spinning by putting the waiting thread to sleep. When a thread cannot acquire the lock, the operating system moves it from the ready queue to a wait queue associated with the lock, and the scheduler runs some other thread. When the lock is released, the releasing thread wakes up one of the waiters.
The advantage of a blocking lock is that the waiting thread consumes no CPU while it waits. On a system with more threads than cores, this is decisive: a spinning thread is actively preventing other threads from making progress. On a system with fewer threads than cores, spinning may be cheaper, because the context-switch cost is avoided.
The disadvantage is the cost of the context switch. Moving a thread from the ready queue to a wait queue, and later waking it up, involves kernel work: saving and restoring state, managing the wait queue, and invoking the scheduler. On a modern machine this costs on the order of a few microseconds — one to two orders of magnitude more than a single spinlock iteration. For a very short critical section, the context-switch cost dominates, and spinning is faster even under contention.
A well-designed blocking lock uses a two-phase
strategy. On acquire, it spins for a short time in user
space (a few hundred iterations, say). If the lock is
released during this period, the thread acquires it without
entering the kernel. If not, the thread blocks, paying the
context-switch cost once. This is the design of the Linux
futex, described in the next section.
4.6 Futexes
A futex (fast userspace mutex) is a Linux kernel primitive that supports fast user-space locking. The essential idea is that the fast path — acquiring an uncontended lock, or acquiring a lightly contended lock after a short spin — is handled entirely in user space, without any system call. Only when the lock is heavily contended does the thread enter the kernel, and only then does it pay the cost of a context switch.
A futex is a 32-bit integer in user-space memory that the kernel treats specially. The user-space lock implementation looks like this:
typedef struct {
atomic_int state; /* 0 = free, 1 = held, 2 = contended */
} futex_lock_t;
void futex_lock(futex_lock_t *f) {
int c;
if ((c = atomic_exchange(&f->state, 1)) == 0)
return; /* fast path: got the lock */
/* slow path: spin a little, then sleep */
while (atomic_exchange(&f->state, 2) != 0) {
syscall(SYS_futex, &f->state, FUTEX_WAIT, 2, NULL, NULL, 0);
}
}
void futex_unlock(futex_lock_t *f) {
if (atomic_exchange(&f->state, 0) == 2)
syscall(SYS_futex, &f->state, FUTEX_WAKE, 1, NULL, NULL, 0);
}
The acquire operation first attempts an atomic exchange. If
the lock was free (old value 0), the fast path succeeds and
the function returns. If the lock was held, the function
enters the slow path: it loops, setting the state to 2
(contended) and calling the futex system call
to sleep on the futex. The kernel wakes the thread when the
futex's value changes.
The release operation uses an atomic exchange to clear the
state. If the old state was 2 (contended), it calls the
futex system call to wake one of the waiting
threads. If the old state was 1 (uncontended), no system
call is needed — the lock was released without any contention.
The futex design is a hybrid that combines the best of both worlds. An uncontended lock is acquired and released entirely in user space, with the cost of a couple of atomic instructions. A contended lock falls back to a kernel wait queue, so the waiting thread does not waste CPU cycles. The transition between the two modes is triggered by the value of the futex itself, which is why the kernel can be involved only when needed.
Futexes are the foundation of POSIX locks
The POSIX pthread_mutex_* functions are
implemented on top of futexes in glibc. When you call
pthread_mutex_lock, the library uses the
futex fast path if the mutex is uncontended, and enters
the kernel only if it must. This is why an uncontended
POSIX mutex is almost as fast as a raw spinlock in
practice — the abstraction costs very little in the
common case.
4.7 Lock granularity and contention
The performance of a lock depends not only on its implementation but on how it is used. Two design decisions determine this.
- Granularity
- The size of the data structure or region of code that a single lock protects. A coarse-grained lock protects a large region — for example, one lock for an entire hash table. A fine-grained lock protects a small region — for example, one lock per bucket in the hash table. Coarse-grained locks are simpler and less error-prone but serialize more of the program; fine-grained locks allow more concurrency but multiply the number of locks to manage and the opportunities for deadlock.
- Contention
- The rate at which threads attempt to acquire the lock while it is held. A lock with no contention is nearly free — the fast path is a couple of atomic instructions. A lock with high contention becomes a serialization point, and the throughput of the program is bounded by the rate at which the lock can be acquired and released.
The trade-off between coarse-grained and fine-grained locking is one of the central design decisions in concurrent programming. A coarse-grained lock is simple to reason about: everything under the lock is mutually exclusive, and there is no risk of a lock-ordering deadlock as long as there is only one lock. A fine-grained lock allows more threads to make progress in parallel, but it introduces the risk of deadlock (two threads each holding one lock and waiting for the other) and of races that were not present when a single lock protected the whole structure.
The right choice depends on the workload. A data structure that is rarely accessed can use a single coarse-grained lock without any performance cost. A data structure that is accessed by many threads simultaneously benefits from fine-grained locking, provided the contention on the individual locks is low enough to justify the complexity.
Contention is not a fixed property of a lock: it depends on the access pattern of the workload. A hash table with a million buckets and ten threads is unlikely to see any two threads contend for the same bucket, so per-bucket locks are effectively uncontended. A hash table with ten buckets and a million threads will see contention on every bucket. The design of the lock must match the access pattern.
4.8 Read-write locks
A read-write lock (or shared-exclusive lock) is a refinement of the basic lock that distinguishes between readers and writers. Multiple readers can hold the lock simultaneously, because reading does not modify the shared state; a writer must have exclusive access, so no other reader or writer can hold the lock at the same time.
pthread_rwlock_t rw = PTHREAD_RWLOCK_INITIALIZER;
/* Reader */
pthread_rwlock_rdlock(&rw);
/* read shared state */
pthread_rwlock_unlock(&rw);
/* Writer */
pthread_rwlock_wrlock(&rw);
/* modify shared state */
pthread_rwlock_unlock(&rw);
Read-write locks are useful when the access pattern is dominated by reads. A cache, a configuration table, or a routing table is read far more often than it is written, and a regular lock would serialize all the readers unnecessarily. With a read-write lock, the readers proceed in parallel and only the occasional writer blocks them.
They are not a universal improvement. When the access pattern is mixed or write-heavy, the bookkeeping overhead of a read-write lock exceeds the benefit, and a regular mutex is faster. A well-known study by David Butenhof found that read-write locks were slower than plain mutexes in most workloads on the hardware of the time, and the result has been replicated since. The lesson is that the abstraction is not free: it must match the workload to be worthwhile.
4.9 Deadlock and lock ordering
The preceding discussion has assumed that a thread acquires and releases a single lock. In a program with multiple locks, the possibility of deadlock arises. Two threads, T1 and T2, each acquire two locks, L1 and L2, in a different order:
T1: lock(L1); lock(L2); ... unlock(L2); unlock(L1);
T2: lock(L2); lock(L1); ... unlock(L1); unlock(L2);
If T1 acquires L1 and T2 acquires L2 before either attempts to acquire the second lock, both will block forever: T1 is waiting for L2, which T2 holds; T2 is waiting for L1, which T1 holds. Neither can proceed.
The standard prevention technique is a global lock order: all threads acquire locks in the same order, so a cycle can never form. If both T1 and T2 always acquire L1 before L2, the deadlock above cannot occur. This is a simple rule, but it must be applied consistently throughout a program, and violations are easy to introduce accidentally — particularly when locks are acquired in different functions or at different layers of a system.
Deadlock is a large enough topic to have its own unit. Unit 7 covers the four necessary conditions for deadlock, the resource-allocation graph, prevention and avoidance techniques, the Banker's algorithm, and detection and recovery. For this lesson, the relevant point is that locks introduce the possibility of deadlock, and the programmer must manage the risk.
4.10 Common pitfalls
Locks are simple in principle but easy to misuse. The following mistakes appear frequently in practice.
Forgetting to unlock
A path through the code that returns, throws, or branches without releasing the lock leaves it held forever. Use RAII in C++ or defer statements in Go, or carefully structure the code so that every path releases.
Unlocking a lock you do not hold
Releasing a lock that another thread holds corrupts the lock's state. POSIX mutexes of the default type may return an error or may silently corrupt; use an error-checking mutex when debugging.
Locking the wrong lock
Using two different locks for the same shared state provides no protection at all. The lock is a property of the data, not the code; every access must use the same lock.
Recursive locking
A thread that acquires a non-recursive mutex it already holds deadlocks itself. If recursive acquisition is needed, use a recursive mutex type.
Holding a lock while blocking
A thread that holds a lock and then blocks on I/O prevents every other thread from making progress. Acquire locks as late as possible and release them as early as possible.
Fine-grained locking without a discipline
Fine-grained locking increases concurrency but also increases the risk of deadlock and of subtle races. Adopt a global lock order and document it.
Assuming spinlocks are faster
A spinlock is faster only when the critical section is short and contention is low. Under contention on a many-core machine, a blocking lock is usually faster.
Using a lock when an atomic is enough
If the entire operation can be expressed as a single atomic instruction — a counter increment, a flag set — a lock is unnecessary overhead. Use the simplest mechanism that is correct.
05 Worked example
The following example traces a ticket lock through a contended acquisition, compares its performance to a simple spinlock and a POSIX mutex on the same workload, and shows how the choice of lock affects the throughput of a concurrent counter.
Three locks, one workload
Four threads each increment a shared counter one million times. Three implementations are compared: a simple spinlock (test-and-set), a ticket lock (bounded waiting), and a POSIX mutex (blocking with futex fast path).
Tracing the ticket lock
Suppose four threads T0, T1, T2, T3 all want to acquire
the lock at the same moment. The ticket lock's counters
start at next_ticket = 0 and
now_serving = 0. The trace is:
| Step | Thread | Action | next_ticket | now_serving | State |
|---|---|---|---|---|---|
| 0 | — | initial | 0 | 0 | lock free |
| 1 | T0 | fetch_add(next_ticket) → 0 | 1 | 0 | T0 holds lock |
| 2 | T1 | fetch_add(next_ticket) → 1 | 2 | 0 | T1 waits (ticket 1) |
| 3 | T2 | fetch_add(next_ticket) → 2 | 3 | 0 | T2 waits (ticket 2) |
| 4 | T3 | fetch_add(next_ticket) → 3 | 4 | 0 | T3 waits (ticket 3) |
| 5 | T0 | unlock: fetch_add(now_serving) | 4 | 1 | T1 may proceed |
| 6 | T1 | sees now_serving == 1, enters | 4 | 1 | T1 holds lock |
| 7 | T1 | unlock: fetch_add(now_serving) | 4 | 2 | T2 may proceed |
| 8 | T2 | sees now_serving == 2, enters | 4 | 2 | T2 holds lock |
| 9 | T2 | unlock: fetch_add(now_serving) | 4 | 3 | T3 may proceed |
| 10 | T3 | sees now_serving == 3, enters | 4 | 3 | T3 holds lock |
The ticket lock processes the waiting threads in strict FIFO order: T0, T1, T2, T3. Each thread knows exactly how many releases must occur before it can proceed, and no thread can overtake another. This is the bounded-waiting property in action.
The simple spinlock would not provide this guarantee. If T0 releases and immediately tries to re-acquire, it might win the race against T1, T2, and T3 and re-enter before any of them. Over many iterations, the same thread might repeatedly win, and some threads might starve.
Performance comparison
The following table reports the throughput of the three implementations on a four-core machine, with four threads each performing one million increments. The numbers are illustrative — the actual values depend on the hardware, the compiler, and the state of the system.
| Lock | Time (relative) | Throughput (relative) | Notes |
|---|---|---|---|
| No lock (racy) | 1.0× | — | Incorrect: result is not 4,000,000 |
| Atomic increment | 4–6× | — | Correct, but limited to one counter |
| Simple spinlock | 15–25× | 1.0× | Fast when lightly contended; cache-bouncing under contention |
| Ticket lock | 18–30× | 0.8× | Fair, but still cache-bounces on now_serving |
| POSIX mutex | 20–35× | 0.7× | Blocks when contended; pays context-switch cost |
| Per-thread counter + reduce | 1.2× | — | Correct, but only works when increments commute |
The numbers tell an important story. The racy version is the fastest but is wrong. The atomic increment is correct and relatively fast, but is limited to single-variable operations. The locks are correct and general, but each pays a significant cost for the mutual exclusion. The per-thread counter is the fastest correct approach for this specific workload — each thread has its own counter, and the counters are summed at the end — which illustrates the principle that the best solution is often to avoid sharing state rather than to synchronize access to it.
The effect of contention
The relative cost of the locks depends strongly on contention. With two threads and a short critical section, the simple spinlock is close to free: the waiting thread spins for a few iterations and acquires the lock as soon as it is released. With eight threads and a longer critical section, the spinlock's throughput collapses as the cache-coherence traffic dominates, and the blocking mutex becomes competitive.
| Threads | Spinlock | Ticket lock | POSIX mutex |
|---|---|---|---|
| 1 | 1.0× | 1.0× | 1.0× |
| 2 | 1.4× | 1.5× | 1.6× |
| 4 | 2.6× | 2.8× | 2.9× |
| 8 | 6.2× | 5.4× | 4.1× |
| 16 | 14.8× | 11.2× | 7.3× |
The pattern is clear: at low contention, all three locks perform similarly, with the spinlock slightly ahead because it avoids the context-switch cost. As contention increases, the spinlock's cache-bouncing penalty grows faster than the mutex's context-switch cost, and the blocking mutex becomes the better choice.
Applying the futex design
The POSIX mutex in the comparison above uses the futex design internally. On the uncontended fast path it is almost as fast as a raw spinlock, because it performs a single atomic exchange and returns. Only under contention does it enter the kernel. This is why it wins at high contention: it combines the speed of the spinlock at low contention with the efficiency of the blocking lock at high contention.
The lesson for the programmer is that the choice between spinning and blocking is not a choice the programmer needs to make — a well-implemented mutex makes the decision adaptively, at runtime, based on the actual contention. The programmer's job is to use the lock correctly and to choose the right granularity for the workload.
Avoiding the lock entirely
The fastest lock is the one you do not need. In the counter example, the per-thread counter — each thread increments its own private variable, and the values are summed at the end — is nearly as fast as the racy version and completely correct. It works because the increments commute: the order in which the threads add their values does not matter, so the threads never need to synchronize with each other.
This is the general principle of scaling by partitioning: if the work can be divided so that each thread has its own copy of the shared state, the synchronization is needed only at the end, when the copies are combined. Lock-free and wait-free data structures extend the idea to data structures: they use atomic operations and careful algorithms to allow concurrent modification without explicit locks. They are beyond the scope of this unit, but they are the direction in which high-performance concurrent programming has moved.
06 Interactive activity
Activity · implement, compare, and analyse locks
This activity lets you implement the three locks from the worked example, measure their performance under varying contention, and observe the trade-offs. Work through the steps in your course VM.
-
Implement a simple spinlock.
Write a spinlock using C11 atomics, as in Lesson 6.2.
Use it to protect the increment in a counter program
with two threads. Compile and run, and verify that the
result is correct.
typedef struct { atomic_int flag; } spinlock_t; void spin_lock(spinlock_t *s) { while (atomic_exchange(&s->flag, 1)) ; } void spin_unlock(spinlock_t *s) { atomic_store(&s->flag, 0); } -
Implement a ticket lock.
Write the ticket lock from section 4.3 and use it in the
same counter program. Verify that the result is
correct.
typedef struct { atomic_int next_ticket; atomic_int now_serving; } ticket_lock_t; void ticket_lock(ticket_lock_t *t) { int my_ticket = atomic_fetch_add(&t->next_ticket, 1); while (atomic_load(&t->now_serving) != my_ticket) ; } void ticket_unlock(ticket_lock_t *t) { atomic_fetch_add(&t->now_serving, 1); } -
Add a POSIX mutex version.
Modify the program to use a POSIX mutex instead of the
custom lock, selected by a command-line option.
Compile all four versions and run each with two, four, and eight threads. Record the execution time for each combination.#include <pthread.h> pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; -
Measure and tabulate.
Use
clock_gettimewithCLOCK_MONOTONICto time the whole computation. Run each combination five times and record the mean and standard deviation. Produce a table like the one in the worked example, and comment on the trend you observe.for lock in spin ticket mutex; do for n in 2 4 8 16; do ./counter --lock=$lock --threads=$n done done -
Observe cache-line bouncing.
Use
perf statto count the cache misses and coherence events for the spinlock version and the mutex version. The spinlock should show significantly more cache-miss traffic on the lock's cache line.
Compare the counts. Do they confirm the cache-bouncing theory?perf stat -e cache-misses,cache-references ./counter --lock=spin --threads=8 perf stat -e cache-misses,cache-references ./counter --lock=mutex --threads=8 - Measure the cost of contention on the critical section. Modify the counter program so that the critical section is longer — for example, by including a short computation loop. Re-measure the four lock types. Which lock performs best with the longer critical section? Does the balance change? Explain the result in terms of the trade-off between spinning and blocking.
-
Implement a read-write lock.
Use the POSIX read-write lock
(
pthread_rwlock_t) in a program where threads mostly read a shared structure and occasionally write it. Measure the throughput with the read-write lock and with a plain mutex. For a read-heavy workload, which performs better? For a write-heavy 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); -
Reproduce a lock-ordering deadlock.
Write a program with two locks, L1 and L2, and two
threads. The first thread acquires L1 and then L2; the
second acquires L2 and then L1. Run the program. What
happens? Use
gdbto attach to the hung process and examine the state of the threads.
Then modify the program so that both threads acquire the locks in the same order. Does the deadlock still occur?pthread_mutex_t l1 = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t l2 = PTHREAD_MUTEX_INITIALIZER; void *t1(void *arg) { pthread_mutex_lock(&l1); sleep(1); pthread_mutex_lock(&l2); /* ... */ }
Reflect: the best-performing correct implementation in this activity was the per-thread counter, which avoided the lock entirely. In what other workloads can the same idea — partition the state so that threads do not share it — be applied? For what kinds of data structures is partitioning impossible, and a lock therefore unavoidable? What does this suggest about the design of concurrent programs?
07 Knowledge check
Check your understanding
Question 1
Describe the lock interface. What are the semantics of
acquire and release? What
three properties should a lock satisfy?
Show answer
acquire blocks the caller until it holds
the lock; when it returns, the caller is the sole
holder. release releases the lock; the
caller must currently hold it. The three properties a
usable lock should satisfy are: (1)
mutual exclusion — at most one
thread holds the lock at any time; (2)
progress — if the lock is free and
some thread wants to acquire it, some thread acquires
it (no deadlock); (3) bounded waiting
— a thread that has requested the lock is not starved
by other threads repeatedly acquiring and releasing
it. A lock that satisfies only mutual exclusion is
correct in the minimal sense but may be unusable in
practice if it can deadlock or starve a thread.
Question 2
Compare spinlocks and blocking locks. Under what circumstances is each the right choice, and why?
Show answer
Question 3
What is a ticket lock, and what property does it add that a simple spinlock does not have? What problem does it not solve?
Show answer
next_ticket counter that each arriving
thread increments to take a ticket, and a
now_serving counter that the lock
advances on each release. A thread spins until
now_serving equals its ticket. The
ticket lock adds bounded waiting: a
thread that takes ticket k will acquire the
lock after exactly k −
now_serving releases, no matter how many
other threads arrive in the meantime. This is a
strict first-come, first-served discipline; the
simple spinlock has no such guarantee, and a thread
can starve if others repeatedly win the race for the
lock. The ticket lock does not solve
the cache-bouncing problem: every waiting thread
spins on the same now_serving counter,
and every release invalidates that cache line for all
of them. On a heavily contended lock, the
cache-coherence traffic is still a bottleneck. The
queue-based lock (MCS) addresses this by giving each
waiting thread its own memory location to spin on.
Question 4
Explain how a futex works. What is the fast path, what is the slow path, and why is this design better than either a pure spinlock or a pure blocking 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.
The release path is also conditional: if the futex
was 1 (uncontended), the releasing thread simply
stores 0 and returns, with no system call; if the
futex was 2 (contended), it stores 0 and calls the
kernel to wake a waiter. This design is better than
a pure 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 better than
a pure blocking lock because it avoids the kernel
entirely when the lock is uncontended — the common
case — and pays the context-switch cost only when
there is genuine contention. The result is a lock
that is fast when uncontended and efficient when
contended, which is why futexes are the foundation of
POSIX mutexes in glibc.
Question 5
What is lock granularity, and what is the trade-off between coarse-grained and fine-grained locking? Give an example of each from a data structure you know.
Show answer
08 Unit discussion
Discussion prompt
The fastest correct implementation in the worked example was the per-thread counter, which avoided the lock entirely. This is a general principle: the best way to handle synchronization is often to avoid it. Partition the state, replicate the data, use message passing instead of shared memory, or express the computation as a set of commutative operations that do not require ordering.
But avoidance is not always possible. A bank account balance cannot be partitioned: every withdrawal must see the current balance. A linked list cannot be replicated: every insertion must see the current structure. A counter that must be exact cannot be made per-thread: every read must see the current total. In these cases a lock is unavoidable, and the design question becomes how to make the lock as efficient as possible.
Where should the balance lie? Should programmers be encouraged to design around locks — partitioning state and using lock-free data structures — even when the design is more complex? Or should they be encouraged to use locks freely, because a well-implemented lock is fast enough for most workloads? What does the answer depend on: the workload, the hardware, the programmer's skill, or the language's support for concurrency?
Post your response to the Unit 6 discussion forum and reply to at least two classmates. Supports CLO2, CLO3, and CLO8.
09 Practice exercise
This ungraded exercise consolidates the lesson and prepares you for Lesson 6.4. Aim to spend 60–90 minutes on it.
- Implement three locks. Implement a simple spinlock, a ticket lock, and a queue-based lock (MCS) using C11 atomics. Test each with the counter program from Lesson 6.1 to verify that the result is always correct. Then measure the execution time for each with two, four, eight, and sixteen threads. Present the results in a table and comment on the trend.
-
Trace a contended ticket lock.
Five threads T0–T4 attempt to acquire a ticket lock in
the order T0, T1, T2, T3, T4. Trace the values of
next_ticketandnow_servingthrough the acquisition and release sequence. Confirm that the order of entry is T0, T1, T2, T3, T4 and that no thread can overtake another. -
Measure cache-line bouncing.
Use
perf stat -e cache-misses,cache-referencesto compare the cache behaviour of the spinlock and the ticket lock under eight-thread contention. Which generates more cache traffic? Explain the difference in terms of the number of cache lines each thread spins on. - Compare spinlock and mutex under different critical-section lengths. Modify the counter program so that the critical section includes a computation loop that takes a configurable time. Run the spinlock and mutex versions with critical sections of 0, 100, 1000, and 10000 iterations, with four threads. Plot or tabulate the times. At what critical-section length does the mutex overtake the spinlock? Explain the crossover in terms of the context-switch cost.
- Implement a read-write lock usage. Write a program with a shared data structure that is read frequently and written occasionally. Use a POSIX read-write lock and a plain mutex in two versions of the program. Measure the throughput of each with a read:write ratio of 100:1, 10:1, and 1:1. For which ratios is the read-write lock faster? For which is the mutex faster? Explain the result.
-
Reproduce and fix a deadlock.
Write the lock-ordering deadlock program from the
interactive activity. Run it and confirm that it hangs.
Use
gdbto attach to the hung process and inspect the state of the two threads. Then modify the program so that both threads acquire the locks in the same order, and confirm that the deadlock is gone. Write a short paragraph explaining why the lock order matters. -
Read
man 3 pthread_mutex_init. Read the man page and list the mutex types that POSIX defines. For each type, describe when it is appropriate and what behaviour it provides. Then write a program that demonstrates the difference between a normal mutex and an error-checking mutex when a thread tries to lock the same mutex twice. - Reflect. In your own words, explain why the best implementation of the counter program did not use a lock at all. Then describe a data structure or workload where a lock is unavoidable, and explain why partitioning or lock-free techniques cannot be applied.
10 Summary
-
A lock (or mutex) is an
object with two operations:
acquireblocks the caller until it holds the lock, andreleasereleases it. A lock protects a critical section by guaranteeing mutual exclusion. - A usable lock should satisfy three properties: mutual exclusion, progress, and bounded waiting. These are the same requirements as for the critical-section problem in Lesson 6.2.
- A spinlock repeatedly tests the lock until it is free. It is fast when uncontended and appropriate for short critical sections, but it wastes CPU cycles and causes cache-line bouncing under contention.
- A ticket lock adds bounded waiting by issuing tickets to arriving threads and serving them in FIFO order. It is fair but still suffers from cache-line bouncing because all waiters spin on the same counter.
- A queue-based lock (such as MCS) gives each waiting thread its own memory location to spin on. A release invalidates only the next thread's cache line, so the lock scales far better under contention.
- A blocking lock puts waiting threads to sleep, avoiding wasted CPU cycles at the cost of a context switch. It is preferable when the critical section is long or when there are more threads than cores.
- A futex (fast userspace mutex) is a hybrid: the uncontended fast path is handled entirely in user space, and only contended acquisitions enter the kernel. POSIX mutexes are implemented on top of futexes in glibc.
- Lock granularity determines how much of the program is serialized. Coarse-grained locks are simpler and deadlock-free (with a single lock); fine-grained locks allow more concurrency but introduce the risk of deadlock and subtle races.
- A read-write lock allows multiple concurrent readers but only one writer. It is useful for read-dominated workloads but is slower than a plain mutex when the workload is mixed or write-heavy.
- Locks introduce the possibility of deadlock when multiple locks are acquired in different orders. The standard prevention is a global lock order, applied consistently. Unit 7 covers deadlock in detail.
- The fastest lock is the one you do not need. Partitioning the state so that threads do not share it — per-thread counters, replicated data, message passing — often eliminates the need for synchronization entirely and is faster than any lock.
Looking ahead
This lesson presented the lock, the basic tool for mutual exclusion. Lesson 6.4 introduces the semaphore, a more general synchronization primitive that can be used not only for mutual exclusion but also for signalling between threads and for controlling access to a pool of resources. A semaphore generalizes both the lock and the condition variable, and it is the tool that Dijkstra introduced for the producer–consumer and reader–writer problems — two of the classic synchronization problems that we will examine in the next lesson. Understanding the lock first is important because a binary semaphore (one that takes only the values 0 and 1) is equivalent to a lock, and the counting semaphore extends the idea in a way the lock cannot.
11 References
-
Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C.
Operating Systems: Three Easy Pieces, Chapter 28.
Available online at
ostep.org. - Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 6, Sections 6.5–6.7.
- Tanenbaum, A. S., & Bos, H. Modern Operating Systems, 4th ed., Chapter 2, Section 2.3.3.
- Mellor-Crummey, J. M., & Scott, M. L. Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors. ACM Transactions on Computer Systems, 1991. The original MCS lock paper.
- Drepper, U. Futexes Are Tricky. Red Hat, 2004. The authoritative discussion of the futex interface and its pitfalls.
- Franke, H., Russell, R., & Kirkwood, M. Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux. Proceedings of the Ottawa Linux Symposium, 2002. The design rationale for futexes.
- Love, R. Linux Kernel Development, 3rd ed., Chapter 9, Kernel Synchronization Methods. The kernel's own locks — spinlocks, mutexes, reader-writer locks, and RCU.
-
Linux man-pages:
man 7 pthreads,man 3 pthread_mutex_init,man 3 pthread_mutex_lock,man 3 pthread_mutexattr_settype,man 3 pthread_spin_lock,man 3 pthread_rwlock_init,man 2 futex. - Butenhof, D. R. Programming with POSIX Threads, Chapter 7. The discussion of mutex and read-write lock performance.