COMP 3150Operating Systems

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.

Spinlock versus queue-based lock under contention Two diagrams compare a spinlock and a queue-based lock. In the spinlock diagram, four threads all spin on the same shared variable, and every release invalidates the cache line for all four. In the queue-based lock diagram, four threads each spin on their own local flag, and a release invalidates the cache line of only the next thread in the queue. SPINLOCK VERSUS QUEUE-BASED LOCK Simple spinlock flag = 0/1 shared cache line T0 T1 T2 T3 Every release invalidates the cache line for all four threads. Result: cache-line bouncing, poor scalability. Queue-based lock (MCS) tail T0 node T1 node T2 node T3 node T0 T1 T2 T3 Each thread spins on its own node's flag. A release invalidates only the next thread's cache line. Why it matters Cache-line bouncing dominates the cost of a contended lock. A spinlock with N waiters causes N cache invalidations per release. A queue-based lock causes one. On a machine with many cores and a hot lock, the difference can be a factor of ten or more. This is why production locks are rarely simple spinlocks.
Figure 6.3 — Spinlock versus queue-based lock under contention. In the spinlock, every thread spins on the same shared cache line, and every release invalidates it for all of them. In the queue-based lock, each thread spins on its own local flag, and a release invalidates the cache line of only the next thread in 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—initial00lock free
1T0fetch_add(next_ticket) → 010T0 holds lock
2T1fetch_add(next_ticket) → 120T1 waits (ticket 1)
3T2fetch_add(next_ticket) → 230T2 waits (ticket 2)
4T3fetch_add(next_ticket) → 340T3 waits (ticket 3)
5T0unlock: fetch_add(now_serving)41T1 may proceed
6T1sees now_serving == 1, enters41T1 holds lock
7T1unlock: fetch_add(now_serving)42T2 may proceed
8T2sees now_serving == 2, enters42T2 holds lock
9T2unlock: fetch_add(now_serving)43T3 may proceed
10T3sees now_serving == 3, enters43T3 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 increment4–6×—Correct, but limited to one counter
Simple spinlock15–25×1.0×Fast when lightly contended; cache-bouncing under contention
Ticket lock18–30×0.8×Fair, but still cache-bounces on now_serving
POSIX mutex20–35×0.7×Blocks when contended; pays context-switch cost
Per-thread counter + reduce1.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
11.0×1.0×1.0×
21.4×1.5×1.6×
42.6×2.8×2.9×
86.2×5.4×4.1×
1614.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.

  1. 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);
    }
  2. 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);
    }
  3. Add a POSIX mutex version. Modify the program to use a POSIX mutex instead of the custom lock, selected by a command-line option.
    #include <pthread.h>
    pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
    Compile all four versions and run each with two, four, and eight threads. Record the execution time for each combination.
  4. Measure and tabulate. Use clock_gettime with CLOCK_MONOTONIC to 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
  5. Observe cache-line bouncing. Use perf stat to 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.
    perf stat -e cache-misses,cache-references ./counter --lock=spin --threads=8
    perf stat -e cache-misses,cache-references ./counter --lock=mutex --threads=8
    Compare the counts. Do they confirm the cache-bouncing theory?
  6. 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.
  7. 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);
  8. 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 gdb to attach to the hung process and examine the state of the threads.
    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);
        /* ... */
    }
    Then modify the program so that both threads acquire the locks in the same order. Does the deadlock still occur?

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
A lock is an object with two operations. 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
A spinlock repeatedly tests the lock until it becomes free; the waiting thread stays on the CPU. A blocking lock puts the waiting thread to sleep and wakes it when the lock is released; the waiting thread consumes no CPU but pays the cost of a context switch. The choice depends on the ratio of the critical-section length to the context-switch cost, and on the number of threads relative to the number of cores. A spinlock is appropriate when the critical section is short (so the wait is brief) and when there are at least as many cores as threads (so the spinning thread is not stealing a core from useful work). A blocking lock is appropriate when the critical section is long (so the wait is substantial) or when the number of threads exceeds the number of cores (so a spinning thread is actively delaying other work). In practice, a production lock uses a hybrid: it spins briefly in user space, then blocks if the lock does not become available. This is the design of the futex and of the POSIX mutex.

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
A ticket lock uses two counters: a 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
A futex (fast userspace mutex) is a 32-bit integer in user-space memory that the kernel treats specially. The lock built on top of it has two paths. The fast path is taken when the lock is uncontended: the acquiring thread performs a single atomic exchange on the futex. If the old value was 0 (free), the exchange sets it to 1 and the thread holds the lock. If the old value was 1 (held), the thread enters the slow path: it sets the futex to 2 (contended) and calls the futex system call to sleep on the futex. The kernel wakes it when another thread releases the lock and calls FUTEX_WAKE. 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
Lock granularity is the size of the data structure or region of code that a single lock protects. Coarse-grained locking uses a small number of locks, each protecting a large region. Fine-grained locking uses many locks, each protecting a small region. The trade-off is between simplicity and concurrency. A coarse-grained lock is simpler to reason about and avoids the risk of lock-ordering deadlock (with a single lock, deadlock is impossible), but it serializes all access to the protected structure, limiting concurrency. A fine-grained lock allows more concurrency — different threads can access different parts of the structure simultaneously — but multiplies the number of locks to manage, introduces the possibility of deadlock if lock ordering is inconsistent, and increases the risk of subtle races that were not present with a single lock. Example: a hash table can be protected by a single lock (coarse-grained) or by one lock per bucket (fine-grained). The coarse-grained version is simpler and adequate when the table is not heavily accessed; the fine-grained version scales to many threads but requires careful design. The right choice depends on the access pattern, the number of threads, and the cost of contention — there is no universal 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.

  1. 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.
  2. 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_ticket and now_serving through the acquisition and release sequence. Confirm that the order of entry is T0, T1, T2, T3, T4 and that no thread can overtake another.
  3. Measure cache-line bouncing. Use perf stat -e cache-misses,cache-references to 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.
  4. 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.
  5. 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.
  6. Reproduce and fix a deadlock. Write the lock-ordering deadlock program from the interactive activity. Run it and confirm that it hangs. Use gdb to 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.
  7. 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.
  8. 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: acquire blocks the caller until it holds the lock, and release releases 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.
👤