Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Spindle, the robot who is the locksmith of the shared axle, beside spools of thread and a heavy padlock on the shared axle

Threads, atomics, and locks

This chapter covers

  • Starting threads with thread::spawn and thread::scope, and collecting their results
  • The Send and Sync traits, which let the compiler reject data races
  • Atomic integers, and why count += 1 from two threads can lose an update
  • Splitting a sum across threads, measuring it, and Amdahl’s law
  • A spin lock built from one atomic flag, with acquire and release ordering
  • A counting semaphore built from a Mutex and a Condvar
  • What happens to a Mutex when its holder panics, and how to detect a deadlock

Chapter 15 showed that a laptop CPU has several cores. A program that runs on one thread uses one of them. To use the others, the program must split its work across several threads: independent sequences of instructions that the operating system schedules onto cores.

Threads share the process’s memory, so they can all read the same data. That sharing also makes them dangerous. When two threads change the same memory at the same time, the result can be wrong in ways that are hard to reproduce.

Rust’s promise here is narrow and specific. Safe Rust code cannot contain a data race. In a data race, two threads access the same memory at the same time, and at least one of them writes. Nothing orders the two accesses. The compiler checks this with two traits, Send and Sync. The chapter starts there.

16.1 Starting threads

16.1.1 What a thread is

A process owns memory: the program’s code, its static data, and its heap. A thread is one path of execution through that code. Each thread has its own stack, which holds the local variables of the functions it is running. Each also has its own copy of the CPU registers, including the instruction pointer that says which instruction runs next. Everything else is shared. Every thread reads the same code and can reach the same heap (figure 16.1).

One process holding three threads, each with its own stack and registers, all connected to one shared region holding code, statics, and the heap. The three threads feed the kernel scheduler, which runs thread 1 on core 0 and thread 3 on core 1.
Figure 16.1 Threads in one process. Stacks and registers are per thread; code, statics, and the heap are shared. The scheduler decides which threads run on the cores.

The kernel’s scheduler decides which threads run. A core runs one thread at a time. When there are more runnable threads than cores, the scheduler gives each a short slice of time. Between slices it saves one thread’s registers and loads another’s. That swap is a context switch, and it costs a few microseconds.

A thread that waits for I/O or for a lock is not runnable. It uses no CPU time while it waits, only the memory of its stack.

Because the heap is shared, two threads can reach the same value at the same moment. Section 16.2 shows what goes wrong then, and the rest of the chapter builds the tools that prevent it.

16.1.2 spawn and join

std::thread::spawn starts a new thread that runs a closure. It returns a JoinHandle. Calling join() on the handle waits for the thread to finish and returns the closure’s result:

let handle = thread::spawn(|| 6 * 7);
assert_eq!(handle.join().expect("worker panicked"), 42);

join returns a Result, because the thread might have panicked. Ok carries the closure’s return value.

A spawned thread may outlive the function that started it. So the closure cannot borrow local variables, which might be gone by the time the thread uses them. The compiler requires the closure to own everything it uses, or to borrow only data that lives for the whole program. That requirement is written 'static. To share data with a spawned thread, you move an Arc into the closure, as section 8.6 did.

16.1.3 Scoped threads

thread::scope removes that restriction for work that finishes before the function continues. It takes a closure that receives a scope value. Threads started with scope.spawn may borrow local data. The scope waits for every one of them before thread::scope returns, so the borrowed data is still alive while they run.

The file threads.rs uses a scope to sum a slice on several threads (figure 16.2).

A slice split into four chunks. Each chunk goes to a worker that sums it. The four partial sums are joined and added.
Figure 16.2 Each worker sums one chunk, borrowing it from the caller's slice.

Listing 16.1 available_workers and parallel_sum (lines 31 to 69). src/problems/threads.rs

use std::{
    sync::{
        OnceLock,
        atomic::{AtomicUsize, Ordering},
    },
    thread,
};

/// Number of hardware threads the runtime can use, defaulting to 1.
pub fn available_workers() -> usize {
    thread::available_parallelism()
        .map(|count| count.get())
        .unwrap_or(1)
}

/// Sum a slice by splitting it into chunks that run on scoped threads.
///
/// `thread::scope` is what makes this ergonomic: the workers borrow `values`
/// directly, so there is no `Arc` and no `'static` bound. The scope waits for
/// every thread, which removes the forgotten-join bug.
pub fn parallel_sum(values: &[i32]) -> i64 {
    if values.is_empty() {
        return 0;
    }

    let workers = available_workers().min(values.len());
    let chunk_size = values.len().div_ceil(workers);

    thread::scope(|scope| {
        let handles: Vec<_> = values
            .chunks(chunk_size)
            .map(|chunk| {
                scope.spawn(move || {
                    chunk
                        .iter()
                        .map(|value| i64::from(*value))
                        .sum::<i64>()
                })
            })
            .collect();

        handles
            .into_iter()
            .map(|handle| handle.join().expect("worker panicked"))
            .sum()
    })
}

thread::available_parallelism() asks the operating system how many threads can run at the same time. On this laptop it returns 8: four cores, each running two hardware threads. It returns a Result, so the code falls back to 1 if the question cannot be answered.

The function never starts more workers than there are values. values.len().div_ceil(workers) computes a chunk size that rounds up, so the chunks cover every value. values.chunks(chunk_size) then yields sub-slices of that size, with a shorter last one.

Each worker converts its values to i64 with i64::from(*value) before adding. The sum of many i32 values can exceed the i32 range, and an i64 has room for it.

The order of two steps decides whether the threads run in parallel. The workers are all started, and their handles collected into a Vec, before any is joined. Iterators are lazy. A single chain like .map(spawn).map(join) would start one thread, wait for it, then start the next. The work would run one chunk at a time. Collecting first starts every thread before waiting on any of them.

The closure passed to scope.spawn is a move closure. It moves chunk, a &[i32], into the thread. Moving a reference copies the reference, not the data.

16.1.4 Send and Sync

How does the compiler know which values may be used from another thread? Two marker traits describe it:

  • A type is Send if a value of it can be moved to another thread.
  • A type is Sync if a reference to it, &T, can be shared with other threads. Put another way, T is Sync exactly when &T is Send.

These traits have no methods. They are auto traits: the compiler implements them for a type automatically when all of the type’s fields have them. i32, String, Vec<i32>, and Arc<Mutex<T>> are both Send and Sync.

Rc is neither. Its reference count is a plain integer, so two threads cloning the same Rc could lose a count, as section 16.2 shows. RefCell is Send but not Sync: its borrow flag is not safe to change from two threads. thread::spawn and scope.spawn require their closures to be Send. A closure that captures an Rc is therefore rejected at compile time.

Listing 16.2 A compile-time check (lines 121 to 125).

/// A compile-time assertion that `T` may cross thread boundaries.
///
/// Calling `assert_send_sync::<Client>()` fails to compile if `Client` is not
/// thread-safe, which turns a concurrency assumption into a build error.
pub fn assert_send_sync<T: Send + Sync>() {}

assert_send_sync has an empty body. Calling assert_send_sync::<AtomicCounter>() compiles only if AtomicCounter is Send + Sync. If a later change added an Rc field to it, the call would stop compiling. The check costs nothing at run time.

16.2 Atomic integers

16.2.1 The lost update

count += 1 looks like one step, but the CPU performs three: read the value, add one, and write the result back. If two threads do this at the same time, their steps can interleave (figure 16.3).

Thread 1 reads 5, thread 2 reads 5, both add 1 to get 6, and both write 6. The counter ends at 6 instead of 7.
Figure 16.3 A lost update. Both threads read 5, so one of the two increments disappears.

Safe Rust does not compile this: sharing a plain usize for writing between threads needs &mut in two places at once. Other languages compile it, and the program loses counts only under load.

An atomic type performs the read, the change, and the write as one indivisible operation. No other thread can see or change the value in between. AtomicUsize::fetch_add(1, ordering) adds 1 and returns the old value.

16.2.2 A counter without a lock

Listing 16.3 AtomicCounter and scoped_increment (lines 71 to 110).

/// A counter that several threads may increment without a lock.
///
/// `Relaxed` is correct here because the counter is the only value being
/// communicated; there is no other data whose visibility depends on it.
#[derive(Default)]
pub struct AtomicCounter {
    value: AtomicUsize,
}

impl AtomicCounter {
    pub const fn new() -> Self {
        Self {
            value: AtomicUsize::new(0),
        }
    }

    /// Increment and return the new value.
    pub fn increment(&self) -> usize {
        self.value
            .fetch_add(1, Ordering::Relaxed)
            + 1
    }

    pub fn get(&self) -> usize {
        self.value.load(Ordering::Relaxed)
    }
}

/// Increment a counter from several scoped threads that share it by reference.
pub fn scoped_increment(counter: &AtomicCounter, threads: usize, per_thread: usize) {
    thread::scope(|scope| {
        for _ in 0..threads {
            scope.spawn(|| {
                for _ in 0..per_thread {
                    counter.increment();
                }
            });
        }
    });
}

increment takes &self, not &mut self. An atomic can be changed through a shared reference, the same way a RefCell can, but safely across threads. So many threads can hold &AtomicCounter and increment it at once.

fetch_add returns the value before the addition, so increment adds 1 to report the new value.

scoped_increment starts threads threads that each increment the counter per_thread times. The closure has no move, so it borrows counter, which the scope allows. The test runs 8 threads × 500 increments and checks that the total is exactly 4,000. No increment is lost.

16.2.3 Memory ordering

Every atomic operation takes an Ordering argument. The ordering answers a question beyond the atomic value itself. When this thread changes the atomic, what may other threads assume about the other memory this thread changed?

Compilers and CPUs reorder memory operations to run faster. Within one thread, the reordering is invisible. Another thread watching the memory can see writes in a different order from the one in the source code. The ordering argument limits that reordering around the atomic operation.

  • Relaxed makes the operation itself atomic, and promises nothing about other memory. That is enough for a counter whose value no other data depends on, which is the case here.
  • Release, used when writing, and Acquire, used when reading, work as a pair. Section 16.4 uses them to build a lock.
  • SeqCst is the strongest. All SeqCst operations appear in one order that every thread agrees on. It is the simplest to reason about, and sometimes slower.

16.2.4 Initializing a value once

Listing 16.4 OnceLock (lines 112 to 119).

/// A value initialized once and shared by every caller.
///
/// `OnceLock` is the primitive behind lazy process-wide configuration, and it is
/// safe because initialization happens at most once even under concurrent calls.
pub fn build_channel() -> &'static str {
    static CHANNEL: OnceLock<&'static str> = OnceLock::new();
    CHANNEL.get_or_init(|| "stable")
}

A static is a single value shared by the whole program. OnceLock holds a value that starts empty and is set at most once. get_or_init runs the closure the first time, and returns the stored value after that. If several threads call it at the same moment, one runs the closure and the others wait for its result.

Listing 16.5 The complete file, with tests. src/problems/threads.rs

//! Threads and fearless concurrency.
//!
//! Rust's claim about concurrency is narrow and checkable: safe code cannot
//! produce a data race, because `Send` and `Sync` describe what may cross a
//! thread boundary and the compiler refuses programs that violate the rules.
//!
//! - `Send` means a value may be moved to another thread.
//! - `Sync` means `&T` may be shared across threads.
//!
//! Both are auto traits: a type is `Send`/`Sync` when its fields are. `Rc` and
//! `RefCell` are the standard counter-examples, which is precisely why sharing
//! them across threads does not compile. The compile error is the feature, not an
//! obstacle.
//!
//! Three ways to get work onto other threads, in increasing order of ceremony:
//!
//! 1. `thread::scope` for work that borrows local data and finishes with the
//!    scope. No `'static`, no `Arc`.
//! 2. `thread::spawn` for work that must own its data and outlive the caller.
//! 3. A thread pool when the number of units of work is large and threads should
//!    be reused.

use std::{
    sync::{
        OnceLock,
        atomic::{AtomicUsize, Ordering},
    },
    thread,
};

/// Number of hardware threads the runtime can use, defaulting to 1.
pub fn available_workers() -> usize {
    thread::available_parallelism()
        .map(|count| count.get())
        .unwrap_or(1)
}

/// Sum a slice by splitting it into chunks that run on scoped threads.
///
/// `thread::scope` is what makes this ergonomic: the workers borrow `values`
/// directly, so there is no `Arc` and no `'static` bound. The scope waits for
/// every thread, which removes the forgotten-join bug.
pub fn parallel_sum(values: &[i32]) -> i64 {
    if values.is_empty() {
        return 0;
    }

    let workers = available_workers().min(values.len());
    let chunk_size = values.len().div_ceil(workers);

    thread::scope(|scope| {
        let handles: Vec<_> = values
            .chunks(chunk_size)
            .map(|chunk| {
                scope.spawn(move || {
                    chunk
                        .iter()
                        .map(|value| i64::from(*value))
                        .sum::<i64>()
                })
            })
            .collect();

        handles
            .into_iter()
            .map(|handle| handle.join().expect("worker panicked"))
            .sum()
    })
}

/// A counter that several threads may increment without a lock.
///
/// `Relaxed` is correct here because the counter is the only value being
/// communicated; there is no other data whose visibility depends on it.
#[derive(Default)]
pub struct AtomicCounter {
    value: AtomicUsize,
}

impl AtomicCounter {
    pub const fn new() -> Self {
        Self {
            value: AtomicUsize::new(0),
        }
    }

    /// Increment and return the new value.
    pub fn increment(&self) -> usize {
        self.value
            .fetch_add(1, Ordering::Relaxed)
            + 1
    }

    pub fn get(&self) -> usize {
        self.value.load(Ordering::Relaxed)
    }
}

/// Increment a counter from several scoped threads that share it by reference.
pub fn scoped_increment(counter: &AtomicCounter, threads: usize, per_thread: usize) {
    thread::scope(|scope| {
        for _ in 0..threads {
            scope.spawn(|| {
                for _ in 0..per_thread {
                    counter.increment();
                }
            });
        }
    });
}

/// A value initialized once and shared by every caller.
///
/// `OnceLock` is the primitive behind lazy process-wide configuration, and it is
/// safe because initialization happens at most once even under concurrent calls.
pub fn build_channel() -> &'static str {
    static CHANNEL: OnceLock<&'static str> = OnceLock::new();
    CHANNEL.get_or_init(|| "stable")
}

/// A compile-time assertion that `T` may cross thread boundaries.
///
/// Calling `assert_send_sync::<Client>()` fails to compile if `Client` is not
/// thread-safe, which turns a concurrency assumption into a build error.
pub fn assert_send_sync<T: Send + Sync>() {}

#[cfg(test)]
mod tests {
    use std::thread;

    use super::*;

    #[test]
    fn parallel_sum_matches_the_sequential_sum() {
        let values: Vec<i32> = (1..=10_000).collect();
        let expected: i64 = values
            .iter()
            .map(|value| i64::from(*value))
            .sum();
        assert_eq!(parallel_sum(&values), expected);
    }

    #[test]
    fn parallel_sum_handles_small_and_empty_inputs() {
        assert_eq!(parallel_sum(&[]), 0);
        assert_eq!(parallel_sum(&[7]), 7);
        assert_eq!(parallel_sum(&[-1, 1, -2, 2]), 0);
    }

    #[test]
    fn scoped_threads_share_a_counter_without_arc() {
        let counter = AtomicCounter::new();
        scoped_increment(&counter, 8, 500);
        assert_eq!(counter.get(), 4_000);
    }

    #[test]
    fn spawn_returns_a_join_handle_that_carries_the_result() {
        let handle = thread::spawn(|| 6 * 7);
        assert_eq!(handle.join().expect("worker panicked"), 42);
    }

    #[test]
    fn once_lock_initializes_a_single_value() {
        assert_eq!(build_channel(), "stable");
        assert_eq!(build_channel(), build_channel());
    }

    #[test]
    fn counter_is_thread_safe_by_construction() {
        assert_send_sync::<AtomicCounter>();
        assert!(available_workers() >= 1);
    }
}
$ cargo test --lib problems::threads
running 6 tests
test problems::threads::tests::counter_is_thread_safe_by_construction ... ok
test problems::threads::tests::once_lock_initializes_a_single_value ... ok
test problems::threads::tests::spawn_returns_a_join_handle_that_carries_the_result ... ok
test problems::threads::tests::scoped_threads_share_a_counter_without_arc ... ok
test problems::threads::tests::parallel_sum_handles_small_and_empty_inputs ... ok
test problems::threads::tests::parallel_sum_matches_the_sequential_sum ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 131 filtered out

16.3 How much faster do threads make it?

16.3.1 A billion elements

parallel_sum.rs times the same idea at a large scale. It sums a billion elements three times, as i32, i64, and i128, once on one thread and once on several.

Listing 16.6 The parallel sum for i32 (lines 15 to 38). src/bin/parallel_sum.rs

use std::{
    env,
    thread,
    time::{Duration, Instant},
};

fn par_i32(data: &[i32], n: usize) -> (i32, Duration) {
    let t = Instant::now();
    let sum = thread::scope(|s| {
        let handles: Vec<_> = (0..n)
            .map(|i| {
                s.spawn(move || {
                    let chunk = data.len() / n;
                    let start = i * chunk;
                    let end = if i == n - 1 {
                        data.len()
                    } else {
                        start + chunk
                    };
                    data[start..end].iter().sum::<i32>()
                })
            })
            .collect();
        handles
            .into_iter()
            .map(|h| h.join().unwrap())
            .sum::<i32>()
    });
    (sum, t.elapsed())
}

This version divides the work by index instead of with chunks. Worker i sums the range from i * chunk to the next boundary. The last worker takes everything to the end, so the leftover elements from the integer division are not lost. The handles are collected before joining, for the reason in section 16.1.3.

Listing 16.7 The complete program. src/bin/parallel_sum.rs

use std::{
    env,
    thread,
    time::{Duration, Instant},
};

const SIZE: usize = 1_000_000_000;

fn seq_i32(data: &[i32]) -> (i32, Duration) {
    let t = Instant::now();
    let sum = data.iter().sum::<i32>();
    (sum, t.elapsed())
}

fn par_i32(data: &[i32], n: usize) -> (i32, Duration) {
    let t = Instant::now();
    let sum = thread::scope(|s| {
        let handles: Vec<_> = (0..n)
            .map(|i| {
                s.spawn(move || {
                    let chunk = data.len() / n;
                    let start = i * chunk;
                    let end = if i == n - 1 {
                        data.len()
                    } else {
                        start + chunk
                    };
                    data[start..end].iter().sum::<i32>()
                })
            })
            .collect();
        handles
            .into_iter()
            .map(|h| h.join().unwrap())
            .sum::<i32>()
    });
    (sum, t.elapsed())
}

fn seq_i64(data: &[i64]) -> (i64, Duration) {
    let t = Instant::now();
    let sum = data.iter().sum::<i64>();
    (sum, t.elapsed())
}

fn par_i64(data: &[i64], n: usize) -> (i64, Duration) {
    let t = Instant::now();
    let sum = thread::scope(|s| {
        let handles: Vec<_> = (0..n)
            .map(|i| {
                s.spawn(move || {
                    let chunk = data.len() / n;
                    let start = i * chunk;
                    let end = if i == n - 1 {
                        data.len()
                    } else {
                        start + chunk
                    };
                    data[start..end].iter().sum::<i64>()
                })
            })
            .collect();
        handles
            .into_iter()
            .map(|h| h.join().unwrap())
            .sum::<i64>()
    });
    (sum, t.elapsed())
}

fn seq_i128(data: &[i128]) -> (i128, Duration) {
    let t = Instant::now();
    let sum = data.iter().sum::<i128>();
    (sum, t.elapsed())
}

fn par_i128(data: &[i128], n: usize) -> (i128, Duration) {
    let t = Instant::now();
    let sum = thread::scope(|s| {
        let handles: Vec<_> = (0..n)
            .map(|i| {
                s.spawn(move || {
                    let chunk = data.len() / n;
                    let start = i * chunk;
                    let end = if i == n - 1 {
                        data.len()
                    } else {
                        start + chunk
                    };
                    data[start..end].iter().sum::<i128>()
                })
            })
            .collect();
        handles
            .into_iter()
            .map(|h| h.join().unwrap())
            .sum::<i128>()
    });
    (sum, t.elapsed())
}

fn main() {
    let n: usize = env::args()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or(8);

    {
        let data: Vec<i32> = vec![1; SIZE];
        let (s1, t1) = seq_i32(&data);
        let (s2, t2) = par_i32(&data, n);
        assert_eq!(s1, s2);
        println!("i32   seq sum = {s1}, elapsed = {t1:?}");
        println!("i32   par sum = {s2}, elapsed = {t2:?}  (n = {n})");
    }

    {
        let data: Vec<i64> = vec![1; SIZE];
        let (s1, t1) = seq_i64(&data);
        let (s2, t2) = par_i64(&data, n);
        assert_eq!(s1, s2);
        println!("i64   seq sum = {s1}, elapsed = {t1:?}");
        println!("i64   par sum = {s2}, elapsed = {t2:?}  (n = {n})");
    }

    {
        let data: Vec<i128> = vec![1; SIZE];
        let (s1, t1) = seq_i128(&data);
        let (s2, t2) = par_i128(&data, n);
        assert_eq!(s1, s2);
        println!("i128  seq sum = {s1}, elapsed = {t1:?}");
        println!("i128  par sum = {s2}, elapsed = {t2:?}  (n = {n})");
    }
}

The thread count comes from the first command-line argument, and defaults to 8. Each block creates its vector, times both sums, and drops the vector at the closing brace before the next block starts.

WARNING: The three vectors take 4 GB, 8 GB, and 16 GB of memory. On a machine with 16 GB, the larger two do not fit next to everything else in memory. The operating system then compresses pages or swaps them to disk. Lower SIZE before you run this on a small machine.

Here is one run on the 16 GB laptop:

$ cargo run --release --bin parallel_sum
i32   seq sum = 1000000000, elapsed = 522.646188ms
i32   par sum = 1000000000, elapsed = 142.427408ms  (n = 8)
i64   seq sum = 1000000000, elapsed = 7.750109265s
i64   par sum = 1000000000, elapsed = 2.591278253s  (n = 8)
i128  seq sum = 1000000000, elapsed = 16.173010504s
i128  par sum = 1000000000, elapsed = 10.198263426s  (n = 8)

Read these numbers with care. The i32 rows are the only ones that measure the sum. Eight threads made it about 3.7 times faster, not 8 times. The i64 and i128 rows are dominated by the memory pressure from the warning. Reading 8 GB should take well under a second, so 7.7 seconds means pages were being brought back from compression or disk.

The runs were not stable, either. In an earlier run, the sequential i32 sum took 4.6 seconds instead of 0.52. To check the i32 result, I wrote a separate test that fills the vector once and times each sum several times. The sequential sum took 0.25 to 0.29 seconds, and eight threads took 0.08 seconds, about 3.2 times faster.

Why not 8 times? Summing is almost no work per byte, so the loop is limited by how fast memory delivers bytes, not by how fast cores add. One core cannot use all of the memory bandwidth, so several cores help, but only until the memory bus is full. The laptop also has four cores, not eight. Its eight hardware threads share those cores in pairs.

16.3.2 Amdahl’s law

Most programs also contain work that cannot be split: reading input, starting threads, combining results. Amdahl’s law states how much that serial part limits the speedup.

Suppose a fraction s of the run time is serial, and p processors share the rest perfectly. The run time becomes s + (1 - s) / p of the original, and the speedup is one divided by that:

speedup(s, p) = 1 / (s + (1 - s) / p)

Work through one case by hand: s = 0.5 and p = 4. The run time is 0.5 + 0.5 / 4 = 0.625 of the original, so the speedup is 1 / 0.625 = 1.6.

Listing 16.8 The complete program. src/bin/concurrency_amdahl.rs

//! Amdahl's law: the serial fraction of the work caps the speedup from adding
//! processors. A program that is 50 percent serial cannot go faster than 2x, no
//! matter how many cores it gets.
//!
//! Run with: cargo run --bin concurrency_amdahl

/// Speedup with `processors` when `serial` is the non-parallel fraction.
fn speedup(serial: f64, processors: f64) -> f64 {
    1.0 / (serial + (1.0 - serial) / processors)
}

fn main() {
    let processor_counts = [1.0, 2.0, 4.0, 8.0, 16.0];
    let serial_fractions = [0.0, 0.05, 0.10, 0.25, 0.50];

    print!("{:>8} |", "serial");
    for processors in processor_counts {
        print!(" {processors:>6.0}");
    }
    println!("   <- processors");
    println!("{:->9}+{}", "", "-".repeat(7 * processor_counts.len()));

    for serial in serial_fractions {
        print!("{serial:>8.2} |");
        for processors in processor_counts {
            print!(" {:>6.2}", speedup(serial, processors));
        }
        println!();
    }

    println!("\nlimit as processors grow: 1 / serial");
    for serial in serial_fractions {
        let limit = if serial == 0.0 {
            f64::INFINITY
        } else {
            1.0 / serial
        };
        println!("  {serial:>4.2} -> {limit:>6.1}");
    }

    assert!((speedup(0.0, 4.0) - 4.0).abs() < 1e-9);
    assert!((speedup(0.5, 4.0) - 1.6).abs() < 1e-9);
    assert!(speedup(0.5, 16.0) < 1.9, "2x is the ceiling for 50% serial");

    println!("\nall checks passed");
}
$ cargo run --bin concurrency_amdahl
  serial |      1      2      4      8     16   <- processors
---------+-----------------------------------
    0.00 |   1.00   2.00   4.00   8.00  16.00
    0.05 |   1.00   1.90   3.48   5.93   9.14
    0.10 |   1.00   1.82   3.08   4.71   6.40
    0.25 |   1.00   1.60   2.29   2.91   3.37
    0.50 |   1.00   1.33   1.60   1.78   1.88

limit as processors grow: 1 / serial
  0.00 ->    inf
  0.05 ->   20.0
  0.10 ->   10.0
  0.25 ->    4.0
  0.50 ->    2.0

all checks passed
Speedup curves from 1 to 16 processors for serial fractions 0, 0.05, 0.10, 0.25, and 0.50, reaching 16, 9.14, 6.40, 3.37, and 1.88 at 16 processors.
Figure 16.4 The table as curves. With 5% serial work, 16 processors give a speedup of 9.14.

As p grows, (1 - s) / p shrinks toward zero, and the speedup approaches 1 / s. A program that is 5% serial can never run more than 20 times faster, however many cores it gets.

16.4 A spin lock

A Mutex from the standard library lets one thread at a time use a value. This section builds a simpler lock from one atomic bool, to show what a lock must do.

A spin lock waits by looping. A thread that wants the lock checks it over and over until it is free. This works well when the lock is held for a very short time. When it is held longer, the waiting threads waste whole cores doing nothing useful. std::sync::Mutex instead asks the operating system to put a waiting thread to sleep.

16.4.1 The type

Listing 16.9 The lock, the guard, and new (lines 24 to 48). src/problems/spin_lock.rs

use std::{
    cell::UnsafeCell,
    hint::spin_loop,
    ops::{Deref, DerefMut},
    sync::atomic::{AtomicBool, Ordering},
};

/// A mutual-exclusion lock that busy-waits instead of parking.
pub struct SpinLock<T> {
    locked: AtomicBool,
    value: UnsafeCell<T>,
}

// SAFETY: `value` is only accessed while `locked` is held, and the acquire/release
// ordering on `locked` makes a hand-off between threads well defined. Sharing
// `&SpinLock` across threads therefore only requires the protected value to be
// `Send`, which lets one thread move data in and another move it out.
unsafe impl<T: Send> Sync for SpinLock<T> {}

/// Grants access to the protected value and releases the lock when dropped.
pub struct SpinGuard<'a, T> {
    lock: &'a SpinLock<T>,
}

impl<T> SpinLock<T> {
    /// Create an unlocked spin lock.
    pub const fn new(value: T) -> Self {
        Self {
            locked: AtomicBool::new(false),
            value: UnsafeCell::new(value),
        }
    }
    // ...
}

SpinLock<T> holds a flag, locked, and the protected value inside an UnsafeCell<T>. UnsafeCell is the one type through which Rust allows changing data behind a shared reference. RefCell, Mutex, and the atomic types are all built on it. Its get() method returns a raw pointer, *mut T. Using that pointer requires unsafe, and the code must make sure no two threads use it at once.

UnsafeCell is not Sync, so SpinLock would not be Sync either. The line unsafe impl<T: Send> Sync for SpinLock<T> {} declares that it is. The compiler cannot check this claim, which is why the implementation needs unsafe. The SAFETY comment above it gives the reason the claim holds: the value is used only while the lock is held.

The bound is T: Send, not T: Sync. Only one thread uses the value at a time, but different threads use it one after another, so the value effectively moves between threads.

SpinGuard is returned by lock. It holds a reference to the lock, gives access to the value, and releases the lock when it is dropped.

16.4.2 Taking and releasing the lock

Listing 16.10 lock, try_lock, and unlock (lines 52 to 87).

impl<T> SpinLock<T> {
    // ...
    /// Acquire the lock, spinning until it is free.
    pub fn lock(&self) -> SpinGuard<'_, T> {
        loop {
            if self
                .locked
                .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
                .is_ok()
            {
                return SpinGuard { lock: self };
            }

            // Spin on a relaxed load so the contention loop does not keep
            // bouncing the cache line with exclusive accesses.
            while self.locked.load(Ordering::Relaxed) {
                spin_loop();
            }
        }
    }

    /// Acquire the lock only if it is free right now.
    pub fn try_lock(&self) -> Option<SpinGuard<'_, T>> {
        self.locked
            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
            .ok()
            .map(|_| SpinGuard { lock: self })
    }

    /// Whether the lock is currently held.
    pub fn is_locked(&self) -> bool {
        self.locked.load(Ordering::Relaxed)
    }

    fn unlock(&self) {
        self.locked
            .store(false, Ordering::Release);
    }
}

Taking the lock must be one atomic step: check that it is free and mark it taken, with no other thread slipping in between. compare_exchange(false, true, ...) does that. If the flag is false, it sets it to true and returns Ok. If it is already true, it changes nothing and returns Err.

lock uses compare_exchange_weak, which may fail even when the flag is false. The weak form is cheaper on some CPUs, and inside a loop a false failure costs one more pass.

When the exchange fails, the thread waits in an inner loop that only reads the flag with a Relaxed load. A read lets the cache line stay shared between the waiting cores. A compare-exchange must take the line for writing, as section 15.4 described, so repeating it would bounce the line between cores. When the read sees false, the outer loop tries the exchange again. spin_loop() tells the CPU that this is a wait loop. The CPU can then save power and let the other hardware thread on the core run.

try_lock makes one attempt and returns None if the lock is taken.

16.4.3 Acquire and release

The orderings in lock and unlock make the lock correct (figure 16.5).

Thread A locks with an Acquire compare-exchange, increments the value, and unlocks with a Release store. Thread B spins, then its Acquire compare-exchange succeeds after A's release, and it sees the increment.
Figure 16.5 The release store in unlock pairs with the acquire exchange in the next lock.
  • unlock stores false with Release. Every write the thread made before the store happens before the store, as far as other threads can tell. That includes its changes to the value.
  • lock exchanges with Acquire. Every read the thread makes after the exchange happens after it.

When B’s acquire reads the false that A’s release wrote, the two operations synchronize. Everything A wrote before unlocking is then visible to B after locking. With Relaxed in both places, B could take the lock and still read an old copy of the value.

Animation 16.1 lock from listing 16.10. B stays on the CPU while it spins. The Release store in unlock pairs with B's Acquire exchange.

The second ordering argument to compare_exchange, Relaxed, applies when the exchange fails. A failed attempt does not take the lock, so it needs no ordering.

16.4.4 The guard

Listing 16.11 The guard's traits (lines 96 to 116).

impl<T> Drop for SpinGuard<'_, T> {
    fn drop(&mut self) {
        self.lock.unlock();
    }
}

impl<T> Deref for SpinGuard<'_, T> {
    type Target = T;

    fn deref(&self) -> &T {
        // SAFETY: holding the guard means this thread has exclusive access.
        unsafe { &*self.lock.value.get() }
    }
}

impl<T> DerefMut for SpinGuard<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        // SAFETY: holding the guard means this thread has exclusive access.
        unsafe { &mut *self.lock.value.get() }
    }
}

The guard implements three traits:

  • Drop releases the lock. The lock is released when the guard goes out of scope, even if the code between panics. This pattern, tying a resource’s release to a value’s drop, is called RAII.
  • Deref lets *guard and method calls on the guard reach the value, as &T.
  • DerefMut gives &mut T, so *lock.lock() += 1 changes the value.

Each method turns the raw pointer from UnsafeCell::get into a reference. That is sound only because holding the guard means holding the lock.

WARNING: SpinGuard has a gap. Its auto traits come from its field &SpinLock<T>, so the guard is Sync whenever T is Send. Take a type such as Cell<u64>, which is Send but not Sync. Safe code can share &SpinGuard<Cell<u64>> between two scoped threads, and both can call Cell::set at once. That is a data race. I compiled such a program to confirm it. std::sync::MutexGuard avoids this by being Sync only when T is Sync. Exercise 3 asks you to close the gap.

Listing 16.12 The complete file, with tests. src/problems/spin_lock.rs

//! A spin lock built from a single `AtomicBool`.
//!
//! The loop is short. The memory ordering and the ownership argument are what
//! make the unsafe block sound.
//!
//! - `lock`/`try_lock` use a compare-exchange with `Acquire` ordering, so the
//!   critical section observes every write the previous holder made before it
//!   released.
//! - `unlock` uses a `Release` store, so writes inside the critical section are
//!   visible to whoever acquires next.
//! - The guard derefs to `&T`/`&mut T`. Mutual exclusion is what makes handing out
//!   `&mut T` sound, and that is exactly what the lock guarantees.
//!
//! A spin lock is the wrong choice when the critical section can block or is
//! long: every waiting thread burns a core. Use it for very short sections on
//! dedicated cores, and prefer `std::sync::Mutex` (which parks the thread) in
//! general code.

use std::{
    cell::UnsafeCell,
    hint::spin_loop,
    ops::{Deref, DerefMut},
    sync::atomic::{AtomicBool, Ordering},
};

/// A mutual-exclusion lock that busy-waits instead of parking.
pub struct SpinLock<T> {
    locked: AtomicBool,
    value: UnsafeCell<T>,
}

// SAFETY: `value` is only accessed while `locked` is held, and the acquire/release
// ordering on `locked` makes a hand-off between threads well defined. Sharing
// `&SpinLock` across threads therefore only requires the protected value to be
// `Send`, which lets one thread move data in and another move it out.
unsafe impl<T: Send> Sync for SpinLock<T> {}

/// Grants access to the protected value and releases the lock when dropped.
pub struct SpinGuard<'a, T> {
    lock: &'a SpinLock<T>,
}

impl<T> SpinLock<T> {
    /// Create an unlocked spin lock.
    pub const fn new(value: T) -> Self {
        Self {
            locked: AtomicBool::new(false),
            value: UnsafeCell::new(value),
        }
    }

    /// Acquire the lock, spinning until it is free.
    pub fn lock(&self) -> SpinGuard<'_, T> {
        loop {
            if self
                .locked
                .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
                .is_ok()
            {
                return SpinGuard { lock: self };
            }

            // Spin on a relaxed load so the contention loop does not keep
            // bouncing the cache line with exclusive accesses.
            while self.locked.load(Ordering::Relaxed) {
                spin_loop();
            }
        }
    }

    /// Acquire the lock only if it is free right now.
    pub fn try_lock(&self) -> Option<SpinGuard<'_, T>> {
        self.locked
            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
            .ok()
            .map(|_| SpinGuard { lock: self })
    }

    /// Whether the lock is currently held.
    pub fn is_locked(&self) -> bool {
        self.locked.load(Ordering::Relaxed)
    }

    fn unlock(&self) {
        self.locked
            .store(false, Ordering::Release);
    }
}

impl<T: Default> Default for SpinLock<T> {
    fn default() -> Self {
        Self::new(T::default())
    }
}

impl<T> Drop for SpinGuard<'_, T> {
    fn drop(&mut self) {
        self.lock.unlock();
    }
}

impl<T> Deref for SpinGuard<'_, T> {
    type Target = T;

    fn deref(&self) -> &T {
        // SAFETY: holding the guard means this thread has exclusive access.
        unsafe { &*self.lock.value.get() }
    }
}

impl<T> DerefMut for SpinGuard<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        // SAFETY: holding the guard means this thread has exclusive access.
        unsafe { &mut *self.lock.value.get() }
    }
}

#[cfg(test)]
mod tests {
    use std::{sync::Arc, thread};

    use super::*;

    #[test]
    fn serializes_concurrent_increments() {
        let lock = Arc::new(SpinLock::new(0usize));
        let mut handles = Vec::new();

        for _ in 0..8 {
            let lock = Arc::clone(&lock);
            handles.push(thread::spawn(move || {
                for _ in 0..1_000 {
                    *lock.lock() += 1;
                }
            }));
        }
        for handle in handles {
            handle.join().expect("worker panicked");
        }

        assert_eq!(*lock.lock(), 8_000);
    }

    #[test]
    fn try_lock_reports_contention() {
        let lock = SpinLock::new(1u32);
        let guard = lock.lock();
        assert!(lock.try_lock().is_none());
        assert!(lock.is_locked());
        drop(guard);
        assert!(lock.try_lock().is_some());
        assert!(!lock.is_locked());
    }

    #[test]
    fn guard_gives_mutable_access() {
        let lock = SpinLock::new(vec![1, 2, 3]);
        {
            let mut guard = lock.lock();
            guard.push(4);
        }
        assert_eq!(*lock.lock(), vec![1, 2, 3, 4]);
    }
}

The first test shares the lock between 8 threads with an Arc, and each adds 1,000. The total must be 8,000.

$ cargo test --lib problems::spin_lock
running 3 tests
test problems::spin_lock::tests::try_lock_reports_contention ... ok
test problems::spin_lock::tests::guard_gives_mutable_access ... ok
test problems::spin_lock::tests::serializes_concurrent_increments ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 134 filtered out

16.5 A counting semaphore

A lock lets one thread at a time proceed. A semaphore lets up to N threads proceed at once. It holds a count of permits. A thread takes a permit before it starts, and returns it when it finishes. When no permit is left, the next thread waits (figure 16.6).

Semaphores limit how much of something runs at once. Examples are open connections to a database, requests to a server, and open files.

A semaphore with 2 permits and 0 available. Threads 1 and 2 hold permits. Threads 3 and 4 wait on the condition variable until a release wakes one of them.
Figure 16.6 A semaphore with two permits, both taken.

16.5.1 Waiting without spinning

A waiting thread should sleep, not spin, because the wait may be long. A condition variable, Condvar, lets a thread sleep until another thread signals it. It always works together with a Mutex:

  1. The waiting thread locks the mutex and checks the condition.
  2. If the condition is false, it calls condvar.wait(guard). That call releases the mutex and puts the thread to sleep, in one step.
  3. Another thread locks the mutex, changes the state, and calls notify_one().
  4. The sleeping thread wakes up, holding the mutex again, and checks the condition again.

The check in step 4 is needed for two reasons. Another thread may have taken the permit first. And a thread can sometimes wake up with no notification at all, which is called a spurious wakeup. So the wait always sits in a while loop, not an if.

Listing 16.13 The state, the guard, and acquire_guard. src/problems/semaphore.rs

use std::{
    sync::{Condvar, Mutex},
    time::Duration,
};

struct State {
    available: usize,
}

/// A counting semaphore. Cloning is not provided; share one behind `Arc`.
pub struct Semaphore {
    state: Mutex<State>,
    released: Condvar,
}

/// Releases one permit when dropped.
pub struct SemaphoreGuard<'a> {
    semaphore: &'a Semaphore,
}

impl Drop for SemaphoreGuard<'_> {
    fn drop(&mut self) {
        self.semaphore.release();
    }
}

impl Semaphore {
    /// Create a semaphore with `permits` permits available.
    pub fn new(permits: usize) -> Self {
        Self {
            state: Mutex::new(State { available: permits }),
            released: Condvar::new(),
        }
    }

    /// Block until a permit is available.
    pub fn acquire(&self) {
        let _guard = self.acquire_guard();
    }

    /// Block until a permit is available, returning a guard that releases it.
    pub fn acquire_guard(&self) -> SemaphoreGuard<'_> {
        let mut state = self
            .state
            .lock()
            .expect("semaphore mutex poisoned");
        while state.available == 0 {
            state = self
                .released
                .wait(state)
                .expect("semaphore mutex poisoned");
        }
        state.available -= 1;
        SemaphoreGuard { semaphore: self }
    }
    // ...
}

acquire_guard returns a SemaphoreGuard. The guard holds only a reference to its semaphore, and its Drop calls release. A permit goes back even if the holder returns early or panics.

acquire_guard follows the four steps. self.released.wait(state) takes the guard, sleeps, and returns a new guard when the thread wakes. When the loop ends, a permit is free and the thread holds the mutex, so it takes the permit by subtracting 1.

The permit is returned by a guard, the same RAII pattern as in section 16.4.

Listing 16.14 The other ways to acquire, and release (lines 47 to 103).

impl Semaphore {
    // ...
    /// Take a permit only if one is free right now.
    pub fn try_acquire(&self) -> Option<SemaphoreGuard<'_>> {
        let mut state = self
            .state
            .lock()
            .expect("semaphore mutex poisoned");
        if state.available == 0 {
            None
        } else {
            state.available -= 1;
            Some(SemaphoreGuard { semaphore: self })
        }
    }

    /// Take a permit, waiting at most `timeout`.
    pub fn try_acquire_timeout(&self, timeout: Duration) -> Option<SemaphoreGuard<'_>> {
        let mut state = self
            .state
            .lock()
            .expect("semaphore mutex poisoned");
        if state.available > 0 {
            state.available -= 1;
            return Some(SemaphoreGuard { semaphore: self });
        }

        let (mut state, _timed_out) = self
            .released
            .wait_timeout(state, timeout)
            .expect("semaphore mutex poisoned");

        if state.available == 0 {
            None
        } else {
            state.available -= 1;
            Some(SemaphoreGuard { semaphore: self })
        }
    }

    /// Permits currently free. Useful for metrics, not for control flow.
    pub fn available_permits(&self) -> usize {
        self.state
            .lock()
            .expect("semaphore mutex poisoned")
            .available
    }

    fn release(&self) {
        let mut state = self
            .state
            .lock()
            .expect("semaphore mutex poisoned");
        state.available += 1;
        self.released.notify_one();
    }
}

/// Releases one permit when dropped.
pub struct SemaphoreGuard<'a> {
    semaphore: &'a Semaphore,
}

impl Drop for SemaphoreGuard<'_> {
    fn drop(&mut self) {
        self.semaphore.release();
    }
}

release adds a permit and wakes one sleeping thread with notify_one. It is private, and only the guard’s Drop calls it. A caller therefore cannot release a permit it never took.

Two methods have problems:

  • acquire calls acquire_guard and stores the guard in _guard. The guard is dropped when acquire returns, so the permit is released at once. A caller of acquire never actually holds a permit. acquire_guard is the method to use.
  • try_acquire_timeout waits once. If it wakes early, from a spurious wakeup or because another thread took the permit first, it returns None before the timeout has passed. A correct version loops, and waits again for the time remaining. Exercise 4 asks you to write it.

Listing 16.15 The complete file, with tests. src/problems/semaphore.rs

//! Counting semaphore built from `Mutex` + `Condvar`.
//!
//! A semaphore bounds how many callers may proceed at once without protecting any
//! data. It is the primitive behind connection pools, per-account limits, and
//! bounded fan-out in a client library or command-line tool.
//!
//! The RAII guard returns the permit on drop, so a panicking worker cannot leak a
//! permit and slowly deadlock the pool.

use std::{
    sync::{Condvar, Mutex},
    time::Duration,
};

struct State {
    available: usize,
}

/// A counting semaphore. Cloning is not provided; share one behind `Arc`.
pub struct Semaphore {
    state: Mutex<State>,
    released: Condvar,
}

impl Semaphore {
    /// Create a semaphore with `permits` permits available.
    pub fn new(permits: usize) -> Self {
        Self {
            state: Mutex::new(State { available: permits }),
            released: Condvar::new(),
        }
    }

    /// Block until a permit is available.
    pub fn acquire(&self) {
        let _guard = self.acquire_guard();
    }

    /// Block until a permit is available, returning a guard that releases it.
    pub fn acquire_guard(&self) -> SemaphoreGuard<'_> {
        let mut state = self
            .state
            .lock()
            .expect("semaphore mutex poisoned");
        while state.available == 0 {
            state = self
                .released
                .wait(state)
                .expect("semaphore mutex poisoned");
        }
        state.available -= 1;
        SemaphoreGuard { semaphore: self }
    }

    /// Take a permit only if one is free right now.
    pub fn try_acquire(&self) -> Option<SemaphoreGuard<'_>> {
        let mut state = self
            .state
            .lock()
            .expect("semaphore mutex poisoned");
        if state.available == 0 {
            None
        } else {
            state.available -= 1;
            Some(SemaphoreGuard { semaphore: self })
        }
    }

    /// Take a permit, waiting at most `timeout`.
    pub fn try_acquire_timeout(&self, timeout: Duration) -> Option<SemaphoreGuard<'_>> {
        let mut state = self
            .state
            .lock()
            .expect("semaphore mutex poisoned");
        if state.available > 0 {
            state.available -= 1;
            return Some(SemaphoreGuard { semaphore: self });
        }

        let (mut state, _timed_out) = self
            .released
            .wait_timeout(state, timeout)
            .expect("semaphore mutex poisoned");

        if state.available == 0 {
            None
        } else {
            state.available -= 1;
            Some(SemaphoreGuard { semaphore: self })
        }
    }

    /// Permits currently free. Useful for metrics, not for control flow.
    pub fn available_permits(&self) -> usize {
        self.state
            .lock()
            .expect("semaphore mutex poisoned")
            .available
    }

    fn release(&self) {
        let mut state = self
            .state
            .lock()
            .expect("semaphore mutex poisoned");
        state.available += 1;
        self.released.notify_one();
    }
}

/// Releases one permit when dropped.
pub struct SemaphoreGuard<'a> {
    semaphore: &'a Semaphore,
}

impl Drop for SemaphoreGuard<'_> {
    fn drop(&mut self) {
        self.semaphore.release();
    }
}

#[cfg(test)]
mod tests {
    use std::{
        sync::{
            Arc,
            atomic::{AtomicUsize, Ordering},
        },
        thread,
    };

    use super::*;

    #[test]
    fn never_exceeds_the_permit_count() {
        let permits = 4;
        let semaphore = Arc::new(Semaphore::new(permits));
        let active = Arc::new(AtomicUsize::new(0));
        let peak = Arc::new(AtomicUsize::new(0));

        let mut handles = Vec::new();
        for _ in 0..32 {
            let semaphore = Arc::clone(&semaphore);
            let active = Arc::clone(&active);
            let peak = Arc::clone(&peak);
            handles.push(thread::spawn(move || {
                let _guard = semaphore.acquire_guard();
                let now = active.fetch_add(1, Ordering::SeqCst) + 1;
                peak.fetch_max(now, Ordering::SeqCst);
                thread::sleep(Duration::from_millis(1));
                active.fetch_sub(1, Ordering::SeqCst);
            }));
        }
        for handle in handles {
            handle.join().expect("worker panicked");
        }

        assert!(peak.load(Ordering::SeqCst) <= permits);
        assert_eq!(semaphore.available_permits(), permits);
    }

    #[test]
    fn guard_returns_the_permit_on_drop() {
        let semaphore = Semaphore::new(1);
        {
            let _guard = semaphore.acquire_guard();
            assert_eq!(semaphore.available_permits(), 0);
        }
        assert_eq!(semaphore.available_permits(), 1);
    }

    #[test]
    fn try_acquire_reports_contention() {
        let semaphore = Semaphore::new(1);
        let first = semaphore
            .try_acquire()
            .expect("first permit is free");
        assert!(semaphore.try_acquire().is_none());
        drop(first);
        assert!(semaphore.try_acquire().is_some());
    }

    #[test]
    fn timeout_gives_up_when_no_permit_arrives() {
        let semaphore = Semaphore::new(0);
        assert!(
            semaphore
                .try_acquire_timeout(Duration::from_millis(5))
                .is_none()
        );
    }
}

The test never_exceeds_the_permit_count starts 32 threads on a semaphore with 4 permits. Each thread counts itself in active while it holds a permit, and records the highest count ever seen in peak with fetch_max. The peak must not exceed 4.

$ cargo test --lib problems::semaphore
running 4 tests
test problems::semaphore::tests::guard_returns_the_permit_on_drop ... ok
test problems::semaphore::tests::try_acquire_reports_contention ... ok
test problems::semaphore::tests::timeout_gives_up_when_no_permit_arrives ... ok
test problems::semaphore::tests::never_exceeds_the_permit_count ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 133 filtered out

16.6 When a lock holder panics

A lock protects data that must stay consistent. A bank transfer, for example, takes money from one account and adds it to another. Between those two steps, the data is briefly inconsistent. The lock hides that moment from other threads.

What if the thread panics between the two steps? The guard is dropped during the panic, so the lock is released. The data, though, stays half-changed. The standard Mutex records that this happened, and the mutex becomes poisoned (figure 16.7). Every later lock() returns Err(PoisonError) instead of Ok(guard).

A timeline: branch A locks, changes stock and cash, panics, and the guard is dropped; the mutex is poisoned; the audit thread's lock returns Err; into_inner gives it the guard to repair the data.
Figure 16.7 The sequence in mutex_poisoning.rs.

The error does not hide the data. PoisonError::into_inner() returns the guard anyway, for code that can check and repair the data.

Listing 16.16 The ledger and the recovery function. src/bin/mutex_poisoning.rs

use std::{
    error::Error,
    sync::{Arc, Mutex, MutexGuard, PoisonError},
    thread,
};

/// Shared ledger state. In a real system this would be behind a transactional
/// store; here it is plain in-memory state to illustrate lock poisoning.
#[derive(Debug, Clone)]
struct Ledger {
    cash_on_hand: i64,
    stock_value: i64,
    transaction_id: u32,
    is_settled: bool,
}

/// Recovers from a poisoned mutex by taking ownership of the guard that the
/// panicked thread left behind.
///
/// `PoisonError::into_inner()` gives us the `MutexGuard`, which means we still
/// hold the lock while repairing state. The guard is dropped when this function
/// returns.
fn recover_ledger(poisoned: PoisonError<MutexGuard<'_, Ledger>>) -> Ledger {
    // into_inner() returns the MutexGuard. We own the lock exclusively even
    // though the previous holder panicked.
    let mut guard = poisoned.into_inner();

    println!("[Reconciliation] Physical audit reveals a cash discrepancy.");
    println!(
        "[Reconciliation] Digital cash: {} | Corrected cash: {}",
        guard.cash_on_hand,
        guard.cash_on_hand - 200
    );

    // Correct the data while we still hold the guard.
    guard.cash_on_hand -= 200;
    guard.is_settled = true;
    guard.transaction_id += 1;

    println!("[Reconciliation] Ledger corrected. Transaction marked failed and closed.");

    // Clone the inner data to return an owned value. The guard drops here and
    // releases the lock.
    guard.clone()
}

Ledger is the shared state: the cash, the stock value, a transaction counter, and a flag that says whether the books balance. A panic between two of its updates leaves them inconsistent.

recover_ledger receives the PoisonError and takes the guard out of it. It holds the lock while it corrects the ledger. It returns a clone of the corrected data, and the guard is dropped at the end of the function.

Listing 16.17 The complete program. src/bin/mutex_poisoning.rs

// ============================================================================
// Mutex poisoning: failed transaction and audit recovery
//
// Ported from the Rust playground repo (`scam_transaction.rs`).
//
// Story:
//   A branch executes a financial transaction that mutates shared state under
//   a `Mutex`. Mid-transaction the thread panics. In Rust, a `Mutex` whose
//   guard was held during a panic becomes **poisoned**. Future `lock()` calls
//   return `Err(PoisonError)` instead of silently letting another thread
//   observe an inconsistent partial update.
//
// Where this appears in practice:
//   - Controllers/reconcilers mutate shared state; a panic while holding a lock
//     is a real failure mode in worker pools, caches, and rate limiters.
//   - Rust chooses fail-fast over data corruption: a poisoned mutex tells
//     callers that the protected invariant may be broken. The caller must
//     decide whether to abort, recover, or reset state.
//   - `PoisonError::into_inner()` returns the `MutexGuard`, so a recovery path
//     can inspect/correct the last known state without waiting for the lock to
//     be released by the panicked thread.
//
// Run:
//   cargo run --bin mutex_poisoning
// ============================================================================

use std::{
    error::Error,
    sync::{Arc, Mutex, MutexGuard, PoisonError},
    thread,
};

/// Shared ledger state. In a real system this would be behind a transactional
/// store; here it is plain in-memory state to illustrate lock poisoning.
#[derive(Debug, Clone)]
struct Ledger {
    cash_on_hand: i64,
    stock_value: i64,
    transaction_id: u32,
    is_settled: bool,
}

impl Ledger {
    fn new() -> Self {
        Ledger {
            cash_on_hand: 10_000,
            stock_value: 5_000,
            transaction_id: 0,
            is_settled: false,
        }
    }
}

/// Recovers from a poisoned mutex by taking ownership of the guard that the
/// panicked thread left behind.
///
/// `PoisonError::into_inner()` gives us the `MutexGuard`, which means we still
/// hold the lock while repairing state. The guard is dropped when this function
/// returns.
fn recover_ledger(poisoned: PoisonError<MutexGuard<'_, Ledger>>) -> Ledger {
    // into_inner() returns the MutexGuard. We own the lock exclusively even
    // though the previous holder panicked.
    let mut guard = poisoned.into_inner();

    println!("[Reconciliation] Physical audit reveals a cash discrepancy.");
    println!(
        "[Reconciliation] Digital cash: {} | Corrected cash: {}",
        guard.cash_on_hand,
        guard.cash_on_hand - 200
    );

    // Correct the data while we still hold the guard.
    guard.cash_on_hand -= 200;
    guard.is_settled = true;
    guard.transaction_id += 1;

    println!("[Reconciliation] Ledger corrected. Transaction marked failed and closed.");

    // Clone the inner data to return an owned value. The guard drops here and
    // releases the lock.
    guard.clone()
}

fn main() -> Result<(), Box<dyn Error>> {
    let ledger = Arc::new(Mutex::new(Ledger::new()));

    // Thread A: the faulty transaction. It mutates one side of the ledger and
    // then panics before marking the transaction settled.
    let ledger_a = Arc::clone(&ledger);
    let handle_a = thread::spawn(move || {
        // SAFETY-free normal code: if the mutex is already poisoned, unwrap()
        // panics; in a controlled demo this is the first lock so it is fine.
        let mut guard = ledger_a.lock().unwrap();

        println!(
            "[Branch A] Transaction {} commenced.",
            guard.transaction_id + 1
        );
        guard.stock_value -= 1_000;
        println!(
            "[Branch A] Stock deducted. New value: {}",
            guard.stock_value
        );
        guard.cash_on_hand += 1_200;
        println!(
            "[Branch A] Cash updated. New balance: {}",
            guard.cash_on_hand
        );

        // Panic while `guard` is still alive -> the Mutex becomes poisoned.
        panic!("[Branch A] Settlement failure: arithmetic overflow in reconciliation.");
    });

    // A panic in a spawned thread does not abort the process unless the panic
    // strategy is set to abort. `join()` returns Err, which is expected here.
    let _ = handle_a.join();

    // Thread B: the forensic audit. It sees Err(PoisonError), inspects the
    // recovered state, and repairs the ledger.
    let ledger_b = Arc::clone(&ledger);
    let handle_b = thread::spawn(move || {
        println!("\n[Audit] Attempting to access the ledger...");

        match ledger_b.lock() {
            Ok(guard) => {
                println!("[Audit] Ledger is not poisoned: {:?}", *guard);
            }
            Err(poisoned) => {
                println!("[Audit] Mutex is poisoned. Recovered state before correction:");
                println!("[Audit] {:?}", *poisoned.get_ref());

                let corrected = recover_ledger(poisoned);
                println!("[Audit] Corrected ledger: {:?}", corrected);
            }
        }
    });

    handle_b.join().unwrap();

    // Important: recovering the data does NOT clear the poison bit on the
    // Mutex itself. Subsequent lock() calls still return Err(PoisonError) so
    // callers can decide whether the repaired state is trustworthy.
    // Uncommenting the next lines would panic:
    //
    // let _guard = ledger.lock().unwrap();

    Ok(())
}

// ============================================================================
// Tests (cargo test --bin mutex_poisoning)
// ============================================================================
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ledger_starts_in_expected_state() {
        let ledger = Ledger::new();
        assert_eq!(ledger.cash_on_hand, 10_000);
        assert_eq!(ledger.stock_value, 5_000);
        assert!(!ledger.is_settled);
    }

    #[test]
    fn recovery_repairs_a_poisoned_ledger() {
        let ledger = Arc::new(Mutex::new(Ledger::new()));

        // Poison the mutex from a helper thread, exactly like the demo.
        let ledger_a = Arc::clone(&ledger);
        let handle = thread::spawn(move || {
            let _guard = ledger_a.lock().unwrap();
            panic!("boom");
        });
        let _ = handle.join();

        let lock_result = ledger.lock();
        assert!(lock_result.is_err(), "mutex should be poisoned");

        let recovered = recover_ledger(lock_result.unwrap_err());
        assert!(recovered.is_settled);
        assert_eq!(recovered.transaction_id, 1);
        assert_eq!(recovered.cash_on_hand, 10_000 - 200);
    }
}

In main, thread A changes the stock and the cash and then panics before marking the transaction settled. The panic does not stop the process. It ends only thread A, and handle_a.join() returns an Err, which main ignores on purpose with let _ =.

Thread B then locks the ledger. match on the result separates the two cases. poisoned.get_ref() shows the data without taking the guard, and recover_ledger repairs it.

$ cargo run --bin mutex_poisoning
[Branch A] Transaction 1 commenced.
[Branch A] Stock deducted. New value: 4000
[Branch A] Cash updated. New balance: 11200

thread '<unnamed>' (5795284) panicked at src/bin/mutex_poisoning.rs:109:9:
[Branch A] Settlement failure: arithmetic overflow in reconciliation.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

[Audit] Attempting to access the ledger...
[Audit] Mutex is poisoned. Recovered state before correction:
[Audit] Ledger { cash_on_hand: 11200, stock_value: 4000, transaction_id: 0, is_settled: false }
[Reconciliation] Physical audit reveals a cash discrepancy.
[Reconciliation] Digital cash: 11200 | Corrected cash: 11000
[Reconciliation] Ledger corrected. Transaction marked failed and closed.
[Audit] Corrected ledger: Ledger { cash_on_hand: 11000, stock_value: 4000, transaction_id: 1, is_settled: true }

The panic message goes to standard error, so in a terminal it can appear between the other lines. The number in parentheses is the thread’s ID and changes on every run.

Recovering the data does not clear the poison flag. A later lock() on the same mutex still returns Err, as the comment at the end of main notes. Rust 1.77 added Mutex::clear_poison for code that has repaired the data and wants to say so.

16.7 Deadlock

A deadlock happens when threads wait for each other in a circle, so none can continue. The classic case needs two locks. Thread 1 holds lock A and waits for lock B. Thread 2 holds lock B and waits for lock A. Neither will ever release what it holds.

To analyze a deadlock, draw a wait-for graph. Each thread is a node. An edge from T0 to T1 means T0 is waiting for a lock that T1 holds. The threads are deadlocked exactly when the graph has a cycle (figure 16.8). Finding a cycle is the three-state DFS from section 12.6.

Left: T0 waits for T1 and T2, and T1 waits for T2. There is no cycle. Right: T0 waits for T1, T1 for T2, and T2 for T0, a cycle.
Figure 16.8 The two graphs from the program. Only the right one is deadlocked.

has_deadlock takes the edges as (waiter, holder) pairs. It first turns them into an adjacency list:

/// Iterative-friendly depth-first search over a small adjacency list.
fn has_deadlock(waits_for: &[(usize, usize)]) -> bool {
    let Some(highest) = waits_for
        .iter()
        .flat_map(|(a, b)| [*a, *b])
        .max()
    else {
        return false;
    };

    let mut edges = vec![Vec::new(); highest + 1];
    for (waiter, holder) in waits_for {
        edges[*waiter].push(*holder);
    }
    // ...
}

It finds the largest thread number with a let ... else, so an empty list returns false at once. The adjacency list then has one entry per thread, holding the threads it waits for.

The search is the DFS of section 12.6. The three states are plain numbers: 0 for unvisited, 1 for on the current path, and 2 for fully explored:

fn has_deadlock(waits_for: &[(usize, usize)]) -> bool {
    // ...
    // 1 = on the current path, 2 = fully explored.
    fn visit(node: usize, edges: &[Vec<usize>], state: &mut [u8]) -> bool {
        match state[node] {
            1 => return true,
            2 => return false,
            _ => state[node] = 1,
        }
        for &next in &edges[node] {
            if visit(next, edges, state) {
                return true;
            }
        }
        state[node] = 2;
        false
    }

    let mut state = vec![0u8; edges.len()];
    (0..edges.len()).any(|node| visit(node, &edges, &mut state))
}

visit returns true as soon as it reaches a node in state 1. That node is on the path that led here, so the edge closes a cycle. A node in state 2 was explored already and is known to lead to no cycle. any starts a search from every node, so a cycle among threads that no other thread waits on is found too.

main checks three graphs. The third, (0, 0), is a thread waiting for itself, as happens when a thread tries to lock a Mutex it already holds.

Listing 16.18 The complete program. src/bin/concurrency_deadlock.rs

//! Deadlock detection: a deadlock exists exactly when the wait-for graph has a
//! cycle. Each edge `(waiter, holder)` means the first task is blocked on the
//! second.
//!
//! Run with: cargo run --bin concurrency_deadlock

/// Iterative-friendly depth-first search over a small adjacency list.
fn has_deadlock(waits_for: &[(usize, usize)]) -> bool {
    let Some(highest) = waits_for
        .iter()
        .flat_map(|(a, b)| [*a, *b])
        .max()
    else {
        return false;
    };

    let mut edges = vec![Vec::new(); highest + 1];
    for (waiter, holder) in waits_for {
        edges[*waiter].push(*holder);
    }

    // 1 = on the current path, 2 = fully explored.
    fn visit(node: usize, edges: &[Vec<usize>], state: &mut [u8]) -> bool {
        match state[node] {
            1 => return true,
            2 => return false,
            _ => state[node] = 1,
        }
        for &next in &edges[node] {
            if visit(next, edges, state) {
                return true;
            }
        }
        state[node] = 2;
        false
    }

    let mut state = vec![0u8; edges.len()];
    (0..edges.len()).any(|node| visit(node, &edges, &mut state))
}

fn main() {
    let acyclic = [(0, 1), (1, 2), (0, 2)];
    let cycle = [(0, 1), (1, 2), (2, 0)];
    let self_wait = [(0, 0)];

    for (label, graph) in [
        ("0->1, 1->2, 0->2", &acyclic[..]),
        ("0->1, 1->2, 2->0", &cycle[..]),
        ("0->0", &self_wait[..]),
    ] {
        println!("{label:<20} deadlock: {}", has_deadlock(graph));
    }

    // The four Coffman conditions must all hold for a deadlock; removing any one
    // of them prevents it.
    println!("\nCoffman conditions:");
    for condition in [
        "mutual exclusion",
        "hold and wait",
        "no preemption",
        "circular wait",
    ] {
        println!("  - {condition}");
    }

    assert!(!has_deadlock(&acyclic));
    assert!(has_deadlock(&cycle));
    assert!(has_deadlock(&self_wait));
    assert!(!has_deadlock(&[]));

    println!("\nall checks passed");
}
$ cargo run --bin concurrency_deadlock
0->1, 1->2, 0->2     deadlock: false
0->1, 1->2, 2->0     deadlock: true
0->0                 deadlock: true

Coffman conditions:
  - mutual exclusion
  - hold and wait
  - no preemption
  - circular wait

all checks passed

The program ends with the four Coffman conditions. A deadlock needs all four:

  1. Mutual exclusion: a lock can be held by only one thread.
  2. Hold and wait: a thread holds one lock while waiting for another.
  3. No preemption: a lock cannot be taken away from its holder.
  4. Circular wait: the waiting forms a cycle.

Removing any one prevents deadlock. The most practical rule removes the fourth: give every lock a fixed order, and always take locks in that order. If every thread takes lock A before lock B, no thread can hold B while waiting for A, and no cycle can form.

Animation 16.2 Each lock is taken correctly; the opposite orders form the cycle. When both threads take m1 first, the waiting thread holds nothing, and no cycle can form.

Summary

  • thread::spawn needs closures that own their data. thread::scope lets threads borrow local data, and waits for them.
  • Collect the join handles before joining, or lazy iterators run the threads one at a time.
  • Send and Sync are auto traits. Rc and RefCell lack them, so the compiler rejects sharing them across threads.
  • An atomic performs read, change, and write as one step. Relaxed suffices for a standalone counter. Release and Acquire pair up to pass other data between threads.
  • Eight threads summed a large array about 3 to 4 times faster, limited by memory bandwidth and four physical cores. Amdahl’s law caps speedup at 1 / s for a serial fraction s.
  • A spin lock is an atomic flag, a compare-exchange loop, and a guard that releases on drop. Its unsafe impl Sync is a promise the code must keep.
  • A semaphore built from a Mutex and a Condvar waits in a while loop, because wakeups can be spurious.
  • A panic while holding a MutexGuard poisons the mutex, and into_inner gives the data to recovery code.
  • Threads are deadlocked when their wait-for graph has a cycle. A global lock order prevents it.

Chapter 17 uses these tools to build queues that pass work between threads, and makes producers wait when the queue is full.

Exercises

  1. Write a version of parallel_sum from listing 16.1 as a single lazy chain, .map(spawn).map(join).sum(), and time both on a large slice. Explain the difference.
  2. Make the three pairs of functions in parallel_sum.rs one generic function over T: Sum<T> + Copy + Send + Sync. Which extra bound does sum::<T>() over &T items need?
  3. Close the gap in SpinGuard. Add a field _marker: PhantomData<&'a mut T>, and explain why this makes the guard Sync only when T: Sync.
  4. Rewrite try_acquire_timeout to loop until a permit is free or the whole timeout has passed. Use Instant::now() to compute the time left for each wait.
  5. Write a program that deadlocks with two Mutex values and two threads. Then fix it by taking the locks in a fixed order.