Cover
About this book
This book is about systems programming in Rust.
By systems programming I mean programs that work close to the computer’s hardware and operating system. Data structures that decide how bytes are laid out in memory are one example. Threads that share work are another, and so are servers that read requests from a network connection. For programs like these, you need to know what happens underneath your code. That includes how memory is allocated and how the processor reads it. It also includes how the operating system runs threads and moves data across a network.
I use Rust because it lets you control these details. Its compiler also checks how your program uses memory and threads before the program runs. When the compiler rejects a program in this book, I explain what it found.
The book starts with small things and builds up. The first chapters cover bindings, files, and collections. The middle chapters build data structures and look at how they use memory. The later chapters add threads and the network, then the kernel boundary and production networking. In each chapter I introduce an idea in plain terms and work through a small example by hand. Only then do I show the code, a few lines at a time. Each chapter ends with the complete program.
Some programs appear in more than one version. When an early version has a problem, I show how the problem was found and what the next version changes.
Who should read this book
This book is for a programmer who has written some Rust and wants to use it for systems work.
You should know fn, let, if, match, struct, enum, Vec, and String. You should have met
ownership and borrowing, for example in The Rust Programming Language. You do not need to know
lifetimes in signatures, trait implementations such as TryFrom, Rc, Arc, atomics, unsafe, or
async. Each of these is introduced in the chapter that first needs it.
No knowledge of operating systems or networking is assumed. When a chapter relies on a hardware or kernel detail, such as a cache line or a page fault, it explains that detail first.
What you will learn
After working through the book, you will be able to
- use Rust’s collections and iterators to solve problems on arrays, strings, and maps
- design types that reject invalid values where input enters a program
- implement linked lists, trees, graphs, and caches in safe Rust
- choose between
Box,Rc, and aVecof nodes to store linked data - measure a data structure with a benchmark you can trust
- explain how CPU caches, false sharing, and page faults affect a running program
- build locks, queues, and thread pools from atomics and condition variables
- parse a network protocol safely and write a server with threads and with epoll
- explain how an async executor runs a
Futureto completion
How this book is organized
The book has ten parts. Each part builds on the ones before it.
Part 1, First steps
- Chapter 1 covers bindings, shadowing, functions, recursion, and loops, and times three ways of computing Fibonacci numbers.
- Chapter 2 covers files, directories, and command-line arguments, and introduces
Resultand?.
Part 2, Collections and algorithms
- Chapter 3 solves problems with vectors, strings, and hash maps.
- Chapter 4 explains iterators and references, and introduces lifetimes.
- Chapter 5 uses heaps and sorted collections.
- Chapter 6 covers recursion and dynamic programming, and ends with jobs that also need a GPU’s memory and cores.
Part 3, Modeling data and ownership
- Chapter 7 builds types that reject invalid values, with structs, enums, and error types.
- Chapter 8 covers the pointer types
Box,Rc,RefCell,Weak,Arc, andCow. - Chapter 9 builds linked lists, and measures four ways to store one.
- Chapter 10 merges many sorted lists into one.
- Chapter 11 covers trees and tries.
- Chapter 12 covers graphs.
Part 4, Memory and the machine
- Chapter 13 builds an LRU cache and measures two ways of storing its nodes.
- Chapter 14 splits a cache across locks and across machines.
- Chapter 15 covers allocation, CPU caches, paging, scheduling, and system calls.
Part 5, Concurrency
- Chapter 16 covers threads, atomics, and locks.
- Chapter 17 builds queues that threads use to pass work.
- Chapter 18 builds thread pools.
- Chapter 19 covers rate limiting, retries, and operations that are safe to repeat.
Part 6, The network
- Chapter 20 covers addresses, TCP, and two designs for a server.
- Chapter 21 parses and serves HTTP/1.1.
- Chapter 22 builds an async executor and two HTTP clients.
Part 7, The kernel boundary
- Chapter 23 maps files into memory, weighs
mmapagainstread, and sends a file to a socket withsendfile. - Chapter 24 opens the file descriptor table, and builds
ls | wc -lfrompipe,fork,dup2, andexecvp. - Chapter 25 covers edge-triggered epoll, the stall a missed edge leaves behind, and the modes that let several threads share one listener.
Part 8, Under the locks
- Chapter 26 builds a futex-backed mutex, counts its system calls, and runs two litmus tests on memory ordering.
Part 9, Production networking
- Chapter 27 follows a TCP connection as it closes, and the states a busy server leaves behind.
- Chapter 28 adds TLS and mutual TLS, with certificates made at startup.
- Chapter 29 builds a client connection pool with a hard limit and a reused connection.
Part 10, Compact implementations
- Chapter 30 gives ten short implementations, each sized to type in a live round, and points at the chapter that holds its full version.
Appendix A is the Rust cheat sheet: the compile errors and idioms that cost the most time. Appendix B is the full benchmark report. Appendix C is the long version of chapter 10, kept for readers who want every borrow trick.
Conventions
Code in the text looks like this. Programs are introduced in numbered listings, usually a few lines at a
time. Each caption names the source file and the lines it shows. At the end of each chapter, the complete
program appears as one listing.
Output from a program follows the command that produced it. Timings name the machine they were measured on. They will differ on yours.
A term is printed in bold where it is defined.
Three kinds of callouts appear in the text:
NOTE: Background or detail you do not need to follow the main text.
TIP: A technique you can apply directly.
WARNING: A mistake that compiles, or behavior that differs from what you might expect.
Each chapter ends with a summary and a few exercises.
About the code
The programs are in the rust-interview-lab directory of the repository at
https://github.com/arpanpathak/cracking-the-systems-programming-interview. They use Rust edition 2024
and were built with rustc 1.96.0. The listings are taken from the source files without changes.
Library code with unit tests is in src/problems/. Runnable programs are in src/bin/. The data
structures measured in chapters 9 and 13 are in benchmarking_examples/.
Run the programs from the rust-interview-lab directory:
cargo test --lib # the library's unit tests
cargo test --bin http_server # the tests inside one program
cargo run --bin tcp_echo_server # run one program
cargo run --release --bin benchmark # benchmarks, always in release mode
Building the book
The book’s source is in the cracking-the-systems-programming-interview directory:
make art # the chapter plates and the cover (needs rsvg-convert)
make figures # the diagrams (needs Graphviz)
make html # the browser edition, in book/
make pdf # the print edition, in build/
make serve # the browser edition at http://localhost:3000
About the cover
The cover shows the Chief Mechanic of the Ferrous Works in a workshop of gears. The crates are labelled
Arc, Mutex, epoll, and Box, and the chain ends in a crate marked unsafe. Each chapter opens
with a member of the same crew at a machine that represents the chapter’s subject.
| Chapter | Mechanic | Machine |
|---|---|---|
| 1 | Rivet | a gate marked TRY_FROM that rejects parts of the wrong shape |
| 2 | Ledger | a filing cabinet, a stack of pages, and an offset counter |
| 3 | Bins | numbered bins fed by a hashing hopper |
| 4 | Shackle | a chain on a crane, one link opened with take() |
| 5 | Conveyor | three belts feeding a heap and one output belt |
| 6 | Branch | a manifold of pipes laid out as a binary tree |
| 7 | Valve | pipes and valves wired into a weighted graph |
| 8 | Cog | a train of gears, each half the size of the last |
| 9 | Drawer | a rack of crates, the oldest leaving by an eviction chute |
| 10 | Turntable | a ring of shards around a hub gear |
| 11 | Gauge | a board of page frames and a cache-miss gauge |
| 12 | Spindle | thread spools on one axle, and a padlock |
| 13 | Carousel | a conveyor queue and a ring-buffer carousel |
| 14 | Foreman | a job hopper feeding four worker robots |
| 15 | Throttle | a token bucket under a refill valve |
| 16 | Signal | a radio mast and a socket panel |
| 17 | Courier | a pneumatic tube delivering a request to status-code slots |
| 18 | Flywheel | a flywheel driven by poll, with futures on a rail |
| 19 | Drill | a drill press and a checklist |
The plates are generated by tools/art.py.
Bindings, functions, and loops
This chapter covers
- What a
letbinding is and where its value lives - Shadowing a name, and how it differs from changing a value with
mut - Writing a function that calls itself, and what happens on the stack when it does
- Replacing that recursion with a loop, and with a table of saved answers
- Timing code with
Instant, and passing a function to another function
This chapter begins Part 1 of the book. Part 1 covers the pieces of Rust that every later chapter uses. This first chapter works with the smallest pieces: names for values, functions, and loops.
You will read and run two short programs. The first gives one name to three different values in turn. It
shows what a let statement does, and how that differs from changing a value in place. The second
program computes Fibonacci numbers in three ways and times each one. It shows how a function that calls
itself uses memory, and it compares the running times of the three versions.
A few terms first:
- A binding is a name attached to a value.
let x = 5;binds the namexto the value5. - A scope is the region of code where a binding can be used. In Rust, a scope is usually a block
between
{and}. - The stack is the memory where a function keeps its local values while it runs.
- A stack frame is the part of the stack that belongs to one running call of a function.
1.1 A binding is a name for a value
When a function starts, Rust reserves a stack frame for it. Each let in the function gets a place in
that frame, sized for its type. A u32 takes 4 bytes, and a usize takes 8 bytes on a 64-bit machine.
A String is different. Its text can grow, so the text is kept in heap memory. The heap is memory the
program requests while it runs and gives back when it is done. The String value on the stack holds three
numbers. They are the address of the text on the heap, the length of the text, and the size of the heap block.
When a function returns, its frame is removed. Every value in the frame is dropped. For a String,
dropping it also frees its heap block.
1.2 Shadowing: one name, several values
You can write a second let with a name that is already in use. This does not change the first value.
It creates a second binding with the same name, and the name now refers to the new value. Rust calls this
shadowing. The first value still exists until the end of its scope, but you can no longer reach it by
that name.
Because the second binding is a new variable, it can have a different type. Figure 1.1 follows the name
value through three bindings of three types.
let creates a new value and moves the name to it. The earlier values are not changed; they lose their name.Here is the function that performs those three steps:
Listing 1.1 One name, three types. src/bin/shadowing.rs, lines 27 to 41
fn demonstrate_shadowing() -> String {
// First binding is an integer.
let value: u32 = 7;
let mut log = format!("integer: {value}");
// Shadow the integer with a String. The old integer is gone.
let value = value.to_string();
log.push_str(&format!("\nstring: {value}"));
// Shadow the String with a usize computed from it.
let value = value.len();
log.push_str(&format!("\nusize: {value}"));
log
}
Read the three let value lines in order:
let value: u32 = 7;creates au32.let value = value.to_string();reads theu32, builds aStringfrom it, and binds the name to theString. On the right-hand side,valuestill means theu32, because the new binding does not exist until the statement finishes.let value = value.len();reads theStringand binds the name to its length, ausize.
None of these lines needs mut, because no value is changed after it is created. The log string does
change, so it is declared let mut log. Each push_str appends one line to it, so the test can check all
three stages.
Figure 1.2 (left) shows the stack frame after the third line. There are three values, and only the last one has a usable name.
1.2.1 When shadowing helps
Programs often receive a value in one form and need it in another. A number typed by a user arrives as text. After you parse it, you want the number and never the text again. Shadowing lets the number take the text’s name:
let count = read_line_from_user(); // a String, for example "42"
let count: u32 = count.trim().parse().expect("a number");
After the second line, count is a u32. Code below it cannot use the unparsed text by mistake, because
no name refers to it any more.
1.2.2 A value captured earlier keeps its old value
A closure or a reference created before a shadowing let still refers to the first value:
let x = 1;
let show = || println!("{x}");
let x = x + 1; // a new x
show(); // prints 1
The closure captured the first x. The second let created a new variable, and the closure never sees it.
WARNING: Inside a loop, let x = x + 1; creates a new x that lives only until the end of that pass
through the loop. On the next pass, the outer x has its old value again. To count across passes, declare
let mut x before the loop and write x += 1 inside it.
1.3 Mutation: one value that changes
When you want one variable whose value changes over time, declare it with mut. The type stays the same,
and each assignment writes into the same place in the frame.
Listing 1.2 A counter that changes in place. src/bin/shadowing.rs, lines 43 to 53
fn demonstrate_mutation() -> String {
// If you need to mutate the same binding's value (same type) without
// creating a new binding, mark it `mut`.
let mut count = 0;
let mut log = String::from("mut count:");
for _ in 0..3 {
count += 1;
log.push_str(&format!(" {count}"));
}
log
}
for _ in 0..3 runs the loop body three times. The _ means the loop does not use the counter it is given.
Each pass adds 1 to count and appends the new value to log. Figure 1.2 (right) shows the one slot for
count at four moments.
Table 1.1 compares the two ways of changing what a name refers to.
Shadowing (let x = ... again) | Mutation (let mut x, then x = ...) | |
|---|---|---|
| Creates a new variable | yes | no |
| Can change the type | yes | no |
Needs mut | no | yes |
A closure that captured x earlier sees | the old value | a compile error if it is still in use |
1.4 The first complete program
The rest of shadowing.rs runs both functions and tests them. The file starts with a comment that
explains the program:
Listing 1.3 The complete program. src/bin/shadowing.rs
// ============================================================================
// Variable shadowing vs mutation
//
// Ported from the Rust playground repo (`shadowing.rs`).
//
// What this demonstrates:
// - Rust allows a new `let` binding with the same name as an existing
// binding. This is called **shadowing**.
// - Shadowing can change the value *and the type* of a variable without
// declaring it `mut`.
// - The old binding is no longer reachable after the shadowing `let`.
//
// Where this appears in practice:
// - Rust SDK code often shadows parsed/deserialized values to convert them
// from raw input to typed domain values:
//
// let raw = request.header("X-GPU-Count")?;
// let raw: u64 = raw.parse()?; // shadow with a typed value
//
// - Shadowing is also useful after unwrapping an `Option`/`Result` when the
// guard value is no longer needed.
//
// Run:
// cargo run --bin shadowing
// ============================================================================
fn demonstrate_shadowing() -> String {
// First binding is an integer.
let value: u32 = 7;
let mut log = format!("integer: {value}");
// Shadow the integer with a String. The old integer is gone.
let value = value.to_string();
log.push_str(&format!("\nstring: {value}"));
// Shadow the String with a usize computed from it.
let value = value.len();
log.push_str(&format!("\nusize: {value}"));
log
}
fn demonstrate_mutation() -> String {
// If you need to mutate the same binding's value (same type) without
// creating a new binding, mark it `mut`.
let mut count = 0;
let mut log = String::from("mut count:");
for _ in 0..3 {
count += 1;
log.push_str(&format!(" {count}"));
}
log
}
fn main() {
println!("=== Shadowing ===");
println!("{}", demonstrate_shadowing());
println!("\n=== Mutation ===");
println!("{}", demonstrate_mutation());
}
// ============================================================================
// Tests (cargo test --bin shadowing)
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shadowing_allows_type_changes() {
let log = demonstrate_shadowing();
assert!(log.contains("integer: 7"));
assert!(log.contains("string: 7"));
assert!(log.contains("usize: 1"));
}
#[test]
fn mutation_requires_mut_binding() {
let log = demonstrate_mutation();
assert!(log.contains("mut count: 1 2 3"));
}
}
The tests at the end are in a module marked #[cfg(test)]. The compiler builds that module only when you
run cargo test. use super::*; brings the functions from the rest of the file into the test module.
Each test calls a function and uses assert! to check that the log contains the expected text.
Run the program and its tests from the rust-interview-lab directory:
$ cargo run --bin shadowing
=== Shadowing ===
integer: 7
string: 7
usize: 1
=== Mutation ===
mut count: 1 2 3
$ cargo test --bin shadowing
running 2 tests
test tests::mutation_requires_mut_binding ... ok
test tests::shadowing_allows_type_changes ... ok
Now that you know where a function keeps its values, you can look at what happens when a function calls itself many times.
1.5 A function that calls itself
The Fibonacci numbers start with 0 and 1. Each later number is the sum of the two before it:
| n | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| fib(n) | 0 | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 |
The definition can be written directly as a Rust function. A function that calls itself is recursive.
Listing 1.4 Fibonacci by recursion. src/bin/cs_fib.rs, lines 8 to 15
use std::time::{Duration, Instant};
/// Exponential: fib(n) recomputes fib(n - 1) and fib(n - 2) from scratch.
fn recursive(n: u64) -> u64 {
if n < 2 {
n
} else {
recursive(n - 1) + recursive(n - 2)
}
}
For n of 0 or 1, the function returns n. This is the base case, the input that needs no further
calls. For larger n, it calls itself twice and adds the results. The if is an expression, so its
value is the function’s return value, and no return keyword is needed.
1.5.1 Counting the calls
Work out recursive(5) by hand. It calls recursive(4) and recursive(3). recursive(4) then calls
recursive(3) again. Figure 1.3 draws every call.
recursive(5). Shaded calls repeat work that another branch already did: fib(3) is computed twice and fib(2) three times.The tree has 15 calls for n = 5. The tree for n contains the trees for n - 1 and n - 2, so the
count grows by about 1.6 times per step. For n = 30, the function makes about 2.7 million calls. For n = 90,
the count would be about 1019.
1.5.2 What each call uses on the stack
Every call gets its own stack frame, holding its n and the address to return to. A frame is removed when
its call returns. So the stack does not hold all the calls at once, only the chain of calls that are
waiting on each other. Figure 1.4 shows the deepest moment of recursive(4).
recursive(4). The depth is about n frames, while the number of calls grows much faster.So the recursive version has two costs. Its time grows exponentially with n, because it repeats work.
Its stack depth grows with n, which is small here but limits recursion on very deep inputs. Chapter 9
shows a list that crashes because of this second cost.
1.6 The same numbers with a loop
You only ever need the last two numbers to compute the next one. A loop can keep those two and move them forward one step at a time.
Listing 1.5 Fibonacci with a loop. src/bin/cs_fib.rs, lines 17 to 24
/// Linear time, constant space.
fn iterative(n: u64) -> u64 {
let (mut previous, mut current) = (0u64, 1u64);
for _ in 0..n {
(previous, current) = (current, previous + current);
}
previous
}
let (mut previous, mut current) = (0u64, 1u64); declares two variables in one statement by matching a
pair. The suffix u64 on each literal sets its type.
(previous, current) = (current, previous + current); assigns both variables at once. Rust evaluates the
whole right-hand side first, using the old values, and only then writes both. Without the pair, you would
need a temporary variable to avoid overwriting previous before it is used.
Trace iterative(5):
| after pass | 0 (start) | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
previous | 0 | 1 | 1 | 2 | 3 | 5 |
current | 1 | 1 | 2 | 3 | 5 | 8 |
After five passes, previous is 5, which is fib(5). The loop runs n times and keeps two numbers, so its
time grows in proportion to n and its memory stays constant.
1.7 Recursion with a memory
The loop is fast, but the recursive version matches the definition more closely. You can keep the recursion and remove the repeated work by saving each answer the first time you compute it. Saving answers this way is called memoization, and the table of saved answers is a cache.
Listing 1.6 Fibonacci with a cache. src/bin/cs_fib.rs, lines 26 to 41
/// Linear time, linear space: recursion plus a cache of subproblem answers.
fn memoized(n: u64) -> u64 {
fn go(n: u64, cache: &mut [u64]) -> u64 {
if n < 2 {
return n;
}
let index = n as usize;
if cache[index] == 0 {
cache[index] = go(n - 1, cache) + go(n - 2, cache);
}
cache[index]
}
let mut cache = vec![0u64; n as usize + 1];
go(n, &mut cache)
}
This listing uses three pieces of syntax you may not have used before:
- A function inside a function.
gois declared insidememoized. Onlymemoizedcan call it. It is an ordinary function; it does not seememoized’s variables. vec![0u64; n as usize + 1]. This creates aVecofn + 1zeros.n as usizeconverts theu64tousize, the type Rust uses for lengths and indexes.&mut [u64]. This is a mutable slice: a borrowed view of the vector’s elements that allows writing.goreceives the cache this way, so every call shares one table instead of copying it.
go checks the base case first. Then it looks in the cache. A zero means “not computed yet”. In that case
it computes the value with two recursive calls and stores it. Either way, it returns the saved value.
Zero can mark an empty slot here because fib(n) is never zero for n of 2 or more. Smaller values
return before the cache is read. Figure 1.5 shows the cache after memoized(6).
memoized(6). Each slot from 2 to 6 is computed once and then read.With the cache, each value from 2 to n is computed once, so the time grows in proportion to n. The
cache uses memory in proportion to n, and the recursion still uses n stack frames.
Animation 1.1 runs memoized(5) and recursive(5) side by side in time. The tree on the left gains a node per
call, and the stack on the right holds only the calls that are still waiting.
1.8 Timing the three versions
To compare the versions, the program measures how long each call takes.
Listing 1.7 Timing a function and printing the result. src/bin/cs_fib.rs, lines 43 to 51
fn timed(label: &str, n: u64, operation: impl Fn(u64) -> u64) {
let start = Instant::now();
let value = operation(n);
report(label, n, value, start.elapsed());
}
fn report(label: &str, n: u64, value: u64, elapsed: Duration) {
println!(" {label:<10} fib({n:>2}) = {value:<20} in {elapsed:?}");
}
timed takes a parameter operation: impl Fn(u64) -> u64. This means “any function or closure that takes
a u64 and returns a u64”. So main can pass recursive, iterative, or memoized by name.
Instant::now() reads a clock that only moves forward. start.elapsed() returns the time since then as a
Duration.
report uses format specifiers inside the braces. {label:<10} pads the label to 10 characters, aligned
left. {n:>2} right-aligns n in 2 characters. {elapsed:?} prints the Duration with its debug format,
which chooses a unit such as µs or ms.
1.9 The complete Fibonacci program
Here is the whole program, including main. main times each version, then checks that all three agree.
Listing 1.8 The complete program. src/bin/cs_fib.rs
//! Three ways to compute Fibonacci: naive recursion, iteration, and memoization.
//! The first shows why exponential blowup happens; the other two fix it.
//!
//! Run with: cargo run --bin cs_fib
use std::time::{Duration, Instant};
/// Exponential: fib(n) recomputes fib(n - 1) and fib(n - 2) from scratch.
fn recursive(n: u64) -> u64 {
if n < 2 {
n
} else {
recursive(n - 1) + recursive(n - 2)
}
}
/// Linear time, constant space.
fn iterative(n: u64) -> u64 {
let (mut previous, mut current) = (0u64, 1u64);
for _ in 0..n {
(previous, current) = (current, previous + current);
}
previous
}
/// Linear time, linear space: recursion plus a cache of subproblem answers.
fn memoized(n: u64) -> u64 {
fn go(n: u64, cache: &mut [u64]) -> u64 {
if n < 2 {
return n;
}
let index = n as usize;
if cache[index] == 0 {
cache[index] = go(n - 1, cache) + go(n - 2, cache);
}
cache[index]
}
let mut cache = vec![0u64; n as usize + 1];
go(n, &mut cache)
}
fn timed(label: &str, n: u64, operation: impl Fn(u64) -> u64) {
let start = Instant::now();
let value = operation(n);
report(label, n, value, start.elapsed());
}
fn report(label: &str, n: u64, value: u64, elapsed: Duration) {
println!(" {label:<10} fib({n:>2}) = {value:<20} in {elapsed:?}");
}
fn main() {
println!("small input, all three should agree");
timed("recursive", 20, recursive);
timed("iterative", 20, iterative);
timed("memoized", 20, memoized);
println!("\nrecursive cost grows quickly");
timed("recursive", 30, recursive);
timed("iterative", 30, iterative);
println!("\nlarge input: recursion alone would not finish");
timed("iterative", 90, iterative);
timed("memoized", 90, memoized);
for n in 0..20 {
assert_eq!(recursive(n), iterative(n));
assert_eq!(memoized(n), iterative(n));
}
assert_eq!(iterative(30), 832_040);
assert_eq!(iterative(90), 2_880_067_194_370_816_120);
println!("\nall checks passed");
}
The loop near the end of main compares the three versions for every n below 20. If any pair disagrees,
assert_eq! stops the program with both values. The two final assertions check known values of fib(30) and
fib(90).
This output comes from a debug build, the default for cargo run:
$ cargo run --bin cs_fib
small input, all three should agree
recursive fib(20) = 6765 in 90.908µs
iterative fib(20) = 6765 in 567ns
memoized fib(20) = 6765 in 10.826µs
recursive cost grows quickly
recursive fib(30) = 832040 in 11.124774ms
iterative fib(30) = 832040 in 542ns
large input: recursion alone would not finish
iterative fib(90) = 2880067194370816120 in 937ns
memoized fib(90) = 2880067194370816120 in 5.749µs
all checks passed
Compare the two recursive timings. Going from n = 20 to n = 30, the time grows about 120 times. That
matches the growth rate from section 1.5.1: 1.618 multiplied by itself 10 times is about 123. At that rate,
recursive(90) would run for over a thousand years, so main does not call it.
The memoized version is slower than the loop, although both grow in proportion to n. It allocates a
Vec and makes function calls, and the loop does neither.
NOTE: fib(93) is the largest Fibonacci number that fits in a u64. iterative(94) would overflow. In
a debug build, an overflow stops the program with a panic. In a release build, the value silently wraps
around.
Summary
- A
letbinding attaches a name to a value in the current stack frame. AStringkeeps its text on the heap and a small header on the stack. - Shadowing creates a new binding, which may have a new type. Earlier values are unchanged and lose their name. Closures that captured the old binding keep the old value.
mutlets one binding change its value. The type stays the same.- A recursive function uses one stack frame per active call. Naive recursive Fibonacci repeats work, so its time grows exponentially.
- A loop that keeps only the last two values computes the same result in linear time and constant memory.
- Memoization keeps the recursion and saves each answer in a cache, making the time linear.
impl Fn(u64) -> u64accepts any function or closure with that signature, andInstantmeasures elapsed time.
Chapter 2 moves from computation to the operating system. You will read and write files, walk
directories, and parse command-line arguments, and meet Result and ?, which every later chapter uses.
Exercises
- Add a counter to
recursivethat records how many calls it makes. Print the count fornfrom 1 to 25, and check that each count is close to 1.6 times the previous one. - Rewrite
memoizedso the cache holdsOption<u64>instead of using 0 as “empty”. Explain why the 0 marker is safe for Fibonacci but would not be safe for a function that can return 0. - Change
iterativeto returnOption<u64>and usechecked_add, so thatiterative(94)returnsNoneinstead of overflowing. - Run
cargo run --release --bin cs_fiband compare the timings with the debug build above.
Files, paths, and the command line
This chapter covers
- What happens between your program and the disk when you read a file
Resultand the?operator, which every later chapter usesPathandPathBuf, and reading a file whole or line by line- Appending, copying, renaming, and deleting files, and telling one error from another
- Listing and walking directories
- Reading a large file one page at a time, and parsing command-line arguments
In chapter 1 your programs only computed values. In this chapter they talk to the operating system. They read and write files, look inside directories, and read the arguments you type after a program’s name.
Every one of these operations can fail. A file may not exist, or you may not have permission to read it.
Rust reports such failures through a type called Result, so this chapter starts there. After that come
paths, reading, writing, and directories. The chapter ends with two complete tools. One reads a file a page
at a time. The other turns command-line arguments into typed options.
A few terms:
- The kernel is the core of the operating system. It controls the disk, memory, and devices.
- A system call is a request from a program to the kernel, such as “open this file” or “read 100 bytes”.
- A file descriptor is a small number the kernel gives your program for each open file. Later system calls use it to name that file.
2.1 What happens when you read a file
Your program cannot read the disk directly. It asks the kernel with system calls, and the kernel does the work. Figure 2.1 shows the steps for reading a small file.
The steps are:
openasks the kernel to open the file. The kernel checks permissions and returns a file descriptor.readasks for bytes from that descriptor. The kernel copies them into a buffer in your program.closetells the kernel you are done, so it can release the descriptor.
Rust’s standard library makes these calls for you. Each call can fail, and the library reports that with
Result.
2.2 Result and the ? operator
Result is an enum with two variants:
enum Result<T, E> {
Ok(T), // the operation succeeded and produced a T
Err(E), // the operation failed with an error of type E
}
For file operations, the error type is std::io::Error, and io::Result<T> is a shorter name for
Result<T, io::Error>. So fs::read_to_string(path) returns io::Result<String>: either the file’s text,
or the reason it could not be read.
You can handle a Result with match. That gets long when a function makes several calls, each of which
can fail. The ? operator shortens it. Put ? after an expression of type Result, and it does one of two
things (figure 2.2):
- If the value is
Ok(x), the expression evaluates toxand the function continues. - If the value is
Err(e), the function returnsErr(e)immediately.
? operator. It can only be used inside a function that itself returns Result.Because ? returns from the function, the function must return a Result too. That is why the programs in
this chapter declare fn main() -> io::Result<()>. If main returns an Err, Rust prints the error and
the program exits with status 1.
2.3 Paths
A path names a file or directory, such as /tmp/data.txt. Rust has two path types. &Path is a
borrowed view of a path, and PathBuf is an owned path that can grow. They relate the same way &str
relates to String (figure 2.3).
join returns a new owned path. push extends a PathBuf in place. A &PathBuf converts to &Path automatically when a function asks for one.Listing 2.1 Building paths and taking them apart. src/bin/path_buff.rs
use std::path::{Path, PathBuf};
fn main() {
let dir = Path::new("/tmp");
let file = dir.join("data.txt");
let mut buf = PathBuf::from(dir);
buf.push("nested");
buf.push("file.txt");
println!("file: {}", file.display());
println!("buf: {}", buf.display());
println!("parent: {:?}", buf.parent());
println!("file_name: {:?}", buf.file_name());
println!("ext: {:?}", buf.extension());
}
Path::new("/tmp") wraps a string as a path without copying it. dir.join("data.txt") builds a new
PathBuf. PathBuf::from(dir) copies the path into an owned buffer, and each push adds one component.
$ cargo run --bin path_buff
file: /tmp/data.txt
buf: /tmp/nested/file.txt
parent: Some("/tmp/nested")
file_name: Some("file.txt")
ext: Some("txt")
parent, file_name, and extension return Option, because some paths lack them: / has no parent.
To print a path you call display(). On Linux and macOS a path is a sequence of bytes that need not be
valid UTF-8. display() replaces any invalid bytes when it prints.
WARNING: If the argument to join or push is an absolute path, it replaces the whole path instead of
extending it. Path::new("/srv").join("/etc/passwd") is /etc/passwd. If part of a path comes from a user,
reject absolute paths and .. before you join it.
2.4 Reading a file
2.4.1 The whole file at once
The simplest read loads the whole file into one String.
Listing 2.2 Write a file, then read it back. src/bin/read_write_file.rs
use std::{
fs,
io,
path::{Path, PathBuf},
};
// `&Path` accepts both a `&Path` and a `&PathBuf` argument; `&PathBuf` would not.
fn read_content_of_file(path: &Path) -> io::Result<String> {
fs::read_to_string(path)
}
fn main() -> io::Result<()> {
fs::write("hello.txt", "Hello, Rust!\n")?;
// `join` builds a new PathBuf and leaves `base_dir` unchanged.
let base_dir = PathBuf::from(".");
let joined_path = base_dir.join("hello.txt");
// `push` mutates the PathBuf in place instead of returning a new one.
let mut mut_base_dir = PathBuf::from(".");
mut_base_dir.push("hello.txt");
let text = read_content_of_file(&joined_path)?;
println!("File contents (via .join()):\n{}", text);
let text_inplace = read_content_of_file(&mut_base_dir)?;
println!("File contents (via .push()):\n{}", text_inplace);
Ok(())
}
fs::write creates the file (or empties an existing one) and writes the text. fs::read_to_string opens,
reads, and closes the file in one call. Both calls end with ?, so a failure in either one returns from
main.
read_content_of_file takes &Path. The program calls it twice, with a path built by join and a path built
by push. Both are PathBuf values that convert to &Path. The two calls print the
same text.
read_to_string keeps the whole file in memory. That suits a small configuration file. For a log of
several gigabytes, read one line at a time instead.
2.4.2 One line at a time
To read line by line, wrap the file in a BufReader. Figure 2.4 shows why the buffer helps.
BufReader asks the kernel for a large block at once and hands out lines from it. Fewer system calls means less time spent crossing into the kernel.Listing 2.3 Numbered lines through a buffered reader. src/bin/readfile_line_by_line.rs
use std::{
fs::File,
io::{BufRead, BufReader},
}; // BufRead brings `lines()` into scope.
fn main() -> std::io::Result<()> {
let file = File::open("log.txt")?;
let reader = BufReader::new(file);
for (i, line) in reader.lines().enumerate() {
let line = line?;
println!("{}: {}", i + 1, line);
}
Ok(())
}
BufReader::new(file) takes ownership of the file and adds an 8 KiB buffer. The lines method comes from
the BufRead trait, so the program imports that trait. Without the import, the method is not visible.
lines() yields one io::Result<String> per line, with the newline removed. Reading can fail partway
through the file, so each line is a Result. let line = line?; replaces the Result with the String
inside it, using the shadowing you saw in section 1.2. enumerate() pairs each line with its index,
starting at 0, which is why the program prints i + 1.
Animation 2.1 runs this loop on a 12-byte file of three lines, and counts the read system calls. Then it runs
the same lines through a reader with no buffer.
BufReader makes one large read and serves the lines from memory: 2 calls. Reading one byte at a time costs a system call per byte: 13 calls.2.4.3 Reusing one buffer, and skipping bad lines
lines() allocates a new String for every line. For a very large file, you can read every line into the
same String instead.
Listing 2.4 One reused buffer, with invalid lines skipped. src/bin/read_large_file.rs
use std::{
fs::File,
io::{self, BufRead, BufReader},
};
fn main() -> io::Result<()> {
let file = File::open("large.txt")?;
let mut reader = BufReader::new(file);
let mut line = String::new();
while match reader.read_line(&mut line) {
Ok(0) => false, // EOF, stop
Ok(_) => {
process(line.trim_end());
line.clear();
true // keep reading
}
Err(e) if e.kind() == io::ErrorKind::InvalidData => {
eprintln!("skipping bad line: {e}");
line.clear();
true
}
Err(e) => return Err(e), // return works inside the condition
} {}
Ok(())
}
fn process(line: &str) {
println!("{line}");
}
reader.read_line(&mut line) appends the next line, including its newline, to line. It returns the
number of bytes read. The loop is written in an unusual way: while match ... { ... } {}. The whole
match is the loop’s condition, and the loop’s body, {}, is empty. Each arm of the match evaluates to
true to continue or false to stop.
Table 2.1 lists the four outcomes.
read_line returns | Meaning | What the loop does |
|---|---|---|
Ok(0) | end of the file | stops |
Ok(n) | a line of n bytes | processes it, clears the buffer, continues |
Err(e) with kind InvalidData | the line is not valid UTF-8 text | prints a message, clears, continues |
any other Err | a real read failure | returns the error from main |
Because read_line appends, the loop must call line.clear() after each line. Otherwise the buffer would
grow to hold the whole file.
The InvalidData arm can continue safely because read_line has already consumed the invalid bytes. You
can check this with a file whose second line holds two bytes that are not UTF-8:
$ printf 'ok\n\xff\xfebad\nfine\n' > large.txt
$ cargo run --bin read_large_file
ok
skipping bad line: stream did not contain valid UTF-8
fine
2.5 Writing and changing files
2.5.1 Appending
fs::write replaces a file’s contents. To add lines to the end of a log instead, open the file with
OpenOptions.
Listing 2.5 Appending lines to a file. src/bin/append_to_file_open_options.rs
use std::{fs::OpenOptions, io::Write};
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.create(true) // create if missing
.append(true) // always write at the end
.open("log.txt")?;
writeln!(file, "New log line")?;
writeln!(file, "Another line")?;
Ok(())
}
OpenOptions collects settings, and open applies them. create(true) creates the file if it is missing.
append(true) asks the kernel to move to the end of the file before every write. The kernel does that move
and the write as one step. So two programs appending to the same log do not overwrite each other’s lines.
writeln! works like println!, but writes to any value that implements the std::io::Write trait, such
as a File. The trait must be imported, as the second use line does.
2.5.2 Copying, renaming, and deleting
The standard library has one function for each of these file operations. The program copies a file, renames the copy, and deletes the original.
Listing 2.6 Copy, rename, remove. src/bin/copy_rename_delete_file.rs
use std::{fs, io};
fn main() -> io::Result<()> {
fs::write("a.txt", "hello")?;
fs::copy("a.txt", "b.txt")?;
fs::rename("b.txt", "c.txt")?;
fs::remove_file("a.txt")?;
println!("Done");
Ok(())
}
After the program runs, a.txt is gone and c.txt holds hello. Each call is one or two system calls.
fs::rename has a property you can build on. When both names are on the same disk, the rename is
atomic. Any other program sees either the old name or the new one, never a half-renamed file. To update
a file safely, write the new contents to a temporary file, then rename it over the old one. A crash in the
middle leaves either the old file or the new file, never a partial one.
2.5.3 Telling one error from another
An io::Error carries a kind, an enum value such as NotFound or PermissionDenied. You can match on
it to handle one failure and report the others.
Listing 2.7 Matching on the kind of an error. src/bin/file_error_handling.rs
use std::{fs, io};
fn read_file(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}
fn main() {
match read_file("missing.txt") {
Ok(text) => println!("{}", text),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
eprintln!("File not found");
}
Err(e) => eprintln!("Error: {}", e),
}
}
The second arm has a match guard, the if after the pattern. It matches only errors whose kind is
NotFound. Every other error falls through to the third arm. This main does not return a Result, so it
cannot use ?; it handles every case itself. Run it where missing.txt does not exist, and it prints
File not found.
2.6 Directories
A directory is a list of entries. Each entry has a name and a type: a file, a directory, or a symbolic link. A symbolic link is an entry that points to another path.
2.6.1 One level
A directory is a list of entries, each a name and a type. The program prints the entries of the current directory and marks which are directories.
Listing 2.8 Listing the current directory. src/bin/list_directory.rs
use std::{fs, io};
fn main() -> io::Result<()> {
for entry in fs::read_dir(".")? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
println!("[DIR] {}", path.display());
} else {
println!("[FILE] {}", path.display());
}
}
Ok(())
}
fs::read_dir(".") returns an iterator over the entries of the current directory. Each item is a
Result, because reading an entry can fail. path.is_dir() asks the kernel for the entry’s details. If
the entry is a symbolic link, is_dir follows it and reports on the target.
2.6.2 Every level
To visit subdirectories, the function calls itself on each directory it finds, the recursion you met in section 1.5.
Listing 2.9 Walking a directory tree. src/bin/recusrive_directory_walk.rs
use std::{fs, io, path::Path};
fn walk(dir: &Path) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
println!("[DIR] {}", path.display());
walk(&path)?;
} else {
println!("[FILE] {}", path.display());
}
}
Ok(())
}
fn main() -> io::Result<()> {
walk(Path::new("."))?;
Ok(())
}
walk uses entry.file_type() instead of path.is_dir(). The difference is how each treats symbolic
links. file_type() does not follow them, so a link to a directory is reported as a link, and walk does
not descend into it. That keeps the walk from looping forever through a link that points back to one of its
own parent directories. On Linux, file_type() is usually also faster, because the kernel returns the type
together with the name.
The recursion goes one level deeper per directory level. Real directory trees are shallow, so the stack is not a concern here.
2.7 Project: reading a large file one page at a time
Suppose a service stores millions of job records, one per line, in a file. A client asks for them a page at a time. With each page, the service returns a cursor, a value the client sends back to get the next page.
If the cursor is the byte position where the next page starts, the service needs no memory of past requests. Any copy of the service can read the next page from the cursor alone.
2.7.1 The page type
A page is the lines it holds, plus the cursor where the next page starts. The cursor is a byte offset into the file.
Listing 2.10 The Page type (lines 1 to 9). src/bin/file_pagination.rs
use std::{
fs::File,
io::{self, BufRead, BufReader, Seek, SeekFrom},
};
struct Page {
jobs: Vec<String>,
next_cursor: Option<u64>, // Byte offset where the next page starts
}
A Page holds the lines and an Option<u64> for the next cursor. None means there are no more pages.
2.7.2 Counting bytes to find the next cursor
The next cursor is the byte where this page ends. There is a trap in finding it. You might ask the file for
its current position after reading three lines. That answer is wrong, because the BufReader read 8 KiB
ahead on its first read, as figure 2.4 showed. The file’s position is past the buffered data, not after the
third line (figure 2.5).
read_line returned: 12 + 12 + 11 = 35 for the first page. The file's own position is far past that, at the end of what the buffer read.So read_page counts the bytes itself:
Listing 2.11 read_page (lines 11 to 38).
fn read_page(path: &str, cursor: u64, page_size: usize) -> io::Result<Page> {
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(cursor))?;
let mut reader = BufReader::new(file);
// Optimization which preserves as large as the max mage size
let mut jobs: Vec<String> = Vec::with_capacity(page_size);
let mut offset = cursor;
let mut line = String::new();
while jobs.len() < page_size {
// Clear the previously read line
line.clear();
match reader.read_line(&mut line)? {
0 => break,
bytes_read => {
offset += bytes_read as u64;
jobs.push(line.trim_end().to_string());
}
}
}
let at_end = reader.fill_buf()?.is_empty();
let next_cursor = if at_end { None } else { Some(offset) };
Ok(Page { jobs, next_cursor })
}
read_page opens the file and calls seek(SeekFrom::Start(cursor)), which moves the file’s read position
to byte cursor. Then it wraps the file in a BufReader. Vec::with_capacity(page_size) reserves room
for a full page, so pushing lines does not reallocate.
offset starts at cursor. Each read_line returns how many bytes it consumed, newline included, and
offset grows by that amount. line.trim_end() removes the newline (and any trailing spaces) before the
line is stored, but the count still includes them, so offset stays exact.
The last page needs care. If the file ends exactly after this page’s last line, the page should report no
next cursor. reader.fill_buf() returns the bytes waiting in the buffer, reading more if the buffer is
empty, without consuming them. If it returns nothing, the file has ended, and the cursor is None.
2.7.3 The complete program
main acts as a client. It asks for pages of three lines until the cursor is None.
Listing 2.12 The complete program. src/bin/file_pagination.rs
use std::{
fs::File,
io::{self, BufRead, BufReader, Seek, SeekFrom},
};
struct Page {
jobs: Vec<String>,
next_cursor: Option<u64>, // Byte offset where the next page starts
}
fn read_page(path: &str, cursor: u64, page_size: usize) -> io::Result<Page> {
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(cursor))?;
let mut reader = BufReader::new(file);
// Optimization which preserves as large as the max mage size
let mut jobs: Vec<String> = Vec::with_capacity(page_size);
let mut offset = cursor;
let mut line = String::new();
while jobs.len() < page_size {
// Clear the previously read line
line.clear();
match reader.read_line(&mut line)? {
0 => break,
bytes_read => {
offset += bytes_read as u64;
jobs.push(line.trim_end().to_string());
}
}
}
let at_end = reader.fill_buf()?.is_empty();
let next_cursor = if at_end { None } else { Some(offset) };
Ok(Page { jobs, next_cursor })
}
fn main() -> io::Result<()> {
let mut cursor = 0;
loop {
let page = read_page("src/bin/large_file.txt", cursor, 3)?;
println!("--- page ---");
for job in &page.jobs {
println!("{job}");
}
match page.next_cursor {
Some(next) => cursor = next,
None => break,
}
}
Ok(())
}
The input file has thirteen lines, so the program prints four full pages and one page of one line:
$ cargo run --bin file_pagination
--- page ---
First Line
Second Line
Thrid Line
--- page ---
4
5
6
--- page ---
7
8
9
--- page ---
10
11
12
--- page ---
13
TIP: If the file can change between requests, an old cursor may point into the middle of a line. You can include a version number with the offset in the cursor, and reject cursors whose version is out of date.
2.8 Project: command-line arguments
When you run command_line_args --name ada --count 2 --verbose, the operating system passes the program a
list of strings (figure 2.6). The first string is the program’s own path. The rest are the arguments, in
the order you typed them.
--name and --count each take the next string as their value.This program turns that list into a struct of typed options, or an error message.
2.8.1 The options and their defaults
The program’s settings live in one struct, with a default value for each setting the user does not give.
Listing 2.13 The options (lines 1 to 17). src/bin/command_line_args.rs
use std::{env::args, process::ExitCode};
/// The options this program understands.
#[derive(Debug, PartialEq)]
struct Options {
name: String,
count: u32,
verbose: bool,
}
impl Default for Options {
fn default() -> Self {
Self {
name: String::from("world"),
count: 1,
verbose: false,
}
}
}
Options has one field per option. The Default trait gives the values used when an option is not given.
#[derive(Debug, PartialEq)] lets the tests print and compare Options values.
2.8.2 Parsing
Parsing walks the arguments one at a time and fills in the options. An unknown argument is an error. So is a flag with no value after it, and a count that is not a number.
Listing 2.14 parse (lines 21 to 47).
/// Turn the arguments after the program name into `Options`.
fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Options, String> {
let mut options = Options::default();
let mut arguments = arguments.into_iter();
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--name" => {
options.name = arguments
.next()
.ok_or("--name needs a value")?;
}
"--count" => {
let value = arguments
.next()
.ok_or("--count needs a value")?;
options.count = value
.parse()
.map_err(|_| format!("--count needs a number, got {value:?}"))?;
}
"--verbose" => options.verbose = true,
other => return Err(format!("unknown argument {other:?}")),
}
}
Ok(options)
}
parse takes impl IntoIterator<Item = String>, which means “anything that can produce Strings one at a
time”. The program passes the real arguments. The tests pass short lists written in the test code.
The loop is while let Some(argument) = arguments.next(). It calls next() itself, rather than using
for, because --name must take the next string from the same iterator as its value. A for loop holds
the iterator for the whole loop, so the body could not call next() on it.
arguments.next().ok_or("--name needs a value")? does two steps. ok_or turns None into an Err with
the message. Then ? returns that error from parse. The --count arm adds a third step. parse()
converts the text to a number, and map_err replaces a parse error with a message that includes the text.
An argument the program does not know is an error, so a misspelled option is reported instead of ignored.
2.8.3 The exit code
main ties the pieces together: it parses the arguments, runs, and tells the shell whether it succeeded.
Listing 2.15 main (lines 49 to 68).
fn main() -> ExitCode {
let options = match parse(args().skip(1)) {
Ok(options) => options,
Err(message) => {
eprintln!("error: {message}");
eprintln!("usage: command_line_args [--name NAME] [--count N] [--verbose]");
return ExitCode::FAILURE;
}
};
for index in 0..options.count {
if options.verbose {
println!("[{index}] hello, {}", options.name);
} else {
println!("hello, {}", options.name);
}
}
ExitCode::SUCCESS
}
A program’s exit code is a number it returns to whoever started it. Zero means success. main returns
ExitCode, so it can report failure after printing its own message. args().skip(1) drops the program’s
path. On an error, main prints the message and a usage line to standard error with eprintln!, then
returns ExitCode::FAILURE, which is 1.
2.8.4 The complete program
Listing 2.16 The complete program, with its tests. src/bin/command_line_args.rs
use std::{env::args, process::ExitCode};
/// The options this program understands.
#[derive(Debug, PartialEq)]
struct Options {
name: String,
count: u32,
verbose: bool,
}
impl Default for Options {
fn default() -> Self {
Self {
name: String::from("world"),
count: 1,
verbose: false,
}
}
}
/// Turn the arguments after the program name into `Options`.
fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Options, String> {
let mut options = Options::default();
let mut arguments = arguments.into_iter();
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--name" => {
options.name = arguments
.next()
.ok_or("--name needs a value")?;
}
"--count" => {
let value = arguments
.next()
.ok_or("--count needs a value")?;
options.count = value
.parse()
.map_err(|_| format!("--count needs a number, got {value:?}"))?;
}
"--verbose" => options.verbose = true,
other => return Err(format!("unknown argument {other:?}")),
}
}
Ok(options)
}
fn main() -> ExitCode {
let options = match parse(args().skip(1)) {
Ok(options) => options,
Err(message) => {
eprintln!("error: {message}");
eprintln!("usage: command_line_args [--name NAME] [--count N] [--verbose]");
return ExitCode::FAILURE;
}
};
for index in 0..options.count {
if options.verbose {
println!("[{index}] hello, {}", options.name);
} else {
println!("hello, {}", options.name);
}
}
ExitCode::SUCCESS
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_args(arguments: &[&str]) -> Result<Options, String> {
parse(
arguments
.iter()
.map(|value| value.to_string()),
)
}
#[test]
fn defaults_apply_when_there_are_no_arguments() {
assert_eq!(parse_args(&[]).unwrap(), Options::default());
}
#[test]
fn a_value_option_consumes_the_next_argument() {
let options = parse_args(&["--name", "ada", "--count", "3"]).unwrap();
assert_eq!(options.name, "ada");
assert_eq!(options.count, 3);
}
#[test]
fn verbose_is_a_flag() {
assert!(
parse_args(&["--verbose"])
.unwrap()
.verbose
);
assert!(!parse_args(&[]).unwrap().verbose);
}
#[test]
fn a_value_option_without_a_value_is_an_error() {
assert!(parse_args(&["--name"]).is_err());
assert!(parse_args(&["--count"]).is_err());
}
#[test]
fn a_non_numeric_count_is_an_error() {
assert!(parse_args(&["--count", "many"]).is_err());
}
#[test]
fn an_unknown_argument_is_an_error() {
assert!(parse_args(&["--colour", "red"]).is_err());
assert!(parse_args(&["extra"]).is_err());
}
}
The helper parse_args in the test module converts a list of &str into Strings, so each test states its
input in one line. The tests cover the defaults, each option, a missing value, a bad number, and an unknown
argument.
$ cargo run --bin command_line_args -- --name ada --count 2 --verbose
[0] hello, ada
[1] hello, ada
$ cargo run --bin command_line_args -- --count many
error: --count needs a number, got "many"
usage: command_line_args [--name NAME] [--count N] [--verbose]
$ echo $?
1
The -- after cargo run --bin command_line_args tells Cargo that the rest of the line belongs to your
program, not to Cargo.
Summary
- A program reads files through system calls. The kernel keeps recent file data in its page cache.
Result<T, E>holds either a value or an error.?returns the error from the current function, so that function must returnResult.&PathandPathBufare the borrowed and owned path types. Functions should take&Path.fs::read_to_stringreads a whole file. ABufReaderreads large files a line at a time with few system calls.read_lineinto one reusedStringavoids an allocation per line.OpenOptionswithappend(true)adds to a file safely. A rename within one disk is atomic.io::Error::kind()lets you handle one kind of failure and report the others.file_type()does not follow symbolic links, which keeps a recursive walk from looping.- A byte offset works as a page cursor if you count the bytes you read, not the file’s position.
- Command-line arguments arrive as strings. Parse them in a function that takes an iterator, and return an exit code.
Chapter 3 begins Part 2. It works with Rust’s main collections, Vec, String, and HashMap, on a series
of problems about arrays and text.
Exercises
- Rewrite
walkwithout recursion. Keep aVec<PathBuf>of directories still to visit, and count the files and total bytes for each file extension. - Write
atomic_write(path, bytes). Write to a temporary file in the same directory, callsync_all()on it, and rename it over the target. - Add a version number to the pagination cursor, and return an error when the file changed between pages.
- Let the argument parser accept
--name=adaas well as--name ada.
Vectors, strings, and hash maps
This chapter covers
- Counting how much work an algorithm does, and the O(n) notation for it
- How a
Vec, aString, and aHashMapare laid out in memory - Finding a pair of numbers with a hash map
- Using a vector as a stack to check brackets and to track a minimum
- Walking an array with two indexes: reversing, sliding windows, and merging intervals
- Binary search, rotating a grid, and searching text
This chapter begins Part 2. Part 2 is about data structures and the algorithms that use them. In this
chapter you work with the three collections you will use most in Rust: Vec, String, and HashMap.
Before any problem, I spend a few sections on groundwork. First, a way to compare two solutions by how much work they do. Then a look at how each collection stores its data. With that in place, each problem in the rest of the chapter is short, and you can see why each solution is fast.
The problems are grouped by technique, not by topic. A technique is a pattern you can reuse. Two examples are “keep a stack of things not yet matched” and “move two indexes toward each other”. Table 3.1 lists them.
| Section | Technique | Problems |
|---|---|---|
| 3.5 | look things up in a hash map | find two numbers that add up to a target |
| 3.6 | a vector used as a stack | check brackets; a stack that knows its minimum |
| 3.7 | two indexes moving toward each other | reverse a string; find three numbers that add to zero |
| 3.8 | a window that slides along a string | longest piece of text with no repeated letter |
| 3.9 | sort, then walk once | merge overlapping ranges |
| 3.10 | halve the search each step | binary search in a rotated array |
| 3.11 | transform in place | rotate a square grid |
| 3.12 | reuse what you already matched | find text inside other text |
3.1 Counting the work an algorithm does
Two programs can give the same answer and take very different amounts of time. You saw this in chapter 1: the recursive Fibonacci took milliseconds where the loop took nanoseconds. To choose between solutions, you need a way to describe how their running time grows as the input grows.
The usual way is to count the basic steps a program takes for an input of size n. Here n is the length of the array or string. A basic step is something that takes a fixed amount of time, such as comparing two numbers or reading one element.
Consider three ways to answer questions about an array of n numbers:
- Reading the first element takes one step, whatever n is.
- Adding up all the elements takes n steps, one per element.
- Comparing every element with every other element takes about n × n steps.
Big-O notation is a short way to write how the count grows. You keep only the part that grows fastest and drop constant factors. So the three examples above are written O(1), O(n), and O(n²). You read O(n) as “order n”: the time grows in proportion to n.
Table 3.2 shows how different these are once n is large.
| n | O(1) | O(log n) | O(n) | O(n log n) | O(n²) |
|---|---|---|---|---|---|
| 10 | 1 | about 3 | 10 | about 33 | 100 |
| 1,000 | 1 | about 10 | 1,000 | about 10,000 | 1,000,000 |
| 1,000,000 | 1 | about 20 | 1,000,000 | about 20,000,000 | 1,000,000,000,000 |
A modern processor does roughly a billion simple steps per second. At a million elements, an O(n) solution finishes in about a millisecond. An O(n²) solution takes about a quarter of an hour.
The same notation describes memory. A solution that uses O(n) extra memory stores something for each input element. A solution that uses O(1) extra memory uses a fixed amount, however large the input.
NOTE: O(log n) appears when each step cuts the remaining work in half. Halving a million items takes about 20 steps to get down to one, because 2 multiplied by itself 20 times is about a million. Section 3.10 uses this.
3.2 How a Vec stores its elements
A Vec<T> is a growable array. Its elements sit next to each other in one block of heap memory. The
Vec value itself is small. It holds three numbers. The first is a pointer to the block. The second is
the number of elements in use, its length. The third is how many elements the block has room for, its
capacity. Figure 3.1
shows a vector of four i32 values with room for six.
Vec<i32> with length 4 and capacity 6. The elements are next to each other, so the address of any element can be computed from its index.Because the elements are next to each other, reading v[i] takes one step. The address of element i is
the block’s address plus i times the size of one element. The length of the vector does not matter.
So indexing a Vec is O(1).
When you push an element and the block is full, the vector needs a bigger block. Figure 3.2 shows what happens.
That copy costs O(n), but it happens rarely, because each growth doubles the capacity. Spread over all the pushes, each push costs a constant amount on average. This is called amortized O(1).
If you know how many elements you will push, Vec::with_capacity(n) allocates the whole block at once, and
no copy happens at all. You will see this in several listings below.
WARNING: A push can move every element to a new block. So Rust does not let you keep a reference to an
element while you push. The compiler rejects code that holds &v[0] across a v.push(...), since
the reference could point to freed memory afterward.
3.3 How a String stores text
A String is a Vec<u8> with one extra rule. Its bytes must always be valid UTF-8. UTF-8 is the
standard way of turning characters into bytes. An English letter takes one byte. Other characters take two,
three, or four bytes. Figure 3.3 shows the four characters of "café".
"café" has four characters and five bytes. The letter é takes two bytes.This has three consequences you will meet in this chapter:
s.len()counts bytes, not characters."café".len()is 5.s.chars()walks the characters one at a time, decoding the bytes as it goes.- You cannot index a
Stringwiths[3]. A byte index might fall in the middle of a character, as index 4 does in figure 3.3, so Rust does not allow it.
3.4 How a HashMap finds a key
A HashMap<K, V> stores pairs of a key and a value, and finds the value for a key quickly. It works in
three steps (figure 3.4):
- It runs the key through a hash function, which turns any key into a large number. The same key always gives the same number.
- It uses some of the bits of that number to pick a bucket, one slot in an array of buckets.
- It stores the pair in that bucket, or looks for it there.
Two keys can land in the same bucket, as 7 and 11 do in the figure. Then a lookup compares the keys in
that bucket one by one. The map keeps the number of keys per bucket small by growing its bucket array as
it fills. So on average, insert and get are O(1), however many keys the map holds.
Rust’s HashMap uses a hash function with a random starting value, chosen when the program starts. This
stops an attacker from sending keys that all land in one bucket. It also means the order in which you
iterate over a map changes from one run to the next.
Now you have the tools for the problems. Each one starts with the simplest correct idea, then shows how a collection makes it faster.
3.5 Finding two numbers that add up to a target
The problem. Given an array of numbers and a target, find two positions whose numbers add up to the
target. For [2, 7, 11, 15] and target 9, the answer is positions 0 and 1, because 2 + 7 = 9.
The simple idea. Try every pair. For each position i, check every later position j. That is about
n × n / 2 pairs, so O(n²).
A faster idea. Walk the array once. For each number, you know which partner it needs: target - num.
If you have already seen that partner, you are done. To answer “have I seen it?” in one step, keep a
HashMap from each number seen so far to its position.
Trace it on [2, 7, 11, 15] with target 9:
| Position | Number | Partner needed | In the map? | Map afterward |
|---|---|---|---|---|
| 0 | 2 | 7 | no | {2: 0} |
| 1 | 7 | 2 | yes, at position 0 | answer: (0, 1) |
Listing 3.1 Two Sum with one pass and a hash map. src/problems/two_sum.rs
//! Two Sum: find the pair of indices whose values add up to a target.
//!
//! Use a `HashMap` for one pass: O(n) time, O(n) space.
use std::collections::HashMap;
pub fn two_sum(nums: &[i32], target: i32) -> Option<(usize, usize)> {
let mut seen = HashMap::with_capacity(nums.len());
for (idx, &num) in nums.iter().enumerate() {
match seen.get(&(target - num)) {
Some(&prev) => return Some((prev, idx)),
None => {
seen.insert(num, idx);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_pair() {
let nums = [2, 7, 11, 15];
assert_eq!(two_sum(&nums, 9), Some((0, 1)));
}
#[test]
fn returns_none_when_missing() {
assert_eq!(two_sum(&[1, 2, 3], 100), None);
}
}
Walk through the function:
- The parameter
nums: &[i32]is a slice, a borrowed view of a sequence ofi32values. You can pass aVec, an array, or part of either. HashMap::with_capacity(nums.len())sizes the map for the worst case, so it never grows during the loop.nums.iter().enumerate()gives pairs of (position, reference to number). The pattern(idx, &num)copies the number out of the reference.seen.get(&(target - num))looks up the partner.gettakes a reference to the key, hence the&.- If the partner is found, the function returns both positions. Otherwise it records this number.
The lookup comes before the insert. Think of target 4 with a single 2 in the array. If the insert came first, the 2 would find itself and pair with itself.
The function returns Option<(usize, usize)>: Some with the two positions, or None if no pair exists.
It runs in O(n) time and uses O(n) extra memory for the map.
WARNING: target - num is computed in i32. For numbers near the ends of the i32 range, such as
i32::MIN, the subtraction overflows, and a debug build stops with a panic. Using i64 for the arithmetic,
or target.checked_sub(num), avoids it.
3.6 A vector used as a stack
A stack is a collection where you add and remove items at the same end, called the top. The last item
you added is the first one you remove. A Vec works as a stack. push adds to the end, and pop removes
the last item and returns it. last looks at the end without removing it. All three are O(1).
3.6.1 Checking brackets
The problem. A string of brackets is valid if every opening bracket is closed by the matching kind, in
the right order. "{[()]}" is valid. "([)]" is not, because ] arrives while ( is still open.
The idea. Read the string from left to right. Push each opening bracket onto a stack. When a closing
bracket arrives, the top of the stack must be its matching opener; pop it and continue. At the end, the
stack must be empty. Figure 3.5 traces "{[()]}".
"{[()]}". Each closing bracket pops the opener on top, which must be its partner.) pops [, so the check fails.Listing 3.2 Valid Parentheses. src/problems/valid_parentheses.rs
//! Valid Parentheses: classic stack problem with idiomatic pattern matching.
//!
pub fn is_valid(s: &str) -> bool {
use std::collections::HashMap;
let mut stack = Vec::with_capacity(s.len());
// Maps each closing delimiter to the opening delimiter it must match.
let expected = HashMap::from([(')', '('), ('}', '{'), (']', '[')]);
for ch in s.chars() {
match ch {
'(' | '[' | '{' => stack.push(ch),
// Every other character, including one outside the six delimiters,
// must close the top of the stack.
_ => {
if stack.pop().as_ref() != expected.get(&ch) {
return false;
}
}
}
}
stack.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_cases() {
assert!(is_valid("()"));
assert!(is_valid("()[]{}"));
assert!(is_valid("{[()]}"));
}
#[test]
fn invalid_cases() {
assert!(!is_valid("(]"));
assert!(!is_valid("([)]"));
assert!(!is_valid("("));
assert!(!is_valid(")"));
assert!(!is_valid("(("));
}
}
The map expected records, for each closing bracket, the opener it needs. HashMap::from([...]) builds a
map from an array of pairs.
The match has two arms. The first pushes any of the three openers. The second handles every other
character and compares two Option values:
stack.pop()removes the top and returnsSome(opener), orNoneif the stack is empty..as_ref()turnsOption<char>intoOption<&char>, so it can be compared with the next value.expected.get(&ch)returnsSome(&opener)for a closing bracket.
If they differ, the string is invalid. An empty stack gives None, which differs from Some, so an extra
closer is caught. After the loop, stack.is_empty() catches openers that were never closed.
The tests list valid and invalid examples, including "([)]", a lone ")", and "((".
WARNING: Suppose a character that is not a bracket arrives while the stack is empty. It gives None
on both sides of the comparison, so it passes. is_valid("a") returns true. If your input can contain other
characters, skip or reject them in their own arm.
3.6.2 A stack that knows its minimum
The problem. Build a stack that supports push, pop, top, and get_min, the smallest value
currently in it, all in O(1).
The idea. Scanning the stack for the minimum takes O(n). Instead, keep a second stack beside the first. Each time you push a value, also push the minimum so far. Then the minimum of the whole stack is always on top of the second stack. When you pop, pop both.
Trace pushes of -2, 0, and -3:
| Operation | values | mins | get_min() |
|---|---|---|---|
| push -2 | [-2] | [-2] | -2 |
| push 0 | [-2, 0] | [-2, -2] | -2 |
| push -3 | [-2, 0, -3] | [-2, -2, -3] | -3 |
| pop | [-2, 0] | [-2, -2] | -2 |
Listing 3.3 Min Stack with a parallel stack of minimums. src/problems/min_stack.rs
//! Min Stack: O(1) push, pop, top, and get_min.
//!
//! We keep a parallel `mins` stack. Each push stores the current minimum at that
//! point in history, so pop is symmetric.
#[derive(Debug, Default)]
pub struct MinStack {
values: Vec<i32>,
mins: Vec<i32>,
}
impl MinStack {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, val: i32) {
match self.mins.last() {
Some(&min) if min <= val => self.mins.push(min),
_ => self.mins.push(val),
}
self.values.push(val);
}
pub fn pop(&mut self) -> Option<i32> {
self.mins.pop();
self.values.pop()
}
pub fn top(&self) -> Option<i32> {
self.values.last().copied()
}
pub fn get_min(&self) -> Option<i32> {
self.mins.last().copied()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stack_tracks_minimum() {
let mut stack = MinStack::new();
stack.push(-2);
stack.push(0);
stack.push(-3);
assert_eq!(stack.get_min(), Some(-3));
assert_eq!(stack.pop(), Some(-3));
assert_eq!(stack.top(), Some(0));
assert_eq!(stack.get_min(), Some(-2));
}
#[test]
fn handles_duplicates() {
let mut stack = MinStack::new();
stack.push(1);
stack.push(1);
stack.pop();
assert_eq!(stack.get_min(), Some(1));
}
}
#[derive(Default)] gives MinStack a default() function that creates two empty vectors, and new calls
it.
In push, self.mins.last() returns the current minimum, if any. The arm Some(&min) if min <= val has a
guard, the if after the pattern. It pushes the old minimum again when the new value is not smaller.
The _ arm covers both an empty stack and a new smaller value, and pushes val.
The guard uses <=, not <. When you push a value equal to the current minimum, the minimum is recorded
again. So popping one copy leaves the other, and the minimum stays correct. The test handles_duplicates
checks this case.
top and get_min use .copied() to turn Option<&i32> into Option<i32>, so the caller gets a plain
number.
3.7 Two indexes moving toward each other
Some problems need you to look at both ends of an array at once. Keep one index at the start and one at the end, and move them toward each other. Each step moves at least one index, so the whole walk is O(n).
3.7.1 Reversing a string in place
The idea. Swap the first and last bytes, then the second and second-to-last, and stop when the indexes meet (figure 3.6).
"abcd". Two swaps, then the indexes cross.There is a complication. As section 3.3 explained, a String must stay valid UTF-8, and swapping the bytes
of a two-byte character would break it. So Rust does not let you change a String’s bytes directly in safe
code. String::as_mut_vec gives you the bytes, but it is marked unsafe. unsafe marks code where the
compiler cannot check a rule, so the programmer must. Here the rule is “the bytes are valid UTF-8 when
you are done”.
Listing 3.4 The reversing function (lines 1 to 14). src/bin/reverse_string.rs
fn reverse_str(s: &mut String) {
// SAFETY: s._as_mut_vec() is unsafe because it exposes the inner mutability to it's character buffer, and we must ensure
// to leave valid UTF-8. We enforce ASCII only characters.
assert!(s.is_ascii());
let bytes = unsafe { s.as_mut_vec() };
let (mut start, mut end) = (0, bytes.len() - 1);
while start < end {
bytes.swap(start, end);
start += 1;
end -= 1;
}
}
The function keeps the rule by checking first: assert!(s.is_ascii()) stops the program unless every byte
is a plain ASCII character. Reversing ASCII bytes gives ASCII, which is valid UTF-8. The unsafe block is
then as small as possible, one call.
bytes.swap(start, end) exchanges two elements of the vector. The loop runs while start < end, so the
middle element of an odd-length string stays where it is.
Listing 3.5 The complete program, with its tests. src/bin/reverse_string.rs
fn reverse_str(s: &mut String) {
// SAFETY: s._as_mut_vec() is unsafe because it exposes the inner mutability to it's character buffer, and we must ensure
// to leave valid UTF-8. We enforce ASCII only characters.
assert!(s.is_ascii());
let bytes = unsafe { s.as_mut_vec() };
let (mut start, mut end) = (0, bytes.len() - 1);
while start < end {
bytes.swap(start, end);
start += 1;
end -= 1;
}
}
fn main() {
for value in ["abcd", "abc", "a", "rust"] {
let mut s = String::from(value);
reverse_str(&mut s);
println!("{value:>6} -> {s}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_even_length_string_is_reversed() {
let mut s = String::from("abcd");
reverse_str(&mut s);
assert_eq!(s, "dcba");
}
#[test]
fn an_odd_length_string_keeps_its_middle_byte() {
let mut s = String::from("abc");
reverse_str(&mut s);
assert_eq!(s, "cba");
}
#[test]
fn a_single_byte_is_unchanged() {
let mut s = String::from("a");
reverse_str(&mut s);
assert_eq!(s, "a");
}
#[test]
fn the_buffer_is_reused() {
let mut s = String::from("abcd");
let capacity = s.capacity();
let address = s.as_ptr();
reverse_str(&mut s);
assert_eq!(s.capacity(), capacity);
assert_eq!(s.as_ptr(), address);
}
#[test]
fn reversing_twice_restores_the_string() {
let mut s = String::from("interview");
reverse_str(&mut s);
reverse_str(&mut s);
assert_eq!(s, "interview");
}
#[test]
#[should_panic]
fn non_ascii_input_panics() {
let mut s = String::from("café");
reverse_str(&mut s);
}
}
The test the_buffer_is_reused records the string’s capacity and the address of its bytes before the
call, then checks both afterward. They are unchanged, so the reversal used no new memory.
non_ascii_input_panics is marked #[should_panic]: it passes only if the call panics, which the
assert! does for "café".
$ cargo run --bin reverse_string
abcd -> dcba
abc -> cba
a -> a
rust -> tsur
WARNING: For an empty string, bytes.len() - 1 subtracts 1 from 0 in usize, which cannot go below
zero. A debug build panics. Return early when the string is empty.
3.7.2 Three numbers that add up to a target
The problem. Find every set of three numbers in an array that adds up to a target, without listing the same set twice.
The idea. Sort the array first. Then fix the first number, and look for the other two in the rest of the
array. Use two indexes: left starts right after the fixed number, and right starts at the end. Because the array is
sorted, you know which index to move:
- If the sum is too small, move
leftright. That can only increase the sum. - If the sum is too large, move
rightleft. That can only decrease it. - If the sum is right, record the three numbers, then move both.
Each fixed number costs one O(n) walk, and there are n fixed numbers, so the whole search is O(n²). Sorting costs O(n log n), which is smaller.
The function first sorts, then fixes each number in turn:
// Import the variants directly into scope
use std::cmp::Ordering::{Equal, Greater, Less};
pub fn three_sum(numbers: &mut [i32], target: i32) -> Vec<(i32, i32, i32)> {
numbers.sort_unstable();
let mut results = Vec::new();
let target = i64::from(target);
for i in 0..numbers.len() {
// Skip a first value that was already used, so triplets stay unique.
if i > 0 && numbers[i] == numbers[i - 1] {
continue;
}
let (mut left, mut right) = (i + 1, numbers.len().saturating_sub(1));
// ...
}
}
use std::cmp::Ordering::{Equal, Greater, Less};lets the code writeLessinstead ofOrdering::Less. The next excerpt matches on these three.- The
continueskips a fixed value equal to the previous one, so the same set is not found twice. numbers.len().saturating_sub(1)gives 0 instead of underflowing when the slice is empty.
For each fixed number, left and right close in from the two ends of the rest:
pub fn three_sum(numbers: &mut [i32], target: i32) -> Vec<(i32, i32, i32)> {
// ...
for i in 0..numbers.len() {
// ...
while left < right {
// Widen to i64 so the sum of three i32 values cannot overflow.
let current_sum = numbers[i] as i64 + numbers[left] as i64 + numbers[right] as i64;
match current_sum.cmp(&target) {
Less => left += 1,
Greater => right -= 1,
Equal => {
results.push((numbers[i], numbers[left], numbers[right]));
while left < right && numbers[left] == numbers[left + 1] {
left += 1;
}
while left < right && numbers[right] == numbers[right - 1] {
right -= 1;
}
left += 1;
right -= 1;
}
}
}
}
// ...
}
- The sum is computed in
i64. Threei32values can add up to more thani32can hold. The testdoes_not_overflow_on_extreme_valuesusesi32::MAXtwice to check this. current_sum.cmp(&target)returnsLess,Greater, orEqual, and thematchturns each into the move from the list above.- After a match, the two inner
whileloops stepleftandrightpast values equal to the ones in the match.
Listing 3.6 Three Sum, with its tests. src/problems/three_sum.rs
//! Three Sum: every unique triplet of values that adds up to a target.
//!
//! Sort, fix the first value, then close in on the other two with two pointers.
//! Sorts the caller's slice in place: O(n^2) time, no extra space beyond the result.
// Import the variants directly into scope
use std::cmp::Ordering::{Equal, Greater, Less};
pub fn three_sum(numbers: &mut [i32], target: i32) -> Vec<(i32, i32, i32)> {
numbers.sort_unstable();
let mut results = Vec::new();
let target = i64::from(target);
for i in 0..numbers.len() {
// Skip a first value that was already used, so triplets stay unique.
if i > 0 && numbers[i] == numbers[i - 1] {
continue;
}
let (mut left, mut right) = (i + 1, numbers.len().saturating_sub(1));
while left < right {
// Widen to i64 so the sum of three i32 values cannot overflow.
let current_sum = numbers[i] as i64 + numbers[left] as i64 + numbers[right] as i64;
match current_sum.cmp(&target) {
Less => left += 1,
Greater => right -= 1,
Equal => {
results.push((numbers[i], numbers[left], numbers[right]));
while left < right && numbers[left] == numbers[left + 1] {
left += 1;
}
while left < right && numbers[right] == numbers[right - 1] {
right -= 1;
}
left += 1;
right -= 1;
}
}
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_all_unique_triplets() {
let result = three_sum(&mut [-1, 0, 1, 2, -1, -4], 0);
assert_eq!(result, vec![(-1, -1, 2), (-1, 0, 1)]);
}
#[test]
fn skips_duplicates_on_every_position() {
assert_eq!(three_sum(&mut [0, 0, 0, 0], 0), vec![(0, 0, 0)]);
assert_eq!(three_sum(&mut [-2, 0, 0, 2, 2], 0), vec![(-2, 0, 2)]);
}
#[test]
fn supports_a_non_zero_target() {
assert_eq!(
three_sum(&mut [1, 2, 3, 4, 5], 9),
vec![(1, 3, 5), (2, 3, 4)]
);
}
#[test]
fn returns_nothing_when_no_triplet_exists() {
assert!(three_sum(&mut [1, 2, 3], 100).is_empty());
assert!(three_sum(&mut [1, 2], 3).is_empty());
assert!(three_sum(&mut [], 0).is_empty());
}
#[test]
fn does_not_overflow_on_extreme_values() {
assert_eq!(
three_sum(&mut [i32::MAX, i32::MAX, i32::MIN], i32::MAX - 1),
vec![(i32::MIN, i32::MAX, i32::MAX)]
);
}
}
[-4, -1, -1, 0, 1, 2], one comparison per step. The sign of the sum decides which pointer moves. The repeated -1 at index 2 is skipped.3.8 A window that slides along a string
The problem. Find the length of the longest piece of a string in which no character repeats. In
"pwwkew", the answer is 3, for "wke" or "kew".
The idea. Keep a window, a range start..=end of the string with no repeated character. Move end
forward one character at a time. When the new character already appears inside the window, move start to
the position right after its earlier one. The window never contains a repeat, and the answer is the largest window
seen.
To know where a character last appeared, keep a HashMap from each character to its last position. Figure
3.7 shows the moment in "pwwkew" when the second w arrives.
end = 5, the w was last seen at index 2. start moves to 3, and the window is "kew".end moves right one character at a time, and start moves only when a repeat inside the window arrives. The run on "abba" shows the case that max handles.Listing 3.7 Longest substring without a repeated character. src/problems/sliding_window.rs
//! Longest substring without repeating characters.
//!
//! Sliding window with `HashMap<char, usize>`; the window start is monotonic.
use std::collections::HashMap;
pub fn length_of_longest_substring(s: &str) -> usize {
let mut last_seen = HashMap::with_capacity(s.len());
let (mut start, mut longest) = (0, 0);
for (end, ch) in s.chars().enumerate() {
if let Some(&previous) = last_seen.get(&ch) {
start = start.max(previous + 1);
}
longest = longest.max(end - start + 1);
last_seen.insert(ch, end);
}
longest
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn examples() {
assert_eq!(length_of_longest_substring("abcabcbb"), 3);
assert_eq!(length_of_longest_substring("bbbbb"), 1);
assert_eq!(length_of_longest_substring("pwwkew"), 3);
assert_eq!(length_of_longest_substring(""), 0);
}
}
if let Some(&previous) = last_seen.get(&ch) runs its block only when the character has been seen before.
Then start = start.max(previous + 1) moves start past the earlier position. The max stops start from
moving backward when the earlier position is already outside the window. For example, in "abba", the last
a was seen at 0, but start is already at 2.
end - start + 1 is the current window’s length. Both start and end only move forward, so the loop does
O(n) work.
The positions come from chars().enumerate(), so they count characters, not bytes. That is what the
problem asks for.
3.9 Sort, then walk once: merging ranges
The problem. Given ranges such as [1,3] [2,6] [8,10] [15,18], merge every group that overlaps. The
answer here is [1,6] [8,10] [15,18].
The idea. Sort the ranges by their start. Then walk them once. Each range either overlaps the last merged range, and extends it, or starts a new merged range (figure 3.8).
The file solves the problem three times: with a named struct, with plain tuples, and in place. First the struct and the first version:
Listing 3.8 Merging ranges held in a struct (lines 6 to 28). src/problems/merge_intervals.rs
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct Interval {
start: i32,
end: i32,
}
pub fn merge_intervals(mut intervals: Vec<Interval>) -> Vec<Interval> {
if intervals.is_empty() {
return intervals;
}
intervals.sort_unstable_by_key(|interval| interval.start);
let mut merged: Vec<Interval> = Vec::with_capacity(intervals.len());
for Interval { start, end } in intervals {
match merged.last_mut() {
Some(previous) if start <= previous.end => {
previous.end = previous.end.max(end);
}
_ => merged.push(Interval { start, end }),
}
}
merged
}
sort_unstable_by_key(|interval| interval.start) sorts by start. “Unstable” means equal starts may change
order, which does not affect the result and is slightly faster.
The loop header for Interval { start, end } in intervals takes each range apart into two variables.
merged.last_mut() returns a mutable reference to the last merged range, or None if there is none yet.
The guard if start <= previous.end checks for overlap. When it holds, the code extends the range in place
through the reference: previous.end = previous.end.max(end). The max handles a range that lies entirely
inside the previous one. In every other case, the _ arm starts a new merged range.
The second version does the same with (i32, i32) tuples, so the end is written previous.1:
Listing 3.9 The same merge over tuples (lines 30 to 48).
pub fn merge_intervals2(mut intervals: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
if intervals.is_empty() {
return intervals;
}
intervals.sort_unstable_by_key(|(start, _)| *start);
let mut merged: Vec<(i32, i32)> = Vec::with_capacity(intervals.len());
for &(start, end) in &intervals {
match merged.last_mut() {
Some(previous) if start <= previous.1 => {
previous.1 = previous.1.max(end);
}
_ => merged.push((start, end)),
}
}
merged
}
It works the same way. The struct version is easier to read, because previous.end says what previous.1
leaves to you to remember.
The third version needs no second vector. It writes the merged ranges into the front of the input vector:
Listing 3.10 Merging in place (lines 50 to 72).
/// In place: `last` is the last merged interval at the front of the vector.
/// O(1) extra space.
pub fn merge_intervals_in_place(intervals: &mut Vec<Interval>) {
if intervals.is_empty() {
return;
}
intervals.sort_unstable_by_key(|interval| interval.start);
let mut last = 0;
for i in 1..intervals.len() {
match (intervals[last], intervals[i]) {
(previous, current) if current.start <= previous.end => {
intervals[last].end = previous.end.max(current.end);
}
(_, current) => {
last += 1;
intervals[last] = current;
}
}
}
intervals.truncate(last + 1);
}
last is the position of the last merged range. For each later range, the code either extends
intervals[last], or moves last forward one place and copies the range there. At the end,
truncate(last + 1) drops the leftovers. This version uses O(1) extra memory.
match (intervals[last], intervals[i]) copies both ranges out of the vector into a pair. That works
because Interval derives Copy. Because the arms work on copies, they are free to write to
intervals[last] without a borrowing conflict.
Listing 3.11 The tests (lines 74 to 106).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_overlapping_and_adjacent() {
let input = [(1, 3), (2, 6), (8, 10), (15, 18)].map(|(start, end)| Interval { start, end });
let expected = [(1, 6), (8, 10), (15, 18)].map(|(start, end)| Interval { start, end });
assert_eq!(merge_intervals(input.into()), expected);
}
#[test]
fn handles_empty_and_disjoint() {
assert_eq!(merge_intervals(vec![]), vec![]);
let input = [(1, 2), (3, 4)].map(|(start, end)| Interval { start, end });
let expected = [(1, 2), (3, 4)].map(|(start, end)| Interval { start, end });
assert_eq!(merge_intervals(input.into()), expected);
}
#[test]
fn merges_in_place() {
let mut intervals: Vec<Interval> = [(8, 10), (1, 3), (15, 18), (2, 6)]
.map(|(start, end)| Interval { start, end })
.into();
merge_intervals_in_place(&mut intervals);
let expected = [(1, 6), (8, 10), (15, 18)].map(|(start, end)| Interval { start, end });
assert_eq!(intervals, expected);
let mut empty = vec![];
merge_intervals_in_place(&mut empty);
assert!(empty.is_empty());
}
}
The tests build ranges from tuples with .map(|(start, end)| Interval { start, end }) on arrays. They cover
overlapping ranges, an empty input, ranges that do not touch, and unsorted input for the in-place version.
All three versions merge ranges that only touch, such as [1,3] and [3,5], because the check is <=.
3.10 Halving the search: binary search
In a sorted array, you can find a value without looking at every element. Compare the value with the middle element. If the value is smaller, it can only be in the left half; if larger, only in the right half. Discard the other half and repeat. Figure 3.9 finds 23 in eight values in three steps.
low..high. Eight elements take three comparisons. The search for 20 ends with an empty range.The code in this section uses a half-open range low..high. low is the first position still in the
range, and high is one past the last. The range is empty when low == high.
3.10.1 Searching a rotated array
The file solves a harder version. A sorted array has been rotated: some elements from the front were
moved to the back, as in [4, 5, 6, 7, 0, 1, 2]. You still want O(log n).
The idea rests on one observation. If you split a rotated array at any middle position, at least one of the two halves is still sorted. You can tell which by comparing its end values. Then:
- If the left half is sorted and the target lies between its first and middle values, search the left half.
- If the right half is sorted and the target lies between its middle and last values, search the right half.
- Otherwise, search the other half.
mid is sorted. The search keeps that half only if the target lies inside its end values.Listing 3.12 Search in a rotated sorted array. src/problems/binary_search.rs
//! Binary search on a rotated sorted array.
//!
//! O(log n). The code uses explicit range bounds and
//! sorted-half checks; no recursion or nested `if` chains are required.
pub fn search_rotated(nums: &[i32], target: i32) -> Option<usize> {
let mut low = 0usize;
let mut high = nums.len();
while low < high {
let mid = low + (high - low) / 2;
if nums[mid] == target {
return Some(mid);
}
let left_is_sorted = nums[low] <= nums[mid];
if left_is_sorted {
if nums[low] <= target && target < nums[mid] {
high = mid;
} else {
low = mid + 1;
}
} else if nums[mid] < target && target <= nums[high - 1] {
low = mid + 1;
} else {
high = mid;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_target_in_rotated_array() {
let nums = [4, 5, 6, 7, 0, 1, 2];
assert_eq!(search_rotated(&nums, 0), Some(4));
assert_eq!(search_rotated(&nums, 3), None);
assert_eq!(search_rotated(&nums, 5), Some(1));
}
#[test]
fn handles_unrotated_and_single() {
assert_eq!(search_rotated(&[1, 2, 3, 4], 3), Some(2));
assert_eq!(search_rotated(&[1], 1), Some(0));
assert_eq!(search_rotated(&[1], 2), None);
}
}
mid = low + (high - low) / 2 finds the middle. The shorter (low + high) / 2 can overflow when both are
very large; this form cannot.
nums[low] <= nums[mid] means the left half is sorted. The test that the target is in it is
nums[low] <= target && target < nums[mid]. The right-half test uses nums[high - 1], the last element,
because high itself is one past the end.
3.11 Rotating a square grid in place
The problem. Rotate an n × n grid a quarter turn clockwise, without a second grid.
The idea. Do it in two simple steps (figure 3.11). First transpose the grid. That means swapping the element at row i, column j with the one at row j, column i. It flips the grid across its diagonal. Then reverse each row.
row.reverse() reverses each row. Starting j at 0 would swap every pair twice.Listing 3.13 The complete program. src/bin/rotate_matrix.rs
fn rotate(m: &mut [Vec<i32>]) {
let n = m.len();
for i in 0..n {
for j in i + 1..n {
let tmp = m[i][j];
m[i][j] = m[j][i];
m[j][i] = tmp;
}
}
for row in m.iter_mut() {
row.reverse();
}
}
fn main() {
let mut m = vec![vec![1, 2, 3], vec![4, 5, 6], vec![7, 8, 9]];
rotate(&mut m);
for row in &m {
println!("{:?}", row);
}
}
The grid is a Vec<Vec<i32>>, a vector of rows. rotate takes it as &mut [Vec<i32>], a mutable slice of rows, because it changes cells but never adds or removes a row. The transpose loop starts j at i + 1, so it visits only
the cells above the diagonal and swaps each pair once. Visiting every cell would swap each pair twice and
undo the work.
The swap uses a temporary variable tmp. std::mem::swap needs two mutable references, and
m[i][j] and m[j][i] are in different rows of the same outer vector. The borrow checker cannot see that
they do not overlap, so it would reject taking both at once.
for row in m.iter_mut() gives a mutable reference to each row, and row.reverse() reverses it.
$ cargo run --bin rotate_matrix
[7, 4, 1]
[8, 5, 2]
[9, 6, 3]
3.12 Finding text inside text
The problem. Find the first position where a short string, the needle, appears in a longer one, the haystack.
The simple idea. Try each position of the haystack, and compare the needle character by character. On a mismatch, move one position right and start over. In the worst case this compares every needle character at every position. For a haystack of length n and a needle of length m, that is O(n × m).
The better idea. Suppose a mismatch comes after several matching characters. You already know what those characters were, because they are the start of the needle. Often a later part of them is also the start of the needle, so you can continue from there instead of starting over. The Knuth-Morris-Pratt algorithm (KMP) uses this. It never moves backward in the haystack, so it runs in O(n + m).
Figure 3.12 shows one such jump.
"aa", are also the start of the needle. The search continues with them already matched, and finds the needle at position 4.left falls back to lps[left - 1] and right stays where it is. The haystack is read once, from left to right.To know how far to jump, KMP first builds a table from the needle alone. For each position i, look at the piece needle[0..=i]. lps[i] is
the length of the longest part of that piece that is both its start and its end. The whole piece does not
count. The name “lps” stands for longest proper prefix that is also a suffix. For the needle in the figure:
| index | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| needle | a | a | b | a | a | a | c |
| lps | 0 | 1 | 0 | 1 | 2 | 2 | 0 |
For example, lps[4] = 2, because "aabaa" starts with "aa" and ends with "aa".
build_lps compares the needle with itself, using the same three branches as the search. The table ends as 0 1 0 1 2 2 0.Listing 3.14 The search and the table (lines 1 to 58). src/bin/kmp_pattern_matching.rs
struct Solution {}
impl Solution {
/// @param haystack the string to search in
/// @param needle the pattern to find
/// @return first index of needle in haystack, or -1
pub fn str_str(haystack: String, needle: String) -> i32 {
let (haystack, needle) = (haystack.as_bytes(), needle.as_bytes());
if needle.is_empty() {
return 0;
}
let lps = Self::build_lps(needle);
// left walks the needle, and right walks the haystack.
let (mut left, mut right) = (0, 0);
while right < haystack.len() {
if haystack[right] == needle[left] {
left += 1;
right += 1;
} else if left > 0 {
// Fall back, keeping the characters that still match.
left = lps[left - 1];
} else {
// A mismatch at the start of the needle.
right += 1;
}
if left == needle.len() {
// The whole needle matched.
return (right - left) as i32;
}
}
-1
}
// LPS: longest proper prefix of needle[0..=right] that is also a suffix
fn build_lps(needle: &[u8]) -> Vec<usize> {
let mut lps = vec![0; needle.len()];
let (mut left, mut right) = (0, 1);
while right < needle.len() {
if needle[right] == needle[left] {
left += 1;
lps[right] = left;
right += 1;
} else if left > 0 {
left = lps[left - 1];
} else {
// lps[right] stays 0.
right += 1;
}
}
lps
}
}
fn main() {
let cases = [
// ...
}
str_str works on bytes, from as_bytes(), so it can index directly. left counts how many needle
characters currently match, and right walks the haystack. Each pass of the loop takes one of three
branches:
- The characters match: advance both.
- They do not match, but some characters had matched: jump
leftback tolps[left - 1], keepingrightwhere it is. - They do not match and nothing had matched: advance
right.
When left reaches the needle’s length, the needle starts at right - left.
build_lps runs the same three-branch loop, comparing the needle against itself. Both loops only move
forward, which gives the O(n + m) total.
Listing 3.15 The complete program. src/bin/kmp_pattern_matching.rs
struct Solution {}
impl Solution {
/// @param haystack the string to search in
/// @param needle the pattern to find
/// @return first index of needle in haystack, or -1
pub fn str_str(haystack: String, needle: String) -> i32 {
let (haystack, needle) = (haystack.as_bytes(), needle.as_bytes());
if needle.is_empty() {
return 0;
}
let lps = Self::build_lps(needle);
// left walks the needle, and right walks the haystack.
let (mut left, mut right) = (0, 0);
while right < haystack.len() {
if haystack[right] == needle[left] {
left += 1;
right += 1;
} else if left > 0 {
// Fall back, keeping the characters that still match.
left = lps[left - 1];
} else {
// A mismatch at the start of the needle.
right += 1;
}
if left == needle.len() {
// The whole needle matched.
return (right - left) as i32;
}
}
-1
}
// LPS: longest proper prefix of needle[0..=right] that is also a suffix
fn build_lps(needle: &[u8]) -> Vec<usize> {
let mut lps = vec![0; needle.len()];
let (mut left, mut right) = (0, 1);
while right < needle.len() {
if needle[right] == needle[left] {
left += 1;
lps[right] = left;
right += 1;
} else if left > 0 {
left = lps[left - 1];
} else {
// lps[right] stays 0.
right += 1;
}
}
lps
}
}
fn main() {
let cases = [
("sadbutsad", "sad"),
("leetcode", "leeto"),
("aabaaabaaac", "aabaaac"),
("hello", ""),
];
for (haystack, needle) in cases {
let result = Solution::str_str(haystack.to_string(), needle.to_string());
println!("str_str({haystack:?}, {needle:?}) = {result}\n");
}
}
$ cargo run --bin kmp_pattern_matching
str_str("sadbutsad", "sad") = 0
str_str("leetcode", "leeto") = -1
str_str("aabaaabaaac", "aabaaac") = 4
str_str("hello", "") = 0
The function returns -1 when the needle is absent, following the convention of the problem it solves. In
your own code, Option<usize> states that more clearly. For everyday searching, the standard library’s
str::find is also linear time.
3.13 The complete files
The sections above showed these files in excerpts. Here each one is whole.
Listing 3.16 Merging overlapping intervals, with its tests. src/problems/merge_intervals.rs
//! Merge Intervals: sort by start, then merge overlapping intervals in one pass.
//!
//! The `match out.last_mut()` pattern is intentionally clean: no deep nesting,
//! no confusing boolean flags.
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct Interval {
start: i32,
end: i32,
}
pub fn merge_intervals(mut intervals: Vec<Interval>) -> Vec<Interval> {
if intervals.is_empty() {
return intervals;
}
intervals.sort_unstable_by_key(|interval| interval.start);
let mut merged: Vec<Interval> = Vec::with_capacity(intervals.len());
for Interval { start, end } in intervals {
match merged.last_mut() {
Some(previous) if start <= previous.end => {
previous.end = previous.end.max(end);
}
_ => merged.push(Interval { start, end }),
}
}
merged
}
pub fn merge_intervals2(mut intervals: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
if intervals.is_empty() {
return intervals;
}
intervals.sort_unstable_by_key(|(start, _)| *start);
let mut merged: Vec<(i32, i32)> = Vec::with_capacity(intervals.len());
for &(start, end) in &intervals {
match merged.last_mut() {
Some(previous) if start <= previous.1 => {
previous.1 = previous.1.max(end);
}
_ => merged.push((start, end)),
}
}
merged
}
/// In place: `last` is the last merged interval at the front of the vector.
/// O(1) extra space.
pub fn merge_intervals_in_place(intervals: &mut Vec<Interval>) {
if intervals.is_empty() {
return;
}
intervals.sort_unstable_by_key(|interval| interval.start);
let mut last = 0;
for i in 1..intervals.len() {
match (intervals[last], intervals[i]) {
(previous, current) if current.start <= previous.end => {
intervals[last].end = previous.end.max(current.end);
}
(_, current) => {
last += 1;
intervals[last] = current;
}
}
}
intervals.truncate(last + 1);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_overlapping_and_adjacent() {
let input = [(1, 3), (2, 6), (8, 10), (15, 18)].map(|(start, end)| Interval { start, end });
let expected = [(1, 6), (8, 10), (15, 18)].map(|(start, end)| Interval { start, end });
assert_eq!(merge_intervals(input.into()), expected);
}
#[test]
fn handles_empty_and_disjoint() {
assert_eq!(merge_intervals(vec![]), vec![]);
let input = [(1, 2), (3, 4)].map(|(start, end)| Interval { start, end });
let expected = [(1, 2), (3, 4)].map(|(start, end)| Interval { start, end });
assert_eq!(merge_intervals(input.into()), expected);
}
#[test]
fn merges_in_place() {
let mut intervals: Vec<Interval> = [(8, 10), (1, 3), (15, 18), (2, 6)]
.map(|(start, end)| Interval { start, end })
.into();
merge_intervals_in_place(&mut intervals);
let expected = [(1, 6), (8, 10), (15, 18)].map(|(start, end)| Interval { start, end });
assert_eq!(intervals, expected);
let mut empty = vec![];
merge_intervals_in_place(&mut empty);
assert!(empty.is_empty());
}
}
Summary
- Big-O notation describes how running time or memory grows with the input size, keeping only the fastest growing part.
- A
Vecstores its elements next to each other on the heap, so indexing is O(1). Pushing is amortized O(1) because the capacity doubles when it runs out. - A
Stringis UTF-8 bytes.len()counts bytes, andchars()walks characters. - A
HashMaphashes each key to a bucket, sogetandinsertare O(1) on average. - A hash map turns “have I seen the partner?” into one step (Two Sum).
- A vector used as a stack tracks unmatched openers (brackets) and running minimums (Min Stack).
- Two indexes moving toward each other reverse a string or search a sorted array for sums in O(n) per pass.
- A sliding window with a map of last positions finds the longest run without repeats in O(n).
- Sorting first lets one pass merge overlapping ranges.
- Binary search halves the range each step, O(log n), and still works on a rotated sorted array.
- KMP reuses matched characters, so text search takes O(n + m).
This chapter used iterators such as iter() and enumerate() without much comment. Chapter 4 looks at them
closely, along with the references they hand you and the lifetimes that keep those references valid.
Exercises
- Change
two_sumto usei64arithmetic so that it cannot overflow, and add a test withi32::MIN. - Fix
is_validso that characters other than brackets are rejected, and add a test for"a". - Make
reverse_strhandle an empty string, then write a version that reverses the characters of any UTF-8 string, including"café". - Write a plain binary search over a sorted slice that returns
Option<usize>, and test it on arrays of length 0, 1, and 2. - Rotate the grid counterclockwise instead, using a transpose and one other reversal.
Iterators and references
This chapter covers
- What an iterator is, and what a
forloop does with one - The three ways to walk a collection: by reference, by mutable reference, and by value
- Chaining
filter,map, andcollect, and why nothing runs untilcollect - Why a closure sometimes receives
&&i32, and how patterns like|&&x|remove the references - Lifetimes: what they are, why the compiler needs them, and how to read
'ain a signature - Building a map that stores references instead of copies
Chapter 3 used iterators and references in almost every listing: nums.iter().enumerate(),
s.chars(), seen.get(&key). I explained each use briefly and moved on. This chapter slows down and
looks at them properly, because every later chapter depends on them.
The chapter has two halves. The first half is about iterators: what they are, how a for loop uses them,
and how to chain them. The second half is about references and lifetimes. A lifetime is the part of a
program during which a reference is guaranteed to point to valid data. You will see why Rust needs them,
how to read them, and how to write a function whose signature uses one.
Both halves meet in one short program. It turns a list of users into a map from each user’s ID to that user, without copying any user.
4.1 What an iterator is
An iterator is a value that hands out items one at a time. It has one required method, next, which
returns Some(item) while there are items left and None when there are none.
You can think of an iterator over a slice as a cursor that starts before the first element. Each call to
next returns the element under the cursor and moves the cursor one step (figure 4.1).
[10, 20, 30]. Each call to next returns one item. After the last item, it returns None.You rarely call next yourself. A for loop calls it for you. This loop:
for x in [10, 20, 30].iter() {
println!("{x}");
}
does the same as this:
let mut it = [10, 20, 30].iter();
while let Some(x) = it.next() {
println!("{x}");
}
while let Some(x) = it.next() keeps looping as long as next returns Some, and binds the item to x.
Section 2.8 used this form directly, because it needed to call next inside the loop body.
4.2 Three ways to walk a collection
A Vec<String> owns its strings. When you walk it, you have to decide what you get for each element: a
shared reference, a mutable reference, or the string itself. Each choice has its own method (figure 4.2).
iter()gives&T, a shared reference to each element. You can read the elements, and the collection is unchanged afterward.iter_mut()gives&mut T. You can change each element in place.into_iter()givesT, the element itself. The collection is moved into the iterator, so you cannot use it afterward.
A for loop chooses for you based on what you write after in. for x in &names means names.iter().
for x in &mut names means names.iter_mut(). for x in names means names.into_iter(), and names is
gone after the loop.
TIP: When you are not sure which one you have, look at the item type the compiler reports. A
deliberate type error such as let () = x; inside the loop makes the compiler print the type of x.
4.3 Chaining iterators
An iterator adapter is a method that takes one iterator and returns another. filter keeps only the
items for which a closure returns true. map replaces each item with the closure’s result. collect
is different: it runs the whole chain and gathers the items into a collection, such as a Vec.
Figure 4.3 follows three numbers through a chain.
filter.Adapters are lazy. iter().filter(...).map(...) only builds a description of the work. Nothing happens
until something asks for items, as collect or a for loop does. Then each item goes through every stage
before the next item starts. No temporary vector is created between stages.
Animation 4.1 runs functional_numbers_drill from listing 4.1 on [10, 15, 20, 25, 30]. The requests go
backward, from collect toward the slice, and the items come forward one at a time.
collect pulls items through the chain one at a time. Without a consumer, no item is ever asked for, and the chain does nothing.WARNING: Because adapters are lazy, a chain that ends without collect, a for loop, or another method
that consumes it does nothing at all. The compiler warns about such a chain: “unused Map that must be used”.
4.4 References to references
Look closely at the chain in figure 4.3. slice.iter() yields items of type &i32. filter passes its
closure a reference to each item, so that the closure can look at the item without taking it. So the
closure receives &&i32: a reference to a reference to an i32 (figure 4.4).
filter's closure.A closure’s parameter can be a pattern, the same as the left side of a let. The pattern &x matches a
reference and binds x to what it points to. So:
.filter(|&&x| x % 2 == 0)removes both layers. Inside the closure,xis ani32..map(|&x| x * 10)removes the one layer thatmapgives.mappasses the item itself, not a reference to it.
This works for i32 because i32 is Copy: removing the reference copies the number. A String is not
Copy. For it, a pattern like |&s| would try to move the value out of a reference, and the compiler
rejects that. You would write |s| and use s as a reference instead.
4.5 The same work in two styles
The program for this chapter starts with a small User type and two functions that do similar work in two
styles.
Listing 4.1 The User type and two ways to transform a slice (lines 1 to 23). src/bin/lifetime_reference_drill.rs
use std::collections::HashMap;
#[derive(Debug)]
struct User {
id: u32,
name: String,
}
fn functional_numbers_drill(slice: &[i32]) -> Vec<i32> {
slice
.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * 10)
.collect()
}
fn imperative_numbers_map(slice: &[i32]) -> HashMap<i32, i32> {
let mut map = HashMap::new();
for &num in slice {
map.insert(num, num * 100);
}
map
}
functional_numbers_drill is the chain from figure 4.3. collect() knows to build a Vec<i32> because
that is the function’s return type. When the type is not clear from context, you write it, as in
collect::<Vec<i32>>().
imperative_numbers_map uses a for loop and builds a HashMap from each number to that number times
100. for &num in slice removes the one reference that iterating a slice gives, so num is an i32.
Neither style is faster. Both compile to similar machine code. Iterator chains read well when the work is a sequence of steps. Loops read well when the body does several unrelated things.
With iterators covered, the rest of the chapter turns to the references they hand out, and to the rules that keep those references safe.
4.6 Lifetimes
A reference points to data owned by something else. If the owner is dropped while the reference is still in use, the reference points to freed memory. Reading through it would give garbage or crash the program. Figure 4.5 shows the smallest example.
To prevent this, the compiler tracks, for every reference, the part of the program where it is used. It also knows where each value is dropped. If a reference is used after its value is dropped, the program does not compile. The part of the program where a reference is valid is its lifetime.
Inside one function, the compiler works out all lifetimes on its own. Across function calls it needs help. Consider a function that takes a reference and returns one:
fn first_word(s: &str) -> &str { ... }
The caller needs to know how long the returned reference is valid. Here the only possible answer is “as
long as s”, and the compiler assumes that without being told. These assumptions are called lifetime
elision rules. For a function with one reference parameter, the returned reference gets that parameter’s
lifetime.
When the rules cannot decide, or when you want to state a relationship yourself, you name lifetimes. A
lifetime name is an apostrophe and a short name, usually 'a. You declare it in angle brackets after the
function name, like a generic type, and then attach it to references:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { ... }
Read this as: “for some lifetime 'a, both inputs are valid for 'a, and so is the output”. The compiler
then checks every caller. The returned reference may not be used after either input’s data is dropped.
A lifetime annotation does not change how long anything lives. It describes a relationship, and the compiler checks that the code keeps it.
4.7 A map that points into a slice
Now you have what you need to read the main functions of the program. The goal is to index a list of users
by ID. You could copy each user into a HashMap<u32, User>. Or you could store only a reference to each
user, in a HashMap<u32, &User>, and leave the users where they are. Figure 4.6 compares the two.
The map of references is cheaper. It also depends on the users staying alive while the map is used. The function’s signature states that dependency with a lifetime.
Listing 4.2 A map whose values point into the slice (lines 27 to 33).
fn struct_slice_to_map<'a>(slice: &'a [User]) -> HashMap<u32, &'a User> {
let mut map = HashMap::new();
for user in slice {
map.insert(user.id, user);
}
map
}
Read the signature the way section 4.6 showed. For some lifetime 'a, the input is a slice of users valid
for 'a. The output is a map whose values are references valid for 'a. Because both mention the same
'a, the map may not be used after the users are dropped. If you tried, the compiler would reject the
program with the same error as figure 4.5.
Inside the function, for user in slice walks the slice by reference, so user is a &User. The map
stores that reference. No User is cloned.
4.8 Two lifetimes in one type
The second function takes a different input. Instead of a slice of users, it takes a slice of references to users.
Listing 4.3 A map built from a slice of references (lines 35 to 41).
fn ref_slice_to_map<'a>(slice: &[&'a User]) -> HashMap<u32, &'a User> {
let mut map = HashMap::new();
for &user in slice {
map.insert(user.id, user);
}
map
}
The type &[&'a User] has two references in it:
- The outer
&is the slice itself, a short list of pointers. Its lifetime has no name here. - The inner
&'a Useris each pointer in the list. Its lifetime'abelongs to the users those pointers point to.
The returned map mentions only 'a. So the map depends on the users, not on the list of pointers. The list
can be dropped right after the call, and the map stays valid (figure 4.7).
for &user in slice copies each pointer out of the slice. A reference is a Copy value, one machine word, so
this copy is cheap.
4.9 The complete program
Listing 4.4 The complete program. src/bin/lifetime_reference_drill.rs
use std::collections::HashMap;
#[derive(Debug)]
struct User {
id: u32,
name: String,
}
fn functional_numbers_drill(slice: &[i32]) -> Vec<i32> {
slice
.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * 10)
.collect()
}
fn imperative_numbers_map(slice: &[i32]) -> HashMap<i32, i32> {
let mut map = HashMap::new();
for &num in slice {
map.insert(num, num * 100);
}
map
}
// The lifetime could be elided. It is written out because section 4.7 reads it.
#[allow(clippy::needless_lifetimes)]
fn struct_slice_to_map<'a>(slice: &'a [User]) -> HashMap<u32, &'a User> {
let mut map = HashMap::new();
for user in slice {
map.insert(user.id, user);
}
map
}
fn ref_slice_to_map<'a>(slice: &[&'a User]) -> HashMap<u32, &'a User> {
let mut map = HashMap::new();
for &user in slice {
map.insert(user.id, user);
}
map
}
fn main() {
let vv = [1, 2, 3, 4, 5, 6];
let filtered: Vec<i32> = vv
.iter()
.filter(|x| *x % 2 == 0)
.copied()
.collect();
println!("Filtered : {:?}", filtered);
println!("--- Ultra-Lean Inline Slice Drill ---\n");
let processed_nums = functional_numbers_drill(&[10, 15, 20, 25, 30]);
println!("1. Functional Numbers (Even * 10): {:?}", processed_nums);
let num_map = imperative_numbers_map(&[10, 15, 20, 25, 30]);
println!("2. Imperative Numbers Map: {:?}", num_map);
// explicit reference binding holding the inline array literal
let users: &[User] = &[
User {
id: 101,
name: "Alice".to_string(),
},
User {
id: 102,
name: "Bob".to_string(),
},
User {
id: 103,
name: "Charlie".to_string(),
},
];
println!("\n3. Zero-Allocation Struct Map (From &[User]):");
let struct_map = struct_slice_to_map(users);
for (id, user_ref) in &struct_map {
println!(" Key ID: {} -> Value Name: {}", id, user_ref.name);
}
// explicit reference binding holding the inline array of &User
let user_refs: &[&User] = &[struct_map[&101], struct_map[&102]];
println!("\n4. Zero-Allocation Ref Slice Map (From &[&User]):");
let ref_slice_map = ref_slice_to_map(user_refs);
for (id, user_ref) in &ref_slice_map {
println!(" Key ID: {} -> Value Name: {}", id, user_ref.name);
}
println!("\n--- Drill Completed Successfully! ---");
}
main starts with one more iterator chain. vv.iter() yields &i32, and filter receives &&i32. This
closure writes |x| and then *x % 2. The * removes one layer of reference, and the % operator works
through the other. .copied() turns each &i32 into an i32, so collect builds a Vec<i32>.
Two lines in main use a rule you have not seen yet:
let users: &[User] = &[ User { ... }, User { ... }, User { ... } ];
&[ ... ] takes a reference to an array that was never given a name of its own. Such a value is a
temporary, and a temporary is normally dropped at the end of the statement. That would leave users
pointing to freed memory. Rust extends the temporary’s life to the end of the enclosing block when it is the
direct value of a let. This is called temporary lifetime extension. It is why users and user_refs
stay valid for the rest of main.
Run the program:
$ cargo run --bin lifetime_reference_drill
Filtered : [2, 4, 6]
--- Ultra-Lean Inline Slice Drill ---
1. Functional Numbers (Even * 10): [100, 200, 300]
2. Imperative Numbers Map: {20: 2000, 10: 1000, 25: 2500, 15: 1500, 30: 3000}
3. Zero-Allocation Struct Map (From &[User]):
Key ID: 101 -> Value Name: Alice
Key ID: 102 -> Value Name: Bob
Key ID: 103 -> Value Name: Charlie
4. Zero-Allocation Ref Slice Map (From &[&User]):
Key ID: 102 -> Value Name: Bob
Key ID: 101 -> Value Name: Alice
--- Drill Completed Successfully! ---
Run it again and the map lines may come out in a different order. As section 3.4 explained, HashMap uses a
randomly seeded hash, so its iteration order is not fixed. When you need a stable order, use a BTreeMap,
which keeps its keys sorted, or sort the keys before printing.
Summary
- An iterator hands out items through
next, which returnsSome(item)orNone. Aforloop callsnextuntil it getsNone. iter()yields&T,iter_mut()yields&mut T, andinto_iter()yieldsTand uses up the collection.- Adapters such as
filterandmapare lazy.collector a loop drives them, one item at a time. filterpasses a reference to each item, so overiter()its closure receives&&T. Patterns like|&&x|remove the references forCopytypes.- A lifetime is the part of the program where a reference is valid. The compiler rejects a reference used after its data is dropped.
'ain a signature names a lifetime and ties inputs to outputs. It describes a relationship; it does not change how long anything lives.- A
HashMap<u32, &'a User>indexes data without copying it, and cannot outlive that data.
Chapter 5 uses iterators and collections to solve problems with heaps and sorted collections, such as finding the most frequent words in a text.
Exercises
- Rewrite
imperative_numbers_mapas an iterator chain that ends incollect::<HashMap<_, _>>(). - Write
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str. Then write amainwhere calling it and using the result fails to compile, and read the error. - Change
struct_slice_to_mapto return aBTreeMap, and check that the output order is now stable. - Write a function that takes
&mut [User]and usesiter_mut()to append “ (admin)“ to every name.
Heaps
This chapter covers
- What a heap is, and how it stores a tree inside a
Vec - How
pushandpopkeep the largest item on top in O(log n) steps - Rust’s
BinaryHeap, and turning it into a min-heap withReverse - How tuples compare, and why
(count, value)orders items by count - Finding the k most frequent items, first with a full heap and then with a heap of size k
- Keeping the median of a stream of numbers with two heaps
Chapter 3 used vectors, strings, and hash maps. This chapter adds one more collection, the heap. A heap answers one question quickly: “what is the largest item right now?” It keeps answering it while items are added and removed.
That question comes up whenever you want the best few items from many. Two examples are the most frequent words in a text and the next job to run from a queue ordered by priority. You will build three programs. Two find the most frequent items. The third keeps the median of a stream of numbers.
As in chapter 3, I start with how the structure works, with pictures, and then show the code.
A few terms used throughout the chapter:
- A binary tree is a set of nodes where each node has at most two children, a left one and a right one. The node with no parent is the root.
- A max-heap is a binary tree where every parent is at least as large as each of its children. So the root holds the largest item.
- A min-heap is the same with “at least as large” replaced by “at most as large”. The root holds the smallest item.
- The median of a list of numbers is the middle value once they are sorted. For an even count, it is the average of the two middle values.
5.1 A tree stored in a vector
A heap is a tree, but it does not store pointers between nodes. It stores the nodes in a Vec, level by
level: the root first, then its two children, then their four children, and so on. Figure 5.1 shows a
max-heap of six numbers and the vector that holds it.
Because the nodes are packed level by level, simple arithmetic finds a node’s relatives. The children of index i are at 2i + 1 and 2i + 2. Its parent is at (i − 1) / 2, rounded down. In figure 5.1, the children of 7 (index 1) are at indexes 3 and 4, which hold 3 and 5.
The heap rule only compares parents with children. It says nothing about brothers or cousins. In figure 5.1, 7 is on the left and 8 on the right, and that is allowed. That weak rule keeps a heap cheap to maintain.
A tree with n nodes packed this way has about log₂ n levels. A heap of a million items is only about 20 levels deep.
5.2 Adding and removing
5.2.1 Push
To add an item, put it at the end of the vector, which is the next free spot on the bottom level. It may be larger than its parent, which breaks the rule. So swap it with its parent, and keep swapping upward until it is not larger than its parent, or it reaches the root. Figure 5.2 pushes 10.
Each swap moves the item one level up, and there are about log₂ n levels. So a push takes O(log n) steps.
5.2.2 Pop
To remove the largest item, take the root. That leaves a hole at the top. Fill it with the last item in the vector, which shrinks the vector by one. The moved item is probably too small for the top. So swap it with its larger child, and keep swapping downward until both its children are smaller. Figure 5.3 pops the 10 that figure 5.2 added.
It swaps with the larger child for a reason. That child is about to become the parent of the other one, and a parent must be at least as large as its children. Pop is also O(log n).
Looking at the largest item without removing it is O(1): it is always at index 0.
5.3 Heaps in Rust
Rust’s standard library provides std::collections::BinaryHeap<T>, a max-heap. Its main methods match the
operations above:
| Method | What it does | Cost |
|---|---|---|
push(x) | adds x | O(log n) |
pop() | removes and returns the largest item, as an Option | O(log n) |
peek() | returns a reference to the largest item, as an Option | O(1) |
len() | the number of items | O(1) |
into_sorted_vec() | consumes the heap and returns its items in ascending order | O(n log n) |
To decide which item is larger, BinaryHeap uses the Ord trait, which types such as i32, String, and
tuples implement.
5.3.1 How tuples compare
Tuples compare element by element, from the left. Two tuples are compared on their first elements, and
only if those are equal does the second element decide. So (3, 1) > (2, 9), because 3 > 2, and the 9 is
never looked at. And (3, 1) < (3, 2), because the first elements tie and 1 < 2.
This gives a way to choose what a heap orders by: put that field first in a tuple. A heap of
(count, value) pairs orders by count, and breaks ties by value.
5.3.2 A min-heap with Reverse
BinaryHeap has no min-heap mode. Instead, the standard library provides a wrapper,
std::cmp::Reverse<T>, whose comparison is the opposite of T’s. Reverse(3) > Reverse(5), because 3 < 5.
So a BinaryHeap<Reverse<i32>> keeps the smallest number on top.
To put a value in, wrap it: heap.push(Reverse(x)). To get it out, unwrap it with a pattern:
if let Some(Reverse(x)) = heap.pop().
Now you can read the three programs.
5.4 The k most frequent numbers
The problem. Given a list of numbers and a count k, return the k numbers that occur most often. For
[1, 1, 1, 2, 2, 3] and k = 2, the answer is 1 and 2.
The idea. Count each number with a HashMap. Put every (count, number) pair into a max-heap. Pop k
times.
Listing 5.1 Top k with a full heap. src/problems/top_k_frequent.rs
//! Top K Frequent Elements.
//!
//! Count occurrences with a HashMap, then use a max-heap (`BinaryHeap`) to pull
//! out the top k elements. O(n log n) worst case; acceptable and readable.
use std::collections::{BinaryHeap, HashMap};
pub fn top_k_frequent(nums: &[i32], k: usize) -> Vec<i32> {
let mut counts: HashMap<i32, usize> = HashMap::with_capacity(nums.len());
for &num in nums.iter() {
*counts.entry(num).or_insert(0) += 1;
}
// BinaryHeap is a max-heap in Rust, so (count, num) keeps highest counts first.
let mut heap: BinaryHeap<(usize, i32)> = counts
.into_iter()
.map(|(num, count)| (count, num))
.collect();
let mut result = Vec::with_capacity(k);
while result.len() < k {
match heap.pop() {
Some((_, num)) => result.push(num),
None => break,
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn returns_top_two() {
let nums = [1, 1, 1, 2, 2, 3];
let mut result = top_k_frequent(&nums, 2);
result.sort_unstable();
assert_eq!(result, vec![1, 2]);
}
#[test]
fn k_larger_than_unique_values_returns_all() {
let result = top_k_frequent(&[1, 2, 3], 10);
assert_eq!(result.len(), 3);
}
}
Step through it:
*counts.entry(num).or_insert(0) += 1;counts one occurrence.entry(num)finds the map slot fornum, andor_insert(0)creates it with 0 if it is missing. Both steps return a mutable reference to the count, and*followed by+= 1adds one to the value it points to.counts.into_iter().map(|(num, count)| (count, num)).collect()turns each map entry around, so the count comes first, and collects the pairs straight into aBinaryHeap. ABinaryHeapcan be built withcollectlike aVec.- The
whileloop pops until it has k numbers. If there are fewer than k distinct numbers,popreturnsNoneand the loop stops early. The second test checks that case.
Counting takes O(n). Building the heap from u distinct numbers takes O(u), and k pops take O(k log u).
The first test sorts the result before comparing it. The order of numbers with equal counts depends on the map’s iteration order. That order changes between runs, so a test must not depend on it.
5.5 The k most frequent words, with a heap of size k
The first version puts every distinct number in the heap. If the input has millions of distinct words and you only want the top 3, most of that heap is wasted. The second version never lets the heap grow past k + 1 entries.
The idea. Use a min-heap. Push each (count, word) pair. Whenever the heap has more than k entries, pop
once. A min-heap pops its smallest entry, which is the least frequent word seen so far, so the k most
frequent words survive (figure 5.4).
The program first turns the text into words, then counts them, then runs the bounded heap.
Listing 5.2 The function (lines 1 to 41). src/bin/top_k_frequent_words.rs
use std::{
cmp::Reverse as Rev,
collections::{BinaryHeap, HashMap},
};
/// Returns vec!<(word, frequency)>, most frequent first.
fn top_k_frequent(sentence: &str, k: usize) -> Vec<(String, u32)> {
// Split string by white spaces. Map it and trim any non alphanumeric characters. Finally,
// filter all the empty strings and map everything to lowercase characters.
let words: Vec<String> = sentence
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect();
// Build the frequency map...
let mut frequencies = HashMap::<&str, u32>::new();
for word in &words {
*frequencies
.entry(word.as_str())
.or_default() += 1;
}
// Keep a min heap of size k. Reverse flips the max heap, so the least
// frequent word sits on top and gets popped once we go over k.
let mut min_heap = BinaryHeap::<Rev<(u32, &str)>>::new();
for (&word, &frequency) in &frequencies {
min_heap.push(Rev((frequency, word)));
if min_heap.len() > k {
min_heap.pop();
}
}
// Ascending order of Reverse means most frequent first.
min_heap
.into_sorted_vec()
.into_iter()
.map(|Rev((frequency, word))| (word.to_string(), frequency))
.collect()
}
Turning text into words. The chain splits on whitespace. Then it trims characters that are not letters
or digits from both ends of each piece, so "sentence." becomes "sentence". It drops empty pieces, which a
lone punctuation mark leaves behind, and lowercases each word. trim_matches takes a closure that decides
which characters to trim. map(str::to_lowercase) passes a function by name instead of writing a closure.
Counting without copying. The map is HashMap<&str, u32>. Its keys are references into the words
vector, so counting allocates nothing for each occurrence. or_default() inserts the type’s default value,
0 for u32, when the key is missing.
The bounded heap. use std::cmp::Reverse as Rev; gives Reverse a shorter name. Each entry is
Rev((frequency, word)), so the heap’s top is the smallest (frequency, word) pair. After each push, if
the heap has more than k entries, one pop removes that smallest pair.
The result. into_sorted_vec() returns the entries in ascending order of Rev, which is descending
order of frequency. The final map unwraps each Rev with a pattern and copies the word into an owned String. The result
must not borrow from words, because words is dropped when the function returns.
The heap holds at most k + 1 entries, so each push and pop costs O(log k). For u distinct words, the heap work is O(u log k) instead of O(u log u), and its memory is O(k) instead of O(u).
5.5.1 The complete program
Listing 5.3 The complete program, with its tests. src/bin/top_k_frequent_words.rs
use std::{
cmp::Reverse as Rev,
collections::{BinaryHeap, HashMap},
};
/// Returns vec!<(word, frequency)>, most frequent first.
fn top_k_frequent(sentence: &str, k: usize) -> Vec<(String, u32)> {
// Split string by white spaces. Map it and trim any non alphanumeric characters. Finally,
// filter all the empty strings and map everything to lowercase characters.
let words: Vec<String> = sentence
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect();
// Build the frequency map...
let mut frequencies = HashMap::<&str, u32>::new();
for word in &words {
*frequencies
.entry(word.as_str())
.or_default() += 1;
}
// Keep a min heap of size k. Reverse flips the max heap, so the least
// frequent word sits on top and gets popped once we go over k.
let mut min_heap = BinaryHeap::<Rev<(u32, &str)>>::new();
for (&word, &frequency) in &frequencies {
min_heap.push(Rev((frequency, word)));
if min_heap.len() > k {
min_heap.pop();
}
}
// Ascending order of Reverse means most frequent first.
min_heap
.into_sorted_vec()
.into_iter()
.map(|Rev((frequency, word))| (word.to_string(), frequency))
.collect()
}
fn main() {
let sentence = r"This is a short sentence. We'll count words.
Let us count words in this sentence
";
let top_k = top_k_frequent(sentence, 3);
println!("Top 3 frequency [(word, frequency)] : \n {:#?}", top_k);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picks_most_frequent() {
let result = top_k_frequent("a a a b b c", 2);
assert_eq!(result, vec![("a".to_string(), 3), ("b".to_string(), 2)]);
}
#[test]
fn ignores_case_and_punctuation() {
let result = top_k_frequent("Hello, hello! HELLO world.", 1);
assert_eq!(result, vec![("hello".to_string(), 3)]);
}
#[test]
fn k_larger_than_word_count() {
assert_eq!(top_k_frequent("x y", 10).len(), 2);
}
#[test]
fn empty_input() {
assert!(top_k_frequent("", 3).is_empty());
}
}
The sentence in main is a raw string literal, written r"...". A raw string treats backslashes as
ordinary characters, and this one also spans several lines. The tests cover the basic case, uppercase and
punctuation, a k larger than the number of words, and an empty input.
$ cargo run --bin top_k_frequent_words
Top 3 frequency [(word, frequency)] :
[
(
"words",
2,
),
(
"this",
2,
),
(
"sentence",
2,
),
]
Four words occur twice in the sentence: “this”, “sentence”, “count”, and “words”. Only three fit, and “count” was evicted. When two entries have the same count, the tuple comparison falls to the word, and the min-heap evicts the alphabetically smallest one. So among equal counts, this version keeps the words that come last in alphabetical order.
TIP: To keep the alphabetically first words instead, push Rev((frequency, Reverse(word))). The inner
Reverse flips the order of words only. Among equal counts, the alphabetically last word then has the
smallest key and is evicted first.
5.6 The median of a stream
The problem. Numbers arrive one at a time. After each one, report the median of all the numbers so far.
The simple idea. Keep every number in a sorted Vec, and read the middle. Inserting into the middle of a
sorted vector shifts the elements after it, which costs O(n) per number.
The idea with heaps. Split the numbers into two halves (figure 5.5):
low, a max-heap holding the smaller half. Its top is the largest of the small numbers.high, a min-heap holding the larger half. Its top is the smallest of the large numbers.
Keep two rules. Every number in low is at most every number in high. And low has either the same
number of items as high, or one more. Then the median is always at the tops. With an odd count, it is the
top of low. With an even count, it is the average of the two tops.
low has six values and high five, so the median is 3, the top of low.5.6.1 Adding a number
Listing 5.4 The structure and add_num (lines 1 to 27). src/bin/median_finder.rs
use std::{cmp::Reverse, collections::BinaryHeap};
#[derive(Default)]
pub struct MedianFinder {
low: BinaryHeap<i32>,
high: BinaryHeap<Reverse<i32>>,
}
impl MedianFinder {
pub fn new() -> Self {
Self::default()
}
pub fn add_num(&mut self, num: i32) {
self.low.push(num);
if let Some(val) = self.low.pop() {
self.high.push(Reverse(val));
}
if self.high.len() > self.low.len()
&& let Some(Reverse(val)) = self.high.pop()
{
self.low.push(val);
}
}
// ...
}
add_num keeps both rules with three moves, and never compares numbers itself:
- Push the new number into
low. - Pop the largest number from
lowand push it intohigh. This keeps the first rule: whateverhighreceives is at least as large as everything left inlow. - If
highnow has more items thanlow, pop its smallest and push it back intolow. This keeps the second rule.
Trace the first three numbers of the sample, 6, 10, and 2:
| Add | After step 1 (low) | After step 2 (low / high) | After step 3 (low / high) | Median |
|---|---|---|---|---|
| 6 | {6} | {} / {6} | {6} / {} | 6 |
| 10 | {6, 10} | {6} / {10} | {6} / {10} | (6 + 10) / 2 = 8 |
| 2 | {2, 6} | {2} / {6, 10} | {2, 6} / {10} | 6 |
Each move is one heap operation, so adding a number is O(log n).
5.6.2 Reading the median
Listing 5.5 find_median (lines 28 to 37).
impl MedianFinder {
// ...
pub fn find_median(&self) -> Option<f64> {
let max_low = *self.low.peek()?;
if self.low.len() > self.high.len() {
Some(max_low as f64)
} else {
let Reverse(min_high) = *self.high.peek()?;
Some((max_low as f64 + min_high as f64) / 2.0)
}
}
}
*self.low.peek()? reads the top of low. The ? works on Option the same way it works on Result. If no number has been added, peek returns
None, and so does the function. The function returns
Option<f64>, a floating-point number, because the average of two integers can be a half.
let Reverse(min_high) = *self.high.peek()?; unwraps the Reverse in the let pattern itself.
5.6.3 The complete program
Listing 5.6 The complete program, with its tests. src/bin/median_finder.rs
use std::{cmp::Reverse, collections::BinaryHeap};
#[derive(Default)]
pub struct MedianFinder {
low: BinaryHeap<i32>,
high: BinaryHeap<Reverse<i32>>,
}
impl MedianFinder {
pub fn new() -> Self {
Self::default()
}
pub fn add_num(&mut self, num: i32) {
self.low.push(num);
if let Some(val) = self.low.pop() {
self.high.push(Reverse(val));
}
if self.high.len() > self.low.len()
&& let Some(Reverse(val)) = self.high.pop()
{
self.low.push(val);
}
}
pub fn find_median(&self) -> Option<f64> {
let max_low = *self.low.peek()?;
if self.low.len() > self.high.len() {
Some(max_low as f64)
} else {
let Reverse(min_high) = *self.high.peek()?;
Some((max_low as f64 + min_high as f64) / 2.0)
}
}
}
const SAMPLE: [i32; 11] = [6, 10, 2, 6, 5, 0, 6, 3, 1, 0, 0];
fn main() {
let mut finder = MedianFinder::new();
for value in SAMPLE {
finder.add_num(value);
match finder.find_median() {
Some(median) => println!("after {value:>2}: median {median}"),
None => println!("after {value:>2}: no median"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The median of a sorted slice, computed directly.
fn median_of_sorted(values: &[i32]) -> f64 {
let middle = values.len() / 2;
if values.len() % 2 == 1 {
values[middle] as f64
} else {
(values[middle - 1] as f64 + values[middle] as f64) / 2.0
}
}
#[test]
fn an_empty_finder_has_no_median() {
assert_eq!(MedianFinder::new().find_median(), None);
}
#[test]
fn one_value_is_the_median() {
let mut finder = MedianFinder::new();
finder.add_num(7);
assert_eq!(finder.find_median(), Some(7.0));
}
#[test]
fn an_even_count_averages_the_middle_two() {
let mut finder = MedianFinder::new();
finder.add_num(1);
finder.add_num(2);
assert_eq!(finder.find_median(), Some(1.5));
}
#[test]
fn three_values_are_ordered() {
let mut finder = MedianFinder::new();
for (value, median) in [1, 2, 3]
.into_iter()
.zip([1.0, 1.5, 2.0])
{
finder.add_num(value);
assert_eq!(finder.find_median(), Some(median));
}
}
#[test]
fn duplicates_are_kept() {
let mut finder = MedianFinder::new();
for value in [4, 4, 4, 4] {
finder.add_num(value);
}
assert_eq!(finder.find_median(), Some(4.0));
}
#[test]
fn the_stream_matches_a_sort_on_every_step() {
let mut finder = MedianFinder::new();
let mut seen = Vec::new();
for value in SAMPLE {
finder.add_num(value);
seen.push(value);
seen.sort_unstable();
assert_eq!(finder.find_median(), Some(median_of_sorted(&seen)));
}
}
#[test]
fn the_two_heaps_never_differ_by_more_than_one() {
let mut finder = MedianFinder::new();
for value in SAMPLE {
finder.add_num(value);
assert!(
finder
.low
.len()
.abs_diff(finder.high.len())
<= 1
);
}
}
}
main adds the eleven sample values and prints the median after each one.
$ cargo run --bin median_finder
after 6: median 6
after 10: median 8
after 2: median 6
after 6: median 6
after 5: median 6
after 0: median 5.5
after 6: median 6
after 3: median 5.5
after 1: median 5
after 0: median 4
after 0: median 3
Look at the test the_stream_matches_a_sort_on_every_step. After every added number, it sorts a copy of all the numbers so far. Then it computes the median the slow
way, with the helper median_of_sorted. Then it checks
that the heaps give the same answer. A test like this, which compares a fast method with a slow method you
can trust, is called an oracle test. It catches mistakes that a few hand-picked examples miss.
The last test checks the second rule directly: after every number, the two heaps’ sizes differ by at most one.
Summary
- A heap is a binary tree stored level by level in a
Vec. The children of index i are at 2i + 1 and 2i + 2. - In a max-heap every parent is at least as large as its children, so the largest item is at index 0.
- Push adds at the end and swaps upward. Pop moves the last item to the top and swaps downward with the larger child. Both are O(log n).
BinaryHeapis a max-heap. Wrapping items inReversemakes it a min-heap.- Tuples compare element by element from the left, so
(count, value)orders by count first. - Top k with a full heap costs O(u) memory. A min-heap bounded to k entries costs O(k) memory and O(u log k) time.
- Two heaps, a max-heap for the small half and a min-heap for the large half, give the running median in O(log n) per number.
Chapter 6 covers recursion and dynamic programming. You will solve problems by combining answers to smaller versions of themselves, and save those answers so none is computed twice.
Exercises
- Implement a max-heap yourself on a
Vec<i32>, withpush,pop, andpeek, using the index formulas from section 5.1. Test it againstBinaryHeapon random input. - Change
top_k_frequent_wordsto keep the alphabetically first words among ties, as the tip in section 5.5 describes, and add a test. - Add a method
lentoMedianFinderthat returns how many numbers it holds. - Extend the running median to remove numbers as well, so it can report the median of the last 100 numbers only. Hint: remove lazily, by remembering which numbers are waiting to be deleted.
Recursion and dynamic programming
This chapter covers
- Recognizing a problem that can be built from answers to smaller versions of itself
- Filling a table of answers from the smallest case upward
- Listing every subset of a set by exploring a tree of choices
- The longest increasing run hidden inside a list, in O(n log n)
- Choosing jobs that do not overlap, to earn the most
In chapter 1 you computed Fibonacci numbers three ways. The recursive version repeated work. The memoized version saved each answer in a table, and the loop version kept only the answers it still needed. That small example contains the whole idea of this chapter.
Dynamic programming is a way to solve a problem by answering smaller versions of the same problem first, saving those answers, and combining them. It applies when two things are true:
- The answer for a large input can be built from the answers for smaller inputs.
- The same smaller inputs come up again and again, so saving their answers avoids repeated work.
This chapter works through four problems. The first, making change with coins, is dynamic programming in its plainest form. The second, listing subsets, uses recursion without any saved answers. The last two combine dynamic programming with the sorting and searching you learned in chapter 3.
For each problem I first say, in one sentence, what one entry of the table means. That sentence is called the subproblem. Once it is written down, the code follows from it.
6.1 Making change with the fewest coins
The problem. You have coins of some values, as many of each as you like. What is the fewest number of coins that add up to a given amount? With coins 1, 2, and 5, the amount 11 needs three coins: 5 + 5 + 1. Some amounts cannot be made at all. With only a 2 coin, the amount 3 is impossible.
6.1.1 Why the obvious approach fails
A natural first try is to take the largest coin that fits, again and again. This is called a greedy approach. It works for 1, 2, and 5. It does not work for every set of coins (figure 6.1).
So the program has to consider every coin at every step. Trying every combination directly takes exponential time. Dynamic programming makes it fast.
6.1.2 The subproblem
Write down what one entry of the table means:
dp[t]is the fewest coins that add up to exactlyt.
Now think about the last coin used to make t. If that coin is c, the coins before it add up to t - c,
and the fewest coins for that is dp[t - c]. So using coin c last costs dp[t - c] + 1 coins. You do not
know which coin is last in the best answer, so try every coin and keep the smallest:
dp[t]= the smallest ofdp[t - c] + 1, over every coincthat is not larger thant.
The starting point is dp[0] = 0: zero coins make zero.
Fill the table by hand for the coins in figure 6.1, which are 1, 3, and 4, and the amount 6. Each row tries every coin that fits, and keeps the smallest count:
t | coins that fit | options from each coin | dp[t] |
|---|---|---|---|
| 1 | 1 | dp[0] + 1 = 1 | 1 |
| 2 | 1 | dp[1] + 1 = 2 | 2 |
| 3 | 1, 3 | dp[2] + 1 = 3, dp[0] + 1 = 1 | 1 |
| 4 | 1, 3, 4 | dp[3] + 1 = 2, dp[1] + 1 = 2, dp[0] + 1 = 1 | 1 |
| 5 | 1, 3, 4 | dp[4] + 1 = 2, dp[2] + 1 = 3, dp[1] + 1 = 2 | 2 |
| 6 | 1, 3, 4 | dp[5] + 1 = 3, dp[3] + 1 = 2, dp[2] + 1 = 3 | 2 |
dp[6] is 2, from 3 + 3. Greedy needed three coins for the same amount, because it took the 4 first and
never went back. The table records the best count for every smaller amount. Each new amount is then one
lookup plus one.
Two properties must hold for that to work. The answers must overlap, so that saving them avoids work.
dp[6] and dp[5] both read dp[1], and a longer table reads each entry many times. The best answer must
also be built from best answers to smaller amounts. Taking the smallest of dp[t - c] + 1 relies on that.
If some way of making t - c used fewer coins, dp[t - c] would hold that smaller number. When either
property fails, the table gives a wrong answer.
Because dp[t] only uses entries for smaller amounts, you can fill the table from left to right. Figure 6.2
shows the table for coins 1, 2, and 5, and how dp[11] is computed.
dp[11], the three coins lead back to dp[10], dp[9], and dp[6].dp[t] reads. The last part shows greedy choosing three coins where two are enough.6.1.3 The code
Listing 6.1 Coin change (lines 6 to 29). src/problems/dp.rs
pub fn coin_change(coins: &[i32], amount: i32) -> Option<i32> {
if amount < 0 {
return None;
}
let amount = amount as usize;
let mut dp = vec![usize::MAX; amount + 1];
dp[0] = 0;
for total in 1..=amount {
for &coin in coins {
let coin = coin as usize;
if coin > total || dp[total - coin] == usize::MAX {
continue;
}
dp[total] = dp[total].min(dp[total - coin] + 1);
}
}
match dp[amount] {
usize::MAX => None,
count => Some(count as i32),
}
}
Read it against the recurrence:
- A negative amount is rejected first. After that check,
amount as usizecannot produce a strange value. vec![usize::MAX; amount + 1]creates the table, one entry per amount from 0 toamount.usize::MAX, the largestusize, stands for “cannot be made yet”.dp[0] = 0is the starting point.- The outer loop fills amounts from 1 upward. The inner loop tries every coin.
- The
continueskips a coin that is larger than the amount, or whose remainder cannot be made. The second check also preventsusize::MAX + 1, which would overflow. dp[total].min(dp[total - coin] + 1)keeps the smaller of the current best and the new option.
WARNING The second half of that continue is not an optimisation. Remove it and the function breaks.
If dp[total - coin] holds the sentinel, then dp[total - coin] + 1 adds one to the largest usize. A
debug build panics with “attempt to add with overflow”. A release build wraps around to 0, and the final
match reads 0 as a real count of zero coins. The table then reports fewer coins than exist. Add the
sentinel check before adding, or usize::MAX cannot stand for “impossible”.
At the end, a match turns the table entry into the result. The pattern usize::MAX matches that exact
value, meaning impossible, and returns None. Any other value is a count. So the function returns
Option<i32>, and the caller cannot mistake “impossible” for a number of coins.
The table has amount + 1 entries and each one tries every coin. The cost is O(amount × coins) time and
O(amount) memory.
Listing 6.2 The complete file. src/problems/dp.rs
//! Coin Change: minimum number of coins to make an amount.
//!
//! Classic dynamic programming. `Option<i32>` expresses "impossible" instead of
//! a magic `-1`.
pub fn coin_change(coins: &[i32], amount: i32) -> Option<i32> {
if amount < 0 {
return None;
}
let amount = amount as usize;
let mut dp = vec![usize::MAX; amount + 1];
dp[0] = 0;
for total in 1..=amount {
for &coin in coins {
let coin = coin as usize;
if coin > total || dp[total - coin] == usize::MAX {
continue;
}
dp[total] = dp[total].min(dp[total - coin] + 1);
}
}
match dp[amount] {
usize::MAX => None,
count => Some(count as i32),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn computes_minimum_coins() {
assert_eq!(coin_change(&[1, 2, 5], 11), Some(3)); // 5 + 5 + 1
assert_eq!(coin_change(&[2], 3), None);
assert_eq!(coin_change(&[1], 0), Some(0));
}
}
Run the tests with cargo test --lib coin_change. The tests check the example from section 6.1, an
impossible amount, and the amount 0.
6.2 Listing every subset
Not every recursive problem needs a table. Sometimes you need every possible answer, not the best one. Then recursion explores all the choices, and there is nothing to save.
The problem. List every subset of a set of distinct numbers. A subset is any selection of the
elements, including none of them and all of them. [1, 2, 3] has eight subsets: [], [1], [2], [3],
[1, 2], [1, 3], [2, 3], and [1, 2, 3].
The idea. Build subsets one element at a time. Start with the empty subset. From any subset, you can add
one more element, as long as it comes later in the input than the elements already chosen. That rule is what
stops [2, 1] from appearing as a copy of [1, 2]. Figure 6.3 draws the choices as a tree. Every node in
the tree is one subset.
[1, 2, 3]. Each edge adds an element that comes after the last one chosen.To visit the whole tree, the program walks it depth first, going down one branch as far as it can before trying the next. Going down means adding an element; coming back up means removing it. This way of exploring choices is called backtracking.
start keeps [2, 1] out of the result.Listing 6.3 Subsets by backtracking. src/problems/backtracking.rs
//! Subsets: enumerate every subset of a set of distinct integers.
//!
//! Backtracking with clean `path.push(...)` + recursive exploration + `pop`.
//! `Vec<Vec<i32>>` is sorted in the tests to avoid order-dependent assertions.
pub fn subsets(nums: Vec<i32>) -> Vec<Vec<i32>> {
let mut result = Vec::with_capacity(1 << nums.len());
let mut path = Vec::new();
fn backtrack(nums: &[i32], start: usize, path: &mut Vec<i32>, result: &mut Vec<Vec<i32>>) {
result.push(path.clone());
for idx in start..nums.len() {
path.push(nums[idx]);
backtrack(nums, idx + 1, path, result);
path.pop();
}
}
backtrack(&nums, 0, &mut path, &mut result);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_all_subsets() {
let mut subsets = subsets(vec![1, 2, 3]);
subsets.sort_unstable();
assert_eq!(subsets.len(), 8);
assert!(subsets.contains(&vec![]));
assert!(subsets.contains(&vec![1, 2, 3]));
}
#[test]
fn empty_input_has_one_subset() {
assert_eq!(subsets(vec![]), vec![vec![]]);
}
}
backtrack is a function defined inside subsets, as go was in chapter 1. It takes four things:
nums, the input, borrowed.start, the first position it may choose from.path, the subset being built. Only onepathvector exists for the whole search, passed as&mut.result, the list of finished subsets, also passed as&mut.
On entry, backtrack records path.clone() in the result. The current path is one subset, the node of
the tree it has reached. Then, for each later position, it pushes that element, recurses to explore the
subtree below, and pops the element. After the pop, path is exactly as it was before the push, ready for
the next choice.
Trace the start of subsets(vec![1, 2, 3]):
| Call | path on entry | Records | Then pushes |
|---|---|---|---|
backtrack(start 0) | [] | [] | 1 |
backtrack(start 1) | [1] | [1] | 2 |
backtrack(start 2) | [1, 2] | [1, 2] | 3 |
backtrack(start 3) | [1, 2, 3] | [1, 2, 3] | nothing: the loop is empty |
back in start 2 | [1, 2] after pop | no more positions | |
back in start 1 | [1] after pop | 3 |
Vec::with_capacity(1 << nums.len()) reserves exactly enough room. 1 << n means 2 to the power n, because
shifting 1 left by n bits doubles it n times. A set of n elements has 2ⁿ subsets.
The work is O(n · 2ⁿ): there are 2ⁿ subsets and each clone copies up to n numbers. No algorithm can do better, because the output itself has that size.
6.3 The longest increasing run inside a list
The problem. In a list of numbers, pick some of them and keep their order. Each picked number must be
larger than the one before. What is the most you can pick? The picked numbers are called an increasing
subsequence. In [10, 9, 2, 5, 3, 7, 101, 18], you can pick 2, 3, 7, 18, so the answer is 4.
A subsequence may skip numbers. That is the difference from the substrings of chapter 3, which were contiguous.
The direct dynamic program. Let dp[i] be the length of the longest increasing subsequence that ends at
position i. For each i, look at every earlier j with a smaller number, and take the best dp[j] + 1. That is
O(n²), because each position looks at every earlier one.
A faster idea. Keep a different table, called tails. Its entries have this meaning:
The k-th smallest entry of
tailsis the smallest number that can end an increasing subsequence of length k.
Keeping the smallest possible ending number is useful because a smaller ending leaves more room for later numbers to extend it. When a new number arrives:
- If it is larger than every entry, it extends the longest subsequence found so far. Add it.
- Otherwise, find the smallest entry that is at least as large as it, and replace that entry with it. Some subsequence of that length can now end with a smaller number.
At the end, the number of entries is the answer. Figure 6.4 follows the example.
tails after each number. The green cell is the one that changed.Look at the last row. tails is 2, 3, 7, 18, which happens to be a real increasing subsequence here. That
is not true in general. tails can mix numbers from different subsequences. Only its length is meaningful.
The entries of tails are always in increasing order, so finding “the smallest entry at least as large” can
use a sorted structure. The program uses a BTreeSet, a set that keeps its elements sorted.
Listing 6.4 The complete program. src/bin/longest_increasing_subsequence.rs
use std::collections::BTreeSet;
fn lis_len(nums: &[i32]) -> usize {
let mut tails = BTreeSet::new();
for &num in nums {
if let Some(&ceiling) = tails.range(num..).next() {
tails.remove(&ceiling);
}
tails.insert(num);
}
tails.len()
}
fn main() {
println!("{}", lis_len(&[10, 9, 2, 5, 3, 7, 101, 18])); // 4
println!("{}", lis_len(&[0, 1, 0, 3, 2, 3])); // 4
println!("{}", lis_len(&[7, 7, 7, 7])); // 1
println!("{}", lis_len(&[5, 4, 3, 2, 1])); // 1
println!("{}", lis_len(&[1, 2, 3, 4, 5])); // 5
println!("{}", lis_len(&[])); // 0
}
tails.range(num..) returns the elements from num upward, in sorted order. .next() takes the first of
them, the smallest element that is at least num. If there is one, the program removes it. Then it inserts
num in every case. Removing and inserting is how the set replaces one element with another.
The range num.. includes num itself, which handles repeated numbers. In [7, 7, 7, 7], the second 7
finds the first 7, removes it, and inserts itself again, so the answer stays 1. A run with a repeated number
is not strictly increasing, so 1 is correct.
Each range, remove, and insert on a BTreeSet costs O(log n), so the whole program is O(n log n).
$ cargo run --bin longest_increasing_subsequence
4
4
1
1
5
0
The six outputs match the comments in main.
6.4 Choosing jobs that do not overlap
The problem. A machine can run one job at a time. Each job has a start time, an end time, and a profit. Choose jobs that do not overlap so that the total profit is as large as possible. Figure 6.5 shows the four sample jobs.
A job written [s, e) runs from time s up to, but not including, time e. So a job ending at 3 and a job
starting at 3 do not overlap.
6.4.1 Building the subproblem
First sort the jobs by end time. Then any job that can come before job i in a schedule ends earlier, so it appears earlier in the sorted list. The subproblem is:
dp[i]is the best profit using only the first i jobs of the sorted list.
For job i there are two choices:
- Skip it. The best profit stays
dp[i], the best without it. - Take it. Then only jobs that end by its start time can come before it. Those jobs are a prefix of the
sorted list, say the first
prevjobs. The profit isdp[prev] + profit of job i.
So dp[i + 1] is the larger of the two. The last entry is the answer.
Finding prev is a search in a sorted list, so it can be a binary search, like chapter 3’s. The standard
library provides one as partition_point. Given a condition that is true for a prefix of the list and false
after it, partition_point returns the length of that prefix.
6.4.2 The code
A job is a small Copy struct: when it starts, when it ends, and what it pays.
#[derive(Debug, Default, Clone, Copy)]
struct Job {
start: u32,
end: u32,
profit: u64,
}
Listing 6.5 The complete program. src/bin/job_scheduling_with_profit_and_deadline.rs
#[derive(Debug, Default, Clone, Copy)]
struct Job {
start: u32,
end: u32,
profit: u64,
}
fn max_profit(mut jobs: Vec<Job>) -> u64 {
// Sort by end time so every job that could come before
// job `i` in a schedule sits somewhere in jobs[..i].
jobs.sort_unstable_by_key(|job| job.end);
// dp[i] = best profit achievable using only the first `i` jobs.
let mut dp = vec![0; jobs.len() + 1];
for (i, job) in jobs.iter().enumerate() {
// Binary search for how many earlier jobs finish by the time
// this one starts. Those are exactly the jobs compatible with it,
// and since they form a prefix, dp[prev] is their best profit.
let prev = jobs[..i].partition_point(|j| j.end <= job.start);
// Either skip this job (keep dp[i]),
// or take it on top of the best compatible prefix.
dp[i + 1] = dp[i].max(dp[prev] + job.profit);
}
// After considering every job, the last entry holds the answer.
dp[jobs.len()]
}
fn main() {
let jobs = vec![
Job {
start: 1,
end: 3,
profit: 50,
},
Job {
start: 2,
end: 4,
profit: 10,
},
Job {
start: 3,
end: 5,
profit: 40,
},
Job {
start: 3,
end: 6,
profit: 70,
},
];
println!("{}", max_profit(jobs)); // 120
}
The comments inside max_profit walk through the same reasoning as section 6.4.1, step by step.
jobs[..i].partition_point(|j| j.end <= job.start) counts the jobs among the first i that end by this job’s
start. Because the list is sorted by end time, those jobs come first, which is the condition
partition_point needs.
Trace it on the sorted jobs A, B, C, D:
| i | job | prev | skip: dp[i] | take: dp[prev] + profit | dp[i + 1] |
|---|---|---|---|---|---|
| 0 | A [1, 3) 50 | 0 | 0 | 0 + 50 = 50 | 50 |
| 1 | B [2, 4) 10 | 0 | 50 | 0 + 10 = 10 | 50 |
| 2 | C [3, 5) 40 | 1 | 50 | 50 + 40 = 90 | 90 |
| 3 | D [3, 6) 70 | 1 | 90 | 50 + 70 = 120 | 120 |
For C and D, prev is 1: only A ends by time 3. Sorting costs O(n log n), and each job does one binary
search, so the whole program is O(n log n).
$ cargo run --bin job_scheduling_with_profit_and_deadline
120
6.5 Jobs that also need memory and cores
The problem. A cluster holds several GPUs. Each GPU has a fixed amount of memory and a fixed number of cores. Each job needs some of both, and it runs only on a GPU that has enough of each. A GPU runs one job at a time, as in section 6.4.
The program below answers a narrow question: which single GPU gives the best schedule? Section 6.5.4 measures what that leaves unused.
The sample cluster has two GPUs and the four jobs from section 6.4, with a memory need and a core need added.
| job | time | memory | cores | profit |
|---|---|---|---|---|
| A | [1, 3) | 12 GB | 20 | 50 |
| B | [2, 4) | 8 GB | 10 | 10 |
| C | [3, 5) | 40 GB | 60 | 40 |
| D | [3, 6) | 60 GB | 90 | 70 |
gpu 0 has 80 GB and 108 cores, so all four jobs fit there. Only A and B fit on gpu 1, which has 16 GB and 40 cores.
6.5.1 The fit test, and the same table
The new step is one boolean. A job fits a GPU when the GPU has at least its memory and at least its cores.
The rest is the table from section 6.4. Sort the jobs by end time and find each job’s compatible
prefix prev. Then fill dp with the better of skipping the job and taking it on top of dp[prev].
The fit test changes one line of that loop. A job that does not fit is skipped, as if it were not in
the list at all. Its entry in dp copies the entry before it.
6.5.2 The code
A job carries its two resource needs beside its time and its profit. A GPU carries the same two numbers, and one method answers whether a job fits.
#[derive(Debug, Default, Clone, Copy)]
struct Gpu {
mem_gb: u32,
cores: u32,
}
#[derive(Debug, Default, Clone, Copy)]
struct Job {
start: u32,
end: u32,
mem_gb: u32,
cores: u32,
profit: u64,
}
impl Gpu {
/// A job fits when the GPU has at least its memory and at least its cores.
fn fits(&self, job: &Job) -> bool {
(0..=self.mem_gb).contains(&job.mem_gb) && (0..=self.cores).contains(&job.cores)
}
}
Listing 6.6 The complete program. src/bin/gpu_job_schedule.rs
//! Choose non-overlapping jobs for a GPU, where each job needs memory and cores.
//!
//! A job runs only on a GPU with enough memory and enough cores. For one GPU the
//! task is weighted interval scheduling: sort by end time, then take or skip each
//! job. The same table is reused for every GPU, and the best GPU wins.
#[derive(Debug, Default, Clone, Copy)]
struct Gpu {
mem_gb: u32,
cores: u32,
}
#[derive(Debug, Default, Clone, Copy)]
struct Job {
start: u32,
end: u32,
mem_gb: u32,
cores: u32,
profit: u64,
}
impl Gpu {
/// A job fits when the GPU has at least its memory and at least its cores.
fn fits(&self, job: &Job) -> bool {
(0..=self.mem_gb).contains(&job.mem_gb) && (0..=self.cores).contains(&job.cores)
}
}
/// The best profit from work that runs on one GPU.
///
/// Every job it picks runs on the same GPU. That is the right model when a workload
/// is pinned to one device. Spreading the jobs over several GPUs is a different
/// problem, and the chapter measures what it is worth on the sample data.
fn max_profit(gpus: &[Gpu], mut jobs: Vec<Job>) -> u64 {
jobs.sort_unstable_by_key(|job| job.end);
// prev[i] = how many jobs finish by the time job `i` starts. The end times
// are sorted, so each count is the length of a prefix, found in one search.
let prev: Vec<usize> = jobs
.iter()
.map(|job| jobs.partition_point(|earlier| earlier.end <= job.start))
.collect();
// dp[i] = best profit using only the first `i` jobs. Reused for every GPU.
let mut dp = vec![0; jobs.len() + 1];
let pick_jobs_dp = |gpu: &Gpu| {
for index in 0..jobs.len() {
let job = &jobs[index];
dp[index + 1] = if gpu.fits(job) {
dp[index].max(dp[prev[index]] + job.profit)
} else {
dp[index]
};
}
dp[jobs.len()]
};
gpus.iter()
.map(pick_jobs_dp)
.max()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn large() -> Gpu {
Gpu {
mem_gb: 80,
cores: 108,
}
}
fn small() -> Gpu {
Gpu {
mem_gb: 16,
cores: 40,
}
}
fn sample_jobs() -> Vec<Job> {
vec![
Job {
start: 1,
end: 3,
mem_gb: 12,
cores: 20,
profit: 50,
},
Job {
start: 2,
end: 4,
mem_gb: 8,
cores: 10,
profit: 10,
},
Job {
start: 3,
end: 5,
mem_gb: 40,
cores: 60,
profit: 40,
},
Job {
start: 3,
end: 6,
mem_gb: 60,
cores: 90,
profit: 70,
},
]
}
#[test]
fn the_large_gpu_runs_the_best_pair() {
assert_eq!(max_profit(&[large()], sample_jobs()), 120);
}
#[test]
fn the_small_gpu_skips_what_does_not_fit() {
assert_eq!(max_profit(&[small()], sample_jobs()), 50);
}
#[test]
fn the_best_gpu_wins() {
assert_eq!(max_profit(&[large(), small()], sample_jobs()), 120);
}
#[test]
fn no_gpu_or_no_job_earns_nothing() {
assert_eq!(max_profit(&[], sample_jobs()), 0);
assert_eq!(max_profit(&[large()], Vec::new()), 0);
}
#[test]
fn cores_alone_can_disqualify_a_job() {
let jobs = vec![Job {
start: 0,
end: 1,
mem_gb: 1,
cores: 200,
profit: 9,
}];
assert_eq!(max_profit(&[large()], jobs), 0);
}
}
fn main() {
let gpus = vec![
Gpu {
mem_gb: 80,
cores: 108,
},
Gpu {
mem_gb: 16,
cores: 40,
},
];
let jobs = vec![
Job {
start: 1,
end: 3,
mem_gb: 12,
cores: 20,
profit: 50,
},
Job {
start: 2,
end: 4,
mem_gb: 8,
cores: 10,
profit: 10,
},
Job {
start: 3,
end: 5,
mem_gb: 40,
cores: 60,
profit: 40,
},
Job {
start: 3,
end: 6,
mem_gb: 60,
cores: 90,
profit: 70,
},
];
println!("{}", max_profit(&gpus, jobs)); // 120
}
max_profit builds prev once and then reuses a single dp vector for each GPU. The closure
pick_jobs_dp fills the table for one GPU and returns its last entry. gpus.iter().map(pick_jobs_dp)
runs that closure for every GPU, and .max() keeps the best result.
Reusing dp is safe. The loop writes dp[i + 1] from dp[i] and dp[prev[i]]. Both indexes are at
most i, so it reads values this pass has already written. Every pass overwrites all of dp except
dp[0], which stays 0.
Animation 6.3 opens with the jobs out of order. It lifts them into end order, then shows each job’s arithmetic in a callout beside the table.
skip = dp[i] against take = dp[prev] + profit and writes max of the two into dp[i+1]. The fit test decides which jobs a GPU may take; the last part shares the jobs out for 130.6.5.3 Two tables
gpu 0 has enough of both for every job, so its table is the one from section 6.4.
| i | job | fits | prev | skip dp[i] | take dp[prev] + profit | dp[i+1] |
|---|---|---|---|---|---|---|
| 0 | A | yes | 0 | 0 | 0 + 50 = 50 | 50 |
| 1 | B | yes | 0 | 50 | 0 + 10 = 10 | 50 |
| 2 | C | yes | 1 | 50 | 50 + 40 = 90 | 90 |
| 3 | D | yes | 1 | 90 | 50 + 70 = 120 | 120 |
gpu 1 fails the fit test for C and D, so both are skipped and the table stops at 50.
| i | job | fits | prev | skip dp[i] | take | dp[i+1] |
|---|---|---|---|---|---|---|
| 0 | A | yes | 0 | 0 | 0 + 50 = 50 | 50 |
| 1 | B | yes | 0 | 50 | 0 + 10 = 10 | 50 |
| 2 | C | no | 1 | 50 | no fit | 50 |
| 3 | D | no | 1 | 50 | no fit | 50 |
The answer is max(120, 50) = 120.
$ cargo run --bin gpu_job_schedule
120
Sorting costs O(n log n), and each job finds its compatible prefix with one binary search. Each GPU
then fills its table in O(n), and the same dp is reused, so memory is O(n).
6.5.4 What one GPU at a time costs
max returns one GPU’s schedule. The other GPU runs nothing in that answer, even when it has room.
The two schedules cannot be added, because they overlap. The best schedule for gpu 1 is A alone, which earns 50. That A is already part of the 120 on gpu 0, so adding the two totals counts A twice.
Sharing the jobs out does better. Put A and D on gpu 0, and B on gpu 1. Neither GPU runs two overlapping jobs, and the total is 120 + 10 = 130.
Finding that split is a different problem. It has to decide which GPU runs each job. A table per GPU cannot express that choice, because the GPUs are not independent. With many jobs and many GPUs the search grows quickly, and production schedulers use heuristics, bin packing, or an integer program instead.
Summary
- Dynamic programming answers smaller versions of a problem first, saves the answers, and combines them.
- Start by writing, in one sentence, what one table entry means. The recurrence and the code follow from it.
- Coin change fills
dp[t]fromdp[t - c] + 1for each coin, left to right, in O(amount × coins). - Greedy choices can fail. Coins 1, 3, and 4 make 6 greedily with three coins, but two suffice.
- Backtracking explores a tree of choices with one shared
path, pushing before each recursive call and popping after it. Listing all subsets is O(n · 2ⁿ), the size of the output. - The longest increasing subsequence keeps the smallest possible ending for each length, in a sorted set, for O(n log n).
- Weighted job scheduling sorts by end time, uses
partition_pointto find compatible jobs, and fills a table of best profits. - A memory and a core need cost one boolean in that loop. A job that does not fit is skipped, and one
dpvector serves every GPU. - The best single GPU is not the same as the best use of all the GPUs. On the sample data, sharing the jobs out earns 130 where one GPU earns 120.
Chapter 7 begins Part 3, on modeling data. You will build types that can only hold valid values, using structs, enums, and error types.
Exercises
- Change
coin_changeto also return the coins it used. Store the last coin chosen for each amount, then follow those choices back fromamount. - Write the O(n²) version of the longest increasing subsequence described in section 6.3, and check that it
agrees with
lis_lenon random lists. - Generate all subsets without recursion. Count a number
maskfrom 0 to 2ⁿ − 1, and include element i when bit i ofmaskis set. - Extend
max_profitto return which jobs it chose, not only the total. - Change
max_profitso the jobs may be split across the GPUs. Work out the best total for the two GPUs and four jobs in section 6.5 by hand. Then explain why one table per GPU cannot find it.
Types that refuse bad input
This chapter covers
- Traits: what they are, how to implement one, and what
#[derive]writes for you - A newtype: a struct around one number that can only hold valid values
- Error types that say what went wrong, with
Displayandstd::error::Error - Converting between types with
FromandTryFrom - Enums whose variants carry data, used for commands, for states, and for errors
- A parser that turns a line of text into a typed command
This chapter begins Part 3, on modeling data. So far your programs have used the types Rust provides: numbers, strings, vectors, and maps. In this chapter you design your own types, so that invalid values cannot be represented at all.
The examples come from a small command-line tool that manages jobs on a remote computer. The jobs are called
workloads. Each workload asks for a number of GPUs, which must be between 1 and 64. A GPU is a graphics processor,
and servers also use GPUs for general computation. A workload moves through states such as pending,
running, and terminated. The tool reads commands typed by a user, such as create triton 4, which asks for a
workload named triton with 4 GPUs. That is all you need to know about the tool.
Here is the problem this chapter solves. A GPU count arrives as text typed by a user. The program must reject
0, 99, and many, and it must do so before the count reaches the code that uses it. If each function
checked the count itself, one forgotten check would let a bad value through. Instead, the program checks the
count once, where the text enters, and stores the result in a type that can only hold valid counts. Every
function that receives that type can rely on it without checking again.
The chapter builds this in order:
- Section 7.1 explains traits, which the rest of the chapter implements.
- Section 7.2 builds the GPU count type.
- Section 7.3 gives it an error type.
- Section 7.4 adds the standard conversions.
- Section 7.5 builds an enum for commands and a parser for them.
- Section 7.6 models the workload’s states and the moves between them.
- Section 7.7 models the errors a server can return.
7.1 Traits
A trait is a set of methods that a type promises to provide. The standard library defines many traits.
For example, the Display trait has one method, fmt, which writes a value as text for a person to read.
println!("{}", x) works for any x whose type implements Display.
You implement a trait for your type with an impl block that names the trait:
impl fmt::Display for GpuCountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "...")
}
}
Some traits are so regular that the compiler can write them for you. #[derive(Debug, Clone, PartialEq)] placed above a struct asks the compiler to generate three traits.
Debug prints with {:?}, Clone copies with .clone(), and PartialEq compares with ==. The derived versions work field by field.
Table 7.1 lists the traits this chapter uses.
| Trait | What it gives you | How you get it |
|---|---|---|
Debug | printing with {:?}, for programmers | #[derive(Debug)] |
Display | printing with {}, for users | write impl Display yourself |
Clone, Copy | .clone(), and for Copy, copying by assignment | derive |
PartialEq, Eq | == and != | derive |
PartialOrd, Ord | <, >, and sorting | derive |
Hash | use as a HashMap key | derive |
std::error::Error | use as an error with ? and Box<dyn Error> | write an empty impl once Debug and Display exist |
From, TryFrom | conversions between types | write impl yourself |
7.2 A GPU count that is always valid
A newtype is a struct with one field, used to give a plain value a type of its own. Here the plain value
is a u32, and the new type is GpuCount.
The trick is privacy. Fields in Rust are private by default. Code outside the module where the struct is defined cannot read or
write them, and cannot build the struct with GpuCount(0). The only way to get a
GpuCount is through the functions the module offers. If those functions check the value, every
GpuCount in the program is valid. Figure 7.1 shows the design.
The module starts with a comment that lists its ideas:
Listing 7.1 The module comment (lines 1 to 12). src/problems/adt_idioms.rs
//! Small algebraic-data-type idioms.
//!
//! These patterns are short and cannot represent invalid states.
//!
//! - A newtype gives a primitive a name and a single validating constructor, so
//! an out-of-range value cannot exist.
//! - An enum with data models a closed set of alternatives, and `match` forces
//! every alternative to be handled.
//! - Typed errors carry the reason instead of a string, so the caller can react.
//! - `TryFrom` puts the validation on the type boundary, where it belongs.
//! - An iterator chain states the intent in one pass and removes index
//! bookkeeping.
Here is the type:
Listing 7.2 GpuCount (lines 14 to 36).
use std::fmt;
/// A GPU count that is validated at construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GpuCount(u32);
impl GpuCount {
pub const MIN: u32 = 1;
pub const MAX: u32 = 64;
/// Validate a raw count.
pub fn new(value: u32) -> Result<Self, GpuCountError> {
if (Self::MIN..=Self::MAX).contains(&value) {
Ok(Self(value))
} else {
Err(GpuCountError(value))
}
}
pub fn get(self) -> u32 {
self.0
}
}
pub struct GpuCount(u32);is a tuple struct: a struct whose field has no name, only a position. The struct is public, but its field is not.pub const MIN: u32 = 1;andMAXare associated constants, constants that belong to the type. You refer to them asGpuCount::MIN, orSelf::MINinside theimpl.newis the gate.(Self::MIN..=Self::MAX)is the range 1 to 64, including both ends, and.contains(&value)checks the value. If it is in range,newreturnsOk(Self(value)). If not, it returnsErr(GpuCountError(value)), an error that carries the rejected value. Section 7.3 shows that type.getreturns the number inside. It takesselfby value, which is fine becauseGpuCountisCopy.
The long #[derive(...)] line gives GpuCount the same abilities as the u32 inside it. You can print it,
copy it, compare it, sort it, and use it as a map key.
7.3 An error that says what went wrong
When new rejects a value, it returns a GpuCountError. Here is that type:
Listing 7.3 The error type (lines 38 to 54).
/// Why a raw GPU count was rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GpuCountError(pub u32);
impl fmt::Display for GpuCountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"gpu count {} is outside {}..={}",
self.0,
GpuCount::MIN,
GpuCount::MAX
)
}
}
impl std::error::Error for GpuCountError {}
GpuCountError(pub u32) keeps the rejected value. Its field is pub, so a caller can read which value was
rejected.
The Display implementation writes the message a user sees. write! works like format!, but writes into
the formatter. The message includes the value and the allowed range: gpu count 0 is outside 1..=64.
impl std::error::Error for GpuCountError {} is empty. The Error trait’s methods all have defaults, and it
requires Debug and Display, which the type already has. Implementing it lets this error work with other
error-handling code, such as Box<dyn std::error::Error>, which you will see in section 7.5.
7.4 Converting with From and TryFrom
The standard library has two traits for converting a value of one type into another:
From<A> for Bis for conversions that always succeed. Implementing it gives youB::from(a), and alsoa.into()wherever aBis expected.TryFrom<A> for Bis for conversions that can fail. Itstry_fromreturns aResult. Implementing it gives youB::try_from(a)anda.try_into().
Listing 7.4 The two conversions (lines 56 to 68).
impl TryFrom<u32> for GpuCount {
type Error = GpuCountError;
fn try_from(value: u32) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<GpuCount> for u32 {
fn from(count: GpuCount) -> u32 {
count.0
}
}
Going from u32 to GpuCount can fail, so it is TryFrom. Its body calls new, so the check still lives
in one place. type Error = GpuCountError; names the error type this conversion returns.
Going from GpuCount back to u32 cannot fail, so it is From. The body returns the field, count.0.
TIP: Rust also has the as keyword, as in x as u32. as never reports failure: converting 300 to a
u8 with as quietly gives 44. Prefer From when a conversion cannot fail and TryFrom when it can, and
keep as for cases where you want truncation.
7.5 Commands: an enum that carries data
7.5.1 Why an enum
The tool accepts four commands. list takes no arguments. get and delete each take an ID. create
takes a name and a GPU count. A first design might put all the possible fields in one struct:
struct Command {
verb: String,
id: Option<String>, // only for get and delete
name: Option<String>, // only for create
gpus: Option<u32>, // only for create
}
This struct can hold a get without an ID, or a list with a GPU count. Every function that receives it
has to check which fields make sense for the verb.
An enum whose variants each carry their own data cannot hold those combinations:
Listing 7.5 The command enum (lines 70 to 77).
/// A closed set of commands, each carrying exactly the data it needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
List,
Get { id: String },
Create { name: String, gpus: GpuCount },
Delete { id: String },
}
A Create always has a name and a valid GpuCount, because Rust will not build one without them. A List
has no fields, because it needs none.
In memory, every Command takes the same space, enough for its largest variant, as figure 7.2 shows.
Command is 32 bytes. Rust also records which variant a value is. Here it stores that in bit patterns the String never uses, so no extra byte is needed.7.5.2 The parser’s errors and helper
Before the parser, here is what it returns when a line is wrong. Each way a line can be malformed gets its own error variant, so the caller learns exactly what failed.
Listing 7.6 CommandError and argument (lines 119 to 148).
/// A typed parse failure, so callers can distinguish the cases.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
Empty,
UnknownVerb(String),
MissingArgument(&'static str),
InvalidGpuCount(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(formatter, "empty command"),
Self::UnknownVerb(verb) => write!(formatter, "unknown verb {verb:?}"),
Self::MissingArgument(name) => write!(formatter, "missing argument: {name}"),
Self::InvalidGpuCount(raw) => write!(formatter, "invalid gpu count: {raw:?}"),
}
}
}
impl std::error::Error for CommandError {}
fn argument<'a>(
parts: &mut impl Iterator<Item = &'a str>,
name: &'static str,
) -> Result<&'a str, CommandError> {
parts
.next()
.ok_or(CommandError::MissingArgument(name))
}
CommandError lists every way parsing can fail, and its Display writes a message for each. Because the
errors are enum variants, a caller can match on them and react differently to each. A CLI could print usage
help for MissingArgument and the allowed range for InvalidGpuCount. With a plain String error, the
caller would have to read the message text to tell the cases apart.
argument takes the next word from the iterator, or fails with the argument’s name. Look at its first
parameter, &mut impl Iterator<Item = &'a str>. It borrows the caller’s iterator mutably, so that each call
consumes one more word. The lifetime 'a, which you met in chapter 4, says the returned &str points into
the original line. So no text is copied until parse decides to keep it with to_string().
7.5.3 Parsing a line of text
Parsing turns one line of text into a Command, or into an error that says what was wrong with the line.
Listing 7.7 Command::parse and verb (lines 79 to 117).
impl Command {
/// Parse one command line.
pub fn parse(line: &str) -> Result<Self, CommandError> {
let mut parts = line.split_whitespace();
let verb = parts
.next()
.ok_or(CommandError::Empty)?;
match verb {
"list" => Ok(Self::List),
"get" => Ok(Self::Get {
id: argument(&mut parts, "id")?.to_string(),
}),
"delete" => Ok(Self::Delete {
id: argument(&mut parts, "id")?.to_string(),
}),
"create" => {
let name = argument(&mut parts, "name")?.to_string();
let raw = argument(&mut parts, "gpu count")?;
let value: u32 = raw
.parse()
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string()))?;
let gpus = GpuCount::new(value)
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string()))?;
Ok(Self::Create { name, gpus })
}
other => Err(CommandError::UnknownVerb(other.to_string())),
}
}
pub fn verb(&self) -> &'static str {
match self {
Self::List => "list",
Self::Get { .. } => "get",
Self::Create { .. } => "create",
Self::Delete { .. } => "delete",
}
}
}
parse returns Result<Self, CommandError>: a command, or the reason the line could not be parsed. Trace
Command::parse("create triton 4") step by step:
| Step | Code | Result |
|---|---|---|
| 1 | line.split_whitespace() | an iterator over "create", "triton", "4" |
| 2 | parts.next().ok_or(CommandError::Empty)? | verb is "create" |
| 3 | match verb | runs the "create" arm |
| 4 | argument(&mut parts, "name")? | "triton", turned into a String |
| 5 | argument(&mut parts, "gpu count")? | raw is "4" |
| 6 | raw.parse() | the number 4 |
| 7 | GpuCount::new(value) | GpuCount(4) |
| 8 | Ok(Self::Create { name, gpus }) | the finished command |
Now try some bad inputs. An empty line fails at step 2, because there is no first word. ok_or turns the
None into Err(CommandError::Empty), and ? returns it. The line create triton many fails at step 6,
because "many" is not a number. The line create triton 0 passes step 6 and fails at step 7.
Animation 7.1 runs the good line and the three bad ones through the stages of the table.
? either passes a value to the next stage or returns a typed error. A count of 0 gets past parsing and stops at the type's gate.Steps 6 and 7 can fail for different reasons, but both failures are reported the same way.
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string())) replaces whatever error came back with
InvalidGpuCount, carrying the text the user typed. The user then sees their own input in the message.
verb goes the other way, from a command to its name. The patterns Self::Get { .. } match a variant and
ignore its fields.
7.5.4 Adding up without an index
The file has one more small function:
Listing 7.8 summarize (lines 150 to 157).
/// Count and sum in one pass, without managing an index.
pub fn summarize(values: &[i32]) -> (usize, i64) {
values
.iter()
.fold((0usize, 0i64), |(count, sum), value| {
(count + 1, sum + i64::from(*value))
})
}
fold walks the slice once and carries a value along. Here the value is a pair, (count, sum), starting at
(0, 0). For each element, the closure returns the new pair. i64::from(*value) widens each i32 to i64.
That conversion is a From, so it can never lose information.
7.5.5 The complete file
Listing 7.9 The complete file, with its tests. src/problems/adt_idioms.rs
//! Small algebraic-data-type idioms.
//!
//! These patterns are short and cannot represent invalid states.
//!
//! - A newtype gives a primitive a name and a single validating constructor, so
//! an out-of-range value cannot exist.
//! - An enum with data models a closed set of alternatives, and `match` forces
//! every alternative to be handled.
//! - Typed errors carry the reason instead of a string, so the caller can react.
//! - `TryFrom` puts the validation on the type boundary, where it belongs.
//! - An iterator chain states the intent in one pass and removes index
//! bookkeeping.
use std::fmt;
/// A GPU count that is validated at construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GpuCount(u32);
impl GpuCount {
pub const MIN: u32 = 1;
pub const MAX: u32 = 64;
/// Validate a raw count.
pub fn new(value: u32) -> Result<Self, GpuCountError> {
if (Self::MIN..=Self::MAX).contains(&value) {
Ok(Self(value))
} else {
Err(GpuCountError(value))
}
}
pub fn get(self) -> u32 {
self.0
}
}
/// Why a raw GPU count was rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GpuCountError(pub u32);
impl fmt::Display for GpuCountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"gpu count {} is outside {}..={}",
self.0,
GpuCount::MIN,
GpuCount::MAX
)
}
}
impl std::error::Error for GpuCountError {}
impl TryFrom<u32> for GpuCount {
type Error = GpuCountError;
fn try_from(value: u32) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<GpuCount> for u32 {
fn from(count: GpuCount) -> u32 {
count.0
}
}
/// A closed set of commands, each carrying exactly the data it needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
List,
Get { id: String },
Create { name: String, gpus: GpuCount },
Delete { id: String },
}
impl Command {
/// Parse one command line.
pub fn parse(line: &str) -> Result<Self, CommandError> {
let mut parts = line.split_whitespace();
let verb = parts
.next()
.ok_or(CommandError::Empty)?;
match verb {
"list" => Ok(Self::List),
"get" => Ok(Self::Get {
id: argument(&mut parts, "id")?.to_string(),
}),
"delete" => Ok(Self::Delete {
id: argument(&mut parts, "id")?.to_string(),
}),
"create" => {
let name = argument(&mut parts, "name")?.to_string();
let raw = argument(&mut parts, "gpu count")?;
let value: u32 = raw
.parse()
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string()))?;
let gpus = GpuCount::new(value)
.map_err(|_| CommandError::InvalidGpuCount(raw.to_string()))?;
Ok(Self::Create { name, gpus })
}
other => Err(CommandError::UnknownVerb(other.to_string())),
}
}
pub fn verb(&self) -> &'static str {
match self {
Self::List => "list",
Self::Get { .. } => "get",
Self::Create { .. } => "create",
Self::Delete { .. } => "delete",
}
}
}
/// A typed parse failure, so callers can distinguish the cases.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
Empty,
UnknownVerb(String),
MissingArgument(&'static str),
InvalidGpuCount(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(formatter, "empty command"),
Self::UnknownVerb(verb) => write!(formatter, "unknown verb {verb:?}"),
Self::MissingArgument(name) => write!(formatter, "missing argument: {name}"),
Self::InvalidGpuCount(raw) => write!(formatter, "invalid gpu count: {raw:?}"),
}
}
}
impl std::error::Error for CommandError {}
fn argument<'a>(
parts: &mut impl Iterator<Item = &'a str>,
name: &'static str,
) -> Result<&'a str, CommandError> {
parts
.next()
.ok_or(CommandError::MissingArgument(name))
}
/// Count and sum in one pass, without managing an index.
pub fn summarize(values: &[i32]) -> (usize, i64) {
values
.iter()
.fold((0usize, 0i64), |(count, sum), value| {
(count + 1, sum + i64::from(*value))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_each_command_variant() {
assert_eq!(Command::parse("list"), Ok(Command::List));
assert_eq!(
Command::parse("get wl-7"),
Ok(Command::Get { id: "wl-7".into() })
);
assert_eq!(
Command::parse("create triton 4"),
Ok(Command::Create {
name: "triton".into(),
gpus: GpuCount::new(4).expect("valid")
})
);
assert_eq!(
Command::parse("delete wl-7"),
Ok(Command::Delete { id: "wl-7".into() })
);
}
#[test]
fn rejects_bad_input_with_typed_errors() {
assert_eq!(Command::parse(""), Err(CommandError::Empty));
assert_eq!(
Command::parse("frobnicate"),
Err(CommandError::UnknownVerb("frobnicate".into()))
);
assert_eq!(
Command::parse("get"),
Err(CommandError::MissingArgument("id"))
);
assert_eq!(
Command::parse("create triton 0"),
Err(CommandError::InvalidGpuCount("0".into()))
);
assert_eq!(
Command::parse("create triton many"),
Err(CommandError::InvalidGpuCount("many".into()))
);
}
#[test]
fn newtype_rejects_out_of_range_values() {
assert!(GpuCount::new(1).is_ok());
assert!(GpuCount::new(64).is_ok());
assert!(GpuCount::new(0).is_err());
assert!(GpuCount::new(65).is_err());
assert_eq!(u32::from(GpuCount::try_from(8).expect("valid")), 8);
assert!(GpuCount::try_from(0).is_err());
}
#[test]
fn summarize_reports_count_and_sum() {
assert_eq!(summarize(&[]), (0, 0));
assert_eq!(summarize(&[1, 2, 3]), (3, 6));
assert_eq!(summarize(&[-5, 5]), (2, 0));
}
#[test]
fn errors_render_a_reason() {
assert_eq!(
Command::parse("create triton 0")
.unwrap_err()
.to_string(),
"invalid gpu count: \"0\""
);
assert!(
GpuCount::new(99)
.unwrap_err()
.to_string()
.contains("outside")
);
}
}
Read the tests as a description of the behavior. parses_each_command_variant shows one valid line per
variant. rejects_bad_input_with_typed_errors shows each failure, and compares the errors with assert_eq!.
That comparison works because CommandError derives PartialEq. newtype_rejects_out_of_range_values
tests both edges of the range, 1 and 64, and one step past each.
The tests run in parallel, so their order in the output can change from run to run:
$ cargo test --lib adt_idioms
running 5 tests
test problems::adt_idioms::tests::newtype_rejects_out_of_range_values ... ok
test problems::adt_idioms::tests::summarize_reports_count_and_sum ... ok
test problems::adt_idioms::tests::rejects_bad_input_with_typed_errors ... ok
test problems::adt_idioms::tests::parses_each_command_variant ... ok
test problems::adt_idioms::tests::errors_render_a_reason ... ok
test result: ok. 5 passed; 0 failed
The command enum describes what a user asks for. The next section uses an enum to describe something that changes over time.
7.6 The states of a workload
A workload is accepted (Pending), gets machines assigned (Provisioning), runs (Running), is shut down
(Terminating), and ends (Terminated). Any of the active states can fail, and a failed workload can be
tried again. A description like this, a set of states and the allowed moves between them, is called a
state machine. Figure 7.3 draws it.
Listing 7.10 The states (lines 1 to 18). src/problems/state_machine.rs
//! Algebraic data types / enum state machines.
//!
//! Cloud SDKs model workloads as finite state machines. Rust `enum` is
//! perfect for this: invalid states are unrepresentable and transitions are
//! explicit `match` arms, not nested `if` chains.
use std::time::Duration;
/// Lifecycle of a GPU cloud workload exposed through an SDK/API.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkloadState {
Pending,
Provisioning,
Running,
Terminating,
Terminated,
Failed,
}
Each state is a variant with no data. Such an enum is stored as a single byte, and it derives Copy, so you
pass states around by value.
Listing 7.11 The allowed moves (lines 20 to 56).
impl WorkloadState {
/// Returns an error string for impossible transitions, or `Ok(())`.
pub fn can_transition_to(self, next: Self) -> Result<(), String> {
match (self, next) {
// Forward lifecycle.
(Self::Pending, Self::Provisioning)
| (Self::Pending, Self::Failed)
| (Self::Provisioning, Self::Running)
| (Self::Provisioning, Self::Failed)
| (Self::Running, Self::Terminating)
| (Self::Running, Self::Failed)
| (Self::Terminating, Self::Terminated)
| (Self::Terminating, Self::Failed) => Ok(()),
// Retry loops allowed from failed states.
(Self::Failed, Self::Pending) | (Self::Failed, Self::Provisioning) => Ok(()),
// Everything else is invalid.
_ => Err(format!("cannot transition from {self:?} to {next:?}")),
}
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Terminated | Self::Failed)
}
pub fn summary(self) -> &'static str {
match self {
Self::Pending => "workload accepted, waiting for scheduler",
Self::Provisioning => "GPU resources being allocated",
Self::Running => "workload is running",
Self::Terminating => "cleanup in progress",
Self::Terminated => "workload stopped and billing ended",
Self::Failed => "workload failed; inspect logs",
}
}
}
can_transition_to checks one move, from self to next. It matches on the pair (self, next), so each
pattern is one arrow in figure 7.3. Patterns joined with | share one arm: this is an or-pattern. The
first arm lists the forward moves and the failure moves. The second lists the retries. The last arm, _,
matches every other pair and returns an error.
That last arm decides what happens to moves nobody listed. With it, any move not written down is rejected. If someone adds a new state later, every move into or out of it is rejected until they list it. That is the safe default for a state machine.
is_terminal uses the matches! macro, which returns true when a value matches a pattern. Failed counts
as terminal even though it can retry, because it holds no machines. summary returns a fixed message for
each state. Its return type &'static str means the text lives for the whole program, because it is written
into the program itself.
7.7 Errors from a server
The same file models the errors a server can send back. Here, different errors carry different information.
Listing 7.12 ApiError and its methods (lines 58 to 91).
/// API error modeled as an enum with associated data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiError {
InvalidRequest(String),
Unauthorized,
RateLimited { retry_after: Duration },
NotFound { resource_id: String },
Server { status: u16, message: String },
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimited { .. }
| Self::Server {
status: 500..=599,
..
}
)
}
pub fn user_message(&self) -> String {
match self {
Self::InvalidRequest(msg) => format!("invalid request: {msg}"),
Self::Unauthorized => "authentication required or token expired".to_string(),
Self::RateLimited { retry_after } => {
format!("rate limited, retry in {}ms", retry_after.as_millis())
}
Self::NotFound { resource_id } => format!("resource not found: {resource_id}"),
Self::Server { status, message } => format!("server error {status}: {message}"),
}
}
}
A rate-limit error says how long to wait before trying again. A not-found error says which resource. A server error carries the HTTP status code and a message.
is_retryable answers a question every client asks after a failure: could trying again help? A rate limit
will pass, and a server error in the 500s may be temporary, so both are retryable. A missing resource will
still be missing. The whole rule is one pattern:
Self::RateLimited { .. } | Self::Server { status: 500..=599, .. }
status: 500..=599 inside the struct pattern matches any status from 500 to 599. .. ignores the other
fields. Chapter 19 builds a retry loop around this method.
user_message turns each error into text for a person. retry_after.as_millis() converts the waiting time
to milliseconds for the message.
Listing 7.13 A small parser (lines 93 to 101).
/// Typical SDK helper: never expose nested `if let` soup to callers.
pub fn parse_gpu_count(raw: &str) -> Result<u32, ApiError> {
match raw.trim() {
"" => Err(ApiError::InvalidRequest("gpuCount is empty".into())),
parsed => parsed.parse::<u32>().map_err(|_| {
ApiError::InvalidRequest(format!("gpuCount must be a number, got {raw:?}"))
}),
}
}
parse_gpu_count is a shorter version of the gate from section 7.2. It trims the text, handles the empty
case with its own message, and converts the standard library’s parse error into ApiError::InvalidRequest.
{raw:?} prints the text with quotes, so an empty or odd input is visible in the message.
Listing 7.14 The complete file, with its tests. src/problems/state_machine.rs
//! Algebraic data types / enum state machines.
//!
//! Cloud SDKs model workloads as finite state machines. Rust `enum` is
//! perfect for this: invalid states are unrepresentable and transitions are
//! explicit `match` arms, not nested `if` chains.
use std::time::Duration;
/// Lifecycle of a GPU cloud workload exposed through an SDK/API.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkloadState {
Pending,
Provisioning,
Running,
Terminating,
Terminated,
Failed,
}
impl WorkloadState {
/// Returns an error string for impossible transitions, or `Ok(())`.
pub fn can_transition_to(self, next: Self) -> Result<(), String> {
match (self, next) {
// Forward lifecycle.
(Self::Pending, Self::Provisioning)
| (Self::Pending, Self::Failed)
| (Self::Provisioning, Self::Running)
| (Self::Provisioning, Self::Failed)
| (Self::Running, Self::Terminating)
| (Self::Running, Self::Failed)
| (Self::Terminating, Self::Terminated)
| (Self::Terminating, Self::Failed) => Ok(()),
// Retry loops allowed from failed states.
(Self::Failed, Self::Pending) | (Self::Failed, Self::Provisioning) => Ok(()),
// Everything else is invalid.
_ => Err(format!("cannot transition from {self:?} to {next:?}")),
}
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Terminated | Self::Failed)
}
pub fn summary(self) -> &'static str {
match self {
Self::Pending => "workload accepted, waiting for scheduler",
Self::Provisioning => "GPU resources being allocated",
Self::Running => "workload is running",
Self::Terminating => "cleanup in progress",
Self::Terminated => "workload stopped and billing ended",
Self::Failed => "workload failed; inspect logs",
}
}
}
/// API error modeled as an enum with associated data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiError {
InvalidRequest(String),
Unauthorized,
RateLimited { retry_after: Duration },
NotFound { resource_id: String },
Server { status: u16, message: String },
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimited { .. }
| Self::Server {
status: 500..=599,
..
}
)
}
pub fn user_message(&self) -> String {
match self {
Self::InvalidRequest(msg) => format!("invalid request: {msg}"),
Self::Unauthorized => "authentication required or token expired".to_string(),
Self::RateLimited { retry_after } => {
format!("rate limited, retry in {}ms", retry_after.as_millis())
}
Self::NotFound { resource_id } => format!("resource not found: {resource_id}"),
Self::Server { status, message } => format!("server error {status}: {message}"),
}
}
}
/// Typical SDK helper: never expose nested `if let` soup to callers.
pub fn parse_gpu_count(raw: &str) -> Result<u32, ApiError> {
match raw.trim() {
"" => Err(ApiError::InvalidRequest("gpuCount is empty".into())),
parsed => parsed.parse::<u32>().map_err(|_| {
ApiError::InvalidRequest(format!("gpuCount must be a number, got {raw:?}"))
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_machine_rejects_invalid_transitions() {
assert!(
WorkloadState::Pending
.can_transition_to(WorkloadState::Provisioning)
.is_ok()
);
assert!(
WorkloadState::Running
.can_transition_to(WorkloadState::Running)
.is_err()
);
assert!(
WorkloadState::Terminated
.can_transition_to(WorkloadState::Running)
.is_err()
);
assert!(!WorkloadState::Terminating.is_terminal());
assert!(WorkloadState::Terminated.is_terminal());
}
#[test]
fn api_error_retry_policy() {
let rate_limited = ApiError::RateLimited {
retry_after: Duration::from_millis(100),
};
let not_found = ApiError::NotFound {
resource_id: "gpu-123".into(),
};
let server = ApiError::Server {
status: 503,
message: "maintenance".into(),
};
assert!(rate_limited.is_retryable());
assert!(!not_found.is_retryable());
assert!(server.is_retryable());
}
#[test]
fn parsing_returns_typed_errors() {
assert_eq!(parse_gpu_count("4"), Ok(4));
assert!(parse_gpu_count("").is_err());
assert!(parse_gpu_count("abc").is_err());
}
}
The tests check an allowed move and two forbidden ones, the retry rule for three errors, and the parser.
Summary
- A trait is a set of methods a type promises. You implement one with
impl Trait for Type, and#[derive]generates the standard ones. - A newtype with a private field and a checking constructor can only hold valid values, so the code that receives it needs no checks.
- An error type with
Displayandstd::error::Errortells the caller what went wrong, and works with?. Fromconverts when it cannot fail.TryFromreturns aResultwhen it can.- An enum whose variants carry their own data replaces a struct full of
Optionfields, and is as large as its largest variant. - A parser turns text into a typed value, and
map_errturns low-level failures into your own errors. - Matching on a pair of states describes a state machine, and a final
_arm rejects every move not listed. - Error enums can carry data and methods such as
is_retryable.
Every value in this chapter had exactly one owner. Chapter 8 covers the pointer types for data that needs several owners, or that must be shared between threads.
Exercises
- Write a
Portnewtype for network ports that rejects 0, with aTryFrom<u16>implementation and its own error type. - Implement the
FromStrtrait forCommand, so that"list".parse::<Command>()works. Its method can callCommand::parse. - Add a
Suspendedstate toWorkloadStatethat can be entered fromRunningand left back toRunning, and add tests for both moves. - Give
ApiErroraDisplayimplementation that usesuser_message, then implementstd::error::Errorfor it.
Ownership and the pointer types
This chapter covers
- Ownership and moves, drawn in memory
Box: putting a value on the heap, and why recursive types need itRc: one value with several owners, countedRefCell: changing a value that has several owners, checked while the program runsWeak: a pointer that does not keep its value alive, and the leak it preventsArcandMutex: sharing and changing a value across threadsCow: borrowing when possible and copying only when needed
In chapter 7 every value had exactly one owner. That is the normal case in Rust, and most code needs nothing more. But some structures cannot be described with one owner per value. A tree node wants to point back to its parent. A counter must be updated by several threads. An expression contains smaller expressions of its own type.
For each of these, the standard library has a pointer type. A pointer type holds the address of a value and adds a rule about who owns it. This chapter covers the main ones, one at a time. For each, I show where its data sits in memory and which problem it solves. The next four chapters, on linked lists, trees, and graphs, use every one of them.
I start by reviewing ownership itself, because each pointer type is a change to its rules.
8.1 Ownership and moves
Every value in Rust has one owner, the variable or structure that holds it. When the owner goes away, at the end of its scope, the value is dropped and its memory is freed.
Assigning a value to another variable moves it: ownership passes to the new variable, and the old one can
no longer be used. Figure 8.1 shows a move of a String.
String. The small header is copied; the heap text is not. Only one variable may free the text, so the old one becomes unusable.A move copies only the header on the stack, the pointer, length, and capacity. The text on the heap stays
where it is. If both a and b were usable, both would try to free the same text when they went out of
scope. The compiler prevents that by making a unusable after the move.
Instead of moving a value, you can borrow it with a reference. A shared reference &T lets you read. A
mutable reference &mut T lets you change. The borrow rules say you may have either any number of &T or exactly one &mut T, but not both at once.
And no reference may outlive its value.
These rules cover most programs. The pointer types in this chapter handle the cases they cannot.
The module for this chapter starts with a table that summarizes them:
Listing 8.1 The module comment and imports (lines 1 to 27). src/problems/smart_pointers.rs
//! Smart pointers and interior mutability.
//!
//! Each pointer type answers a different ownership question, and picking the
//! wrong one shows up as either a compile error or a runtime bug.
//!
//! | Type | Ownership | Mutation | Across threads |
//! |---|---|---|---|
//! | `Box<T>` | single | through `&mut` | if `T: Send` |
//! | `Rc<T>` | shared, non-atomic count | no | no |
//! | `Arc<T>` | shared, atomic count | no | if `T: Send + Sync` |
//! | `RefCell<T>` | single | runtime checked | no |
//! | `Mutex<T>` | single | blocking lock | yes |
//! | `Weak<T>` | non-owning | no | as `Arc` |
//! | `Cow<'a, T>` | borrow or own | no | as the borrow |
//!
//! Two rules cover most decisions. `Rc`/`RefCell` is for graphs and caches inside
//! one thread; `Arc`/`Mutex` is for state shared between threads. `Weak` exists to
//! break reference cycles, because two strong references in a cycle never reach a
//! count of zero and the memory is never freed.
use std::{
borrow::Cow,
cell::RefCell,
rc::{Rc, Weak},
sync::{Arc, Mutex},
thread,
};
Do not try to memorize the table yet. Each section below covers one row.
8.2 Box: one owner, on the heap
A Box<T> puts a value of type T on the heap and owns it. The Box itself is one pointer, 8 bytes on a
64-bit machine. When the Box is dropped, it drops the value and frees the heap memory. So a Box behaves
like an owned value that happens to live on the heap.
You need a Box most often for a type that contains itself. Consider an arithmetic expression: it is either
a number, or the sum of two smaller expressions. Written directly, that type would have no size (figure 8.2).
Add that contains two Expr values directly would need infinite space. Right: with Box, an Add holds two pointers.To lay out a type, the compiler must know its size. An Add(Expr, Expr) contains two Expr values, each of
which may be another Add. The size has no end, so the compiler rejects the type. Box<Expr> is always 8
bytes, whatever it points to, so Add(Box<Expr>, Box<Expr>) has a fixed size.
Listing 8.2 An expression tree (lines 29 to 43).
/// A recursive type needs indirection, because `Expr` cannot contain itself by
/// value. `Box` provides the fixed-size indirection.
#[derive(Debug, PartialEq, Eq)]
pub enum Expr {
Lit(i64),
Add(Box<Expr>, Box<Expr>),
}
/// Evaluate an expression tree.
pub fn eval(expr: &Expr) -> i64 {
match expr {
Expr::Lit(value) => *value,
Expr::Add(left, right) => eval(left) + eval(right),
}
}
eval computes the value of an expression. For a number, the value is the number. For a sum, it evaluates
both sides and adds them. The call eval(left) passes a &Box<Expr> where the function expects &Expr.
Rust converts it automatically, following the box to the value inside. This automatic conversion is called
deref coercion.
To build 1 + 2, you write Expr::Add(Box::new(Expr::Lit(1)), Box::new(Expr::Lit(2))). Box::new
allocates on the heap and moves the value there.
8.3 Rc: several owners, counted
Sometimes one value really has several owners, and none of them can be picked as the one that frees it. A
node in a graph with several incoming edges is an example. For this, Rust has Rc<T>, short for reference
counted.
Rc::new(value) puts the value on the heap together with a count of how many Rc handles point to it.
Rc::clone(&rc) does not copy the value. It adds 1 to the count and returns another handle to the same value.
Dropping a handle subtracts 1. When the count reaches zero, the value is dropped. Figure 8.3 shows two handles.
Rc handles share one heap block. The block holds a strong count (the number of Rc handles), a weak count (section 8.5), and the value.An Rc hands out only shared references to its value. With several owners, a &mut through one of them
would break the rule that a &mut is the only reference. So by itself, a value inside an Rc cannot be
changed. The next section adds that ability.
NOTE: Rc updates its count with ordinary, non-atomic arithmetic. If two threads updated it at the same
moment, the count could end up wrong. So the compiler does not let an Rc be sent to another thread.
Section 8.6 covers Arc, the version for threads.
8.4 RefCell: changing a shared value, checked at run time
RefCell<T> holds a value and allows it to be changed through a shared reference. It keeps the borrow rules,
but checks them while the program runs instead of at compile time. Figure 8.4 shows what it tracks.
RefCell. Breaking the rules is not a compile error; it panics when it happens.cell.borrow()returns a guard that acts like&T. Many can exist at once.cell.borrow_mut()returns a guard that acts like&mut T. It panics if any other borrow is active.- When a guard is dropped, its borrow ends.
Rc and RefCell are used together: Rc<RefCell<T>> gives several owners who can each change the value.
Listing 8.3 Two handles to one counter (lines 45 to 55).
/// Shared mutation inside one thread, with `Rc` for aliasing and `RefCell` for
/// the runtime borrow check.
pub fn shared_counter_with_rc_refcell() -> i32 {
let counter = Rc::new(RefCell::new(0));
let alias = Rc::clone(&counter);
*alias.borrow_mut() += 1;
*counter.borrow_mut() += 10;
*counter.borrow()
}
Step through it:
Rc::new(RefCell::new(0))creates a counter holding 0, with a strong count of 1.Rc::clone(&counter)makes a second handle,alias. The strong count is now 2. There is still one counter.*alias.borrow_mut() += 1;borrows mutably throughaliasand adds 1. The guard is dropped at the end of the statement, so the borrow ends there.*counter.borrow_mut() += 10;does the same through the other handle.*counter.borrow()reads 11.
Animation 8.1 runs these steps, with the strong count and the RefCell flag drawn on the heap block.
borrow_mut panic at run time.Each borrow_mut ends before the next starts, so no panic occurs. If you stored the first guard in a variable
and then called borrow_mut again while it was still alive, the program would panic.
8.5 Weak: a pointer that does not own
Counting has a weakness. If two values hold Rc handles to each other, each keeps the other’s count above
zero. When you drop your own handles, both counts stay at 1, and neither value is ever freed. This is a
reference cycle, and it leaks memory (figure 8.5, left).
Weak breaks the cycle.The fix is a pointer that does not count as an owner. Weak<T> is made from an Rc with Rc::downgrade.
It increases only the weak count, which does not keep the value alive. To use the value, you call
upgrade(), which returns Some(Rc) if the value still exists and None if it has been dropped.
The usual pattern is that parents own children with Rc, and children point back to their parents with
Weak.
Listing 8.4 A tree node with a weak parent pointer (lines 57 to 87).
/// A node whose parent link is weak, so a child does not keep its parent alive.
pub struct TreeNode {
pub value: i32,
parent: RefCell<Weak<TreeNode>>,
}
impl TreeNode {
/// A node with no parent.
pub fn root(value: i32) -> Rc<Self> {
Rc::new(Self {
value,
parent: RefCell::new(Weak::new()),
})
}
/// A node that points back weakly at `parent`.
pub fn child_of(parent: &Rc<Self>, value: i32) -> Rc<Self> {
Rc::new(Self {
value,
parent: RefCell::new(Rc::downgrade(parent)),
})
}
/// The parent's value, or `None` once the parent has been dropped.
pub fn parent_value(&self) -> Option<i32> {
self.parent
.borrow()
.upgrade()
.map(|parent| parent.value)
}
}
parent: RefCell<Weak<TreeNode>>stores the link to the parent. TheRefCellallows the link to be changed after the node exists.rootcreates a node withWeak::new(), an empty weak pointer whoseupgradealways returnsNone.child_ofcreates a node whose parent link isRc::downgrade(parent).parent_valueborrows the link, callsupgrade(), and reads the parent’s value if the parent still exists.Option::mapapplies the closure only when there is a value.
The test weak_parent_link_does_not_keep_the_parent_alive in listing 8.7 checks the behavior.
Animation 8.2 follows that test, then shows the cycle that Weak prevents.
Weak parent link lets the parent be freed. With Rc in both directions, each node keeps the other alive forever.The test creates a
root and a child, drops the root, and then checks that the child’s parent_value() is None.
8.6 Arc and Mutex: sharing across threads
Arc<T> is Rc<T> with an atomic count. An atomic operation is one the processor completes as a single step. That stays true even when several
cores run it on the same memory at the same moment. So Arc handles can
be sent to other threads. Chapter 16 explains atomic operations in detail.
Mutex<T> is the thread-safe counterpart of RefCell. lock() returns a guard that gives access to the
value. If another thread holds the lock, lock() waits until it is released, instead of panicking.
Listing 8.5 Several threads adding to one total (lines 89 to 110).
/// Shared mutation across threads, with `Arc` for ownership and `Mutex` for
/// exclusive access.
pub fn total_with_arc_mutex(threads: usize, per_thread: usize) -> usize {
let total = Arc::new(Mutex::new(0usize));
let mut handles = Vec::with_capacity(threads);
for _ in 0..threads {
let total = Arc::clone(&total);
handles.push(thread::spawn(move || {
for _ in 0..per_thread {
let mut guard = total.lock().expect("mutex poisoned");
*guard += 1;
}
}));
}
for handle in handles {
handle.join().expect("worker panicked");
}
*total.lock().expect("mutex poisoned")
}
Step through it:
Arc::new(Mutex::new(0usize))creates the shared total.- For each thread,
Arc::clone(&total)makes a new handle, andthread::spawn(move || ...)moves that handle into the new thread.movemeans the closure takes ownership of what it uses. - Each thread locks the mutex, adds 1, and drops the guard,
per_threadtimes. handle.join()waits for each thread to finish.- The last line locks once more and reads the result.
lock() returns a Result, because a thread that panics while holding the lock leaves the mutex marked as
poisoned. .expect("mutex poisoned") stops the program with that message in that case. Chapter 16 covers
poisoning.
This function locks once for every single increment, so the threads spend most of their time waiting for each other. It is correct but slow. Chapter 16 measures faster ways to count across threads.
8.7 Cow: borrow when you can
Cow<'a, str> holds either a borrowed &'a str or an owned String. Its name stands for “clone on write”.
It fits functions that usually return their input unchanged but sometimes need to change it.
Listing 8.6 Uppercase only when needed (lines 112 to 126).
/// Borrow when no change is needed, allocate only when there is one.
///
/// `Cow` is the idiomatic return type for a function that usually passes its
/// input through unchanged.
pub fn normalize(input: &str, uppercase: bool) -> Cow<'_, str> {
if uppercase
&& input
.bytes()
.any(|byte| byte.is_ascii_lowercase())
{
Cow::Owned(input.to_uppercase())
} else {
Cow::Borrowed(input)
}
}
normalize checks whether any byte is a lowercase letter. If there is nothing to change, it returns
Cow::Borrowed(input), which costs nothing. Only when a change is needed does it allocate a new uppercase
String and return Cow::Owned. The caller can use either variant as a &str.
8.8 The complete file
Listing 8.7 The complete file, with its tests. src/problems/smart_pointers.rs
//! Smart pointers and interior mutability.
//!
//! Each pointer type answers a different ownership question, and picking the
//! wrong one shows up as either a compile error or a runtime bug.
//!
//! | Type | Ownership | Mutation | Across threads |
//! |---|---|---|---|
//! | `Box<T>` | single | through `&mut` | if `T: Send` |
//! | `Rc<T>` | shared, non-atomic count | no | no |
//! | `Arc<T>` | shared, atomic count | no | if `T: Send + Sync` |
//! | `RefCell<T>` | single | runtime checked | no |
//! | `Mutex<T>` | single | blocking lock | yes |
//! | `Weak<T>` | non-owning | no | as `Arc` |
//! | `Cow<'a, T>` | borrow or own | no | as the borrow |
//!
//! Two rules cover most decisions. `Rc`/`RefCell` is for graphs and caches inside
//! one thread; `Arc`/`Mutex` is for state shared between threads. `Weak` exists to
//! break reference cycles, because two strong references in a cycle never reach a
//! count of zero and the memory is never freed.
use std::{
borrow::Cow,
cell::RefCell,
rc::{Rc, Weak},
sync::{Arc, Mutex},
thread,
};
/// A recursive type needs indirection, because `Expr` cannot contain itself by
/// value. `Box` provides the fixed-size indirection.
#[derive(Debug, PartialEq, Eq)]
pub enum Expr {
Lit(i64),
Add(Box<Expr>, Box<Expr>),
}
/// Evaluate an expression tree.
pub fn eval(expr: &Expr) -> i64 {
match expr {
Expr::Lit(value) => *value,
Expr::Add(left, right) => eval(left) + eval(right),
}
}
/// Shared mutation inside one thread, with `Rc` for aliasing and `RefCell` for
/// the runtime borrow check.
pub fn shared_counter_with_rc_refcell() -> i32 {
let counter = Rc::new(RefCell::new(0));
let alias = Rc::clone(&counter);
*alias.borrow_mut() += 1;
*counter.borrow_mut() += 10;
*counter.borrow()
}
/// A node whose parent link is weak, so a child does not keep its parent alive.
pub struct TreeNode {
pub value: i32,
parent: RefCell<Weak<TreeNode>>,
}
impl TreeNode {
/// A node with no parent.
pub fn root(value: i32) -> Rc<Self> {
Rc::new(Self {
value,
parent: RefCell::new(Weak::new()),
})
}
/// A node that points back weakly at `parent`.
pub fn child_of(parent: &Rc<Self>, value: i32) -> Rc<Self> {
Rc::new(Self {
value,
parent: RefCell::new(Rc::downgrade(parent)),
})
}
/// The parent's value, or `None` once the parent has been dropped.
pub fn parent_value(&self) -> Option<i32> {
self.parent
.borrow()
.upgrade()
.map(|parent| parent.value)
}
}
/// Shared mutation across threads, with `Arc` for ownership and `Mutex` for
/// exclusive access.
pub fn total_with_arc_mutex(threads: usize, per_thread: usize) -> usize {
let total = Arc::new(Mutex::new(0usize));
let mut handles = Vec::with_capacity(threads);
for _ in 0..threads {
let total = Arc::clone(&total);
handles.push(thread::spawn(move || {
for _ in 0..per_thread {
let mut guard = total.lock().expect("mutex poisoned");
*guard += 1;
}
}));
}
for handle in handles {
handle.join().expect("worker panicked");
}
*total.lock().expect("mutex poisoned")
}
/// Borrow when no change is needed, allocate only when there is one.
///
/// `Cow` is the idiomatic return type for a function that usually passes its
/// input through unchanged.
pub fn normalize(input: &str, uppercase: bool) -> Cow<'_, str> {
if uppercase
&& input
.bytes()
.any(|byte| byte.is_ascii_lowercase())
{
Cow::Owned(input.to_uppercase())
} else {
Cow::Borrowed(input)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn boxed_expression_evaluates() {
let expr = Expr::Add(Box::new(Expr::Lit(1)), Box::new(Expr::Lit(2)));
assert_eq!(eval(&expr), 3);
}
#[test]
fn rc_refcell_shares_mutation_within_a_thread() {
assert_eq!(shared_counter_with_rc_refcell(), 11);
}
#[test]
fn weak_parent_link_does_not_keep_the_parent_alive() {
let root = TreeNode::root(1);
let child = TreeNode::child_of(&root, 2);
assert_eq!(child.parent_value(), Some(1));
drop(root);
assert_eq!(child.parent_value(), None);
}
#[test]
fn arc_mutex_sums_concurrent_updates() {
assert_eq!(total_with_arc_mutex(8, 250), 2_000);
}
#[test]
fn cow_borrows_and_owns_as_needed() {
assert!(matches!(normalize("A100", true), Cow::Borrowed(_)));
assert!(matches!(normalize("a100", true), Cow::Owned(_)));
assert_eq!(normalize("a100", true), "A100");
assert_eq!(normalize("a100", false), "a100");
}
}
The tests check one behavior per pointer type. cow_borrows_and_owns_as_needed uses matches! to check
which variant came back, not only the text. That is how a test shows that the unchanged case did not
allocate. Run them with cargo test --lib smart_pointers.
Figure 8.6 turns the chapter into three questions you can ask when choosing a pointer type.
Summary
- Every value has one owner. Assignment moves ownership, copying only the stack header, and borrowing lends access without moving.
Box<T>owns a value on the heap. It gives recursive types a fixed size.Rc<T>lets several owners share a value, counted, within one thread. Cloning anRcadds to the count.RefCell<T>allows changes through shared references and checks the borrow rules at run time, panicking on a violation.- Two
Rchandles pointing at each other leak.Weak<T>does not count as an owner and breaks such cycles. Arc<T>isRcwith an atomic count, for threads, andMutex<T>makes other threads wait instead of panicking.Cowreturns borrowed data unchanged and allocates only when it must change it.
Chapter 9 puts these types to work. You will build linked lists with Box and see how dropping a very long one can crash. Then you will build
a doubly linked list with Rc, RefCell, and Weak.
Exercises
- Build the expression
(1 + 2) + 3withExprand check thatevalreturns 6. - In
shared_counter_with_rc_refcell, keep the firstborrow_mutguard in a variable and callborrow_mutagain. Run it and read the panic message. - Build a parent with two children that point back with
Weak. PrintRc::strong_countandRc::weak_countfor the parent before and after creating the children. - Change
total_with_arc_mutexso each thread counts in a local variable and adds its total to the mutex once. Compare the running time for 8 threads and one million increments each.
Linked lists
This chapter covers
- What a linked list is, and how it differs from a
Vecin memory - Moving ownership of nodes with
Option::takeandstd::mem::replace - Reversing a list in place
- Four ways to write a list node, measured against each other
- Why dropping a long list can crash the program, and how to prevent it
- A doubly linked list built from
Rc,RefCell, andWeak
Chapter 8 introduced the pointer types. This chapter uses them to build the classic linked structure, the linked list. Linked lists are a good first test of ownership. Each operation moves a node from one owner to another, and the compiler checks every move.
The chapter goes from simple to hard. First a singly linked list, where each node points only to the next, and an algorithm that reverses it. Then four ways to write the same list, and a benchmark that compares them. The benchmark finds a size at which the list crashes the program when it is dropped. After explaining and fixing that, the chapter ends with a doubly linked list, where each node also points back to the one before.
9.1 What a linked list is
A linked list stores a sequence of values in separate nodes. Each node holds one value and a pointer to the next node. The list itself holds a pointer to the first node, called the head. The last node’s pointer is empty, which marks the end.
Figure 9.1 compares a linked list with a Vec holding the same three values.
Vec stores its values next to each other. A linked list stores each value in its own heap block and connects the blocks with pointers.The two structures are good at different things:
| Operation | Vec | Singly linked list |
|---|---|---|
| Read the value at position i | O(1): compute the address | O(i): follow i pointers |
| Add or remove at the front | O(n): shift every element | O(1): change the head pointer |
| Add at the end | amortized O(1) | O(n), unless the list keeps a tail pointer |
| Memory per value | the value | the value, a pointer, and a heap block’s overhead |
A linked list works like a treasure hunt. Each clue says where the next clue is hidden. To read the fifth
clue, you must find the first four. To add a clue at the start, you write one new clue that names the old
first one, and nothing else moves. A Vec works like a numbered row of lockers. Locker 5 is found in one step.
A new locker at the front means renumbering the whole row.
In practice, a Vec is faster for most work. Its values are next to each other in memory, and the
processor reads nearby memory quickly; chapter 15 measures that effect. The techniques you learn here for
moving nodes between owners carry over to the trees and graphs of chapters 11 and 12.
In Rust, a node owns the next node through a Box, and the “empty” pointer at the end is None. So a node’s
next field has type Option<Box<Node>>.
9.2 Moving nodes: take and replace
Every list operation moves a node from one owner to another. Rust has a rule that makes this tricky. You cannot move a value out of a place that you only borrowed. That would leave the place empty, and the owner would later find garbage there.
Two standard functions solve this by putting something valid in the place as they take the value out:
option.take()returns the value inside anOptionand leavesNonein its place.std::mem::replace(&mut place, new_value)returns the old value and putsnew_valuein its place.
Figure 9.2 shows take on a node’s next field.
node.next.take() moves the rest of the list out of the node into a variable, and leaves None behind.You will see these two calls in almost every listing of this chapter.
9.3 Reversing a list
The problem. Given the list 1 → 2 → 3 → 4, turn it into 4 → 3 → 2 → 1. Change the pointers; do not copy the values.
The idea. Walk the list from the front. Keep two variables: previous, the part already reversed, and
current, the part not yet visited. At each step, take the first node of current, point it at previous,
and make it the new front of previous. Figure 9.3 shows the state after two steps.
previous owns the reversed part (2 → 1), and current owns the rest (3 → 4).reverse_list one node at a time: take the rest, point the node back, move previous forward. No node is copied. The last part flips the link before taking the rest, and the rest of the list is dropped.First, the node type and two helpers for building and reading lists:
Listing 9.1 The node and two helpers (lines 1 to 33). src/problems/linked_list.rs
//! Reverse a singly linked list, and remove nodes from it.
//!
//! Uses `Option<Box<ListNode>>` and a `while let` loop to move pointers in a
//! borrow-safe way. Removal walks a cursor: a mutable borrow of one link.
#[derive(Debug, PartialEq, Eq)]
pub struct ListNode {
pub val: i32,
pub next: Option<Box<ListNode>>,
}
impl ListNode {
pub fn from_slice(values: &[i32]) -> Option<Box<ListNode>> {
let mut head = None;
for &value in values.iter().rev() {
head = Some(Box::new(ListNode {
val: value,
next: head,
}));
}
head
}
pub fn to_vec(head: Option<Box<ListNode>>) -> Vec<i32> {
let mut result = Vec::new();
let mut current = head;
while let Some(node) = current {
result.push(node.val);
current = node.next;
}
result
}
}
from_slice builds a list from a slice. It walks the slice backward, putting each value in front of the list
built so far. The last value pushed is values[0], so it ends up first. Building from the back means it never
needs a pointer to the end of the list.
to_vec reads a list into a Vec. It takes the list by value and consumes it. In while let Some(node) = current, each pass moves one box out of current. current = node.next then moves the rest of the list out
of that node before the node is freed.
Animation 9.2 builds [1, 2, 3] with from_slice, reads it back with to_vec, and then shows what the
loop does without .rev().
values[0] first. Without .rev(), pushing at the front reverses the order.Now the reversal:
Listing 9.2 Reversing the list (lines 35 to 46).
pub fn reverse_list(head: Option<Box<ListNode>>) -> Option<Box<ListNode>> {
let mut previous = None;
let mut current = head;
while let Some(mut node) = current {
current = node.next.take();
node.next = previous;
previous = Some(node);
}
previous
}
Each pass through the loop does three moves:
current = node.next.take();takes the rest of the list out of the node, as in figure 9.2.node.next = previous;points the node at the reversed part.previous = Some(node);makes the node the front of the reversed part.
Trace it on 1 → 2 → 3:
| After pass | previous | current |
|---|---|---|
| start | None | 1 → 2 → 3 |
| 1 | 1 | 2 → 3 |
| 2 | 2 → 1 | 3 |
| 3 | 3 → 2 → 1 | None |
At no moment do two variables own the same node, which is why the compiler accepts the code. The function visits each node once, so it is O(n), and it uses O(1) extra memory.
The complete file is listing 9.20 at the end of the chapter. Its tests reverse a four-element list, an empty list, and a one-element list.
9.4 Removing nodes with a cursor
The problem. Remove every node whose value equals target, in one pass. The head may match, and so may
several nodes in a row.
The idea. To unlink a node, you change the link that points at it. That link is either head or the
next field of the node before. So the loop holds a link, not a node. It is a &mut Option<Box<ListNode>>
called the cursor.
Think of the cursor as a finger on one arrow of the chain. If the arrow points at a match, you bend the arrow past that node, and the finger stays where it is. If not, the finger moves to the next arrow.
Listing 9.3 Removing every match (lines 48 to 71). src/problems/linked_list.rs
/// Remove every node whose value is `target`, in one pass.
///
/// `cursor` is a mutable borrow of a link: first `head`, then the `next` of
/// each node that stays. A matching node is unlinked by moving its `next` into
/// the link that pointed at it. The cursor does not move after a removal,
/// because the link it holds now points at a node not yet checked.
pub fn remove_all(mut head: Option<Box<ListNode>>, target: i32) -> Option<Box<ListNode>> {
let mut cursor = &mut head;
while cursor.is_some() {
let node = cursor
.as_mut()
.expect("checked by is_some");
if node.val == target {
let next = node.next.take();
*cursor = next;
} else {
cursor = &mut cursor
.as_mut()
.expect("checked by is_some")
.next;
}
}
head
}
The loop reads:
let mut cursor = &mut head;puts the cursor on the head link. The head is a link like any other, so removing the first node needs no extra code.cursor.as_mut()borrows the node the link points at.- On a match,
node.next.take()moves the rest of the list out of the node.*cursor = nextwrites it into the link. Nothing owns the old node any more, so it is freed there. - Otherwise,
cursor = &mut cursor...nextmoves the cursor to that node’snextlink.
The cursor does not move after a removal. The link now points at the node that slid into place, and that node
has not been checked yet. Trace it on 2 → 1 → 2 → 2 → 3 with target 2:
| Step | The cursor holds | The node there | Action |
|---|---|---|---|
| 1 | head | 2 | match: head now points at 1 |
| 2 | head | 1 | keep: the cursor moves to 1’s next |
| 3 | 1’s next | 2 | match: 1’s next now points at the second 2 |
| 4 | 1’s next | 2 | match: 1’s next now points at 3 |
| 5 | 1’s next | 3 | keep: the cursor moves to 3’s next |
| 6 | 3’s next | None | the loop ends: 1 → 3 |
Each node is checked once, so the function is O(n). It uses O(1) extra memory.
Animation 9.3 plays this trace, then three unhappy cases. The target may be absent, every node may match, or the cursor may move after a removal.
The tests check the cases that break removal code. They remove a match at the head and matches in a row. They also try a target that is absent, a list where every node matches, and an empty list.
9.5 A list written as an enum
The first list separated the node type from the Option that marks the end. Another way puts both into one
enum: a list is either empty, or a node holding a value and the rest of the list.
Listing 9.4 The list type (lines 1 to 13). src/bin/singly_linked_list.rs
// Singly linked list using an algebraic data type.
//
// List<T> = Empty
// | Node { value: T, next: Link<T> }
//
// `Empty` is the end of the list.
type Link<T> = Box<List<T>>;
pub enum List<T> {
Empty,
Node { value: T, next: Link<T> },
}
List<T> is generic. The T is a placeholder for the type of the values, so the same code works for
a list of numbers or of strings. type Link<T> = Box<List<T>>; gives the boxed rest of the list a short name.
Listing 9.5 Creating, checking, and measuring (lines 16 to 29).
impl<T> List<T> {
pub fn new() -> Self {
List::Empty
}
pub fn is_empty(&self) -> bool {
matches!(self, List::Empty)
}
pub fn len(&self) -> usize {
match self {
List::Empty => 0,
List::Node { next, .. } => 1 + next.len(),
}
}
// ...
}
len counts the nodes recursively: an empty list has length 0, and a node adds 1 to the length of the rest.
Each call waits for the next, so a list of n nodes uses n stack frames. Section 9.7 shows where that ends.
Listing 9.6 Adding and removing at the front (lines 31 to 49).
impl<T> List<T> {
// ...
/// O(1)
pub fn push_front(&mut self, value: T) {
let old = std::mem::replace(self, List::Empty);
*self = List::Node {
value,
next: Link::new(old),
};
}
/// O(1)
pub fn pop_front(&mut self) -> Option<T> {
match std::mem::replace(self, List::Empty) {
List::Empty => None,
List::Node { value, next } => {
*self = *next;
Some(value)
}
}
}
}
Both methods take &mut self, so they change the list in place. They use std::mem::replace from section 9.2. The method cannot move the old list out of *self and assign a new one later. Between those two steps,
*self would be empty. replace(self, List::Empty) puts a temporary Empty in place
and hands back the old list in one step.
push_front wraps the old list in a new node with the new value, and writes it back into *self.
pop_front matches on the old list. If it was Empty, there is nothing to pop, and *self is already Empty
again. If it was a Node, the method writes the rest of the list back into *self with *self = *next, and
returns the value. *next moves the list out of its Box.
Animation 9.4 shows both methods, then the version without replace that the compiler refuses.
replace puts Empty in the place as it takes the list out, so the borrowed place is never empty. A plain move out of *self would leave a hole, and the compiler rejects it.The complete program is listing 9.21 at the end of the chapter. Its main pushes three values and pops them back in reverse order, the last one pushed coming out first. A list
that adds and removes at the same end behaves as a stack, like the Vec in chapter 3.
$ cargo run --bin singly_linked_list
ok
9.6 Four node layouts, measured
Section 9.3 wrote a node as a struct with an Option<Box<Node>>, and section 9.5 wrote the list as an enum.
Which is faster? To answer, the benchmark in this section puts four versions of the same list behind one trait. Then
one piece of code can measure them all.
9.6.1 One trait for four lists
The four list types are measured by one benchmark, so they share a trait with the operations the benchmark calls.
Listing 9.7 The trait every list implements. benchmarking_examples/lists/mod.rs
//! Singly linked lists in four variants, so one benchmark loop can cover all of
//! them.
//!
//! The variants differ only in how a node is stored and in how the list frees
//! itself. `boxed` and `enum_node` free a chain of boxes recursively, which
//! overflows the stack past roughly 265,000 nodes. `boxed_drop` and `enum_drop`
//! unlink iteratively and scale.
pub mod boxed;
pub mod boxed_drop;
pub mod enum_drop;
pub mod enum_node;
/// The operations every variant supports, so the benchmark can be written once.
pub trait SinglyList<T>: Sized {
/// A new, empty list.
fn new() -> Self;
/// Adds `value` to the front of the list.
fn push_front(&mut self, value: T);
/// Removes and returns the front value, if there is one.
fn pop_front(&mut self) -> Option<T>;
/// Whether the list holds nothing.
fn is_empty(&self) -> bool;
/// The variant's name, for benchmark output and error messages.
fn variant() -> &'static str;
}
The trait lists the operations: new, push_front, pop_front, is_empty, and variant, which returns a
name for printing. SinglyList<T>: Sized limits the trait to types whose size is known at compile time. The benchmark uses
the trait only through generic functions, so the limit costs nothing. The four pub mod lines declare the four versions, each in its own
file.
The first two versions are the struct form and the enum form:
Listing 9.8 Option<Box<Node<T>>>: the types, push_front, and pop_front (lines 9 to 38). benchmarking_examples/lists/boxed.rs
struct Node<T> {
value: T,
next: Option<Box<Node<T>>>,
}
pub struct LinkedList<T> {
head: Option<Box<Node<T>>>,
}
impl<T> SinglyList<T> for LinkedList<T> {
fn new() -> Self {
Self { head: None }
}
fn push_front(&mut self, value: T) {
self.head = Some(Box::new(Node {
value,
// .take() leaves None in its place and extracts the old head
next: self.head.take(),
}));
}
fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|node| {
// Point the list head to the next node in line
self.head = node.next;
// Return the unboxed value
node.value
})
}
// ...
}
Listing 9.9 enum ListNode<T>: the types, push_front, and pop_front (lines 9 to 44). benchmarking_examples/lists/enum_node.rs
enum ListNode<T> {
Empty,
Next(T, Box<ListNode<T>>),
}
pub struct LinkedList<T> {
head: ListNode<T>,
}
impl<T> SinglyList<T> for LinkedList<T> {
fn new() -> Self {
Self {
head: ListNode::Empty,
}
}
fn push_front(&mut self, value: T) {
// Extract the old head and leave Empty in its place
let old_head = std::mem::replace(&mut self.head, ListNode::Empty);
// Wrap the old head inside a Box behind the new value
self.head = ListNode::Next(value, Box::new(old_head));
}
fn pop_front(&mut self) -> Option<T> {
let old_head = std::mem::replace(&mut self.head, ListNode::Empty);
match old_head {
ListNode::Empty => None,
ListNode::Next(value, next_node) => {
// Point the list head to the next node in the sequence
self.head = *next_node;
Some(value)
}
}
}
// ...
}
Each file also has is_empty and variant, which are one line each. The two files implement the trait with impl<T> SinglyList<T> for LinkedList<T>. Each uses the tool from
section 9.2 that fits its type: take() on the Option in the struct version, and mem::replace on the enum.
9.6.2 What each node costs in memory
Figure 9.4 draws both lists after pushing 10, 20, and 30.
Empty.On a 64-bit machine, with u64 values, each node is 16 bytes in both versions: 8 for the value and 8 for the
pointer. Option<Box<Node>> is only 8 bytes, because a Box is never null, so Rust uses the null address to
mean None. The enum gets the same treatment, using a null Box to mean Empty.
The versions still differ in two ways. The enum list keeps its first node, 16 bytes, inside the list value. So every push moves those 16 bytes
into a new box, where the struct version moves one 8-byte pointer. And the enum
chain ends with a boxed Empty. Both lists make one allocation per push, but one of the enum’s boxes holds no
value.
Animation 9.5 pushes 10, 20, and 30 onto both lists, pops one value, and then pops from two empty lists.
Empty.Two small demo programs, list_box.rs and list_enum.rs, push and pop on each version. They import the
lists from the crate’s library with use systems_lab::lists::..., so every program shares one compiled copy of
the list code. Both are listed at the end of the chapter.
9.7 When dropping a list crashes
Here is a short program built on the struct version:
let mut list = boxed::LinkedList::new();
for value in 0..270_000u64 {
list.push_front(value);
}
// list goes out of scope here and is dropped
It builds the list without trouble, and then it crashes at the closing brace:
thread 'main' has overflowed its stack
fatal runtime error: stack overflow, aborting
None of the list’s methods is recursive. The recursion is in the code the compiler generates to drop the list.
To drop the list, Rust drops head. To drop a Box<Node>, it drops the node, which means dropping its fields,
including next, another Box<Node>. That drops the next node, and so on. Each step is a function call
nested inside the previous one, so each node adds one stack frame (figure 9.5, left).
Drop detaches each node's successor first, so freeing one node never frees another.Animation 9.6 runs both. Watch the stack on the right: one frame per node for the default drop, and never more than three for the loop.
Drop loop frees one node per pass, so the stack stays flat.The main thread’s stack is 8 MiB on most systems. The crash happened between 260,000 and 270,000 nodes, so
each level of nesting used about 32 bytes of stack. A thread you start with std::thread::spawn gets a 2 MiB stack by default. On such a thread the limit
would be about a quarter as many nodes.
The fix is to write the list’s Drop yourself, as a loop. Drop is a trait. Rust calls its drop method when a value goes out of scope. If you implement it, your code runs before the fields are dropped.
Listing 9.10 The struct version's Drop (lines 46 to 55). benchmarking_examples/lists/boxed_drop.rs
impl<T> Drop for LinkedList<T> {
fn drop(&mut self) {
// Walk the chain and unlink each node before it is freed, so no node's
// drop has to carry the rest of the list on the stack.
let mut current = self.head.take();
while let Some(mut node) = current {
current = node.next.take();
}
}
}
The file boxed_drop.rs repeats the types and operations of listing 9.8. Only this Drop implementation is new. Its loop reads:
let mut current = self.head.take();takes the whole chain out of the list.while let Some(mut node) = currentmoves the first box out ofcurrent.current = node.next.take();moves the rest of the chain out of that node.- At the end of the loop body,
nodegoes out of scope and is freed. Itsnextis alreadyNone, so freeing it frees nothing else.
The loop runs in one stack frame, whatever the length of the list.
Listing 9.11 The enum version's Drop (lines 52 to 62). benchmarking_examples/lists/enum_drop.rs
impl<T> Drop for LinkedList<T> {
fn drop(&mut self) {
// Take the chain out of the list first, then step it forward one node at
// a time. Each node has already lost its `next` by the time it is freed,
// so the drop stays on the heap.
let mut current = std::mem::replace(&mut self.head, ListNode::Empty);
while let ListNode::Next(_, next) = current {
current = *next;
}
}
}
The enum version does the same with mem::replace and a while let ListNode::Next(_, next) = current loop.
current = *next; moves the rest of the chain out of the box. Assigning to current drops its old value, a
Next whose box has already been emptied, so again nothing nests.
A third demo builds a list of five million nodes and drops it:
Listing 9.12 Five million nodes, dropped with a loop. benchmarking_examples/list_drop.rs
//! Demo of the iterative `Drop` in `lists::boxed_drop`.
//!
//! The same program against `lists::boxed` aborts with a stack overflow at this
//! size, because that variant frees its chain recursively.
//!
//! Run with: cargo run --release --bin list_drop
use systems_lab::lists::{SinglyList, boxed_drop::LinkedList};
fn main() {
const N: u64 = 5_000_000;
let mut list = LinkedList::new();
for value in 0..N {
list.push_front(value);
}
println!("built a list of {N} nodes");
list.pop_front();
println!("dropping the remaining {} nodes", N - 1);
drop(list);
println!("dropped without recursing");
}
9.8 The benchmark
The benchmark program measures all four versions with one generic function. The full program also measures the caches of chapter 13, and is listed there. This is the part that measures the lists.
Listing 9.13 The list benchmark (lines 160 to 236). benchmarking_examples/benchmark.rs
use std::{
hint::black_box,
time::{Duration, Instant},
};
use systems_lab::{
cache::{Cache, arena::LruCache as ArenaCache, rc_list::LruCache as RcCache},
lists::{SinglyList, boxed, boxed_drop, enum_drop, enum_node},
};
// ---------------------------------------------------------------------------
// Lists: four variants, one loop
// ---------------------------------------------------------------------------
fn list_benchmark(variant: Option<&str>, elements: usize) {
println!(
"Singly linked list: push {} values, pop them back in order, then drop a full list",
with_thousands(elements as u64)
);
println!("Best of {LIST_RUNS} runs per variant, allocator warmed up.\n");
println!(
" {:<10} {:>12} {:>13} {:>13}",
"variant", "elements", "push", "drop"
);
println!(" {}", "-".repeat(52));
match variant {
Some("box") => run_list::<boxed::LinkedList<u64>>(elements),
Some("enum") => run_list::<enum_node::LinkedList<u64>>(elements),
Some("box+drop") => run_list::<boxed_drop::LinkedList<u64>>(elements),
Some("enum+drop") => run_list::<enum_drop::LinkedList<u64>>(elements),
_ => {
run_list::<boxed::LinkedList<u64>>(elements);
run_list::<enum_node::LinkedList<u64>>(elements);
run_list::<boxed_drop::LinkedList<u64>>(elements);
run_list::<enum_drop::LinkedList<u64>>(elements);
}
}
}
fn run_list<L: SinglyList<u64>>(elements: usize) {
// Warm the allocator first: without this the first variant in a process pays
// for fresh heap pages and looks several times slower than an identical one.
let mut warm_up = L::new();
for value in 0..elements as u64 {
warm_up.push_front(value);
}
drop(warm_up);
let mut best_push = Duration::MAX;
let mut best_drop = Duration::MAX;
for _ in 0..LIST_RUNS {
let mut list = L::new();
let start = Instant::now();
for value in 0..elements as u64 {
list.push_front(value);
}
best_push = best_push.min(start.elapsed());
// Pop every value back and check the order, so a broken list cannot post
// a fast time.
let mut expected = elements as u64;
while let Some(value) = list.pop_front() {
expected -= 1;
assert_eq!(value, expected, "{} lost LIFO order", L::variant());
}
assert_eq!(expected, 0);
black_box(expected);
// Rebuild, then time the drop of a full list. This is where the
// recursive variants overflow the stack.
for value in 0..elements as u64 {
list.push_front(value);
}
let start = Instant::now();
drop(list);
best_drop = best_drop.min(start.elapsed());
}
println!(
" {:<10} {:>12} {best_push:>13.2?} {best_drop:>13.2?}",
L::variant(),
with_thousands(elements as u64)
);
}
The whole file is listing 13.16. Chapter 13 adds the cache benchmarks to it, and shows it complete there.
run_list::<L: SinglyList<u64>> is generic over the list type L. The compiler generates a separate copy of the function for each list type. So each list is measured with
its own code.
The function follows three rules that apply to any small benchmark:
- Warm up first. The first time a program allocates a lot of memory, the operating system has to supply fresh pages, which is slow. A warm-up pass pays that cost before timing starts.
- Run several times and keep the fastest. Other programs can slow a run down but never speed it up. So the fastest run is the closest to the true cost.
- Check the result before trusting the time. Every run pops all the values and checks their order. A broken list cannot report a fast time.
std::hint::black_box(expected) tells the compiler to treat the value as used, so it cannot delete the loop
that computed it.
These results come from a release build on an 8-core aarch64 development board:
| list | how it drops | 260,000 nodes | 270,000 nodes |
|---|---|---|---|
box | recursively | completed | crashed |
enum | recursively | completed | crashed |
box+drop | with a loop | completed | completed |
enum+drop | with a loop | completed | completed |
variant elements push drop
----------------------------------------------------
box 200,000 2.40ms 3.23ms
enum 200,000 3.42ms 3.20ms
box+drop 200,000 2.32ms 2.91ms
enum+drop 200,000 3.62ms 2.52ms
box+drop 5,000,000 58.11ms 72.32ms
enum+drop 5,000,000 90.98ms 67.51ms
Read the table this way:
- A push costs about 12 nanoseconds for the struct node and about 17 for the enum node. Section 9.6.2 gave two differences that could explain the gap. The benchmark does not measure them separately.
boxandbox+dropshare the same push code, and their push times agree, 2.40 against 2.32 ms. That agreement is a sign the measurement is stable.- The loop drop is not much faster than the recursive one. Its purpose is to remove the size limit, not to save time.
You can reproduce the crash and the fix:
cargo run --release --bin benchmark list box 260000 # completes
cargo run --release --bin benchmark list box 270000 # crashes
cargo run --release --bin list_drop # five million nodes, no crash
9.9 A doubly linked list
In a doubly linked list, each node points to the next node and also back to the previous one. That lets you add and remove at both ends in O(1), and walk the list in either direction.
In Rust this creates an ownership problem. Every node in the middle is pointed to twice: by the previous
node’s next and by the following node’s prev. A Box has exactly one owner, so it cannot express this.
The solution uses the three types from chapter 8. Nodes live in Rc<RefCell<Node>>, so they can have more than
one handle and still be changed. The forward links, next, are strong Rc handles, so the list owns its nodes
from front to back. The backward links, prev, are Weak handles, so they do not create reference cycles
(figure 9.6).
9.9.1 The types
A node of the doubly linked list has a value and two links. The link forward owns the next node; the link back is weak, so the two directions do not keep each other alive.
Listing 9.14 Type names, the node, and the list (lines 1 to 25). src/bin/ll.rs
// ============================================================================
// Type aliases for clarity.
// ============================================================================
use std::cell::{Ref, RefCell}; // Ref is needed for peek return types
use std::rc::{Rc, Weak};
type NodeRef<T> = Rc<RefCell<Node<T>>>;
type WeakNodeRef<T> = Weak<RefCell<Node<T>>>;
type OptNodeRef<T> = Option<NodeRef<T>>;
type OptWeakNodeRef<T> = Option<WeakNodeRef<T>>;
// ============================================================================
// Node and LinkedList definitions.
// ============================================================================
struct Node<T> {
data: T,
next: OptNodeRef<T>,
prev: OptWeakNodeRef<T>,
}
pub struct LinkedList<T> {
head: OptNodeRef<T>,
tail: OptNodeRef<T>,
len: usize,
}
The full type of a link is Option<Rc<RefCell<Node<T>>>>. It appears in almost every function, so the file
names it once, OptNodeRef<T>, with a type alias. The list keeps a head, a tail, and its length.
9.9.2 Adding at either end
A new node at the front must point forward to the old head, and the old head must point back to it. The back of the list works the same way, mirrored.
Listing 9.15 new, len, and push_front (lines 28 to 69).
impl<T> LinkedList<T> {
/// Creates an empty list.
pub fn new() -> Self {
LinkedList {
head: None,
tail: None,
len: 0,
}
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
// --------------------------------------------------------------------
// push_front
// --------------------------------------------------------------------
pub fn push_front(&mut self, data: T) {
let new_node = Rc::new(RefCell::new(Node {
data,
next: None,
prev: None,
}));
// take() moves the old head out of the Option, leaving None.
match self.head.take() {
Some(old_head) => {
// Link old head back to new node (weakly).
old_head.borrow_mut().prev = Some(Rc::downgrade(&new_node));
// Link new node forward to old head.
new_node.borrow_mut().next = Some(old_head);
}
None => {
// List was empty: new node is also the tail.
self.tail = Some(new_node.clone());
}
}
self.head = Some(new_node);
self.len += 1;
}
// ...
}
push_front creates the new node, then looks at the old head with self.head.take():
- If there was an old head, its
prevbecomes a weak link to the new node, made withRc::downgrade. The new node’snextbecomes the old head. - If there was no old head, the list was empty, so the new node is also the tail.
new_node.clone()makes a second strong handle fortail.
Finally the new node becomes the head, and the length grows by one. borrow_mut() is the RefCell method
from section 8.4. Each call is released at the end of its statement, so no two borrows of the same node
overlap.
Listing 9.16 push_back (lines 71 to 93).
impl<T> LinkedList<T> {
// ...
// --------------------------------------------------------------------
// push_back
// --------------------------------------------------------------------
pub fn push_back(&mut self, data: T) {
let new_node = Rc::new(RefCell::new(Node {
data,
next: None,
// self.tail.as_ref() gives Option<&NodeRef<T>>,
// map(Rc::downgrade) turns it into Option<WeakNodeRef<T>>.
prev: self.tail.as_ref().map(Rc::downgrade),
}));
match self.tail.take() {
Some(old_tail) => {
old_tail.borrow_mut().next = Some(new_node.clone());
}
None => {
self.head = Some(new_node.clone());
}
}
self.tail = Some(new_node);
self.len += 1;
}
// ...
}
push_back mirrors it at the other end. The new node’s prev is set as it is created:
self.tail.as_ref().map(Rc::downgrade). as_ref() turns Option<Rc<...>> into Option<&Rc<...>>, so the
tail is looked at without being moved. map(Rc::downgrade) turns that reference into a weak link.
9.9.3 Removing from either end
Removing the front node means unlinking it from its neighbor and moving its value out to the caller.
Listing 9.17 pop_front (lines 95 to 125).
impl<T> LinkedList<T> {
// ...
// --------------------------------------------------------------------
// pop_front
// --------------------------------------------------------------------
pub fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|old_head| {
// Take ownership of the next node (if any).
let next = old_head.borrow_mut().next.take();
match next {
Some(next_node) => {
// Disconnect the new head's prev pointer.
next_node.borrow_mut().prev = None;
self.head = Some(next_node);
}
None => {
self.tail = None; // list becomes empty
}
}
self.len -= 1;
// Safely extract the data. At this point, old_head is the only
// strong reference (because we removed it from the list and the
// new head's prev doesn't point to it).
let node = Rc::try_unwrap(old_head)
.ok()
.unwrap()
.into_inner();
node.data
})
}
// ...
}
pop_front must return the value by ownership, not a reference. To move the value out, the node itself must
be moved out of its Rc, and Rc::try_unwrap does that. It succeeds only if the handle is the last strong
one. So the method first removes every other strong link to the node:
self.head.take()removes the list’s link to it.- The next node’s
previs set toNone. That link was weak, so it did not count anyway. - If there was no next node, the list is now empty, so
self.tailis cleared, removing the tail’s link.
Now Rc::try_unwrap(old_head) succeeds, and .into_inner() moves the Node out of its RefCell.
The call is written .ok().unwrap() rather than .unwrap(). Result::unwrap needs to print the error if it
fails, which requires the error type to implement Debug. Here the error type is the Rc itself, and Node
does not implement Debug, so .unwrap() would not compile. .ok() turns the Result into an Option,
whose unwrap needs no Debug.
Animation 9.7 builds the list of figure 9.6 and pops its front. The badge on each node is its strong count.
The last part makes prev strong, to show the leak that Weak prevents.
try_unwrap succeeds. With strong back-links, neighbours keep each other alive and the list leaks.Listing 9.18 pop_back (lines 127 to 157).
impl<T> LinkedList<T> {
// ...
// --------------------------------------------------------------------
// pop_back
// --------------------------------------------------------------------
pub fn pop_back(&mut self) -> Option<T> {
self.tail.take().map(|old_tail| {
// Get the previous node (upgrade the weak reference).
let prev = old_tail
.borrow_mut()
.prev
.take()
.and_then(|w| w.upgrade());
match prev {
Some(prev_node) => {
prev_node.borrow_mut().next = None;
self.tail = Some(prev_node);
}
None => {
self.head = None; // list becomes empty
}
}
self.len -= 1;
let node = Rc::try_unwrap(old_tail)
.ok()
.unwrap()
.into_inner();
node.data
})
}
// ...
}
pop_back mirrors pop_front. It follows the weak prev link with upgrade(), which returns None if the
previous node is gone, then clears that node’s next.
Animation 9.8 runs pop_back on 10, 30, 40. Then it pops down to one node, and pops once more from the
empty list.
upgrade() turns the weak back-link into a strong handle for as long as pop_back needs it. A one-node list clears head as well, and an empty list returns None.9.9.4 Looking without removing
Looking at the front value must not move it out of the list. The caller gets a borrow of the value instead.
Listing 9.19 peek_front and peek_back (lines 159 to 180).
impl<T> LinkedList<T> {
// ...
// --------------------------------------------------------------------
// peek_front: returns a guarded reference to the front data.
// The returned Ref<'_, T> holds the borrow of the RefCell until it goes
// out of scope, ensuring the reference stays valid.
// --------------------------------------------------------------------
pub fn peek_front(&self) -> Option<Ref<'_, T>> {
// self.head.as_ref() -> Option<&NodeRef<T>>
self.head.as_ref().map(|node| {
// node.borrow() returns Ref<'_, Node<T>>.
// Ref::map projects that to a Ref<'_, T> pointing to the data field.
Ref::map(node.borrow(), |n| &n.data)
})
}
// --------------------------------------------------------------------
// peek_back: similar to peek_front.
// --------------------------------------------------------------------
pub fn peek_back(&self) -> Option<Ref<'_, T>> {
self.tail
.as_ref()
.map(|node| Ref::map(node.borrow(), |n| &n.data))
}
}
peek_front returns Option<Ref<'_, T>>, not Option<&T>. The value is inside a RefCell, and a reference
to it is only safe while the cell’s borrow is active. A Ref is the guard that keeps the borrow active, as
section 8.4 showed. Ref::map narrows a guard for the whole node into a guard for its data field. The
caller reads the value with *, as main does.
9.9.5 Running it
The complete program, with its tests, is listing 9.28 at the end of the chapter. Running it:
$ cargo run --bin ll
Length: 4
Front: 20
Back: 40
Pop front: Some(20)
Pop back: Some(40)
Popped: 10
Popped: 30
Empty? true
The test alternating_push_pop_keeps_invariants pushes 100 values at each end, then pops from both ends and
checks every value. The comment block at the end of the file lists each standard-library method the file
uses, with a one-line description.
This list costs more than the singly linked list. Each node needs a heap block, two counts, and a borrow
flag, and every access is checked. It also keeps the recursive drop from section 9.7, because dropping the head drops the
next Rc, which drops the next node. Chapter 13 avoids both costs for an LRU cache by storing its nodes in a
Vec and linking them by index.
9.10 The complete files
Each file below is shown whole, in the order the chapter used it.
Listing 9.20 The node, the helpers, reverse_list, and remove_all, with tests. src/problems/linked_list.rs
//! Reverse a singly linked list, and remove nodes from it.
//!
//! Uses `Option<Box<ListNode>>` and a `while let` loop to move pointers in a
//! borrow-safe way. Removal walks a cursor: a mutable borrow of one link.
#[derive(Debug, PartialEq, Eq)]
pub struct ListNode {
pub val: i32,
pub next: Option<Box<ListNode>>,
}
impl ListNode {
pub fn from_slice(values: &[i32]) -> Option<Box<ListNode>> {
let mut head = None;
for &value in values.iter().rev() {
head = Some(Box::new(ListNode {
val: value,
next: head,
}));
}
head
}
pub fn to_vec(head: Option<Box<ListNode>>) -> Vec<i32> {
let mut result = Vec::new();
let mut current = head;
while let Some(node) = current {
result.push(node.val);
current = node.next;
}
result
}
}
pub fn reverse_list(head: Option<Box<ListNode>>) -> Option<Box<ListNode>> {
let mut previous = None;
let mut current = head;
while let Some(mut node) = current {
current = node.next.take();
node.next = previous;
previous = Some(node);
}
previous
}
/// Remove every node whose value is `target`, in one pass.
///
/// `cursor` is a mutable borrow of a link: first `head`, then the `next` of
/// each node that stays. A matching node is unlinked by moving its `next` into
/// the link that pointed at it. The cursor does not move after a removal,
/// because the link it holds now points at a node not yet checked.
pub fn remove_all(mut head: Option<Box<ListNode>>, target: i32) -> Option<Box<ListNode>> {
let mut cursor = &mut head;
while cursor.is_some() {
let node = cursor
.as_mut()
.expect("checked by is_some");
if node.val == target {
let next = node.next.take();
*cursor = next;
} else {
cursor = &mut cursor
.as_mut()
.expect("checked by is_some")
.next;
}
}
head
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reverses_list() {
let head = ListNode::from_slice(&[1, 2, 3, 4]);
assert_eq!(ListNode::to_vec(reverse_list(head)), vec![4, 3, 2, 1]);
}
#[test]
fn removes_head_middle_and_consecutive_matches() {
let head = ListNode::from_slice(&[2, 1, 2, 2, 3, 2]);
assert_eq!(ListNode::to_vec(remove_all(head, 2)), vec![1, 3]);
}
#[test]
fn remove_all_edge_cases() {
let absent = ListNode::from_slice(&[1, 3]);
assert_eq!(ListNode::to_vec(remove_all(absent, 2)), vec![1, 3]);
let every = ListNode::from_slice(&[7, 7, 7]);
assert_eq!(remove_all(every, 7), None);
assert_eq!(remove_all(None, 7), None);
}
#[test]
fn handles_empty_and_single() {
assert_eq!(reverse_list(None), None);
assert_eq!(
ListNode::to_vec(reverse_list(ListNode::from_slice(&[42]))),
vec![42]
);
}
}
Listing 9.21 The enum list and its main. src/bin/singly_linked_list.rs
// Singly linked list using an algebraic data type.
//
// List<T> = Empty
// | Node { value: T, next: Link<T> }
//
// `Empty` is the end of the list.
type Link<T> = Box<List<T>>;
pub enum List<T> {
Empty,
Node { value: T, next: Link<T> },
}
impl<T> List<T> {
pub fn new() -> Self {
List::Empty
}
pub fn is_empty(&self) -> bool {
matches!(self, List::Empty)
}
pub fn len(&self) -> usize {
match self {
List::Empty => 0,
List::Node { next, .. } => 1 + next.len(),
}
}
/// O(1)
pub fn push_front(&mut self, value: T) {
let old = std::mem::replace(self, List::Empty);
*self = List::Node {
value,
next: Link::new(old),
};
}
/// O(1)
pub fn pop_front(&mut self) -> Option<T> {
match std::mem::replace(self, List::Empty) {
List::Empty => None,
List::Node { value, next } => {
*self = *next;
Some(value)
}
}
}
}
fn main() {
let mut list = List::new();
list.push_front(2);
list.push_front(1);
list.push_front(0);
assert_eq!(list.len(), 3);
assert_eq!(list.pop_front(), Some(0));
assert_eq!(list.pop_front(), Some(1));
assert_eq!(list.pop_front(), Some(2));
println!("ok");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_pop_works() {
let mut list = List::new();
list.push_front(1);
list.push_front(2);
assert_eq!(list.pop_front(), Some(2));
assert_eq!(list.pop_front(), Some(1));
assert_eq!(list.pop_front(), None);
}
#[test]
fn len_works() {
let mut list = List::new();
assert_eq!(list.len(), 0);
list.push_front(1);
list.push_front(2);
assert_eq!(list.len(), 2);
}
}
Listing 9.22 The Option<Box<Node>> list. benchmarking_examples/lists/boxed.rs
//! `Option<Box<Node<T>>>`: one heap allocation per node.
//!
//! Pushing takes the old head and boxes it inside the new node. Freeing the head
//! frees its `next`, which frees its `next`, so the drop recurses once per
//! element and overflows the stack on a long list.
use super::SinglyList;
struct Node<T> {
value: T,
next: Option<Box<Node<T>>>,
}
pub struct LinkedList<T> {
head: Option<Box<Node<T>>>,
}
impl<T> SinglyList<T> for LinkedList<T> {
fn new() -> Self {
Self { head: None }
}
fn push_front(&mut self, value: T) {
self.head = Some(Box::new(Node {
value,
// .take() leaves None in its place and extracts the old head
next: self.head.take(),
}));
}
fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|node| {
// Point the list head to the next node in line
self.head = node.next;
// Return the unboxed value
node.value
})
}
fn is_empty(&self) -> bool {
self.head.is_none()
}
fn variant() -> &'static str {
"box"
}
}
Listing 9.23 The enum list behind the trait. benchmarking_examples/lists/enum_node.rs
//! `enum ListNode<T> { Empty, Next(T, Box<ListNode<T>>) }`.
//!
//! Same shape as the boxed variant, written with an enum instead of an
//! `Option`. The enum cannot contain itself by value, so the recursive arm holds
//! a `Box`. The drop is recursive for the same reason.
use super::SinglyList;
enum ListNode<T> {
Empty,
Next(T, Box<ListNode<T>>),
}
pub struct LinkedList<T> {
head: ListNode<T>,
}
impl<T> SinglyList<T> for LinkedList<T> {
fn new() -> Self {
Self {
head: ListNode::Empty,
}
}
fn push_front(&mut self, value: T) {
// Extract the old head and leave Empty in its place
let old_head = std::mem::replace(&mut self.head, ListNode::Empty);
// Wrap the old head inside a Box behind the new value
self.head = ListNode::Next(value, Box::new(old_head));
}
fn pop_front(&mut self) -> Option<T> {
let old_head = std::mem::replace(&mut self.head, ListNode::Empty);
match old_head {
ListNode::Empty => None,
ListNode::Next(value, next_node) => {
// Point the list head to the next node in the sequence
self.head = *next_node;
Some(value)
}
}
}
fn is_empty(&self) -> bool {
matches!(self.head, ListNode::Empty)
}
fn variant() -> &'static str {
"enum"
}
}
Listing 9.24 The demo of the boxed list. benchmarking_examples/list_box.rs
//! Demo of the `Option<Box<Node<T>>>` list in `lists::boxed`.
//!
//! Run with: cargo run --bin list_box
use systems_lab::lists::{SinglyList, boxed::LinkedList};
fn main() {
let mut list = LinkedList::new();
list.push_front(10);
list.push_front(20);
list.push_front(30);
assert_eq!(list.pop_front(), Some(30));
assert_eq!(list.pop_front(), Some(20));
assert_eq!(list.pop_front(), Some(10));
assert_eq!(list.pop_front(), None);
assert!(list.is_empty());
println!("All tests passed successfully!");
}
Listing 9.25 The demo of the enum list. benchmarking_examples/list_enum.rs
//! Demo of the `enum ListNode<T>` list in `lists::enum_node`.
//!
//! Run with: cargo run --bin list_enum
use systems_lab::lists::{SinglyList, enum_node::LinkedList};
fn main() {
let mut list = LinkedList::new();
list.push_front(42);
list.push_front(100);
assert_eq!(list.pop_front(), Some(100));
assert_eq!(list.pop_front(), Some(42));
assert_eq!(list.pop_front(), None);
assert!(list.is_empty());
}
Listing 9.26 The boxed list with its own Drop. benchmarking_examples/lists/boxed_drop.rs
//! `Option<Box<Node<T>>>` with an iterative `Drop`.
//!
//! Push and pop are identical to [`boxed`](super::boxed); only freeing changes.
//! Dropping a node would otherwise drop its `next` and recurse once per element,
//! so the list is unlinked in a loop before any node is freed.
use super::SinglyList;
struct Node<T> {
value: T,
next: Option<Box<Node<T>>>,
}
pub struct LinkedList<T> {
head: Option<Box<Node<T>>>,
}
impl<T> SinglyList<T> for LinkedList<T> {
fn new() -> Self {
Self { head: None }
}
fn push_front(&mut self, value: T) {
self.head = Some(Box::new(Node {
value,
next: self.head.take(),
}));
}
fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|node| {
self.head = node.next;
node.value
})
}
fn is_empty(&self) -> bool {
self.head.is_none()
}
fn variant() -> &'static str {
"box+drop"
}
}
impl<T> Drop for LinkedList<T> {
fn drop(&mut self) {
// Walk the chain and unlink each node before it is freed, so no node's
// drop has to carry the rest of the list on the stack.
let mut current = self.head.take();
while let Some(mut node) = current {
current = node.next.take();
}
}
}
Listing 9.27 The enum list with its own Drop. benchmarking_examples/lists/enum_drop.rs
//! `enum ListNode<T>` with an iterative `Drop`.
//!
//! Push and pop are identical to [`enum_node`](super::enum_node); only freeing
//! changes. The recursive arm holds a `Box`, so the default drop walks the chain
//! on the stack. This one replaces the head with `Empty`, then takes the box out
//! of each node in turn.
use super::SinglyList;
enum ListNode<T> {
Empty,
Next(T, Box<ListNode<T>>),
}
pub struct LinkedList<T> {
head: ListNode<T>,
}
impl<T> SinglyList<T> for LinkedList<T> {
fn new() -> Self {
Self {
head: ListNode::Empty,
}
}
fn push_front(&mut self, value: T) {
let old_head = std::mem::replace(&mut self.head, ListNode::Empty);
self.head = ListNode::Next(value, Box::new(old_head));
}
fn pop_front(&mut self) -> Option<T> {
let old_head = std::mem::replace(&mut self.head, ListNode::Empty);
match old_head {
ListNode::Empty => None,
ListNode::Next(value, next_node) => {
self.head = *next_node;
Some(value)
}
}
}
fn is_empty(&self) -> bool {
matches!(self.head, ListNode::Empty)
}
fn variant() -> &'static str {
"enum+drop"
}
}
impl<T> Drop for LinkedList<T> {
fn drop(&mut self) {
// Take the chain out of the list first, then step it forward one node at
// a time. Each node has already lost its `next` by the time it is freed,
// so the drop stays on the heap.
let mut current = std::mem::replace(&mut self.head, ListNode::Empty);
while let ListNode::Next(_, next) = current {
current = *next;
}
}
}
Listing 9.28 The doubly linked list, with its tests. src/bin/ll.rs
// ============================================================================
// Type aliases for clarity.
// ============================================================================
use std::cell::{Ref, RefCell}; // Ref is needed for peek return types
use std::rc::{Rc, Weak};
type NodeRef<T> = Rc<RefCell<Node<T>>>;
type WeakNodeRef<T> = Weak<RefCell<Node<T>>>;
type OptNodeRef<T> = Option<NodeRef<T>>;
type OptWeakNodeRef<T> = Option<WeakNodeRef<T>>;
// ============================================================================
// Node and LinkedList definitions.
// ============================================================================
struct Node<T> {
data: T,
next: OptNodeRef<T>,
prev: OptWeakNodeRef<T>,
}
pub struct LinkedList<T> {
head: OptNodeRef<T>,
tail: OptNodeRef<T>,
len: usize,
}
impl<T> LinkedList<T> {
/// Creates an empty list.
pub fn new() -> Self {
LinkedList {
head: None,
tail: None,
len: 0,
}
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
// --------------------------------------------------------------------
// push_front
// --------------------------------------------------------------------
pub fn push_front(&mut self, data: T) {
let new_node = Rc::new(RefCell::new(Node {
data,
next: None,
prev: None,
}));
// take() moves the old head out of the Option, leaving None.
match self.head.take() {
Some(old_head) => {
// Link old head back to new node (weakly).
old_head.borrow_mut().prev = Some(Rc::downgrade(&new_node));
// Link new node forward to old head.
new_node.borrow_mut().next = Some(old_head);
}
None => {
// List was empty: new node is also the tail.
self.tail = Some(new_node.clone());
}
}
self.head = Some(new_node);
self.len += 1;
}
// --------------------------------------------------------------------
// push_back
// --------------------------------------------------------------------
pub fn push_back(&mut self, data: T) {
let new_node = Rc::new(RefCell::new(Node {
data,
next: None,
// self.tail.as_ref() gives Option<&NodeRef<T>>,
// map(Rc::downgrade) turns it into Option<WeakNodeRef<T>>.
prev: self.tail.as_ref().map(Rc::downgrade),
}));
match self.tail.take() {
Some(old_tail) => {
old_tail.borrow_mut().next = Some(new_node.clone());
}
None => {
self.head = Some(new_node.clone());
}
}
self.tail = Some(new_node);
self.len += 1;
}
// --------------------------------------------------------------------
// pop_front
// --------------------------------------------------------------------
pub fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|old_head| {
// Take ownership of the next node (if any).
let next = old_head.borrow_mut().next.take();
match next {
Some(next_node) => {
// Disconnect the new head's prev pointer.
next_node.borrow_mut().prev = None;
self.head = Some(next_node);
}
None => {
self.tail = None; // list becomes empty
}
}
self.len -= 1;
// Safely extract the data. At this point, old_head is the only
// strong reference (because we removed it from the list and the
// new head's prev doesn't point to it).
let node = Rc::try_unwrap(old_head)
.ok()
.unwrap()
.into_inner();
node.data
})
}
// --------------------------------------------------------------------
// pop_back
// --------------------------------------------------------------------
pub fn pop_back(&mut self) -> Option<T> {
self.tail.take().map(|old_tail| {
// Get the previous node (upgrade the weak reference).
let prev = old_tail
.borrow_mut()
.prev
.take()
.and_then(|w| w.upgrade());
match prev {
Some(prev_node) => {
prev_node.borrow_mut().next = None;
self.tail = Some(prev_node);
}
None => {
self.head = None; // list becomes empty
}
}
self.len -= 1;
let node = Rc::try_unwrap(old_tail)
.ok()
.unwrap()
.into_inner();
node.data
})
}
// --------------------------------------------------------------------
// peek_front: returns a guarded reference to the front data.
// The returned Ref<'_, T> holds the borrow of the RefCell until it goes
// out of scope, ensuring the reference stays valid.
// --------------------------------------------------------------------
pub fn peek_front(&self) -> Option<Ref<'_, T>> {
// self.head.as_ref() -> Option<&NodeRef<T>>
self.head.as_ref().map(|node| {
// node.borrow() returns Ref<'_, Node<T>>.
// Ref::map projects that to a Ref<'_, T> pointing to the data field.
Ref::map(node.borrow(), |n| &n.data)
})
}
// --------------------------------------------------------------------
// peek_back: similar to peek_front.
// --------------------------------------------------------------------
pub fn peek_back(&self) -> Option<Ref<'_, T>> {
self.tail
.as_ref()
.map(|node| Ref::map(node.borrow(), |n| &n.data))
}
}
// ============================================================================
// Demonstration with main().
// ============================================================================
fn main() {
let mut list = LinkedList::new();
list.push_front(10);
list.push_front(20);
list.push_back(30);
list.push_back(40);
println!("Length: {}", list.len());
// Peek returns a Ref; we can dereference it with *.
if let Some(front) = list.peek_front() {
println!("Front: {}", *front);
}
if let Some(back) = list.peek_back() {
println!("Back: {}", *back);
}
println!("Pop front: {:?}", list.pop_front()); // 20
println!("Pop back: {:?}", list.pop_back()); // 40
// Drain the list.
while let Some(val) = list.pop_front() {
println!("Popped: {}", val);
}
println!("Empty? {}", list.is_empty());
}
// ============================================================================
// Tests (cargo test --bin ll)
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_list_is_empty() {
let list: LinkedList<i32> = LinkedList::new();
assert!(list.is_empty());
assert_eq!(list.len(), 0);
assert!(list.peek_front().is_none());
assert!(list.peek_back().is_none());
}
#[test]
fn push_front_back_and_len() {
let mut list = LinkedList::new();
list.push_back(1);
list.push_back(2);
list.push_front(0);
assert_eq!(list.len(), 3);
assert_eq!(*list.peek_front().unwrap(), 0);
assert_eq!(*list.peek_back().unwrap(), 2);
}
#[test]
fn pop_front_and_back_work_in_order() {
let mut list = LinkedList::new();
list.push_back(10);
list.push_back(20);
list.push_back(30);
assert_eq!(list.pop_front(), Some(10));
assert_eq!(list.pop_back(), Some(30));
assert_eq!(list.pop_front(), Some(20));
assert_eq!(list.pop_front(), None);
assert_eq!(list.pop_back(), None);
assert!(list.is_empty());
}
#[test]
fn popping_last_item_empties_the_list() {
let mut list = LinkedList::new();
list.push_back(42);
assert_eq!(list.peek_front().map(|x| *x), Some(42));
assert_eq!(list.pop_back(), Some(42));
assert!(list.is_empty());
}
#[test]
fn alternating_push_pop_keeps_invariants() {
let mut list = LinkedList::new();
for i in 0..100 {
list.push_back(i);
list.push_front(-i);
}
assert_eq!(list.len(), 200);
for i in (0..100).rev() {
assert_eq!(list.pop_front(), Some(-i));
assert_eq!(list.pop_back(), Some(i));
}
assert!(list.is_empty());
}
}
// ============================================================================
// Explanation of key methods used:
//
// - Rc::new / RefCell::new : create a reference-counted, mutable cell.
// - borrow() / borrow_mut() : borrow the RefCell's contents (immutably/mutably).
// - Rc::downgrade() : turn an Rc into a Weak (no ownership).
// - Weak::upgrade() : attempt to get an Rc from a Weak (returns Option).
// - Option::take() : take the value out of an Option, leaving None.
// - Option::as_ref() : convert Option<T> to Option<&T> without moving.
// - Option::map() : apply a function to the inner value if Some.
// - Option::and_then() : like map but returns an Option (for chaining).
// - Rc::try_unwrap() : move the value out of the Rc if it's the only strong ref.
// - Ref::map() : project a Ref to a subfield, keeping the borrow alive.
// ============================================================================
Summary
- A linked list stores each value in its own node and links the nodes with pointers. It adds and removes at the front in O(1), but reaching position i takes O(i).
Option::takeandstd::mem::replacemove a value out of a place and leave a valid value behind. List code uses them to move nodes between owners.- Reversing a list moves three owners per node: take the rest, point the node back, make it the new front.
- Removal walks a cursor, a
&mutborrow of one link. A match is unlinked by moving itsnextinto that link, and the cursor stays put. - A list can be a struct with
Option<Box<Node>>or an enum. Both nodes are 16 bytes foru64values. - The compiler-generated drop of a
Boxlist recurses once per node and overflows the stack for long lists. A hand-writtenDroploop removes the limit. - A benchmark should warm up, keep the fastest of several runs, and check its results.
- A doubly linked list needs
Rc<RefCell<Node>>with strong forward links andWeakback-links.
Chapter 10 uses the singly linked list from section 9.3 to merge many sorted lists into one, and compares eleven ways of doing it.
Exercises
- Write
lenfor the enum list of section 9.5 with a loop instead of recursion. - Implement
Iteratorfor the struct list of section 9.3, with aniter()method that yields&i32. - Implement
Dropfor the doubly linked list, and test it with one million nodes. - Build a doubly linked list whose nodes live in a
Vec, withOption<usize>indexes as links. Compare its push and pop times with theRcversion.
Merging k sorted lists
This chapter covers
- Merging two sorted linked lists by moving nodes, not copying values
- Three strategies for merging many lists, and why two of them are much faster than the third
- A merge that swaps two lists by value, so that only one mutable reference remains
- Borrowing two elements of one
Vecat the same time withsplit_at_mut - A heap of list heads, and a recursion on halves
Chapter 9 built singly linked lists and moved their nodes between owners with take. Here you have k linked
lists, each already sorted, and you want one sorted list that contains all their nodes.
Section 10.2 compares three orders of merging. Sections 10.3 to 10.6 implement them, and section 10.7 lists the time and memory each program needs. Appendix C keeps the first, much longer version of this chapter, with eleven programs.
Two letters appear throughout. k is the number of lists. N is the total number of nodes across all of them.
10.1 Merging two sorted lists
Every program in this chapter merges two sorted lists at a time. Compare the front nodes of the two lists. Move the smaller one to the end of the output. Repeat until one list is empty, then attach the other list whole. Figure 10.1 works through an example.
With linked lists, “move a node to the output” does not copy the value. It detaches the node from the front of
10.2 Three strategies for k lists
Figure 10.2 shows three ways to extend two-list merging to k lists.
One at a time. Merge list 2 into list 1, then list 3 into the result, and so on. Each merge walks the whole result so far, which keeps growing. The first nodes are walked k times, so the total is about O(kN).
Pairwise rounds. Merge the lists in pairs, like the rounds of a tournament. After the first round there are k / 2 lists, after the second k / 4, and so on. In every round, each node takes part in exactly one merge. There are about log₂ k rounds, so the total is O(N log k).
A heap of heads. Put the front node of each list into a min-heap, the structure from chapter 5. Pop the smallest, append it to the output, and push the next node from the same list. Each node goes through one push and one pop on a heap of at most k entries. Each costs O(log k), so the total is again O(N log k).
Animation 10.1 counts node moves for eight lists of three nodes: pairwise rounds against one list at a time.
For 100 lists of 10,000 nodes each, N is a million. O(kN) is about 100 million steps. O(N log k) is about 7 million.
The pairwise strategy has a compact in-place form. It keeps the lists in a Vec and merges positions that are a
growing interval apart (figure 10.3). The code in section 10.3 calls the interval gap.
lists[0].In each round, the list at position i absorbs the list at i + interval. The position i runs through
0, 2 × interval, 4 × interval, and so on. The odd list out, number 4 here, waits until the interval is large enough to reach it.
10.3 Merging with the lists swapped by value
The merge builds its output after a placeholder node, the dummy head, so the first real node needs no special
case. tail always points at the last node of the output (figure 10.4).
tail always points at the last node, where the next one is attached.merge_two takes both lists by value. When the right list has the smaller front node, the two lists swap, so
left always holds the node to move. The only mutable reference is tail. This keeps the loop readable and
reduces the mutable borrow fatigue: there is one borrow to follow.
Listing 10.1 The node type and merge_two (lines 4 to 27). src/bin/merge_k_sorted_lists_owned.rs
type Link = Option<Box<Node>>;
struct Node {
val: i32,
next: Link,
}
/// Merge two sorted lists. O(n) time, O(1) extra memory.
fn merge_two(mut left: Link, mut right: Link) -> Link {
let mut dummy = Box::new(Node { val: 0, next: None });
let mut tail = &mut dummy;
while let (Some(l), Some(r)) = (&left, &right) {
if r.val < l.val {
(left, right) = (right, left); // left now holds the smaller front
}
let mut node = left.unwrap(); // safe: checked by while let
left = node.next.take();
tail = tail.next.insert(node);
}
tail.next = left.or(right);
dummy.next
}
while let (Some(l), Some(r)) = (&left, &right) runs while both lists have a node. &left and &right are
shared references, used only to read the two front values.
If the right front is smaller, (left, right) = (right, left) swaps the two lists. The assignment moves the two
Option values; no node moves, and no reference is taken. After it, the smaller front node is always in left.
left.unwrap() moves that list out of left. It cannot fail, because the while let checked that
left is Some. node.next.take() cuts the front node off, and the rest of the list goes back into left.
tail.next.insert(node) attaches the node at the end of the output and returns a mutable reference to
it. That node is the new tail.
When the loop ends, one list is empty. left.or(right) returns the other one, and tail.next takes it whole.
Animation 10.2 runs merge_two one line at a time on the lists of the trace below. The table beside the lists
shows what each variable owns or borrows after every line. The last part reads l after the swap, which does not
compile.
merge_two line by line. l and r borrow the front nodes until the comparison. unwrap moves the list into node, and take returns the rest to left. tail is the only mutable reference. Reading l after the swap does not compile.Trace the merge of 1 → 4 with 2 → 3 → 5:
| Pass | left | right | Swap? | Node moved | Output after dummy |
|---|---|---|---|---|---|
| 1 | 1 → 4 | 2 → 3 → 5 | no | 1 | 1 |
| 2 | 4 | 2 → 3 → 5 | yes: 2 < 4 | 2 | 1 → 2 |
| 3 | 3 → 5 | 4 | no | 3 | 1 → 2 → 3 |
| 4 | 5 | 4 | yes: 4 < 5 | 4 | 1 → 2 → 3 → 4 |
| 5 | None | 5 | the loop ends; or attaches 5 | 1 → 2 → 3 → 4 → 5 |
Each pass moves one node, so merging n nodes takes O(n) time. The merge uses O(1) extra memory, and allocates only the dummy node.
Listing 10.2 merge_k (lines 29 to 47).
/// Merge k sorted lists in place: round 1 merges lists 1 apart, round 2 lists
/// 2 apart, and so on. O(N log k) time, O(1) extra memory.
fn merge_k(mut lists: Vec<Link>) -> Link {
if lists.is_empty() {
return None;
}
let size = lists.len();
let mut gap = 1;
while gap < size {
let mut i = 0;
while i + gap < size {
lists[i] = merge_two(lists[i].take(), lists[i + gap].take());
i += gap * 2;
}
gap *= 2;
}
lists[0].take()
}
merge_k follows figure 10.3. The outer while doubles gap. The inner while steps i by gap * 2 and merges
position i with position i + gap. lists[i].take() moves each list out of its slot and leaves None, so
merge_two receives two owned lists.
Animation 10.3 runs the merge of the trace. The last part leaves out the line after the loop.
left always holds the next node. When one list runs out, or attaches the other. Without that line, the nodes left in right are dropped.$ cargo run --bin merge_k_sorted_lists_owned
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 1, 1, 3, 5]
[]
The complete program is listing 10.5 at the end of the chapter.
10.4 Borrowing two elements of one Vec
merge_k moves both lists out of the vector before the call. A merge that takes two &mut lists instead cannot be
called on two slots of the same vector:
merge_two(&mut lists[i], &mut lists[i + gap]) // does not compile
Both &mut borrows are borrows of the whole vector lists, and Rust allows only one mutable borrow of a value at
a time. The compiler does not reason about the two indexes being different.
split_at_mut(i + gap) splits the vector into two slices that do not overlap: the part before position i + gap,
and the rest. It returns a mutable borrow of each. In the long version of this chapter, listing C.1 merges this
way:
let (left, right) = lists.split_at_mut(i + interval);
left[i] = Self::merge_two(&mut left[i], &mut right[0]);
Now left[i] and right[0] come from different slices, and borrowing both is allowed. Section 10.6 uses
split_at_mut to split a slice of lists in half.
10.5 A heap of list heads
This is the third strategy from figure 10.2. The heap does not hold nodes. It holds pairs of (front value, list number), wrapped in Reverse to make it a min-heap, as in chapter 5. Storing the list number instead of the
node keeps the nodes in their lists, and avoids needing an ordering on nodes.
Listing 10.3 merge_k_lists (lines 10 to 33). src/bin/merge_k_sorted_lists_heap.rs
fn merge_k_lists(mut lists: Vec<Link>) -> Link {
let mut heap = BinaryHeap::new();
for (i, list) in lists.iter().enumerate() {
if let Some(node) = list {
heap.push(Reverse((node.data, i)));
}
}
let mut dummy = Box::new(ListNode {
data: 0,
next: None,
});
let mut tail = &mut dummy;
while let Some(Reverse((_, i))) = heap.pop() {
let mut node = lists[i].take().unwrap();
lists[i] = node.next.take();
if let Some(next) = &lists[i] {
heap.push(Reverse((next.data, i)));
}
tail = tail.next.insert(node);
}
dummy.next
}
Each pass of the loop:
- Pops the smallest pair. Its second field,
i, says which list holds that value. - Takes the front node out of
lists[i]and puts the rest of that list back. - If the list still has a node, pushes that node’s value with
i. - Attaches the taken node to the output with
tail.next.insert(node), as in listing 10.1.
When two fronts have equal values, the pairs compare by list number, so the lower-numbered list goes first.
The heap needs only the current front of each list. That makes this strategy the right one when the lists are too large to hold in memory. One example is k sorted files read line by line.
$ cargo run --bin merge_k_sorted_lists_heap
1 1 2 3 4 4 5 6
The complete program is listing 10.6 at the end of the chapter.
10.6 Recursion on halves
This version merges from the top down. It splits the lists into two halves, merges each half recursively, and
merges the two results. Its node holds an i32 named val, and List is the alias for a whole list:
#[derive(Debug)]
struct ListNode {
val: i32,
next: Option<Box<ListNode>>,
}
type List = Option<Box<ListNode>>;
Listing 10.4 The merge and the recursion on a slice (lines 11 to 34). src/bin/merge_k_sorted_lists_halves.rs
fn merge(a: List, b: List) -> List {
match (a, b) {
(None, rest) | (rest, None) => rest,
(Some(mut left), Some(mut right)) => {
if left.val > right.val {
mem::swap(&mut left, &mut right);
}
left.next = merge(left.next.take(), Some(right));
Some(left)
}
}
}
fn merge_k_slice(lists: &mut [List]) -> List {
match lists.len() {
0 => None,
1 => lists[0].take(),
_ => {
let mid = lists.len() / 2;
let (left_half, right_half) = lists.split_at_mut(mid);
merge(merge_k_slice(left_half), merge_k_slice(right_half))
}
}
}
The first arm of merge, (None, rest) | (rest, None) => rest, handles an empty list on either side: the other
list is the answer. In the second arm, mem::swap makes left the list with the smaller front. Its node stays at
the front, and its next becomes the merge of the rest of left with right. No node is allocated; the boxes
move.
merge_k_slice works on a slice, &mut [List], and splits it with split_at_mut from section 10.4. Splitting a
slice creates two views of the same memory, so nothing is allocated to split.
merge calls itself once for each node of its output. The last merge outputs all N nodes, so the stack reaches
O(N) frames. In section 9.6, a recursive drop overflowed the 8 MiB main-thread stack between 260,000 and 270,000
nodes. For long lists, use the loop of section 10.3.
$ cargo run --bin merge_k_sorted_lists_halves
Input lists:
[1 -> 4 -> 5]
[1 -> 3 -> 4]
[2 -> 6]
Merged list:
[1 -> 1 -> 2 -> 3 -> 4 -> 4 -> 5 -> 6]
The complete program is listing 10.7 at the end of the chapter.
10.7 The three programs side by side
In the table, N is the total number of nodes and k the number of lists. The extra memory column counts memory
beyond the input Vec and the nodes. The stack column is the deepest the call stack goes, in frames.
| Program | Strategy | Time | Extra memory | Stack | Allocations | How a merge moves nodes |
|---|---|---|---|---|---|---|
merge_k_sorted_lists_owned | gap rounds | O(N log k) | O(1) | O(1) | one dummy node per merge | lists swapped by value, so tail is the only &mut |
merge_k_sorted_lists_heap | heap of heads | O(N log k) | O(k) | O(1) | one dummy node | list numbers in the heap, Option::insert |
merge_k_sorted_lists_halves | recursive halves | O(N log k) | O(1) | O(N) | none | split_at_mut, recursive merge |
- Time. Each node moves about log₂ k times: O(N log k). The “one at a time” strategy of figure 10.2 would be O(kN).
- Extra memory. The heap holds one entry per list: O(k). The other two programs reuse the input
Vec. - Stack. The recursive merge makes one call per node of its output, so its stack reaches O(N) frames. The loop and the heap use a fixed number of frames.
- Allocations. The gap rounds allocate one dummy node per merge, k - 1 in total. The recursive merge allocates nothing.
10.8 The complete files
Listing 10.5 Gap rounds with the lists swapped by value. src/bin/merge_k_sorted_lists_owned.rs
//! Merge k sorted lists: the gap loop, and a merge that swaps the two lists by
//! value so that `left` always holds the smaller front node.
type Link = Option<Box<Node>>;
struct Node {
val: i32,
next: Link,
}
/// Merge two sorted lists. O(n) time, O(1) extra memory.
fn merge_two(mut left: Link, mut right: Link) -> Link {
let mut dummy = Box::new(Node { val: 0, next: None });
let mut tail = &mut dummy;
while let (Some(l), Some(r)) = (&left, &right) {
if r.val < l.val {
(left, right) = (right, left); // left now holds the smaller front
}
let mut node = left.unwrap(); // safe: checked by while let
left = node.next.take();
tail = tail.next.insert(node);
}
tail.next = left.or(right);
dummy.next
}
/// Merge k sorted lists in place: round 1 merges lists 1 apart, round 2 lists
/// 2 apart, and so on. O(N log k) time, O(1) extra memory.
fn merge_k(mut lists: Vec<Link>) -> Link {
if lists.is_empty() {
return None;
}
let size = lists.len();
let mut gap = 1;
while gap < size {
let mut i = 0;
while i + gap < size {
lists[i] = merge_two(lists[i].take(), lists[i + gap].take());
i += gap * 2;
}
gap *= 2;
}
lists[0].take()
}
fn build(values: &[i32]) -> Link {
let mut head = None;
for &val in values.iter().rev() {
head = Some(Box::new(Node { val, next: head }));
}
head
}
fn to_vec(mut current: Link) -> Vec<i32> {
let mut out = Vec::new();
while let Some(node) = current {
out.push(node.val);
current = node.next;
}
out
}
fn main() {
let lists = vec![
build(&[1, 4, 7]),
build(&[2, 5, 8]),
build(&[0, 3, 6, 9]),
None,
];
println!("{:?}", to_vec(merge_k(lists))); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
println!(
"{:?}",
to_vec(merge_k(vec![build(&[1, 1, 5]), build(&[1, 3])]))
); // [1, 1, 1, 3, 5]
println!("{:?}", to_vec(merge_k(vec![]))); // []
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_two_lists_keeping_duplicates() {
let merged = merge_two(build(&[1, 1, 5]), build(&[1, 3]));
assert_eq!(to_vec(merged), vec![1, 1, 1, 3, 5]);
}
#[test]
fn merges_with_empty_lists() {
assert_eq!(to_vec(merge_two(None, build(&[2, 4]))), vec![2, 4]);
assert_eq!(to_vec(merge_two(build(&[2, 4]), None)), vec![2, 4]);
assert!(merge_two(None, None).is_none());
assert!(merge_k(vec![]).is_none());
assert!(merge_k(vec![None, None]).is_none());
}
#[test]
fn merges_an_odd_number_of_lists() {
let five = vec![
build(&[9]),
build(&[1, 5]),
build(&[3]),
build(&[2, 8]),
build(&[4]),
];
assert_eq!(to_vec(merge_k(five)), vec![1, 2, 3, 4, 5, 8, 9]);
}
#[test]
fn merges_many_long_lists() {
let lists: Vec<Link> = (0..7)
.map(|k| {
build(
&(0..100)
.map(|i| i * 7 + k)
.collect::<Vec<_>>(),
)
})
.collect();
let expected: Vec<i32> = (0..700).collect();
assert_eq!(to_vec(merge_k(lists)), expected);
}
}
Listing 10.6 A heap of list heads. src/bin/merge_k_sorted_lists_heap.rs
use std::{cmp::Reverse, collections::BinaryHeap};
struct ListNode {
data: u32,
next: Option<Box<ListNode>>,
}
type Link = Option<Box<ListNode>>;
fn merge_k_lists(mut lists: Vec<Link>) -> Link {
let mut heap = BinaryHeap::new();
for (i, list) in lists.iter().enumerate() {
if let Some(node) = list {
heap.push(Reverse((node.data, i)));
}
}
let mut dummy = Box::new(ListNode {
data: 0,
next: None,
});
let mut tail = &mut dummy;
while let Some(Reverse((_, i))) = heap.pop() {
let mut node = lists[i].take().unwrap();
lists[i] = node.next.take();
if let Some(next) = &lists[i] {
heap.push(Reverse((next.data, i)));
}
tail = tail.next.insert(node);
}
dummy.next
}
fn main() {
let mut lists = Vec::new();
for values in [vec![1, 4, 5], vec![1, 3, 4], vec![2, 6]] {
let mut head = None;
for data in values.into_iter().rev() {
head = Some(Box::new(ListNode { data, next: head }));
}
lists.push(head);
}
let mut cur = merge_k_lists(lists);
while let Some(node) = cur {
print!("{} ", node.data);
cur = node.next;
}
println!();
}
Listing 10.7 Recursion on halves. src/bin/merge_k_sorted_lists_halves.rs
use std::mem;
#[derive(Debug)]
struct ListNode {
val: i32,
next: Option<Box<ListNode>>,
}
type List = Option<Box<ListNode>>;
fn merge(a: List, b: List) -> List {
match (a, b) {
(None, rest) | (rest, None) => rest,
(Some(mut left), Some(mut right)) => {
if left.val > right.val {
mem::swap(&mut left, &mut right);
}
left.next = merge(left.next.take(), Some(right));
Some(left)
}
}
}
fn merge_k_slice(lists: &mut [List]) -> List {
match lists.len() {
0 => None,
1 => lists[0].take(),
_ => {
let mid = lists.len() / 2;
let (left_half, right_half) = lists.split_at_mut(mid);
merge(merge_k_slice(left_half), merge_k_slice(right_half))
}
}
}
fn create_list(arr: &[i32]) -> List {
let mut head = None;
let mut tail = &mut head;
for &val in arr {
*tail = Some(Box::new(ListNode { val, next: None }));
if let Some(node) = tail {
tail = &mut node.next;
}
}
head
}
fn print_list(mut list: &List) {
let mut elements = Vec::new();
while let Some(node) = list {
elements.push(node.val.to_string());
list = &node.next;
}
println!("[{}]", elements.join(" -> "));
}
fn main() {
let mut lists = vec![
create_list(&[1, 4, 5]),
create_list(&[1, 3, 4]),
create_list(&[2, 6]),
];
println!("Input lists:");
for list in &lists {
print_list(list);
}
let merged = merge_k_slice(&mut lists);
println!("\nMerged list:");
print_list(&merged);
}
Summary
- Merging two sorted lists moves the smaller front node to the output until one list is empty, in O(a + b).
- Merging k lists one at a time costs O(kN). Pairwise rounds and a heap of heads both cost O(N log k).
- Swapping the two lists by value keeps the smaller front in
left, sotailis the only mutable reference. Option::insertattaches a node and returns a reference to it, sotailmoves in one call.- Two
&mutborrows of oneVecdo not compile.split_at_mutsplits it into two slices that do not overlap. - A heap of
(value, list number)pairs needs only the front of each list, which suits lists read from disk. - A recursive merge is short, but it recurses once per output node.
Chapter 11 moves from lists to trees, where each node has up to two children. It ends with tries, where each node has one child per letter.
Exercises
- Write the “one at a time” merge and time it against listing 10.5 on 200 lists of 5,000 nodes each.
- Make
merge_twofrom listing 10.1 generic over anyT: Ord. - Write a k-way merge of sorted text files with the heap strategy, keeping only one line per file in memory.
- Rewrite
mergefrom listing 10.4 as a loop with the swap of listing 10.1. Test it on two lists of a million nodes each.
Trees and tries
This chapter covers
- The vocabulary of trees: root, leaf, child, subtree, depth
- A binary tree as an enum, and the three depth-first visiting orders
- Binary search trees: insert, search, remove, and range queries, written two ways
- Trees with any number of children: depth-first and breadth-first visits, and grouping by level
- Saving a tree to a file and loading it back
- A trie, a tree for finding words by their first letters
In a linked list, each node points to at most one next node. In a tree, each node can point to several nodes, called its children. Trees describe anything organized as a hierarchy. Examples are folders on a disk, the parts of an expression, and a sorted set of numbers arranged for fast search.
Trees are easier to write in Rust than the doubly linked list of chapter 9. Each node has exactly one parent,
so a parent can own its children outright, with Box or with a Vec. No Rc or RefCell is needed. The
harder parts are elsewhere: replacing a node from inside a method, and choosing between recursion and an
explicit stack or queue.
The chapter starts with vocabulary, then binary trees, then binary search trees, then trees with any number of children, and ends with the trie.
11.1 Tree vocabulary
Figure 11.1 names the parts of a tree.
- The root is the one node with no parent. Trees are drawn with the root at the top.
- A leaf is a node with no children.
- The subtree of a node is that node and everything below it. Every subtree is a tree itself. So a function on a tree can call itself on each subtree, which is recursion.
- The depth of a tree is the number of levels from the root down to its deepest leaf.
A binary tree is a tree in which each node has at most two children, called the left child and the right child.
11.2 A binary tree as an enum
A binary tree is either empty, or a node holding a value and two smaller binary trees. That sentence translates directly into an enum:
Listing 11.1 The tree type (lines 1 to 16). src/problems/binary_tree.rs
//! Binary tree as an algebraic data type.
//!
//! ```text
//! Tree = Empty
//! | Node { value, left: Box<Tree>, right: Box<Tree> }
//! ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tree {
Empty,
Node {
value: i32,
left: Box<Tree>,
right: Box<Tree>,
},
}
The children are Box<Tree> for the reason chapter 8 gave. A Tree cannot contain two Tree values
directly, because its size would have no end. A Box is one pointer, so a node has a fixed size.
11.2.1 Depth and inversion
Listing 11.2 Depth and inversion (lines 19 to 35).
impl Tree {
pub fn max_depth(&self) -> usize {
match self {
Tree::Empty => 0,
Tree::Node { left, right, .. } => 1 + left.max_depth().max(right.max_depth()),
}
}
pub fn invert(&mut self) {
match self {
Tree::Empty => {}
Tree::Node { left, right, .. } => {
left.invert();
right.invert();
std::mem::swap(left, right);
}
}
}
// ...
}
Each method is a match with two arms. The Empty arm is the base case, the input small enough to answer
directly. The Node arm calls the method on the children and combines the results.
max_depth reads as a definition. An empty tree has depth 0. A node has depth 1 plus the larger depth of
its two subtrees. For the tree in figure 11.1: the leaves have depth 1, node 20 has depth 2, and the root has
depth 3.
invert swaps every node’s left and right children, so the tree becomes its mirror image. It inverts both
subtrees first and then calls std::mem::swap(left, right). The pattern binds left and right as mutable
references to the boxes, so the swap exchanges two pointers and moves no nodes.
11.2.2 The three depth-first orders
Visiting every node of a tree once is called a traversal. Going down one branch as far as possible before trying the next is depth-first traversal. There are three common depth-first orders. They differ only in when a node is visited relative to its children (figure 11.2).
- Pre-order visits the node first, then its left subtree, then its right subtree.
- In-order visits the left subtree, then the node, then the right subtree.
- Post-order visits both subtrees, then the node.
Listing 11.3 The public traversal methods (lines 37 to 53).
impl Tree {
// ...
pub fn preorder(&self) -> Vec<i32> {
let mut out = Vec::new();
self.collect_preorder(&mut out);
out
}
pub fn inorder(&self) -> Vec<i32> {
let mut out = Vec::new();
self.collect_inorder(&mut out);
out
}
pub fn postorder(&self) -> Vec<i32> {
let mut out = Vec::new();
self.collect_postorder(&mut out);
out
}
// ...
}
Each public method creates an empty Vec, passes it to a private helper, and returns it. Creating the vector
once and passing it down avoids building a new vector at every node.
Listing 11.4 The recursive helpers (lines 55 to 86).
impl Tree {
// ...
fn collect_preorder(&self, out: &mut Vec<i32>) {
match self {
Tree::Empty => {}
Tree::Node { value, left, right } => {
out.push(*value);
left.collect_preorder(out);
right.collect_preorder(out);
}
}
}
fn collect_inorder(&self, out: &mut Vec<i32>) {
match self {
Tree::Empty => {}
Tree::Node { value, left, right } => {
left.collect_inorder(out);
out.push(*value);
right.collect_inorder(out);
}
}
}
fn collect_postorder(&self, out: &mut Vec<i32>) {
match self {
Tree::Empty => {}
Tree::Node { value, left, right } => {
left.collect_postorder(out);
right.collect_postorder(out);
out.push(*value);
}
}
}
}
The three helpers are identical except for one line: where out.push(*value) appears relative to the two
recursive calls. *value copies the i32 out of the reference.
Listing 11.5 The complete file, with its tests. src/problems/binary_tree.rs
//! Binary tree as an algebraic data type.
//!
//! ```text
//! Tree = Empty
//! | Node { value, left: Box<Tree>, right: Box<Tree> }
//! ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tree {
Empty,
Node {
value: i32,
left: Box<Tree>,
right: Box<Tree>,
},
}
impl Tree {
pub fn max_depth(&self) -> usize {
match self {
Tree::Empty => 0,
Tree::Node { left, right, .. } => 1 + left.max_depth().max(right.max_depth()),
}
}
pub fn invert(&mut self) {
match self {
Tree::Empty => {}
Tree::Node { left, right, .. } => {
left.invert();
right.invert();
std::mem::swap(left, right);
}
}
}
pub fn preorder(&self) -> Vec<i32> {
let mut out = Vec::new();
self.collect_preorder(&mut out);
out
}
pub fn inorder(&self) -> Vec<i32> {
let mut out = Vec::new();
self.collect_inorder(&mut out);
out
}
pub fn postorder(&self) -> Vec<i32> {
let mut out = Vec::new();
self.collect_postorder(&mut out);
out
}
fn collect_preorder(&self, out: &mut Vec<i32>) {
match self {
Tree::Empty => {}
Tree::Node { value, left, right } => {
out.push(*value);
left.collect_preorder(out);
right.collect_preorder(out);
}
}
}
fn collect_inorder(&self, out: &mut Vec<i32>) {
match self {
Tree::Empty => {}
Tree::Node { value, left, right } => {
left.collect_inorder(out);
out.push(*value);
right.collect_inorder(out);
}
}
}
fn collect_postorder(&self, out: &mut Vec<i32>) {
match self {
Tree::Empty => {}
Tree::Node { value, left, right } => {
left.collect_postorder(out);
right.collect_postorder(out);
out.push(*value);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn leaf(value: i32) -> Tree {
Tree::Node {
value,
left: Box::new(Tree::Empty),
right: Box::new(Tree::Empty),
}
}
fn node(value: i32, left: Tree, right: Tree) -> Tree {
Tree::Node {
value,
left: Box::new(left),
right: Box::new(right),
}
}
fn sample() -> Tree {
node(3, leaf(9), node(20, leaf(15), leaf(7)))
}
#[test]
fn max_depth_works() {
assert_eq!(Tree::Empty.max_depth(), 0);
assert_eq!(leaf(1).max_depth(), 1);
assert_eq!(sample().max_depth(), 3);
}
#[test]
fn invert_swaps_subtrees() {
let mut tree = sample();
tree.invert();
match &tree {
Tree::Node { left, right, .. } => {
match left.as_ref() {
Tree::Node { value, .. } => assert_eq!(*value, 20),
_ => panic!("expected left node"),
}
match right.as_ref() {
Tree::Node { value, .. } => assert_eq!(*value, 9),
_ => panic!("expected right node"),
}
}
_ => panic!("expected node"),
}
}
#[test]
fn traversals_work() {
let tree = sample();
assert_eq!(tree.preorder(), vec![3, 9, 20, 15, 7]);
assert_eq!(tree.inorder(), vec![9, 3, 15, 20, 7]);
assert_eq!(tree.postorder(), vec![9, 15, 7, 20, 3]);
}
}
The test module defines two small helpers, leaf and node, so a test tree can be built in one readable
expression. sample() builds the tree of figure 11.1. The traversal test checks all three orders from figure
11.2.
11.3 Binary search trees
A binary search tree (BST) is a binary tree with an ordering rule. For every node, all values in its left subtree are smaller than the node’s value, and all values in its right subtree are larger.
The rule makes searching fast. To find a value, compare it with the root. If it is smaller, it can only be in the left subtree; if larger, only in the right. Each comparison discards one subtree (figure 11.3).
The code for this chapter has two implementations of the same BST. They agree on inserting and searching, and differ in how they remove values and answer range queries. I’ll go through the first fully, then the parts where the second differs.
11.3.1 The type, insertion, and search
Listing 11.6 The type (lines 1 to 14). src/bin/bst_clean.rs
use std::cmp::Ordering;
type TreeLink<T> = Box<BST<T>>;
#[derive(Debug, Clone, Default)]
pub enum BST<T> {
#[default]
Empty,
Node {
value: T,
left: TreeLink<T>,
right: TreeLink<T>,
},
}
BST<T> is generic over the value type. The methods require T: Ord, meaning values of type T can be
ordered, which a search tree needs. #[default] on Empty makes an empty tree the default value, which the
removal code uses.
Listing 11.7 Creating, inserting, and searching (lines 17 to 47).
impl<T: Ord> BST<T> {
pub fn new() -> Self {
Self::Empty
}
pub fn insert(&mut self, val: T) {
match self {
Self::Empty => {
*self = Self::Node {
value: val,
left: Box::new(Self::Empty),
right: Box::new(Self::Empty),
};
}
Self::Node { value, left, right } => match val.cmp(value) {
Ordering::Less => left.insert(val),
Ordering::Greater => right.insert(val),
Ordering::Equal => {}
},
}
}
pub fn contains(&self, val: &T) -> bool {
match self {
Self::Empty => false,
Self::Node { value, left, right } => match val.cmp(value) {
Ordering::Less => left.contains(val),
Ordering::Greater => right.contains(val),
Ordering::Equal => true,
},
}
}
// ...
}
insert takes &mut self because it changes the tree in place. It walks down with recursive calls. When it
reaches an Empty tree, it replaces that spot with a new leaf: *self = Self::Node { ... }. On the way down,
val.cmp(value) compares the new value with the node’s and returns Ordering::Less, Greater, or Equal. A
value equal to one already in the tree is ignored, so the tree holds each value once.
contains follows the same path without changing anything.
11.3.2 Removal
Removing a value is the hardest operation, because the tree must keep its ordering rule. There are three cases (figure 11.4).
- No left child. Replace the node with its right subtree.
- No right child. Replace the node with its left subtree.
- Two children. Keep the node, but give it a new value: the smallest value in its right subtree. That value is larger than everything on the left and smaller than everything else on the right, so the rule still holds. Then remove that smallest value from the right subtree. The smallest value has no left child, so its removal is case 1.
Listing 11.8 Removal (lines 49 to 90).
impl<T: Ord> BST<T> {
// ...
pub fn remove(&mut self, val: &T) -> bool {
match self {
Self::Empty => false,
Self::Node { value, left, right } => match val.cmp(value) {
Ordering::Less => left.remove(val),
Ordering::Greater => right.remove(val),
Ordering::Equal => {
if left.is_empty() && right.is_empty() {
*self = Self::Empty;
return true;
}
if left.is_empty() {
*self = *std::mem::take(right);
return true;
}
if right.is_empty() {
*self = *std::mem::take(left);
return true;
}
let min_val = right.remove_min().unwrap();
*value = min_val;
true
}
},
}
}
fn remove_min(&mut self) -> Option<T> {
match self {
Self::Empty => None,
Self::Node { left, .. } if left.is_empty() => {
let node = std::mem::take(self);
if let Self::Node { value, right, .. } = node {
*self = *right;
Some(value)
} else {
unreachable!()
}
}
Self::Node { left, .. } => left.remove_min(),
}
}
// ...
}
The first two cases need to replace *self with one of its own children. That is not straightforward, because
right is a reference into *self. You cannot move a subtree out through right and assign it to *self in
one step. std::mem::take(right) solves it: it moves the subtree out and leaves the default value, an empty
tree, in its place. Then *self = *std::mem::take(right) assigns the subtree, and the * moves it out of its
Box. take needs Default, which is why the type derives it.
The code also handles a leaf, a node with no children, separately. Case 1 would already cover it.
For case 3, remove_min removes and returns the smallest value of the right subtree. It walks left until it
reaches a node whose left child is empty, using a match guard: Self::Node { left, .. } if left.is_empty().
That node holds the smallest value. The method takes the whole node out with std::mem::take(self), puts its
right subtree in its place, and returns its value. The if let ... else { unreachable!() } is there because
the compiler cannot see that the value taken from a Node arm is a Node. unreachable!() panics if that
ever turns out false.
remove returns true if the value was found and removed, and false otherwise, so a caller can tell the two
apart.
11.3.3 Range queries
A range query returns every value between a low and a high bound, in order. A BST can answer it without visiting subtrees that cannot contain an answer.
Listing 11.9 Range queries (lines 92 to 114).
impl<T: Ord> BST<T> {
// ...
pub fn range(&self, low: &T, high: &T) -> Vec<&T> {
let mut result = Vec::new();
self.range_helper(low, high, &mut result);
result
}
// Explicit lifetime 'a ties the references in the vector to the lifetime of &self.
fn range_helper<'a>(&'a self, low: &T, high: &T, acc: &mut Vec<&'a T>) {
match self {
Self::Empty => {}
Self::Node { value, left, right } => {
if value < low {
right.range_helper(low, high, acc);
} else if value > high {
left.range_helper(low, high, acc);
} else {
left.range_helper(low, high, acc);
acc.push(value);
right.range_helper(low, high, acc);
}
}
}
}
// ...
}
range_helper collects matching values into an accumulator, acc. Its three branches skip work:
- If the node’s value is below
low, everything in its left subtree is lower still, so only the right subtree is searched. - If the value is above
high, only the left subtree is searched. - Otherwise, the helper searches the left subtree, adds the value, and searches the right subtree. That order is in-order traversal, so the results come out sorted.
The signature has an explicit lifetime: fn range_helper<'a>(&'a self, ..., acc: &mut Vec<&'a T>). The
references pushed into acc point into the tree, so they must live as long as the tree borrow &'a self.
Without the named 'a, the elision rules from chapter 4 would give self and the vector’s elements
separate lifetimes. The push would then not compile.
Listing 11.10 The complete program. src/bin/bst_clean.rs
use std::cmp::Ordering;
type TreeLink<T> = Box<BST<T>>;
#[derive(Debug, Clone, Default)]
pub enum BST<T> {
#[default]
Empty,
Node {
value: T,
left: TreeLink<T>,
right: TreeLink<T>,
},
}
impl<T: Ord> BST<T> {
pub fn new() -> Self {
Self::Empty
}
pub fn insert(&mut self, val: T) {
match self {
Self::Empty => {
*self = Self::Node {
value: val,
left: Box::new(Self::Empty),
right: Box::new(Self::Empty),
};
}
Self::Node { value, left, right } => match val.cmp(value) {
Ordering::Less => left.insert(val),
Ordering::Greater => right.insert(val),
Ordering::Equal => {}
},
}
}
pub fn contains(&self, val: &T) -> bool {
match self {
Self::Empty => false,
Self::Node { value, left, right } => match val.cmp(value) {
Ordering::Less => left.contains(val),
Ordering::Greater => right.contains(val),
Ordering::Equal => true,
},
}
}
pub fn remove(&mut self, val: &T) -> bool {
match self {
Self::Empty => false,
Self::Node { value, left, right } => match val.cmp(value) {
Ordering::Less => left.remove(val),
Ordering::Greater => right.remove(val),
Ordering::Equal => {
if left.is_empty() && right.is_empty() {
*self = Self::Empty;
return true;
}
if left.is_empty() {
*self = *std::mem::take(right);
return true;
}
if right.is_empty() {
*self = *std::mem::take(left);
return true;
}
let min_val = right.remove_min().unwrap();
*value = min_val;
true
}
},
}
}
fn remove_min(&mut self) -> Option<T> {
match self {
Self::Empty => None,
Self::Node { left, .. } if left.is_empty() => {
let node = std::mem::take(self);
if let Self::Node { value, right, .. } = node {
*self = *right;
Some(value)
} else {
unreachable!()
}
}
Self::Node { left, .. } => left.remove_min(),
}
}
pub fn range(&self, low: &T, high: &T) -> Vec<&T> {
let mut result = Vec::new();
self.range_helper(low, high, &mut result);
result
}
// Explicit lifetime 'a ties the references in the vector to the lifetime of &self.
fn range_helper<'a>(&'a self, low: &T, high: &T, acc: &mut Vec<&'a T>) {
match self {
Self::Empty => {}
Self::Node { value, left, right } => {
if value < low {
right.range_helper(low, high, acc);
} else if value > high {
left.range_helper(low, high, acc);
} else {
left.range_helper(low, high, acc);
acc.push(value);
right.range_helper(low, high, acc);
}
}
}
}
fn is_empty(&self) -> bool {
matches!(self, Self::Empty)
}
}
fn main() {
let mut bst = BST::new();
bst.insert(10);
bst.insert(5);
bst.insert(15);
bst.insert(3);
bst.insert(7);
bst.insert(12);
bst.insert(18);
println!("Contains 7: {}", bst.contains(&7));
println!("Contains 12: {}", bst.contains(&12));
let range = bst.range(&4, &16);
println!("Values in [4, 16]: {:?}", range);
println!("Remove 5: {}", bst.remove(&5));
println!("Remove 10: {}", bst.remove(&10));
println!("Remove 99: {}", bst.remove(&99));
println!("After removals: {:#?}", bst);
}
$ cargo run --bin bst_clean
Contains 7: true
Contains 12: true
Values in [4, 16]: [5, 7, 10, 12, 15]
Remove 5: true
Remove 10: true
Remove 99: false
After removals: Node {
value: 12,
left: Node {
value: 7,
left: Node {
value: 3,
left: Empty,
right: Empty,
},
right: Empty,
},
right: Node {
value: 15,
left: Empty,
right: Node {
value: 18,
left: Empty,
right: Empty,
},
},
}
Follow the two removals by hand. Node 5 has two children, 3 and 7. So it takes 7, the smallest value on its right, and the old 7 leaf is removed. Node 10 also has two children, so the root takes 12, the smallest value in its right subtree. The printed tree shows 12 at the root and 7 where 5 was.
11.3.4 A second BST, with an iterative minimum
The second implementation writes removal and range queries differently.
Listing 11.11 Removal and pop_min (lines 49 to 84). src/bin/bst_easy.rs
use std::{
cmp::Ordering::{Equal, Greater, Less},
mem,
};
impl<T: Ord> BST<T> {
// ...
pub fn remove(&mut self, val: &T) {
match self {
Self::Empty => {}
Self::Node { value, left, right } => match val.cmp(value) {
Less => left.remove(val),
Greater => right.remove(val),
Equal => {
if left.is_empty() {
*self = right.take();
} else if right.is_empty() {
*self = left.take();
} else {
*value = right.pop_min().unwrap();
}
}
},
}
}
pub fn pop_min(&mut self) -> Option<T> {
let mut node = self;
while node.has_left() {
if let Self::Node { left, .. } = node {
node = left;
}
}
match node.take() {
Self::Empty => None,
Self::Node { value, right, .. } => {
*node = *right;
Some(value)
}
}
}
// ...
}
This remove uses a helper take, defined at the end of the file, that swaps in Empty with
mem::replace. So the type does not need Default. right.take() reaches through the Box automatically
and returns the tree inside.
pop_min finds the smallest value with a loop instead of recursion. It moves a mutable reference, node, down
the left side of the tree:
let mut node = self;
while node.has_left() {
if let Self::Node { left, .. } = node {
node = left;
}
}
When the loop ends, node refers to the leftmost node. match node.take() splits it into its value and its
right subtree, with no unreachable!. The loop uses one stack frame however deep the tree is. pop_min is also public, so the tree can serve as
a queue that always returns the smallest value.
Listing 11.12 A range query that returns a new vector (lines 86 to 102).
impl<T: Ord> BST<T> {
// ...
pub fn range(&self, from: &T, to: &T) -> Vec<&T> {
let mut result = Vec::new();
if let Self::Node { value, left, right } = self {
if from < value {
result.extend(left.range(from, to));
}
if from <= value && value <= to {
result.push(value);
}
if value < to {
result.extend(right.range(from, to));
}
}
result
}
// ...
}
This range returns a new Vec from each call and joins the children’s results with extend. It is shorter
than the accumulator version, and it allocates a vector at every node it visits, where the accumulator version
allocates one.
Listing 11.13 The complete program. src/bin/bst_easy.rs
use std::{
cmp::Ordering::{Equal, Greater, Less},
mem,
};
#[derive(Debug, Clone)]
pub enum BST<T> {
Empty,
Node {
value: T,
left: Box<BST<T>>,
right: Box<BST<T>>,
},
}
impl<T: Ord> BST<T> {
pub fn new() -> Self {
Self::Empty
}
pub fn insert(&mut self, val: T) {
match self {
Self::Empty => {
*self = Self::Node {
value: val,
left: Box::new(Self::Empty),
right: Box::new(Self::Empty),
};
}
Self::Node { value, left, right } => match val.cmp(value) {
Less => left.insert(val),
Greater => right.insert(val),
Equal => {}
},
}
}
pub fn contains(&self, val: &T) -> bool {
match self {
Self::Empty => false,
Self::Node { value, left, right } => match val.cmp(value) {
Less => left.contains(val),
Greater => right.contains(val),
Equal => true,
},
}
}
pub fn remove(&mut self, val: &T) {
match self {
Self::Empty => {}
Self::Node { value, left, right } => match val.cmp(value) {
Less => left.remove(val),
Greater => right.remove(val),
Equal => {
if left.is_empty() {
*self = right.take();
} else if right.is_empty() {
*self = left.take();
} else {
*value = right.pop_min().unwrap();
}
}
},
}
}
pub fn pop_min(&mut self) -> Option<T> {
let mut node = self;
while node.has_left() {
if let Self::Node { left, .. } = node {
node = left;
}
}
match node.take() {
Self::Empty => None,
Self::Node { value, right, .. } => {
*node = *right;
Some(value)
}
}
}
pub fn range(&self, from: &T, to: &T) -> Vec<&T> {
let mut result = Vec::new();
if let Self::Node { value, left, right } = self {
if from < value {
result.extend(left.range(from, to));
}
if from <= value && value <= to {
result.push(value);
}
if value < to {
result.extend(right.range(from, to));
}
}
result
}
fn is_empty(&self) -> bool {
match self {
Self::Empty => true,
Self::Node { .. } => false,
}
}
fn has_left(&self) -> bool {
match self {
Self::Empty => false,
Self::Node { left, .. } => !left.is_empty(),
}
}
fn take(&mut self) -> Self {
mem::replace(self, Self::Empty)
}
}
fn main() {
let mut bst = BST::new();
bst.insert(10);
bst.insert(5);
bst.insert(15);
bst.insert(3);
bst.insert(7);
bst.insert(12);
println!("Contains 7: {}", bst.contains(&7));
println!("Range 4 to 12: {:?}", bst.range(&4, &12));
bst.remove(&10);
println!("After removing 10: {:?}", bst.range(&0, &100));
println!("Pop min: {:?}", bst.pop_min());
println!("{:#?}", bst);
}
$ cargo run --bin bst_easy
Contains 7: true
Range 4 to 12: [5, 7, 10, 12]
After removing 10: [3, 5, 7, 12, 15]
Pop min: Some(3)
WARNING: Neither tree keeps itself balanced. Suppose you insert 1, 2, 3, and so on in increasing order. Each value becomes the right child of the
previous one, and the tree turns into a chain n levels deep. Searches
then take O(n), and every recursive method recurses n levels. The standard library’s BTreeMap stays balanced.
11.4 Trees with any number of children
Many real trees are not binary. A folder can hold any number of files and folders. A node that owns a
Vec<TreeNode<T>> of children describes such a tree. It needs no Box, because the Vec already keeps its
elements on the heap, so the node’s own size is fixed.
11.4.1 Depth first and breadth first
Figure 11.5 shows the two basic ways to visit such a tree. A depth-first visit goes down one branch completely before starting the next, as in section 11.2.2. A breadth-first visit goes level by level: the root, then all its children, then all their children.
Depth first is naturally recursive. Breadth first needs a queue: a collection where items leave in the order they arrived. You put the root in the queue. Then, repeatedly, you take the node at the front, visit it, and add its children at the back. Because children join at the back, every node of one level leaves the queue before any node of the next.
A node owns its value and a Vec of child nodes. The children are stored inside the vector itself, not behind pointers, so a node can have any number of them:
use std::collections::VecDeque;
struct TreeNode<T> {
value: T,
children: Vec<TreeNode<T>>, // Owned children directly inline
}
Listing 11.14 The complete program. src/bin/test_tree.rs
use std::collections::VecDeque;
struct TreeNode<T> {
value: T,
children: Vec<TreeNode<T>>, // Owned children directly inline
}
impl<T> TreeNode<T>
where
T: Clone + std::fmt::Display,
{
/// Recursive DFS (Pre-order) - No callbacks
pub fn dfs(&self) {
println!("{}", self.value);
for child in self.children.iter() {
child.dfs();
}
}
/// Level Order BFS - Idiomatic VecDeque::from([self])
pub fn bfs(&self) {
let mut queue = VecDeque::from([self]); // Idiomatic initialization
while let Some(node) = queue.pop_front() {
println!("{}", node.value);
for child in &node.children {
queue.push_back(child);
}
}
}
}
fn main() {
// --- Inline Construction (No variables, no push) ---
let root = TreeNode {
value: 1,
children: vec![
TreeNode {
value: 2,
children: vec![
TreeNode {
value: 4,
children: vec![],
},
TreeNode {
value: 5,
children: Vec::new(),
},
],
},
TreeNode {
value: 3,
children: vec![TreeNode {
value: 6,
children: Vec::new(),
}],
},
],
};
println!("=== DFS ===");
root.dfs();
// Output: 1, 2, 4, 5, 3, 6
println!("\n=== BFS ===");
root.bfs();
// Output: 1, 2, 3, 4, 5, 6
}
dfs prints the node and then calls itself on each child, which is pre-order.
bfs uses a VecDeque as the queue. VecDeque::from([self]) starts it with the root. The queue holds
references, &TreeNode<T>, so no node is moved or copied. pop_front takes from the front, and push_back
adds each child at the back.
The tree in main is written as one nested value. The shape of the code matches the shape of the tree, which
makes test data easy to check.
$ cargo run --bin test_tree
=== DFS ===
1
2
4
5
3
6
=== BFS ===
1
2
3
4
5
6
11.4.2 Grouping values by level
A common variation returns the values grouped by level, as a list of lists. The first list holds the root’s value, the second the values of its children, and so on. The file solves it twice.
Listing 11.15 The type and the first version (lines 1 to 20). src/bin/tree_under_pressure.rs
use std::collections::VecDeque;
#[derive(Debug)]
struct TreeNode<T> {
value: T,
children: Vec<TreeNode<T>>,
}
fn level_order<T>(root: &TreeNode<T>) -> Vec<Vec<&T>> {
let mut result = Vec::new();
let mut level = vec![root];
while !level.is_empty() {
result.push(level.iter().map(|n| &n.value).collect());
level = level
.iter()
.flat_map(|n| &n.children)
.collect();
}
result
}
level_order keeps one whole level in level, a Vec of references to nodes. Each pass of the loop does two
things. It adds the values of this level to the result. Then it builds the next level from all the children of
all the nodes in this level: level.iter().flat_map(|n| &n.children).collect(). flat_map turns each node into
its children and joins all those children into one sequence. The loop ends when a level has no nodes.
Listing 11.16 The second version, with a queue (lines 22 to 40).
fn level_order_readable<T>(root: &TreeNode<T>) -> Vec<Vec<&T>> {
let mut result = Vec::new();
let mut queue = VecDeque::from([(root)]);
while !queue.is_empty() {
let current_size = queue.len();
let mut level = Vec::with_capacity(current_size);
for _ in 1..=current_size {
let Some(node) = queue.pop_front() else { break };
level.push(&node.value);
queue.extend(&node.children);
}
result.push(level);
}
result
}
level_order_readable uses one queue, as bfs did. The trick is queue.len() at the start of each round. At
that moment, the queue holds exactly one level. So the loop pops exactly that many nodes, collecting their
values, while their children pile up behind them for the next round.
let Some(node) = queue.pop_front() else { break }; is a let-else statement. It binds node if the pattern
matches, and runs the else block, which must leave the loop or the function, if it does not.
queue.extend(&node.children) adds references to all the children at once.
Listing 11.17 The complete program. src/bin/tree_under_pressure.rs
use std::collections::VecDeque;
#[derive(Debug)]
struct TreeNode<T> {
value: T,
children: Vec<TreeNode<T>>,
}
fn level_order<T>(root: &TreeNode<T>) -> Vec<Vec<&T>> {
let mut result = Vec::new();
let mut level = vec![root];
while !level.is_empty() {
result.push(level.iter().map(|n| &n.value).collect());
level = level
.iter()
.flat_map(|n| &n.children)
.collect();
}
result
}
fn level_order_readable<T>(root: &TreeNode<T>) -> Vec<Vec<&T>> {
let mut result = Vec::new();
let mut queue = VecDeque::from([(root)]);
while !queue.is_empty() {
let current_size = queue.len();
let mut level = Vec::with_capacity(current_size);
for _ in 1..=current_size {
let Some(node) = queue.pop_front() else { break };
level.push(&node.value);
queue.extend(&node.children);
}
result.push(level);
}
result
}
fn main() {
let root: TreeNode<String> = TreeNode {
value: "root".into(),
children: vec![
TreeNode {
value: "branch_a".into(),
children: vec![
TreeNode {
value: "leaf_1".into(),
children: vec![],
},
TreeNode {
value: "leaf_2".into(),
children: vec![],
},
],
},
TreeNode {
value: "branch_b".into(),
children: vec![TreeNode {
value: "leaf_3".into(),
children: vec![],
}],
},
],
};
let levels = level_order(&root);
println!("Tree\n {:#?}", root);
for level in levels {
println!("{:?}", level);
}
println!("one queue, one level per round:");
for level in level_order_readable(&root) {
println!("{:?}", level);
}
}
main prints the tree with {:#?}, then the levels from each version. Both give the same three levels:
["root"]
["branch_a", "branch_b"]
["leaf_1", "leaf_2", "leaf_3"]
one queue, one level per round:
["root"]
["branch_a", "branch_b"]
["leaf_1", "leaf_2", "leaf_3"]
Both functions return Vec<Vec<&T>>, references into the tree, and both run in O(n).
11.5 Saving a tree to a file and loading it back
To save a tree, you turn it into a flat sequence of text. Later, you rebuild exactly the same tree from that text. Turning a structure into a flat sequence is called serialization.
A list of values in pre-order is not enough, because it does not say where one node’s children end. The file’s format adds, after each value, the number of children that follow (figure 11.6). A reader can then rebuild the tree: read a value and a count, then read that many subtrees.
Listing 11.18 The tree type, and writing the pairs (lines 1 to 47). src/bin/tree_serializ_deserialize_into_file.rs
use std::{collections::VecDeque, fs, io, str::FromStr};
#[derive(Debug)]
struct TreeNode<T> {
value: T,
children: Vec<TreeNode<T>>,
}
impl<T> TreeNode<T> {
// ...
fn serialize(&self) -> Vec<String>
where
T: ToString,
{
let mut serialized = Vec::new();
let mut stack = vec![self];
while let Some(node) = stack.pop() {
serialized.push(node.value.to_string());
serialized.push(node.children.len().to_string());
for child in node.children.iter().rev() {
stack.push(child);
}
}
serialized
}
// ...
}
TreeNode<T> is the same shape as in section 11.4: a value and a Vec of child nodes. A node can have any
number of children.
serialize walks the tree in pre-order without recursion, using a Vec as a stack: the last item pushed
is the first popped. It pushes the children in reverse order, with iter().rev(), so that the first child is on
top and is popped first. For each node it writes the value and the child count as strings.
The line where T: ToString sits on the method, not on the whole impl. So a tree of any type exists, and only
a tree whose values can become strings gets a serialize method.
Listing 11.19 Reading the pairs back (lines 49 to 63).
impl<T> TreeNode<T> {
// ...
fn deserialize(serialized: &[String], pos: &mut usize) -> Option<Self>
where
T: FromStr,
{
let value = serialized.get(*pos)?.parse().ok()?;
let child_count: usize = serialized.get(*pos + 1)?.parse().ok()?;
*pos += 2;
let mut children = Vec::new();
for _ in 0..child_count {
children.push(Self::deserialize(serialized, pos)?);
}
Some(TreeNode { value, children })
}
// ...
}
deserialize is recursive. pos is a &mut usize, a position in the token list shared by all the calls. Each
call reads a value and a count at pos, advances pos by two, and then calls itself child_count times to read
the children.
Every step that can fail uses ? on an Option. The failures are a missing token, a value that does not
parse, and a count that is not a number. .parse().ok()? first turns the parse Result into an Option. Any failure makes the whole
function return None.
Listing 11.20 The complete program. src/bin/tree_serializ_deserialize_into_file.rs
use std::{collections::VecDeque, fs, io, str::FromStr};
#[derive(Debug)]
struct TreeNode<T> {
value: T,
children: Vec<TreeNode<T>>,
}
impl<T> TreeNode<T> {
fn level_order(&self) -> Vec<Vec<&T>> {
let mut result = Vec::new();
let mut queue = VecDeque::from([self]);
while !queue.is_empty() {
let current_size = queue.len();
let mut level = Vec::with_capacity(current_size);
for _ in 1..=current_size {
let Some(node) = queue.pop_front() else { break };
level.push(&node.value);
queue.extend(&node.children);
}
result.push(level);
}
result
}
fn serialize(&self) -> Vec<String>
where
T: ToString,
{
let mut serialized = Vec::new();
let mut stack = vec![self];
while let Some(node) = stack.pop() {
serialized.push(node.value.to_string());
serialized.push(node.children.len().to_string());
for child in node.children.iter().rev() {
stack.push(child);
}
}
serialized
}
fn deserialize(serialized: &[String], pos: &mut usize) -> Option<Self>
where
T: FromStr,
{
let value = serialized.get(*pos)?.parse().ok()?;
let child_count: usize = serialized.get(*pos + 1)?.parse().ok()?;
*pos += 2;
let mut children = Vec::new();
for _ in 0..child_count {
children.push(Self::deserialize(serialized, pos)?);
}
Some(TreeNode { value, children })
}
fn save(&self, path: &str) -> io::Result<()>
where
T: ToString,
{
fs::write(path, self.serialize().join("\n"))
}
fn load(path: &str) -> Option<Self>
where
T: FromStr,
{
let text = fs::read_to_string(path).ok()?;
let serialized: Vec<String> = text.lines().map(String::from).collect();
Self::deserialize(&serialized, &mut 0)
}
}
fn main() -> io::Result<()> {
let root = TreeNode {
value: "root".to_string(),
children: vec![
TreeNode {
value: "a".to_string(),
children: vec![
TreeNode {
value: "a1".to_string(),
children: vec![],
},
TreeNode {
value: "a2".to_string(),
children: vec![],
},
],
},
TreeNode {
value: "b".to_string(),
children: vec![TreeNode {
value: "b1".to_string(),
children: vec![],
}],
},
],
};
root.save("tree.bin")?;
let Some(loaded) = TreeNode::<String>::load("tree.bin") else {
println!("invalid tree file");
return Ok(());
};
for level in loaded.level_order() {
println!("{:?}", level);
}
Ok(())
}
save joins the tokens with newlines and writes the file. load reads the file, splits it into lines, and starts
reading at position 0 with &mut 0. main saves a tree, loads it back, and prints its levels to show the shape
survived:
$ cargo run --bin tree_serializ_deserialize_into_file
["root"]
["a", "b"]
["a1", "a2", "b1"]
The format breaks if a value contains a newline, because the reader splits on newlines. For arbitrary text,
write each value’s length before it, or use a serialization library such as serde.
11.6 A trie for finding words by prefix
A trie (usually pronounced “try”) stores a set of words by sharing their common beginnings. Each edge is one character. Walking from the root along the characters of a word leads to the node for that word. A flag on the node marks whether a stored word ends there (figure 11.7).
"gpu" and "gpucloud". The two words share their first three nodes.A prefix is the beginning of a word: "gp" is a prefix of "gpu". A trie answers “is any stored word
starting with this prefix?” by walking the prefix’s characters. That makes it the usual structure behind
autocompletion.
insert creates a node only when the character has no child yet, so words with a common start share nodes. "ca" is a prefix but not a word, and "cab" stops at a missing child.A trie is one root node. Each node maps a character to the child node for that character, and terminal marks a node where a stored word ends:
pub struct Trie {
root: Node,
}
#[derive(Default)]
struct Node {
children: HashMap<char, Node>,
terminal: bool,
}
Listing 11.21 The complete file. src/problems/trie.rs
//! Trie / prefix tree.
//!
//! Used for autocomplete, command completion, and prefix matching.
use std::collections::HashMap;
#[derive(Default)]
pub struct Trie {
root: Node,
}
#[derive(Default)]
struct Node {
children: HashMap<char, Node>,
terminal: bool,
}
impl Trie {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, word: &str) {
let mut node = &mut self.root;
for ch in word.chars() {
node = node.children.entry(ch).or_default();
}
node.terminal = true;
}
pub fn search(&self, word: &str) -> bool {
self.find_node(word)
.is_some_and(|node| node.terminal)
}
pub fn starts_with(&self, prefix: &str) -> bool {
self.find_node(prefix).is_some()
}
fn find_node(&self, word: &str) -> Option<&Node> {
let mut node = &self.root;
for ch in word.chars() {
node = node.children.get(&ch)?;
}
Some(node)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trie_insert_search_and_prefix() {
let mut trie = Trie::new();
trie.insert("gpu");
trie.insert("gpucloud");
assert!(trie.search("gpu"));
assert!(trie.search("gpucloud"));
assert!(!trie.search("gp"));
assert!(!trie.search("gpucloudapi"));
assert!(trie.starts_with("gp"));
assert!(trie.starts_with("gpucloud"));
assert!(!trie.starts_with("cpu"));
}
}
Each Node has a HashMap from a character to a child node, and a terminal flag. Both types derive Default,
so an empty node is Node::default().
insert walks a mutable cursor, node, down the word, one character per line:
node = node.children.entry(ch).or_default();
entry(ch).or_default() finds the child for ch, creating an empty one if it is missing, and returns a mutable
reference to it. Assigning that to node moves the cursor down one level. After the last character, the node is
marked terminal.
find_node walks the same way without changing anything. node.children.get(&ch)? returns None from the
function at the first missing character. search then checks that the node exists and is terminal, with
is_some_and. starts_with only checks that the node exists.
Searching for a word of m characters takes m steps, whatever the number of stored words. A HashMap per node is
flexible but uses a lot of memory. For words of lowercase English letters only, an array of 26 child pointers
per node is faster and smaller.
Summary
- A tree is a hierarchy of nodes. Each subtree is itself a tree, so recursion fits tree problems.
- A binary tree fits in an enum with
Boxchildren. Pre-order, in-order, and post-order differ only in when a node is visited relative to its subtrees. - A binary search tree keeps smaller values on the left and larger on the right, so a search discards a subtree at each step.
- Removing from a BST has three cases. The two-children case copies in the smallest value of the right subtree.
std::mem::takeandmem::replacelet a method replace*selfwith one of its own children.- A range query skips subtrees outside the range and visits the rest in order.
- A node with
Vecchildren describes a tree with any number of children. Breadth-first traversal uses a queue, andqueue.len()at the start of a round gives the size of a level. - Pre-order with child counts saves a tree in a form that can be rebuilt exactly.
- A trie shares prefixes between words, and finds a word in time proportional to its length.
Chapter 12 generalizes trees to graphs, where any node can connect to any other, and cycles are possible.
Exercises
- Write an in-order traversal of the binary tree from listing 11.1 without recursion, using a
Vecof references as a stack. - Add a
heightmethod to one of the BSTs. Insert the numbers 1 to 1,000 in order, then in shuffled order, and compare the heights. - Serialize a binary tree using a marker such as
#for each empty child, and write the matching reader. - Add
words_with_prefix(&self, prefix: &str) -> Vec<String>to the trie.
Graphs
This chapter covers
- What a graph is, and two ways to store one in memory
- Breadth-first and depth-first search, and the queue and stack behind them
- Finding cycles with three node states
- Treating a grid of cells as a graph, to count islands
- Ordering tasks so that each comes after its prerequisites
- Shortest paths with Dijkstra’s algorithm, written five ways
- Connecting every node at the lowest cost with Kruskal’s algorithm and a union-find
In chapter 11, every node had exactly one parent, and no path could lead back to where it started. A graph drops both rules. Any node can connect to any other node, and a path can come back to its start.
Graphs describe many real systems. Roads between cities form a graph. So do links between web pages, and network links between machines. Courses and their prerequisites form a graph, and so do build steps and their inputs.
The freedom has a cost. A traversal that follows edges can reach the same node many times, or walk around a loop
forever. Every algorithm in this chapter therefore keeps a record of which nodes it has already handled. The
record is a Vec<bool>, a HashSet, or a HashMap of states.
12.1 Graph vocabulary
Figure 12.1 names the parts of a graph.
- A node, also called a vertex, is one of the things in the graph.
- An edge connects two nodes. In a directed graph, an edge goes one way, from one node to another. In an undirected graph, an edge works both ways, like a two-way road.
- The neighbors of a node are the nodes its edges lead to.
- A weight is a number on an edge, such as a distance or a cost.
- A path is a sequence of edges that you can follow one after another.
- A cycle is a path that ends at the node where it started.
A directed graph with no cycles is called a directed acyclic graph, or DAG. A tree is a special graph: it has no cycles, and every node except the root has exactly one parent.
12.2 Storing a graph
The most common way to store a graph is an adjacency list. For every node, you keep a list of its outgoing edges. Each edge is stored as a pair: the neighbor, and the weight of the edge.
The code in this chapter uses two forms of adjacency list (figure 12.2).
In the index form, the nodes are numbered 0, 1, 2, and so on. The graph is a Vec<Vec<(usize, u32)>>.
graph[i] is the list of edges out of node i, and each edge is a (neighbor, weight) pair. Finding a node’s
edges is one array index. The record of visited nodes can be a Vec<bool> with one entry per node.
In the map form, nodes can be any type that can be hashed, such as names. The graph is a HashMap from a
node to its edges. Finding a node’s edges means hashing the key. The visited record becomes a HashSet.
The index form is faster, and the map form is more convenient when nodes have natural names. Several files in this chapter solve the same problem both ways, so you can compare them side by side.
The map form in graph_bfs.rs has a type alias:
use std::{
collections::{HashMap, HashSet, VecDeque},
hash::Hash,
};
/// Adjacency list keyed by node: each node maps to its `(neighbor, weight)` edges.
pub type GraphAdjList<'a, Node> = HashMap<Node, &'a [(Node, u32)]>;
GraphAdjList<'a, Node> is a HashMap whose values are borrowed slices, &'a [(Node, u32)]. The map does not
own the edges. They live somewhere else for at least the lifetime 'a, and the map points at them. The tests
keep the edges in constant arrays, which live for the whole program.
12.3 Breadth-first search
Breadth-first search (BFS) visits the start node first, then every node one edge away, then every node two edges away, and so on. It works the same way as the level-order tree traversal of section 11.4: a queue holds the nodes waiting to be visited.
Unlike a tree, a graph can offer two paths to the same node. In the sample graph, A reaches C directly, and also through B. Without a record, C would enter the queue twice. So BFS marks a node as seen when it first enters the queue, and never adds it again.
The top half of figure 12.3 follows the queue step by step.
bfs_vec from node 0. The queue hands out the oldest entry first, so nodes are visited in order of distance. Node 6 is not connected and is never reached.12.3.1 BFS with numbered nodes
Listing 12.1 BFS over the index form (lines 18 to 36). src/problems/graph_bfs.rs
/// BFS over an index-based adjacency list. Returns nodes in visiting order.
pub fn bfs_vec(graph: &[Vec<(usize, u32)>], start_node: usize) -> Vec<usize> {
let mut visited = vec![false; graph.len()];
let mut queue = VecDeque::from([start_node]);
let mut result = vec![];
visited[start_node] = true;
while let Some(current_node) = queue.pop_front() {
result.push(current_node);
for &(neighbor, _) in &graph[current_node] {
if !visited[neighbor] {
visited[neighbor] = true;
queue.push_back(neighbor);
}
}
}
result
}
The function sets up three things. visited has one false per node. queue holds the start node. result
collects the nodes in the order they are visited.
The loop takes the front node, records it, and looks at its edges. The pattern &(neighbor, _) takes each edge
apart: it copies out the neighbor and ignores the weight with _. Each neighbor that has not been seen is
marked and added to the back of the queue.
Every node enters the queue at most once, and every edge is looked at once. So BFS runs in O(V + E) time, where V is the number of nodes and E the number of edges. Nodes that cannot be reached from the start never enter the queue, and are not in the result.
12.3.2 BFS with any node type
Listing 12.2 BFS over the map form (lines 38 to 59).
/// BFS over a `HashMap` adjacency list. Returns nodes in visiting order.
pub fn bfs<Node>(graph: &GraphAdjList<Node>, start_node: Node) -> Vec<Node>
where
Node: Copy + Eq + Hash,
{
let mut visited = HashSet::new();
let mut queue = VecDeque::from([start_node]);
let mut result = vec![];
visited.insert(start_node);
while let Some(current_node) = queue.pop_front() {
result.push(current_node);
// slice iter yields &(Node, u32); `&` destructures it
for &(neighbor, _) in graph[¤t_node] {
if visited.insert(neighbor) {
queue.push_back(neighbor);
}
}
}
result
}
The steps are the same. Three things change:
- The function is generic over
Node. Thewhereclause asks forCopy, so a node can be copied into the queue and the result. It also asks forEq + Hash, so a node can be a key in aHashSet. graph[¤t_node]looks up the edges by key. Indexing aHashMappanics if the key is missing, so every node needs an entry, even a node with no edges.visited.insert(neighbor)does the check and the marking in one call.insertreturnstrueif the value was not in the set before.
The tests at the end of the file build the sample graph in both forms and check that the two functions agree:
$ cargo test --lib problems::graph_bfs
running 5 tests
test problems::graph_bfs::tests::vec_variant_skips_unreachable_nodes ... ok
test problems::graph_bfs::tests::vec_variant_visits_level_by_level ... ok
test problems::graph_bfs::tests::vec_variant_handles_cycles ... ok
test problems::graph_bfs::tests::both_variants_agree ... ok
test problems::graph_bfs::tests::map_variant_visits_level_by_level ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 132 filtered out
Tests run in parallel, so the order of the lines can change from run to run.
12.4 Depth-first search
Depth-first search (DFS) follows one path as far as it can before it tries another. You can write it with
recursion, as the tree traversals of chapter 11 were. The versions here use a Vec as an explicit stack
instead. An explicit stack has no depth limit other than memory, while deep recursion can overflow the thread’s
stack.
Listing 12.3 DFS over the index form (lines 16 to 33). src/problems/graph_dfs.rs
use std::{collections::HashSet, hash::Hash};
use crate::problems::graph_bfs::GraphAdjList;
/// DFS over an index-based adjacency list. Returns nodes in visiting order.
pub fn dfs_vec(graph: &[Vec<(usize, u32)>], start_node: usize) -> Vec<usize> {
let mut visited = vec![false; graph.len()];
let mut stack = vec![start_node];
let mut result = vec![];
while let Some(current_node) = stack.pop() {
if visited[current_node] {
continue;
}
visited[current_node] = true;
result.push(current_node);
for &(neighbor, _) in &graph[current_node] {
stack.push(neighbor);
}
}
result
}
Compare this with listing 12.1. The queue became a stack, so pop_front became pop. The other change is when
a node is marked. BFS marked a node when it was added. DFS marks a node when it is taken off the stack, and skips
it if it was already visited.
Marking late gives the true depth-first order, at a small cost. A node can sit on the stack more than once, as C
and D do in the lower half of figure 12.3. The stack can hold up to one entry per edge, and the duplicates are
skipped with continue when they come off.
The neighbors are pushed in list order, so the last neighbor pushed is the first taken. From A, the stack receives B and then C, so C is explored first. The visit order is A, C, D, B.
dfs_vec uses the same loop as BFS with a stack. The newest entry is taken first, so the walk goes deep before it goes wide.Listing 12.4 DFS over the map form (lines 35 to 54).
/// DFS over a `HashMap` adjacency list. Returns nodes in visiting order.
pub fn dfs<Node>(graph: &GraphAdjList<Node>, start_node: Node) -> Vec<Node>
where
Node: Copy + Eq + Hash,
{
let mut visited = HashSet::new();
let mut stack = vec![start_node];
let mut result = vec![];
while let Some(current_node) = stack.pop() {
if !visited.insert(current_node) {
continue;
}
result.push(current_node);
for &(neighbor, _) in graph[¤t_node] {
stack.push(neighbor);
}
}
result
}
Here if !visited.insert(current_node) is the “already visited” check. If insert returns false, the node
was in the set, and the loop moves on.
The file starts with use crate::problems::graph_bfs::GraphAdjList;, so both files share one type alias.
12.4.1 A stack of references
When the graph is a tree, DFS needs no visited record, because no node can be reached twice. The following program walks a tree with a stack of references.
Listing 12.5 The complete program. src/bin/rust_interview_hacks_graph.rs
#[derive(Debug)]
struct Node {
value: i32,
children: Vec<Node>,
}
fn visit_iter(root: &Node) {
let mut stack = Vec::<&Node>::from([root]);
while let Some(node) = stack.pop() {
println!("{:?}", node);
// Idiomatic and concise way to extend stack with neighbour iterator
stack.extend(node.children.iter());
}
}
fn main() {
let tree = Node {
value: 1,
children: vec![
Node {
value: 2,
children: vec![],
},
Node {
value: 3,
children: vec![Node {
value: 4,
children: vec![],
}],
},
],
};
visit_iter(&tree);
println!("still own it: {}", tree.value); // tree wasn't moved
}
The stack has type Vec<&Node>. It holds borrowed references into the tree, so the loop moves no nodes and
copies none. stack.extend(node.children.iter()) pushes a reference to each child. The last line of main uses
tree.value after the walk, which shows that tree still owns its nodes.
$ cargo run --bin rust_interview_hacks_graph
Node { value: 1, children: [Node { value: 2, children: [] }, Node { value: 3, children: [Node { value: 4, children: [] }] }] }
Node { value: 3, children: [Node { value: 4, children: [] }] }
Node { value: 4, children: [] }
Node { value: 2, children: [] }
still own it: 1
{:?} prints each node with all its children, so the first line shows the whole tree. The order is 1, 3, 4, 2,
because the last child pushed is the first popped.
12.5 A graph with named nodes
Programs often receive a graph as pairs of names, such as ("A", "B"). A common way to handle names is to give
each name a number the first time you see it. The algorithm then runs on numbers, with the fast index form.
Assigning numbers to names this way is called interning.
The graph keeps two things: ids, which interns each name as a number, and adj, the adjacency list indexed by those numbers:
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default)]
struct Graph {
ids: HashMap<String, usize>,
adj: Vec<Vec<usize>>,
}
Listing 12.6 The complete program. src/bin/graph_bfs.rs
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default)]
struct Graph {
ids: HashMap<String, usize>,
adj: Vec<Vec<usize>>,
}
impl Graph {
fn id(&mut self, name: &str) -> usize {
*self
.ids
.entry(name.to_owned())
.or_insert_with(|| {
self.adj.push(vec![]);
self.adj.len() - 1
})
}
fn add_edge(&mut self, from: &str, to: &str) {
let (u, v) = (self.id(from), self.id(to));
self.adj[u].push(v);
}
fn bfs(&self, start: &str) -> Vec<usize> {
let mut visited = HashSet::new();
let mut queue = VecDeque::from([self.ids[start]]);
let mut order = Vec::new();
while let Some(node) = queue.pop_front() {
if !visited.insert(node) {
continue;
}
order.push(node);
queue.extend(self.adj[node].iter());
}
order
}
}
fn main() {
let mut g = Graph::default();
g.add_edge("A", "B");
g.add_edge("A", "C");
g.add_edge("B", "D");
g.add_edge("C", "E");
g.add_edge("D", "F");
println!("{:?}", g.bfs("A")); // [0, 1, 2, 3, 4, 5]
}
Graph has two fields. ids maps a name to its number, and adj is an index-form adjacency list without
weights.
The id method does the interning in one expression. self.ids.entry(name.to_owned()) looks up the name.
or_insert_with runs its closure only if the name is new. The closure adds an empty edge list to adj and
returns its index, which becomes the name’s number. Either way, the expression returns a reference to the
number, and the * at the front copies it out.
The closure changes self.adj while self.ids is borrowed by the entry. The compiler allows this because a
closure borrows only the fields it uses, here self.adj. The two borrows touch different fields, so they do not
conflict.
This bfs marks nodes when they leave the queue, as the DFS of section 12.4 did. A node can enter the queue
more than once, and the second copy is skipped. queue.extend(self.adj[node].iter()) adds all neighbors at
once.
$ cargo run --bin graph_bfs
[0, 1, 2, 3, 4, 5]
The result is numbers, not names. A to F were numbered 0 to 5 in the order they first appeared.
12.6 Finding cycles
Many problems need to know whether a directed graph has a cycle. A build system cannot build a file that depends on itself. A course plan cannot require a course before itself.
A visited flag is not enough to find cycles. Look at the diamond on the right of figure 12.4. DFS reaches D through B, and later reaches D again through C. That second visit does not mean there is a loop. It only means two paths join.
The fix is to give each node one of three states:
- Unvisited: DFS has not reached the node yet.
- Visiting: the node is on the path DFS is currently following. DFS has entered it and not yet finished it.
- Done: DFS has explored everything reachable from the node.
An edge that leads to a Visiting node goes back to an earlier point on the current path, so it closes a loop. Such an edge is called a back edge. An edge that leads to a Done node joins a path that was already fully explored, and cannot close a loop.
12.6.1 A graph that owns its node data
Listing 12.7 The graph and its states (lines 1 to 30). src/bin/cyclic_graph.rs
use std::collections::VecDeque;
type NodeId = usize;
struct Graph<T> {
nodes: Vec<T>,
adj: Vec<Vec<NodeId>>, // adj[i] = nodes that i points to
}
#[derive(Clone, Copy)]
enum State {
Unvisited,
Visiting,
Done,
}
impl<T> Graph<T> {
fn new() -> Self {
Self {
nodes: Vec::new(),
adj: Vec::new(),
}
}
fn add_node(&mut self, data: T) -> NodeId {
self.nodes.push(data);
self.adj.push(Vec::new());
self.nodes.len() - 1
}
fn add_edge(&mut self, from: NodeId, to: NodeId) {
self.adj[from].push(to);
}
fn neighbors(&self, id: NodeId) -> impl Iterator<Item = NodeId> + '_ {
self.adj[id].iter().copied()
}
// ...
}
This Graph<T> keeps the node data in nodes, and the edges in adj, as numbers. NodeId is a type alias
for usize, so signatures say what a number means. add_node returns the new node’s number, and the caller
uses that number to add edges.
Storing numbers instead of references is the usual way to build graphs in Rust. A cycle of references between
nodes would need Rc and RefCell, as chapter 9’s doubly linked list did. Numbers into a Vec have no
ownership problems at all.
neighbors returns impl Iterator<Item = NodeId> + '_. That return type means “some iterator of node
numbers”. The + '_ says the iterator borrows from self, so it cannot outlive the graph. .copied() turns
the &usize items of the slice iterator into usize values.
Listing 12.8 BFS and DFS (lines 39 to 74).
impl<T> Graph<T> {
// ...
fn bfs(&self, start: NodeId) -> Vec<NodeId> {
let mut visited = vec![false; self.nodes.len()];
let mut queue = VecDeque::from([start]);
let mut order = Vec::new();
visited[start] = true;
while let Some(node) = queue.pop_front() {
order.push(node);
for next in self.neighbors(node) {
if !visited[next] {
visited[next] = true;
queue.push_back(next);
}
}
}
order
}
fn dfs(&self, start: NodeId) -> Vec<NodeId> {
let mut visited = vec![false; self.nodes.len()];
let mut stack = vec![start];
let mut order = Vec::new();
while let Some(node) = stack.pop() {
if visited[node] {
continue;
}
visited[node] = true;
order.push(node);
stack.extend(
self.neighbors(node)
.filter(|&n| !visited[n]),
);
}
order
}
// ...
}
These are the algorithms of sections 12.3 and 12.4, written with neighbors. The DFS filters out visited
neighbors before pushing them, with .filter(|&n| !visited[n]). The filter keeps the stack smaller, but the
check after pop is still needed. A node can be pushed by two different nodes before either copy is popped.
Listing 12.9 Cycle detection (lines 76 to 94).
impl<T> Graph<T> {
// ...
fn has_cycle(&self) -> bool {
let mut state = vec![State::Unvisited; self.nodes.len()];
(0..self.nodes.len()).any(|n| self.visit(n, &mut state))
}
fn visit(&self, node: NodeId, state: &mut [State]) -> bool {
match state[node] {
State::Visiting => true, // back edge: cycle found
State::Done => false,
State::Unvisited => {
state[node] = State::Visiting;
let found = self.adj[node]
.iter()
.any(|&n| self.visit(n, state));
state[node] = State::Done;
found
}
}
}
}
visit is a recursive DFS that returns true as soon as it finds a cycle. It reads the node’s state:
Visitingmeans this edge is a back edge, so the answer istrue.Donemeans the node was explored before and no cycle was found through it, so the answer isfalse.Unvisitedmeans this is the first visit. The node becomesVisiting, andvisitrecurses into each neighbor.anystops at the first neighbor that reports a cycle. Then the node becomesDone.
has_cycle calls visit from every node, because a cycle might not be reachable from node 0. All the calls
share one state vector, so each node is fully explored only once, and the whole check runs in O(V + E).
Listing 12.10 The complete program. src/bin/cyclic_graph.rs
use std::collections::VecDeque;
type NodeId = usize;
struct Graph<T> {
nodes: Vec<T>,
adj: Vec<Vec<NodeId>>, // adj[i] = nodes that i points to
}
#[derive(Clone, Copy)]
enum State {
Unvisited,
Visiting,
Done,
}
impl<T> Graph<T> {
fn new() -> Self {
Self {
nodes: Vec::new(),
adj: Vec::new(),
}
}
fn add_node(&mut self, data: T) -> NodeId {
self.nodes.push(data);
self.adj.push(Vec::new());
self.nodes.len() - 1
}
fn add_edge(&mut self, from: NodeId, to: NodeId) {
self.adj[from].push(to);
}
fn neighbors(&self, id: NodeId) -> impl Iterator<Item = NodeId> + '_ {
self.adj[id].iter().copied()
}
fn bfs(&self, start: NodeId) -> Vec<NodeId> {
let mut visited = vec![false; self.nodes.len()];
let mut queue = VecDeque::from([start]);
let mut order = Vec::new();
visited[start] = true;
while let Some(node) = queue.pop_front() {
order.push(node);
for next in self.neighbors(node) {
if !visited[next] {
visited[next] = true;
queue.push_back(next);
}
}
}
order
}
fn dfs(&self, start: NodeId) -> Vec<NodeId> {
let mut visited = vec![false; self.nodes.len()];
let mut stack = vec![start];
let mut order = Vec::new();
while let Some(node) = stack.pop() {
if visited[node] {
continue;
}
visited[node] = true;
order.push(node);
stack.extend(
self.neighbors(node)
.filter(|&n| !visited[n]),
);
}
order
}
fn has_cycle(&self) -> bool {
let mut state = vec![State::Unvisited; self.nodes.len()];
(0..self.nodes.len()).any(|n| self.visit(n, &mut state))
}
fn visit(&self, node: NodeId, state: &mut [State]) -> bool {
match state[node] {
State::Visiting => true, // back edge: cycle found
State::Done => false,
State::Unvisited => {
state[node] = State::Visiting;
let found = self.adj[node]
.iter()
.any(|&n| self.visit(n, state));
state[node] = State::Done;
found
}
}
}
}
fn main() {
let mut g = Graph::new();
let a = g.add_node("A");
let b = g.add_node("B");
let c = g.add_node("C");
g.add_edge(a, b);
g.add_edge(b, c);
g.add_edge(c, a); // closes the cycle
let names = |ids: Vec<NodeId>| -> Vec<&str> {
ids.into_iter()
.map(|i| g.nodes[i])
.collect()
};
println!("BFS: {:?}", names(g.bfs(a))); // ["A", "B", "C"]
println!("DFS: {:?}", names(g.dfs(a))); // ["A", "B", "C"]
println!("Cycle: {}", g.has_cycle()); // true
}
main builds the loop A to B to C to A. The closure names converts a list of numbers back into names for
printing.
$ cargo run --bin cyclic_graph
BFS: ["A", "B", "C"]
DFS: ["A", "B", "C"]
Cycle: true
12.6.2 The same graph with a HashMap
The second version stores the graph as a map from a node to its neighbors, with no numbering step.
The cycle check marks each node with a State: Visiting while its descendants are being explored, and Done after. The graph is a map from a node to its neighbors, generic over any Copy node type:
#[derive(Clone, Copy)]
enum State {
Visiting,
Done,
}
#[derive(Default)]
struct Graph<T> {
adj: HashMap<T, Vec<T>>,
}
Listing 12.11 The complete program. src/bin/cyclic_graph_map.rs
use std::{
collections::{HashMap, HashSet, VecDeque},
hash::Hash,
};
#[derive(Clone, Copy)]
enum State {
Visiting,
Done,
}
#[derive(Default)]
struct Graph<T> {
adj: HashMap<T, Vec<T>>,
}
impl<T: Copy + Eq + Hash> Graph<T> {
fn add_edge(&mut self, u: T, v: T) {
self.adj.entry(v).or_default();
self.adj.entry(u).or_default().push(v);
}
fn bfs(&self, start: T) -> Vec<T> {
let mut seen = HashSet::from([start]);
let mut queue = VecDeque::from([start]);
let mut order = vec![];
while let Some(u) = queue.pop_front() {
order.push(u);
for &v in &self.adj[&u] {
if seen.insert(v) {
queue.push_back(v);
}
}
}
order
}
fn has_cycle(&self) -> bool {
fn dfs<T: Copy + Eq + Hash>(g: &Graph<T>, u: T, state: &mut HashMap<T, State>) -> bool {
match state.get(&u) {
Some(State::Visiting) => return true,
Some(State::Done) => return false,
None => {}
}
state.insert(u, State::Visiting);
let found = g.adj[&u]
.iter()
.any(|&v| dfs(g, v, state));
state.insert(u, State::Done);
found
}
let mut state = HashMap::new();
self.adj
.keys()
.any(|&u| dfs(self, u, &mut state))
}
}
fn main() {
let mut g = Graph::default();
g.add_edge("A", "B");
g.add_edge("B", "C");
g.add_edge("C", "A");
println!("{:?}", g.bfs("A"));
println!("{}", g.has_cycle()); // true
}
Three details differ from listing 12.10:
Statehas onlyVisitingandDone. A node that is not in thestatemap is unvisited, so the map itself plays the role of the third state.add_edgeinserts the targetvas a key too, withentry(v).or_default(). Every node therefore has an entry, andself.adj[&u]cannot panic on a node that has no outgoing edges.- The recursive helper is a function declared inside
has_cycle. A nestedfncannot use the local variables of the function around it, so it receives the graph and the state map as arguments.
$ cargo run --bin cyclic_graph_map
["A", "B", "C"]
true
12.7 A grid is a graph
A grid of cells is a graph too, even though it is not stored as one. Each cell is a node, and each cell has an edge to the cells directly above, below, left, and right of it. The edges are computed from the coordinates when needed, not stored.
The classic grid problem counts islands. The grid holds land and water. An island is a group of land cells connected up, down, left, or right. Diagonal neighbors do not count.
The method is to scan every cell. When the scan finds a land cell that no earlier search has reached, that cell starts a new island. The count goes up by one, and a BFS from that cell marks every cell of the island. Filling a connected area this way is called a flood fill (figure 12.5).
main. Each color is one island, found by one flood fill.Listing 12.12 Counting islands (lines 27 to 72). src/bin/count_islands.rs
use std::collections::{HashSet, VecDeque};
fn count_islands<T: PartialEq>(grid: &[&[T]], land: T) -> usize {
if grid.is_empty() || grid[0].is_empty() {
return 0;
}
let rows = grid.len();
let cols = grid[0].len();
let mut visited = HashSet::new();
let mut count = 0;
let dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)];
// BFS closure: flood-fill one island starting from a known land cell.
// Passing `visited` explicitly avoids borrow-checker friction when calling
// the closure from the outer loop.
let bfs = |start: (usize, usize), visited: &mut HashSet<(usize, usize)>| {
let mut queue = VecDeque::new();
queue.push_back(start);
visited.insert(start);
while let Some((cr, cc)) = queue.pop_front() {
for &(dr, dc) in &dirs {
let nr = cr as i32 + dr;
let nc = cc as i32 + dc;
if (0..rows as i32).contains(&nr) && (0..cols as i32).contains(&nc) {
let (ur, uc) = (nr as usize, nc as usize);
if grid[ur][uc] == land && !visited.contains(&(ur, uc)) {
visited.insert((ur, uc));
queue.push_back((ur, uc));
}
}
}
}
};
for r in 0..rows {
for c in 0..cols {
if grid[r][c] == land && !visited.contains(&(r, c)) {
count += 1;
bfs((r, c), &mut visited);
}
}
}
count
}
The grid has type &[&[T]], a slice of rows, where each row is a slice of cells. The function is generic, so
cells can be characters, booleans, or numbers. land: T says which value counts as land, and T: PartialEq
lets the code compare a cell with it.
dirs lists the four moves as (row change, column change) pairs. The BFS adds each move to the current cell’s
coordinates. The coordinates are usize, which cannot be negative, and a move up from row 0 would go below
zero. So the code converts to i32 first. It checks that the new position is inside the grid with (0..rows as i32).contains(&nr), and only then converts back to usize.
The BFS is a closure stored in bfs. It reads grid, dirs, rows, cols, and land from the function
around it. It does not capture visited, though. It takes visited as a parameter instead. If the closure
captured visited mutably, that borrow would last as long as the closure. The outer loop could then not call
visited.contains while the closure exists. Passing it in on each call keeps the borrow short.
Each cell is looked at a constant number of times, so the function runs in O(rows × cols). A Vec<bool> with
one entry per cell would be faster than the HashSet of coordinates, because it avoids hashing.
Listing 12.13 The complete program, with tests. src/bin/count_islands.rs
// ============================================================================
// Count Islands
//
// Ported from the Rust playground repo.
//
// Problem: given a grid of cells, count the number of connected "land"
// regions. Two land cells are connected when they are adjacent horizontally
// or vertically (4-directional). Water cells are not traversable.
//
// Where this shape appears in practice:
// - "Islands" are connected-component counting, the same shape used to find
// connected machines, failure domains, network partitions, or storage
// groups in distributed systems.
// - The generic implementation accepts any land type (char, u8, bool) and
// uses BFS with an explicit visited set, which is easy to reason about.
//
// Time complexity: O(rows * cols)
// Space complexity: O(rows * cols) for the visited set and BFS queue.
// ============================================================================
use std::collections::{HashSet, VecDeque};
/// Counts 4-directionally connected components of `land` in a rectangular grid.
///
/// The grid is generic over cell values so callers can use `'1'/'0'`,
/// `true/false`, `1u8/0u8`, or any other PartialEq value.
fn count_islands<T: PartialEq>(grid: &[&[T]], land: T) -> usize {
if grid.is_empty() || grid[0].is_empty() {
return 0;
}
let rows = grid.len();
let cols = grid[0].len();
let mut visited = HashSet::new();
let mut count = 0;
let dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)];
// BFS closure: flood-fill one island starting from a known land cell.
// Passing `visited` explicitly avoids borrow-checker friction when calling
// the closure from the outer loop.
let bfs = |start: (usize, usize), visited: &mut HashSet<(usize, usize)>| {
let mut queue = VecDeque::new();
queue.push_back(start);
visited.insert(start);
while let Some((cr, cc)) = queue.pop_front() {
for &(dr, dc) in &dirs {
let nr = cr as i32 + dr;
let nc = cc as i32 + dc;
if (0..rows as i32).contains(&nr) && (0..cols as i32).contains(&nc) {
let (ur, uc) = (nr as usize, nc as usize);
if grid[ur][uc] == land && !visited.contains(&(ur, uc)) {
visited.insert((ur, uc));
queue.push_back((ur, uc));
}
}
}
}
};
for r in 0..rows {
for c in 0..cols {
if grid[r][c] == land && !visited.contains(&(r, c)) {
count += 1;
bfs((r, c), &mut visited);
}
}
}
count
}
// ============================================================================
// Runnable demonstration
// ============================================================================
fn main() {
let grid = [
&['1', '1', '0', '0', '0'][..],
&['1', '1', '0', '0', '0'][..],
&['0', '0', '1', '0', '0'][..],
&['0', '0', '0', '1', '1'][..],
];
println!("Islands: {}", count_islands(&grid, '1'));
let boolean_grid = [
&[true, true, false][..],
&[false, false, false][..],
&[false, true, true][..],
];
println!("Boolean islands: {}", count_islands(&boolean_grid, true));
}
// ============================================================================
// Tests (cargo test --bin count_islands)
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_grid_has_zero_islands() {
let empty: [&[char]; 0] = [];
assert_eq!(count_islands(&empty, '1'), 0);
let no_cols: [&[char]; 2] = [&[], &[]];
assert_eq!(count_islands(&no_cols, '1'), 0);
}
#[test]
fn counts_classic_example() {
let grid = [
&['1', '1', '0', '0', '0'][..],
&['1', '1', '0', '0', '0'][..],
&['0', '0', '1', '0', '0'][..],
&['0', '0', '0', '1', '1'][..],
];
assert_eq!(count_islands(&grid, '1'), 3);
}
#[test]
fn all_water_is_zero() {
let grid = [&['0', '0'][..], &['0', '0'][..]];
assert_eq!(count_islands(&grid, '1'), 0);
}
#[test]
fn generic_bool_grid_works() {
let grid = [
&[true, false, false][..],
&[true, false, true][..],
&[false, false, true][..],
];
assert_eq!(count_islands(&grid, true), 2);
}
#[test]
fn single_row_and_column() {
let row = [&['1', '0', '1', '1'][..]];
assert_eq!(count_islands(&row, '1'), 2);
let col = [&['1'][..], &['1'][..], &['0'][..], &['1'][..]];
assert_eq!(count_islands(&col, '1'), 2);
}
}
$ cargo run --bin count_islands
Islands: 3
Boolean islands: 2
$ cargo test --bin count_islands
running 5 tests
test tests::empty_grid_has_zero_islands ... ok
test tests::all_water_is_zero ... ok
test tests::generic_bool_grid_works ... ok
test tests::single_row_and_column ... ok
test tests::counts_classic_example ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
12.8 Putting tasks in order
Suppose you have tasks, and some tasks must finish before others can start. A topological order lists every task so that each one comes after all of its prerequisites. Build tools, package managers, and course planners all compute one.
Draw an arrow from each task to the tasks that depend on it. The indegree of a node is the number of arrows coming into it: the number of prerequisites it is still waiting for.
Kahn’s algorithm builds the order step by step (figure 12.6):
- Put every node with indegree 0 in a queue. They have no prerequisites.
- Take a node from the queue and add it to the order.
- For each arrow out of that node, lower the target’s indegree by one. The target has one fewer prerequisite to wait for. If its indegree reaches 0, add it to the queue.
- Repeat until the queue is empty.
If the graph has a cycle, the nodes on the cycle wait for each other forever. Their indegrees never reach 0, so they never enter the order. The order then comes out shorter than the number of nodes, and that is how the algorithm detects a cycle.
None.12.8.1 Kahn’s algorithm on numbered nodes
The input is a node count and a slice of (from, to) edges. The first step builds the adjacency list and
counts each node’s indegree at the same time:
use std::collections::VecDeque;
pub fn topological_sort(num_nodes: usize, edges: &[(usize, usize)]) -> Option<Vec<usize>> {
let mut graph = vec![Vec::new(); num_nodes];
let mut indegree = vec![0usize; num_nodes];
for &(from, to) in edges {
graph[from].push(to);
indegree[to] += 1;
}
// ...
}
The starting queue comes from one iterator chain:
pub fn topological_sort(num_nodes: usize, edges: &[(usize, usize)]) -> Option<Vec<usize>> {
// ...
let mut queue: VecDeque<usize> = (0..num_nodes)
.filter(|&n| indegree[n] == 0)
.collect();
let mut order = Vec::with_capacity(num_nodes);
// ...
}
(0..num_nodes) produces every node number. .filter(|&n| indegree[n] == 0) keeps the nodes with no
prerequisites, and .collect() builds the VecDeque.
The loop is Kahn’s algorithm itself:
pub fn topological_sort(num_nodes: usize, edges: &[(usize, usize)]) -> Option<Vec<usize>> {
// ...
while let Some(node) = queue.pop_front() {
order.push(node);
for &next in &graph[node] {
indegree[next] -= 1;
if indegree[next] == 0 {
queue.push_back(next);
}
}
}
(order.len() == num_nodes).then_some(order)
}
Each node taken from the queue goes into order. Removing it removes its outgoing edges, so each neighbor’s
indegree drops by one. A neighbor whose indegree reaches 0 has no prerequisites left, and joins the queue.
The last line returns the answer. bool::then_some(order) gives Some(order) if the condition is true, and
None if it is false. So the function returns None exactly when a cycle kept some nodes out of the order.
The nodes on a cycle never reach indegree 0.
Listing 12.14 The complete file. src/problems/graph_topology.rs
//! Topological sort (Kahn's algorithm).
//!
//! Common in scheduling problems: prerequisite graphs, pipeline
//! stages, and build or job dependency ordering.
use std::collections::VecDeque;
pub fn topological_sort(num_nodes: usize, edges: &[(usize, usize)]) -> Option<Vec<usize>> {
let mut graph = vec![Vec::new(); num_nodes];
let mut indegree = vec![0usize; num_nodes];
for &(from, to) in edges {
graph[from].push(to);
indegree[to] += 1;
}
let mut queue: VecDeque<usize> = (0..num_nodes)
.filter(|&n| indegree[n] == 0)
.collect();
let mut order = Vec::with_capacity(num_nodes);
while let Some(node) = queue.pop_front() {
order.push(node);
for &next in &graph[node] {
indegree[next] -= 1;
if indegree[next] == 0 {
queue.push_back(next);
}
}
}
(order.len() == num_nodes).then_some(order)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn returns_order_for_dag() {
// 0 -> 1, 0 -> 2, 1 -> 3, 2 -> 3
let edges = [(0, 1), (0, 2), (1, 3), (2, 3)];
let order = topological_sort(4, &edges).expect("DAG has an order");
assert_eq!(order.len(), 4);
assert!(
order
.iter()
.position(|&n| n == 0)
.unwrap()
< order
.iter()
.position(|&n| n == 1)
.unwrap()
);
assert!(
order
.iter()
.position(|&n| n == 1)
.unwrap()
< order
.iter()
.position(|&n| n == 3)
.unwrap()
);
}
#[test]
fn detects_cycle() {
// 0 -> 1 -> 0 is a cycle.
let edges = [(0, 1), (1, 0)];
assert_eq!(topological_sort(2, &edges), None);
}
#[test]
fn disconnected_nodes_are_included() {
let edges = [(0, 1)];
let order = topological_sort(3, &edges).expect("DAG has an order");
assert_eq!(order.len(), 3);
}
}
The tests check positions rather than one exact order. A graph usually has several valid topological orders, and the tests only require that each edge’s source comes before its target.
$ cargo test --lib problems::graph_topology
running 3 tests
test problems::graph_topology::tests::disconnected_nodes_are_included ... ok
test problems::graph_topology::tests::returns_order_for_dag ... ok
test problems::graph_topology::tests::detects_cycle ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 134 filtered out
12.8.2 Kahn’s algorithm on named courses
The second version takes courses with names and lists of prerequisites, the way the data might arrive from a file.
A course has a name and a list of prerequisites, the names of the courses that must come before it:
use std::collections::{HashMap, VecDeque};
#[derive(Clone)]
struct Course {
name: String,
depends_on: Vec<String>,
}
Listing 12.15 The complete program. src/bin/dependency_resolutiom.rs
use std::collections::{HashMap, VecDeque};
#[derive(Clone)]
struct Course {
name: String,
depends_on: Vec<String>,
}
fn topo_sort(courses: &[Course]) -> Vec<String> {
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
let mut indegree: HashMap<String, usize> = HashMap::new();
for Course { name, depends_on } in courses {
indegree.insert(name.clone(), depends_on.len());
adj.entry(name.clone()).or_default();
for dep in depends_on {
adj.entry(dep.clone())
.or_default()
.push(name.clone());
indegree.entry(dep.clone()).or_insert(0);
}
}
let mut queue: VecDeque<_> = indegree
.iter()
.filter(|&(_, deg)| *deg == 0)
.map(|(name, _)| name.clone())
.collect();
let mut order = Vec::new();
while let Some(node) = queue.pop_front() {
for next in &adj[&node] {
let d = indegree.get_mut(next).unwrap();
*d -= 1;
if *d == 0 {
queue.push_back(next.clone());
}
}
order.push(node);
}
if order.len() == indegree.len() {
order
} else {
Vec::new()
}
}
fn main() {
let courses = vec![
Course {
name: "algos".into(),
depends_on: vec!["datastructs".into()],
},
Course {
name: "compilers".into(),
depends_on: vec!["algos".into(), "os".into()],
},
Course {
name: "os".into(),
depends_on: vec!["datastructs".into()],
},
];
println!("{:?}", topo_sort(&courses));
}
The input describes edges backward from the way the algorithm needs them. Each course lists its prerequisites,
but Kahn’s algorithm follows arrows from a prerequisite to the courses that need it. The loop turns them around.
For each prerequisite dep of course name, it adds name to adj[dep].
A course’s indegree is the length of its depends_on list. A prerequisite that is never listed as a course,
like datastructs here, still needs an entry. indegree.entry(dep.clone()).or_insert(0) adds it with indegree
0, and or_insert leaves an existing entry alone.
for Course { name, depends_on } in courses takes each course apart in the loop header. Because courses is a
slice reference, name and depends_on are references to the fields.
If a cycle keeps some courses out of the order, the function returns an empty Vec. With Option as in listing
12.14, the caller could tell “no courses” from “a cycle”.
$ cargo run --bin dependency_resolutiom
["datastructs", "algos", "os", "compilers"]
The starting queue is built by iterating over a HashMap, and that order changes between runs. This input has
only one course with no prerequisites, so the output is always the same. With several such courses, the order
among them could change from run to run.
12.9 Shortest paths
In a weighted graph, the cost of a path is the sum of its edge weights. The shortest path from A to D is the path with the lowest cost. It is not always the one with the fewest edges.
BFS finds the path with the fewest edges, which is a different question. Dijkstra’s algorithm finds the lowest cost from one start node to every other node, as long as no weight is negative.
12.9.1 How Dijkstra’s algorithm works
The algorithm keeps two things:
dist, the lowest cost found so far to each node. At the start, only the start node has a cost, 0.- A min-heap of
(cost, node)entries, so the cheapest entry is always taken next. Chapter 5 built min-heaps withBinaryHeapandReverse.
Each step takes the cheapest entry from the heap. For each edge out of that node, it computes the cost of reaching the neighbor through this node. If that cost beats the neighbor’s recorded cost, the new cost is recorded and pushed onto the heap. Updating a recorded cost to a lower one is called relaxing the edge.
When a node is taken from the heap with its recorded cost, that cost is final. Every other entry in the heap costs at least as much, and weights are never negative. So no path found later can come back cheaper.
A node can be in the heap several times. When a cheaper path to B is found, the old entry for B stays in the heap.
BinaryHeap has no way to find and update it. When the old entry is eventually taken, its cost is higher than
B’s recorded cost, and the code skips it. Such an entry is called stale.
Figure 12.7 follows every step on a four-node graph.
dijkstra_vec settles the node with the smallest cost in the heap, then relaxes its edges. An entry whose cost is above the recorded distance is stale and skipped.Read the table row by row. Taking A records B at 4 and C at 1. Taking C finds B at 1 + 2 = 3, which beats 4, so
B is recorded at 3 and pushed again. The old (4, B) entry is now stale. Taking B at 3 finds D at 4, which
beats the 6 recorded through C. Later, (4, B) and (6, D) come off the heap, and each is skipped because its
cost is higher than the recorded one.
12.9.2 Two versions side by side
Listing 12.16 Dijkstra over the index form (lines 23 to 44). src/problems/graph_dijkstra.rs
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap},
hash::Hash,
};
use crate::problems::graph_bfs::GraphAdjList;
/// Shortest distances from `start_node` over an index-based adjacency list.
pub fn dijkstra_vec(graph: &[Vec<(usize, u32)>], start_node: usize) -> Vec<Option<u32>> {
let mut distances = vec![None; graph.len()];
let mut min_heap = BinaryHeap::from([Reverse((0, start_node))]);
distances[start_node] = Some(0);
while let Some(Reverse((current_cost, current_node))) = min_heap.pop() {
// stale heap entry, a shorter path was already recorded
if distances[current_node].is_some_and(|best| current_cost > best) {
continue;
}
for &(neighbor, weight) in &graph[current_node] {
let new_distance = current_cost + weight;
if distances[neighbor].is_none_or(|best| new_distance < best) {
distances[neighbor] = Some(new_distance);
min_heap.push(Reverse((new_distance, neighbor)));
}
}
}
distances
}
The distances are a Vec<Option<u32>>, where None means “no path found yet”. Two methods of Option read
well here:
is_some_and(|best| current_cost > best)is true only if there is a recorded cost and the popped cost is higher. That is the stale check.is_none_or(|best| new_distance < best)is true if there is no recorded cost, or the new cost is lower. That is the relax check.
The heap holds Reverse((cost, node)). BinaryHeap pops the largest item, and Reverse flips the comparison,
so the smallest cost comes out first. Tuples compare by their first field, then by their second, so the cost
decides and the node number breaks ties.
Listing 12.17 Dijkstra over the map form (lines 46 to 73).
/// Shortest distances from `start_node` over a `HashMap` adjacency list.
pub fn dijkstra<Node>(graph: &GraphAdjList<Node>, start_node: Node) -> HashMap<Node, u32>
where
Node: Copy + Eq + Hash + Ord,
{
let mut distances = HashMap::from([(start_node, 0)]);
let mut min_heap = BinaryHeap::from([Reverse((0, start_node))]);
while let Some(Reverse((current_cost, current_node))) = min_heap.pop() {
// stale heap entry, a shorter path was already recorded
if current_cost > distances[¤t_node] {
continue;
}
for &(neighbor, weight) in graph[¤t_node] {
let new_distance = current_cost + weight;
if new_distance
< *distances
.get(&neighbor)
.unwrap_or(&u32::MAX)
{
distances.insert(neighbor, new_distance);
min_heap.push(Reverse((new_distance, neighbor)));
}
}
}
distances
}
In this version, distances is a HashMap that contains only nodes that have been reached. Node also needs
Ord, because nodes sit inside the tuples that the heap compares.
distances[¤t_node] cannot panic here. A node is pushed onto the heap only after its distance is inserted,
so every popped node has an entry. distances.get(&neighbor).unwrap_or(&u32::MAX) treats a missing entry as
the largest possible cost, so any real cost beats it.
Each edge can push one heap entry, and each heap operation takes O(log E). The whole algorithm runs in O(E log E), which is the same as O(E log V) for simple graphs.
$ cargo test --lib problems::graph_dijkstra
running 4 tests
test problems::graph_dijkstra::tests::vec_variant_finds_shorter_multi_hop_paths ... ok
test problems::graph_dijkstra::tests::vec_variant_marks_unreachable_nodes ... ok
test problems::graph_dijkstra::tests::map_variant_omits_unreachable_nodes ... ok
test problems::graph_dijkstra::tests::map_variant_finds_shorter_multi_hop_paths ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 133 filtered out
WARNING: current_cost + weight panics on overflow in a debug build, and wraps around silently in a
release build. With u32 weights, a long path of large weights can overflow. Listing 12.20 shows the fix with
checked_add.
12.9.3 Edges as a struct
The next three programs use a named Edge struct instead of a (neighbor, weight) tuple, and u64 costs.
They show three steps of the same function: it first clones node names, then borrows them, and finally handles
missing nodes and overflow.
An Edge holds the node it leads to and its weight. AdjMap is an alias for the graph: each node, with the list of edges that leave it. Both are generic over the node type:
#[derive(Debug)]
struct Edge<Node> {
to: Node,
weight: u64,
}
type AdjMap<Node> = HashMap<Node, Vec<Edge<Node>>>;
Listing 12.18 The complete program. src/bin/dijkstra.rs
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap},
hash::Hash,
};
#[derive(Debug)]
struct Edge<Node> {
to: Node,
weight: u64,
}
type AdjMap<Node> = HashMap<Node, Vec<Edge<Node>>>;
fn dijkstra_ssmd_shortest<Node>(graph: &AdjMap<Node>, start: Node) -> HashMap<Node, u64>
where
Node: Clone + Hash + Ord,
{
let mut dist = HashMap::from([(start.clone(), 0)]);
let mut heap = BinaryHeap::from([Reverse((0, start))]);
while let Some(Reverse((cost, node))) = heap.pop() {
if cost > dist[&node] {
continue; // stale entry
}
for edge in &graph[&node] {
let new_cost = cost + edge.weight;
if dist
.get(&edge.to)
.is_none_or(|&d| new_cost < d)
{
dist.insert(edge.to.clone(), new_cost);
heap.push(Reverse((new_cost, edge.to.clone())));
}
}
}
dist
}
fn main() {
let graph: AdjMap<&str> = HashMap::from([
(
"A",
vec![Edge { to: "B", weight: 4 }, Edge { to: "C", weight: 1 }],
),
("B", vec![Edge { to: "D", weight: 1 }]),
(
"C",
vec![Edge { to: "B", weight: 2 }, Edge { to: "D", weight: 5 }],
),
("D", vec![]),
]);
println!("{:#?}", graph);
let dist = dijkstra_ssmd_shortest(&graph, "A");
println!("{:#?}", dist); // {"A": 0, "C": 1, "B": 3, "D": 4} (order varies)
}
edge.to and edge.weight read better than .0 and .1. The node type needs Clone instead of Copy, so
it could be a String. The function clones a node each time it stores one in dist or on the heap.
graph[&node] panics if a node has no entry in the map. main includes "D" with an empty list, so every node
has one.
$ cargo run --bin dijkstra
{
"B": [
Edge {
to: "D",
weight: 1,
},
],
...
}
{
"B": 3,
"C": 1,
"A": 0,
"D": 4,
}
The first print shows the graph and is cut short here. A HashMap does not keep its entries in any order. The lines of both prints can come out in a different
order on your machine.
Listing 12.19 The graph types, and borrowing nodes instead of cloning them (lines 1 to 47). src/bin/dijkstra_with_lifetime.rs
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap},
hash::Hash,
};
#[derive(Debug)]
struct Edge<Node> {
to: Node,
weight: u64,
}
type AdjMap<Node> = HashMap<Node, Vec<Edge<Node>>>;
fn dijkstra_ssmd_shortest<'a, Node>(
graph: &'a AdjMap<Node>,
start: &'a Node,
) -> HashMap<&'a Node, u64>
where
Node: Hash + Ord,
{
let mut dist = HashMap::from([(start, 0u64)]);
let mut heap = BinaryHeap::from([Reverse((0u64, start))]);
while let Some(Reverse((cost, node))) = heap.pop() {
if cost > dist[node] {
continue; // stale entry
}
let Some(edges) = graph.get(node) else {
continue;
};
for edge in edges {
let Some(new_cost) = cost.checked_add(edge.weight) else {
continue;
};
if dist
.get(&edge.to)
.is_none_or(|&d| new_cost < d)
{
dist.insert(&edge.to, new_cost);
heap.push(Reverse((new_cost, &edge.to)));
}
}
}
dist
}
The types are generic over Node, the type that names a node, such as &str or u32. An Edge<Node> holds
the node it leads to and a weight. AdjMap<Node> is an alias for the adjacency map: each node, and the list
of edges that leave it.
This version stores references to nodes, &'a Node, in dist and in the heap, so Node does not need Clone.
With String nodes, that saves an allocation per clone.
The lifetime 'a ties everything together. The graph and the start node are both borrowed for 'a, and the
returned map holds &'a Node keys. The compiler therefore keeps the graph alive as long as the result is in
use. Every reference pushed onto the heap, &edge.to, points into the graph, so it lives long enough.
Two let ... else lines make the function safer than listing 12.18:
let Some(edges) = graph.get(node) else { continue };handles a node that has no entry in the map.mainleaves"D"out of the map, and the function still works.let Some(new_cost) = cost.checked_add(edge.weight) else { continue };skips an edge whose total cost would overflow.checked_addreturnsNoneon overflow instead of panicking or wrapping around.
Listing 12.20 The complete program. src/bin/dijkstra_with_lifetime.rs
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap},
hash::Hash,
};
#[derive(Debug)]
struct Edge<Node> {
to: Node,
weight: u64,
}
type AdjMap<Node> = HashMap<Node, Vec<Edge<Node>>>;
fn dijkstra_ssmd_shortest<'a, Node>(
graph: &'a AdjMap<Node>,
start: &'a Node,
) -> HashMap<&'a Node, u64>
where
Node: Hash + Ord,
{
let mut dist = HashMap::from([(start, 0u64)]);
let mut heap = BinaryHeap::from([Reverse((0u64, start))]);
while let Some(Reverse((cost, node))) = heap.pop() {
if cost > dist[node] {
continue; // stale entry
}
let Some(edges) = graph.get(node) else {
continue;
};
for edge in edges {
let Some(new_cost) = cost.checked_add(edge.weight) else {
continue;
};
if dist
.get(&edge.to)
.is_none_or(|&d| new_cost < d)
{
dist.insert(&edge.to, new_cost);
heap.push(Reverse((new_cost, &edge.to)));
}
}
}
dist
}
fn main() {
let graph: AdjMap<String> = HashMap::from([
(
"A".into(),
vec![
Edge {
to: "B".into(),
weight: 4,
},
Edge {
to: "C".into(),
weight: 1,
},
],
),
(
"B".into(),
vec![Edge {
to: "D".into(),
weight: 1,
}],
),
(
"C".into(),
vec![
Edge {
to: "B".into(),
weight: 2,
},
Edge {
to: "D".into(),
weight: 5,
},
],
),
]);
println!("Graph => {:#?}", graph);
let start = "A".into();
println!(
"Shortest path {:#?}",
dijkstra_ssmd_shortest(&graph, &start)
); // A:0, C:1, B:3, D:4
}
In main, let start = "A".into(); makes a String in its own variable, because the function needs a
reference that lives as long as the graph borrow.
$ cargo run --bin dijkstra_with_lifetime
Graph => {
...
}
Shortest path {
"A": 0,
"B": 3,
"D": 4,
"C": 1,
}
The last version keeps the owned, cloning style of listing 12.18 and adds the two safety checks.
Listing 12.21 The complete program. src/bin/dijkstra_bruce_lee.rs
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap},
hash::Hash,
};
struct Edge<Node> {
to: Node,
weight: u64,
}
type AdjMap<Node> = HashMap<Node, Vec<Edge<Node>>>;
fn dijkstra_ssmd_shortest<Node>(graph: &AdjMap<Node>, start: Node) -> HashMap<Node, u64>
where
Node: Clone + Hash + Ord,
{
let mut dist = HashMap::from([(start.clone(), 0)]);
let mut heap = BinaryHeap::from([Reverse((0u64, start))]);
while let Some(Reverse((cost, node))) = heap.pop() {
if cost > dist[&node] {
continue; // stale entry
}
let Some(edges) = graph.get(&node) else {
continue;
}; // node with no outgoing edges
for edge in edges {
let Some(new_cost) = cost.checked_add(edge.weight) else {
continue;
}; // overflow
if dist
.get(&edge.to)
.is_none_or(|&d| new_cost < d)
{
dist.insert(edge.to.clone(), new_cost);
heap.push(Reverse((new_cost, edge.to.clone())));
}
}
}
dist
}
fn main() {
let graph: AdjMap<&str> = HashMap::from([
(
"A",
vec![Edge { to: "B", weight: 4 }, Edge { to: "C", weight: 1 }],
),
("B", vec![Edge { to: "D", weight: 1 }]),
(
"C",
vec![Edge { to: "B", weight: 2 }, Edge { to: "D", weight: 5 }],
),
]); // "D" has no entry now, and it no longer panics
println!("{:?}", dijkstra_ssmd_shortest(&graph, "A"));
}
$ cargo run --bin dijkstra_bruce_lee
{"B": 3, "C": 1, "D": 4, "A": 0}
Table 12.1 compares the five versions.
Table 12.1 The five Dijkstra implementations
| Listing | Nodes | Edges | Missing node entry | Overflow |
|---|---|---|---|---|
12.16 dijkstra_vec | usize indices | (usize, u32) | cannot happen | panics in debug |
12.17 dijkstra | Copy values | (Node, u32) | panics | panics in debug |
12.18 dijkstra.rs | cloned | Edge, u64 | panics | panics in debug |
12.20 dijkstra_with_lifetime.rs | borrowed | Edge, u64 | skipped | skipped |
12.21 dijkstra_bruce_lee.rs | cloned | Edge, u64 | skipped | skipped |
12.10 Connecting every node at the lowest cost
The last problem uses an undirected, weighted graph. Think of the nodes as buildings and the weights as the cost of laying cable between two buildings. You want to connect every building, directly or indirectly, at the lowest total cost.
A set of edges that connects all the nodes without forming a cycle is a spanning tree. A spanning tree with the lowest total weight is a minimum spanning tree (MST). With n nodes, a spanning tree has exactly n − 1 edges. One edge fewer would leave a node disconnected, and one edge more would close a loop.
Kruskal’s algorithm builds an MST by taking edges from lightest to heaviest. It keeps an edge if the edge joins two groups of nodes that are not yet connected. It skips an edge whose two ends are already connected, because that edge would close a loop.
12.10.1 Union-find
Kruskal’s algorithm needs to answer one question fast: are these two nodes already connected? A union-find structure, also called a disjoint set, answers it.
Union-find keeps nodes in groups. Each group is a small tree stored in a parent array, where parent[i] is
the node that i points to. The node at the top of a group points to itself and is called the group’s root.
Two nodes are in the same group when they have the same root.
There are two operations:
find(x)followsparentlinks fromxup to the root.union(x, y)finds both roots. If the roots differ, it makes one root point to the other, which joins the two groups.
Listing 12.22 Union-find (lines 10 to 49). src/bin/kruskals_algorithm.rs
use std::cmp::Ordering::{Equal, Greater, Less};
struct DisjointSet {
parent: Vec<usize>,
rank: Vec<usize>,
}
impl DisjointSet {
fn new(n: usize) -> Self {
Self {
parent: (0..n).collect(),
rank: vec![0; n],
}
}
fn find(&mut self, elem: usize) -> usize {
if self.parent[elem] != elem {
self.parent[elem] = self.find(self.parent[elem]);
}
self.parent[elem]
}
fn union(&mut self, x: usize, y: usize) -> bool {
let (x, y) = (self.find(x), self.find(y));
if x == y {
return false;
}
match self.rank[x].cmp(&self.rank[y]) {
Less => self.parent[x] = y,
Greater => self.parent[y] = x,
Equal => {
self.parent[x] = y;
self.rank[y] += 1;
}
}
true
}
}
new makes every node its own group: parent is [0, 1, 2, ...], built with (0..n).collect().
find is recursive. On the way back from the root, it sets parent[elem] to the root directly. The next find
on that node then takes one step instead of many. This trick is called path compression.
union returns false if both nodes already have the same root. Otherwise it joins the groups using rank, a
rough measure of each group’s height. The shorter group goes under the taller one, so the trees stay flat. If
both have the same rank, either can go under the other, and the new root’s rank grows by one. This is called
union by rank.
With both tricks, find and union take nearly constant time in practice.
12.10.2 Kruskal’s algorithm
Listing 12.23 The edge type and Kruskal's algorithm (lines 3 to 65).
#[derive(Debug)]
struct Edge {
u: usize,
v: usize,
w: u32,
}
fn kruskals(n: usize, mut edges: Vec<Edge>) -> Vec<Edge> {
// Kruskal's algorithm is greedy: it takes the cheapest edges first.
edges.sort_by_key(|e| e.w);
let mut uf = DisjointSet::new(n);
let mut mst = Vec::new();
for edge in edges {
if uf.union(edge.u, edge.v) {
mst.push(edge);
}
}
mst
}
An Edge joins nodes u and v, numbered from 0, with weight w. The graph is undirected, so the
order of u and v does not matter.
edges.sort_by_key(|e| e.w) sorts the edges by weight. Then each edge is offered to union. If union
returns true, the edge joined two groups, and it goes into the tree. The edge is moved into mst, not copied,
because for edge in edges takes ownership of each one.
Sorting dominates the running time, so the algorithm runs in O(E log E).
Figure 12.8 follows the parent array through every edge of the program’s graph.
sort_by_key is a stable sort: edges with equal weights keep their input order. So 1-3 comes before 3-4, and
0-1 comes before 2-3.
union keeps an edge only when its ends are in different sets, so no kept edge closes a cycle.Listing 12.24 The complete program. src/bin/kruskals_algorithm.rs
use std::cmp::Ordering::{Equal, Greater, Less};
#[derive(Debug)]
struct Edge {
u: usize,
v: usize,
w: u32,
}
struct DisjointSet {
parent: Vec<usize>,
rank: Vec<usize>,
}
impl DisjointSet {
fn new(n: usize) -> Self {
Self {
parent: (0..n).collect(),
rank: vec![0; n],
}
}
fn find(&mut self, elem: usize) -> usize {
if self.parent[elem] != elem {
self.parent[elem] = self.find(self.parent[elem]);
}
self.parent[elem]
}
fn union(&mut self, x: usize, y: usize) -> bool {
let (x, y) = (self.find(x), self.find(y));
if x == y {
return false;
}
match self.rank[x].cmp(&self.rank[y]) {
Less => self.parent[x] = y,
Greater => self.parent[y] = x,
Equal => {
self.parent[x] = y;
self.rank[y] += 1;
}
}
true
}
}
fn kruskals(n: usize, mut edges: Vec<Edge>) -> Vec<Edge> {
// Kruskal's algorithm is greedy: it takes the cheapest edges first.
edges.sort_by_key(|e| e.w);
let mut uf = DisjointSet::new(n);
let mut mst = Vec::new();
for edge in edges {
if uf.union(edge.u, edge.v) {
mst.push(edge);
}
}
mst
}
fn main() {
let edges = vec![
Edge { u: 0, v: 1, w: 4 },
Edge { u: 0, v: 2, w: 3 },
Edge { u: 1, v: 2, w: 1 },
Edge { u: 1, v: 3, w: 2 },
Edge { u: 2, v: 3, w: 4 },
Edge { u: 3, v: 4, w: 2 },
];
let mst = kruskals(5, edges);
let total: u32 = mst.iter().map(|e| e.w).sum();
for e in &mst {
println!("{} - {} ({})", e.u, e.v, e.w);
}
println!("total: {total}");
}
$ cargo run --bin kruskals_algorithm
1 - 2 (1)
1 - 3 (2)
3 - 4 (2)
0 - 2 (3)
total: 8
The tree has four edges for five nodes, as expected.
12.11 The complete files
The sections above showed these files in excerpts. Here each one is whole.
Listing 12.25 Breadth-first search, with its tests. src/problems/graph_bfs.rs
//! Breadth-first search.
//!
//! Two variants of the same algorithm:
//!
//! - `bfs_vec`: nodes are `usize` indices and the graph is `Vec<Vec<(usize, u32)>>`,
//! so `visited` is a plain `Vec<bool>`. This is the version to write first.
//! - `bfs`: nodes are any `Copy + Eq + Hash` type and the graph is a `HashMap`
//! from a node to a slice of `(neighbor, weight)` edges.
use std::{
collections::{HashMap, HashSet, VecDeque},
hash::Hash,
};
/// Adjacency list keyed by node: each node maps to its `(neighbor, weight)` edges.
pub type GraphAdjList<'a, Node> = HashMap<Node, &'a [(Node, u32)]>;
/// BFS over an index-based adjacency list. Returns nodes in visiting order.
pub fn bfs_vec(graph: &[Vec<(usize, u32)>], start_node: usize) -> Vec<usize> {
let mut visited = vec![false; graph.len()];
let mut queue = VecDeque::from([start_node]);
let mut result = vec![];
visited[start_node] = true;
while let Some(current_node) = queue.pop_front() {
result.push(current_node);
for &(neighbor, _) in &graph[current_node] {
if !visited[neighbor] {
visited[neighbor] = true;
queue.push_back(neighbor);
}
}
}
result
}
/// BFS over a `HashMap` adjacency list. Returns nodes in visiting order.
pub fn bfs<Node>(graph: &GraphAdjList<Node>, start_node: Node) -> Vec<Node>
where
Node: Copy + Eq + Hash,
{
let mut visited = HashSet::new();
let mut queue = VecDeque::from([start_node]);
let mut result = vec![];
visited.insert(start_node);
while let Some(current_node) = queue.pop_front() {
result.push(current_node);
// slice iter yields &(Node, u32); `&` destructures it
for &(neighbor, _) in graph[¤t_node] {
if visited.insert(neighbor) {
queue.push_back(neighbor);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
/// A -> B (1), A -> C (4), B -> C (2), B -> D (5), C -> D (1)
fn sample_vec() -> Vec<Vec<(usize, u32)>> {
vec![
vec![(1, 1), (2, 4)],
vec![(2, 2), (3, 5)],
vec![(3, 1)],
vec![],
]
}
fn sample_map() -> HashMap<&'static str, &'static [(&'static str, u32)]> {
HashMap::from([
("A", &[("B", 1), ("C", 4)][..]),
("B", &[("C", 2), ("D", 5)][..]),
("C", &[("D", 1)][..]),
("D", &[][..]),
])
}
#[test]
fn vec_variant_visits_level_by_level() {
assert_eq!(bfs_vec(&sample_vec(), 0), vec![0, 1, 2, 3]);
}
#[test]
fn vec_variant_skips_unreachable_nodes() {
let graph = vec![vec![(1, 1)], vec![], vec![(0, 1)]];
assert_eq!(bfs_vec(&graph, 0), vec![0, 1]);
}
#[test]
fn vec_variant_handles_cycles() {
let graph = vec![vec![(1, 1)], vec![(0, 1)]];
assert_eq!(bfs_vec(&graph, 0), vec![0, 1]);
}
#[test]
fn map_variant_visits_level_by_level() {
assert_eq!(bfs(&sample_map(), "A"), vec!["A", "B", "C", "D"]);
}
#[test]
fn both_variants_agree() {
let names = ["A", "B", "C", "D"];
let from_vec: Vec<&str> = bfs_vec(&sample_vec(), 0)
.into_iter()
.map(|index| names[index])
.collect();
assert_eq!(from_vec, bfs(&sample_map(), "A"));
}
}
Listing 12.26 Depth-first search, with its tests. src/problems/graph_dfs.rs
//! Iterative depth-first search with an explicit stack.
//!
//! Two variants of the same algorithm:
//!
//! - `dfs_vec`: nodes are `usize` indices and the graph is `Vec<Vec<(usize, u32)>>`.
//! - `dfs`: nodes are any `Copy + Eq + Hash` type and the graph is a `HashMap`.
//!
//! A node is marked visited when it is popped, not when it is pushed, so it may
//! sit on the stack more than once. Neighbors are pushed in order, which means the
//! last neighbor is explored first.
use std::{collections::HashSet, hash::Hash};
use crate::problems::graph_bfs::GraphAdjList;
/// DFS over an index-based adjacency list. Returns nodes in visiting order.
pub fn dfs_vec(graph: &[Vec<(usize, u32)>], start_node: usize) -> Vec<usize> {
let mut visited = vec![false; graph.len()];
let mut stack = vec![start_node];
let mut result = vec![];
while let Some(current_node) = stack.pop() {
if visited[current_node] {
continue;
}
visited[current_node] = true;
result.push(current_node);
for &(neighbor, _) in &graph[current_node] {
stack.push(neighbor);
}
}
result
}
/// DFS over a `HashMap` adjacency list. Returns nodes in visiting order.
pub fn dfs<Node>(graph: &GraphAdjList<Node>, start_node: Node) -> Vec<Node>
where
Node: Copy + Eq + Hash,
{
let mut visited = HashSet::new();
let mut stack = vec![start_node];
let mut result = vec![];
while let Some(current_node) = stack.pop() {
if !visited.insert(current_node) {
continue;
}
result.push(current_node);
for &(neighbor, _) in graph[¤t_node] {
stack.push(neighbor);
}
}
result
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
/// A -> B (1), A -> C (4), B -> C (2), B -> D (5), C -> D (1)
fn sample_vec() -> Vec<Vec<(usize, u32)>> {
vec![
vec![(1, 1), (2, 4)],
vec![(2, 2), (3, 5)],
vec![(3, 1)],
vec![],
]
}
fn sample_map() -> HashMap<&'static str, &'static [(&'static str, u32)]> {
HashMap::from([
("A", &[("B", 1), ("C", 4)][..]),
("B", &[("C", 2), ("D", 5)][..]),
("C", &[("D", 1)][..]),
("D", &[][..]),
])
}
#[test]
fn vec_variant_explores_the_last_neighbor_first() {
// Pop 0, push 1 and 2; pop 2, push 3; pop 3; pop 1.
assert_eq!(dfs_vec(&sample_vec(), 0), vec![0, 2, 3, 1]);
}
#[test]
fn vec_variant_handles_cycles() {
let graph = vec![vec![(1, 1)], vec![(2, 1)], vec![(0, 1)]];
assert_eq!(dfs_vec(&graph, 0), vec![0, 1, 2]);
}
#[test]
fn map_variant_explores_the_last_neighbor_first() {
assert_eq!(dfs(&sample_map(), "A"), vec!["A", "C", "D", "B"]);
}
#[test]
fn both_variants_agree() {
let names = ["A", "B", "C", "D"];
let from_vec: Vec<&str> = dfs_vec(&sample_vec(), 0)
.into_iter()
.map(|index| names[index])
.collect();
assert_eq!(from_vec, dfs(&sample_map(), "A"));
}
}
Listing 12.27 Dijkstra's algorithm, with its tests. src/problems/graph_dijkstra.rs
//! Dijkstra's shortest paths with a binary min-heap.
//!
//! Two variants of the same algorithm:
//!
//! - `dijkstra_vec`: nodes are `usize` indices and distances are a
//! `Vec<Option<u32>>`, where `None` means unreachable.
//! - `dijkstra`: nodes are any `Copy + Eq + Hash + Ord` type and distances are a
//! `HashMap` containing only the reachable nodes.
//!
//! Edge weights must be non-negative, which `u32` guarantees. `BinaryHeap` is a
//! max-heap, so entries are wrapped in `Reverse` to pop the smallest cost first.
//! A node can be in the heap several times; an entry whose cost is larger than the
//! recorded distance is stale and skipped.
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap},
hash::Hash,
};
use crate::problems::graph_bfs::GraphAdjList;
/// Shortest distances from `start_node` over an index-based adjacency list.
pub fn dijkstra_vec(graph: &[Vec<(usize, u32)>], start_node: usize) -> Vec<Option<u32>> {
let mut distances = vec![None; graph.len()];
let mut min_heap = BinaryHeap::from([Reverse((0, start_node))]);
distances[start_node] = Some(0);
while let Some(Reverse((current_cost, current_node))) = min_heap.pop() {
// stale heap entry, a shorter path was already recorded
if distances[current_node].is_some_and(|best| current_cost > best) {
continue;
}
for &(neighbor, weight) in &graph[current_node] {
let new_distance = current_cost + weight;
if distances[neighbor].is_none_or(|best| new_distance < best) {
distances[neighbor] = Some(new_distance);
min_heap.push(Reverse((new_distance, neighbor)));
}
}
}
distances
}
/// Shortest distances from `start_node` over a `HashMap` adjacency list.
pub fn dijkstra<Node>(graph: &GraphAdjList<Node>, start_node: Node) -> HashMap<Node, u32>
where
Node: Copy + Eq + Hash + Ord,
{
let mut distances = HashMap::from([(start_node, 0)]);
let mut min_heap = BinaryHeap::from([Reverse((0, start_node))]);
while let Some(Reverse((current_cost, current_node))) = min_heap.pop() {
// stale heap entry, a shorter path was already recorded
if current_cost > distances[¤t_node] {
continue;
}
for &(neighbor, weight) in graph[¤t_node] {
let new_distance = current_cost + weight;
if new_distance
< *distances
.get(&neighbor)
.unwrap_or(&u32::MAX)
{
distances.insert(neighbor, new_distance);
min_heap.push(Reverse((new_distance, neighbor)));
}
}
}
distances
}
#[cfg(test)]
mod tests {
use super::*;
/// A -> B (1), A -> C (4), B -> C (2), B -> D (5), C -> D (1)
fn sample_vec() -> Vec<Vec<(usize, u32)>> {
vec![
vec![(1, 1), (2, 4)],
vec![(2, 2), (3, 5)],
vec![(3, 1)],
vec![],
]
}
fn sample_map() -> HashMap<&'static str, &'static [(&'static str, u32)]> {
HashMap::from([
("A", &[("B", 1), ("C", 4)][..]),
("B", &[("C", 2), ("D", 5)][..]),
("C", &[("D", 1)][..]),
("D", &[][..]),
])
}
#[test]
fn vec_variant_finds_shorter_multi_hop_paths() {
// A -> C directly costs 4, A -> B -> C costs 3; D is reached via C for 4.
assert_eq!(
dijkstra_vec(&sample_vec(), 0),
vec![Some(0), Some(1), Some(3), Some(4)]
);
}
#[test]
fn vec_variant_marks_unreachable_nodes() {
let graph = vec![vec![(1, 7)], vec![], vec![(0, 1)]];
assert_eq!(dijkstra_vec(&graph, 0), vec![Some(0), Some(7), None]);
}
#[test]
fn map_variant_finds_shorter_multi_hop_paths() {
let distances = dijkstra(&sample_map(), "A");
assert_eq!(
distances,
HashMap::from([("A", 0), ("B", 1), ("C", 3), ("D", 4)])
);
}
#[test]
fn map_variant_omits_unreachable_nodes() {
let graph: HashMap<u8, &[(u8, u32)]> =
HashMap::from([(0, &[(1, 2)][..]), (1, &[][..]), (2, &[(0, 1)][..])]);
assert_eq!(dijkstra(&graph, 0), HashMap::from([(0, 0), (1, 2)]));
}
}
Summary
- A graph is a set of nodes and edges, with no rule against cycles. Every traversal needs a record of the nodes it has handled.
- An adjacency list stores each node’s outgoing edges. The index form uses
Vec<Vec<...>>and numbered nodes. The map form uses aHashMapand allows any hashable node. - BFS uses a queue and visits nodes in order of edge count from the start. DFS uses a stack and follows one path as deep as it goes. Both run in O(V + E).
- Graph nodes in Rust are usually numbers into a
Vec, which avoids reference cycles. - Cycle detection needs three states. An edge to a node on the current path, a Visiting node, closes a cycle.
- A grid is a graph whose edges are computed from coordinates. Counting islands runs one flood fill per island.
- Kahn’s algorithm produces a topological order by repeatedly taking nodes with indegree 0. A shorter order than the node count means the graph has a cycle.
- Dijkstra’s algorithm finds lowest-cost paths with a min-heap, skipping stale entries.
checked_addandlet ... elseguard against overflow and missing nodes. - Kruskal’s algorithm builds a minimum spanning tree from the lightest edges, using union-find to skip edges that would close a loop.
Chapter 13 builds an LRU cache, which combines a hash map with a linked list. It brings back the ownership questions of chapters 8 and 9.
Exercises
- Change
bfs_vecin listing 12.1 to also return, for each node, the number of edges on the shortest path from the start. - Extend
dijkstra_vecto record each node’s predecessor on its shortest path. Then write a function that rebuilds the path from the start to a given node. - Rewrite
count_islandswith aVec<bool>forvisited, and with a DFS stack instead of a BFS queue. Check that the tests still pass. - Change
topo_sortin listing 12.15 to returnOption<Vec<String>>, and add a test with a cycle. - Rewrite
findin listing 12.22 with a loop instead of recursion, keeping path compression.
An LRU cache
This chapter covers
- What a cache is, and the least-recently-used rule for deciding what to throw away
- An LRU cache built from a map and a queue of stamped events
- An LRU cache built from a doubly linked list stored in a
Vec, with indices as links - The same list split into small operations with stated preconditions
- A benchmark of the
Vecversion against a list ofRc<RefCell<_>>nodes
A cache keeps a limited number of recent results close at hand, so a program does not have to compute or fetch them again. A web browser caches images. A database caches disk pages in memory. A CPU caches memory in small, fast storage next to its cores, which chapter 15 looks at.
A cache has a fixed size. When it is full and a new entry arrives, it must remove, or evict, an old entry. The rule for choosing which entry to evict is the cache’s eviction policy.
This chapter builds a cache with the least recently used (LRU) policy: evict the entry that has gone longest without being used. The idea is that an entry used recently is likely to be used again soon.
You have already built both parts of an LRU cache. Chapter 3 covered HashMap, and chapters 8 and 9 covered
linked lists and Rc. This chapter combines them, four ways, and then measures two of them.
13.1 What an LRU cache does
An LRU cache has two operations:
get(key)returns the value forkey, or nothing if the key is not cached. A successfulgetcounts as a use, so the key becomes the most recently used.put(key, value)stores the value. The key becomes the most recently used. If the cache was full and the key is new, the least recently used entry is evicted.
Figure 13.1 follows five calls on a cache with room for two entries.
The third call changes the outcome. Without get(A), A would be the least recent entry, and put(C, 3) would
evict A. Because get(A) counted as a use, B is evicted instead.
13.1.1 What a cache saves
A cache pays off when a hit is much cheaper than the work it replaces. Suppose a lookup in the cache takes 100 ns, and a miss means a database query that takes 1 ms, ten thousand times longer. The average cost of a request is then set almost entirely by the misses:
| Hit rate | Average cost of a request |
|---|---|
| 0% | 1 ms |
| 50% | about 500 µs |
| 90% | about 100 µs |
| 99% | about 10 µs |
Going from 90% to 99% makes the average ten times smaller. The hit rate depends on what stays in the cache, and the eviction policy decides that.
Here is a longer trace. The cache holds three entries, and eight requests arrive: A, B, A, C, A, D, A, B. A request for a cached key is a hit; any other request is a miss, which fetches the value and stores it. The table follows the LRU rule, and a simpler rule for comparison. FIFO, first in, first out, evicts the entry that arrived earliest, and ignores use. The order is shown most recent first.
| Request | LRU: entries after | LRU | FIFO: entries after | FIFO |
|---|---|---|---|---|
| A | A | miss | A | miss |
| B | B A | miss | B A | miss |
| A | A B | hit | B A | hit |
| C | C A B | miss | C B A | miss |
| A | A C B | hit | C B A | hit |
| D | D A C | miss, evicts B | D C B | miss, evicts A |
| A | A D C | hit | A D C | miss, evicts B |
| B | B A D | miss, evicts C | B A D | miss, evicts C |
LRU scores 3 hits, and FIFO 2. A is used most, and LRU never evicts it: every use moves it back to the front. FIFO evicts A at the sixth request because A arrived first, and the very next request misses on A.
Animation 13.1 plays the same trace, with a robot for the cache and one for the slow store behind it.
LRU works when the recent past predicts the near future, which holds for most workloads. One pattern breaks it: a scan that reads many keys once each. Every key in the scan becomes the most recent, and the scan pushes out the entries that were in steady use. Databases defend against this with variants that admit a key fully only on its second use.
A cache needs to answer two questions fast:
- Is this key cached, and what is its value? A
HashMapanswers that in O(1). - Which entry is the least recently used? This needs an order that changes on every call.
Keeping the order in a Vec would be slow. Moving an entry from the middle to the front shifts every element
between, which is O(n). The four versions in this chapter keep the order in structures where each step is O(1).
13.2 Version 1: stamped events in a queue
The first version avoids a linked list entirely. Its idea is to record each use as an event, and to sort out which events still count only when something must be evicted.
The cache has a counter called generation that goes up by one on every call. Each call stamps the key with the
current generation in two places (figure 13.2):
- The map stores each key’s value and its latest stamp:
HashMap<K, (V, u64)>. - A queue,
recency, receives a(key, stamp)event at the back on every use.
The queue is in the order of use, oldest at the front. A key used several times has several events in it. Only the event whose stamp matches the map is current. The others are stale, the same idea as the stale heap entries of Dijkstra’s algorithm in section 12.9.
To evict, the cache pops events from the front. It skips stale events, and removes the key of the first current event. That key is the least recently used one.
Listing 13.1 The type (lines 10 to 24). src/problems/lru_cache.rs
use std::{
collections::{HashMap, VecDeque},
hash::Hash,
};
pub struct LruCache<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
capacity: usize,
map: HashMap<K, (V, u64)>,
recency: VecDeque<(K, u64)>,
generation: u64,
}
The key must be Clone, because a copy of it goes into the queue with every event. The value must be Clone,
because get returns a copy of it.
Listing 13.2 get and put (lines 49 to 71).
impl<K, V> LruCache<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
// ...
pub fn get(&mut self, key: &K) -> Option<V> {
let (value, _) = self.map.get(key)?.clone();
self.generation += 1;
self.recency
.push_back((key.clone(), self.generation));
self.map
.insert(key.clone(), (value.clone(), self.generation));
Some(value)
}
pub fn put(&mut self, key: K, value: V) {
self.generation += 1;
let is_new = self
.map
.insert(key.clone(), (value, self.generation))
.is_none();
self.recency
.push_back((key, self.generation));
if is_new {
self.evict_if_needed();
}
}
// ...
}
get looks up the key and returns early with None if it is missing: self.map.get(key)?. Then it advances the
generation, records an event, and stores the value again with the new stamp.
put stores the value with a new stamp, and records an event. HashMap::insert returns the old value if the key
was already present, or None if it was new. Only a new key can make the map too large, so only a new key
triggers eviction.
Listing 13.3 Eviction (lines 73 to 87).
impl<K, V> LruCache<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
// ...
fn evict_if_needed(&mut self) {
while self.map.len() > self.capacity {
let Some((key, generation)) = self.recency.pop_front() else {
break;
};
match self.map.get(&key) {
Some((_, current)) if *current == generation => {
self.map.remove(&key);
break;
}
_ => {}
}
}
}
}
The loop runs while the map holds more keys than the capacity. Each pass pops the oldest event. The match guard
Some((_, current)) if *current == generation accepts the event only if its stamp matches the key’s stamp in
the map. A match removes the key and ends the loop. A stale event matches the _ arm and is dropped.
Each event is pushed once and popped at most once. So the total work over many calls is proportional to the number of calls, and each call costs O(1) on average. A cost measured this way, averaged over a sequence of calls, is called amortized.
Animation 13.2 runs the four calls of figure 13.2 with a capacity of 2, then a run of hits.
WARNING: The queue gets one new event per call, but loses events only during eviction. A workload of mostly hits evicts rarely, so the queue can grow far longer than the capacity. The memory used depends on the number of calls, not on the number of cached entries. Exercise 1 asks you to fix it.
13.3 Version 2: a linked list inside a Vec
The classic LRU design keeps the entries in a doubly linked list, ordered from most to least recently used. A doubly linked list can move an entry to the front in O(1). Removing the last entry and adding one at the front are O(1) too. The map stores, for each key, where that key’s node is.
Chapter 9 showed that a doubly linked list of Rc<RefCell<_>> nodes is awkward in Rust. Each node is owned
jointly, every change goes through runtime borrow checks, and the back links must be Weak to avoid leaks.
This version uses a different approach. All nodes live in one Vec, and a link is the index of another node in
that Vec. A Vec used as a pool of nodes this way is called an arena. Figure 13.3 shows one.
Vec is the order of insertion. The prev and next indices give the order of use.An index is a plain usize. The borrow checker does not track it, so a node can be “pointed to” by several other
nodes with no Rc and no RefCell. The cost is that the compiler cannot catch a wrong index. The code must keep
the links correct by itself.
Listing 13.4 The node and the cache (lines 20 to 45). src/problems/lru_cache_easy.rs
use std::{collections::HashMap, hash::Hash};
/// A single cache entry, linked into the recency list by index.
struct Node<K, V> {
/// Retained so we can delete the corresponding map entry when evicting.
key: K,
value: V,
/// Neighbour closer to the MRU end (or `None` if this is `head`).
prev: Option<usize>,
/// Neighbour closer to the LRU end (or `None` if this is `tail`).
next: Option<usize>,
}
/// Fixed-capacity LRU cache.
pub struct LruCache<K, V> {
/// Maximum number of entries; `>= 1` (enforced by `new`).
cap: usize,
/// Key → arena index. The single source of truth for "is this key cached?".
lookup_table: HashMap<K, usize>,
/// Arena of nodes; indices are the "pointers" of the linked list.
nodes: Vec<Node<K, V>>,
/// Arena index of the most recently used entry.
head: Option<usize>,
/// Arena index of the least recently used entry (next to be evicted).
tail: Option<usize>,
}
Each node stores its key as well as its value. When a node is evicted, the cache reads the key from the node to
remove the key from lookup_table.
prev and next are Option<usize>, where None means “no neighbor on that side”. head is the index of the
most recently used node, and tail is the index of the least recently used one.
13.3.1 Moving a node to the front
Every get and put ends by moving one node to the front of the list. The method touch does it in two steps
(figure 13.4):
- Detach the node: make its two neighbors point at each other, so the list skips it.
- Push it to the front: make it point at the old head, and make it the new head.
Animation 13.3 runs touch on a list of four entries. The Vec sits underneath and never moves. Only the
links above it change. Watch the arrows: when C is lifted out, A’s next swings over to D.
touch: detach the node, close the gap, push it at the head. Without the tail update, the tail marker travels to the front with its node.Listing 13.5 new and touch (lines 51 to 101).
impl<K, V> LruCache<K, V>
where
K: Hash + Eq + Clone,
{
/// Create a cache holding at most `cap` entries.
///
/// # Panics
/// Panics if `cap == 0`, since a zero-capacity cache cannot store anything.
pub fn new(cap: usize) -> Self {
assert!(cap > 0, "capacity must be > 0");
Self {
cap,
lookup_table: HashMap::new(),
nodes: Vec::new(),
head: None,
tail: None,
}
}
/// Promote node `i` to `head` (MRU).
///
/// Handles both cases in one pass:
/// * **Already linked** — detach from its current neighbours first.
/// * **Brand new** — the node has `prev = next = None` and is not yet
/// referenced by `head`/`tail`, so the detach step is skipped.
fn touch(&mut self, i: usize) {
let (prev, next) = (self.nodes[i].prev, self.nodes[i].next);
// A node is "linked" if any of its four references exist.
let linked =
prev.is_some() || next.is_some() || self.head == Some(i) || self.tail == Some(i);
if linked {
// Splice `i` out of the list.
match prev {
Some(p) => self.nodes[p].next = next,
None => self.head = next,
}
match next {
Some(n) => self.nodes[n].prev = prev,
None => self.tail = prev,
}
}
// Splice `i` in at the front.
self.nodes[i].prev = None;
self.nodes[i].next = self.head;
if let Some(h) = self.head {
self.nodes[h].prev = Some(i);
}
self.head = Some(i);
if self.tail.is_none() {
self.tail = Some(i);
}
}
// ...
}
touch first copies the node’s prev and next into local variables. Option<usize> is Copy, so this reads
the values without keeping a borrow of self.nodes.
A brand-new node is not in the list yet, so there is nothing to detach. touch tells the two cases apart with
linked. A node is in the list if it has a neighbor, or if it is the head or the tail. A list with one node has
no neighbors, so the head and tail checks are needed for that case.
The detach step handles the ends of the list. If the node has no prev, it was the head, so the head moves to
its next. If it has no next, it was the tail, so the tail moves to its prev.
The push step sets the node’s links, points the old head back at it, and makes it the head. If the list was empty, the node is also the tail.
13.3.2 get and put
Listing 13.6 get and put (lines 103 to 147).
impl<K, V> LruCache<K, V>
where
K: Hash + Eq + Clone,
{
// ...
/// Look up `key`, promoting it to MRU on a hit.
///
/// Returns `None` if the key is not cached.
pub fn get(&mut self, key: &K) -> Option<&V> {
let i = *self.lookup_table.get(key)?;
self.touch(i);
Some(&self.nodes[i].value)
}
/// Insert or update `key`. Promotes it to MRU. Evicts the LRU entry if
/// the cache is full.
pub fn put(&mut self, key: K, value: V) {
// Case 1: key already present → update value, promote.
if let Some(&i) = self.lookup_table.get(&key) {
self.nodes[i].value = value;
self.touch(i);
return;
}
// Case 2: room to grow → append a fresh node.
if self.nodes.len() < self.cap {
let i = self.nodes.len();
self.nodes.push(Node {
key: key.clone(),
value,
prev: None,
next: None,
});
self.lookup_table.insert(key, i);
self.touch(i);
return;
}
// Case 3: at capacity → reuse the LRU slot.
// `tail` is guaranteed to be `Some` here because `cap >= 1` and the
// arena is full, but we still handle `None` gracefully.
if let Some(i) = self.tail {
let old_key = std::mem::replace(&mut self.nodes[i].key, key);
self.nodes[i].value = value;
self.lookup_table.remove(&old_key);
self.lookup_table
.insert(self.nodes[i].key.clone(), i);
self.touch(i);
}
}
}
get finds the index, touches the node, and returns a reference to the value. The return type Option<&V>
borrows from the cache. Because get takes &mut self, the cache stays mutably borrowed while you hold that
reference.
put has three cases:
- The key is present. Replace the value and touch the node.
- There is room. Push a new node onto the end of the
Vec, record its index, and touch it. - The cache is full. Reuse the tail node’s slot for the new entry.
The third case is the arena’s advantage. Eviction does not free anything, and insertion does not allocate. The
tail slot receives the new key and value, and touch moves it to the front.
std::mem::replace(&mut self.nodes[i].key, key) puts the new key into the node and returns the old key, in one
step. The old key is then removed from lookup_table. The new key was moved into the node, so the map receives a
clone of it.
Animation 13.4 plays the three cases in order. It fills the cache, updates an entry, and then evicts the tail
by reusing its slot. The last run leaves out the remove of the old key.
put: update in place, push into a free slot, or reuse the tail's slot. A reused slot must leave the table under its old key, or a stale key finds another key's value.The file src/bin/lru_cache_arena.rs has the same cache with a main function:
Listing 13.7 The main function (lines 150 to 165). src/bin/lru_cache_arena.rs
use std::{collections::HashMap, hash::Hash};
fn main() {
let mut cache = LruCache::new(2);
cache.put("A", 10);
cache.put("B", 20);
println!("get A = {:?}", cache.get(&"A")); // Some(10), A now MRU
cache.put("C", 30); // evicts B (LRU)
println!("get B = {:?}", cache.get(&"B")); // None
println!("get A = {:?}", cache.get(&"A")); // Some(10)
println!("get C = {:?}", cache.get(&"C")); // Some(30)
cache.put("A", 99);
println!("get A = {:?}", cache.get(&"A")); // Some(99)
}
$ cargo run --bin lru_cache_arena
get A = Some(10)
get B = None
get A = Some(10)
get C = Some(30)
get A = Some(99)
13.4 Version 3: one operation per method
touch in version 2 does two jobs, and must first work out which situation it is in. Version 3 splits it into
three methods, each with a stated precondition. A precondition is something that must be true when a
function is called. The compiler cannot check these preconditions, so the doc comments state them.
This version also fixes the key and value types to i32. i32 is Copy, so the code never clones a key.
Listing 13.8 The link operations (lines 63 to 94). src/bin/lru_cache_modular.rs
use std::collections::HashMap;
impl LruCache {
// ...
/// Unlink node `i` from the list. `i` must currently be linked.
fn detach(&mut self, i: usize) {
let (prev, next) = (self.nodes[i].prev, self.nodes[i].next);
match prev {
Some(p) => self.nodes[p].next = next,
None => self.head = next,
}
match next {
Some(n) => self.nodes[n].prev = prev,
None => self.tail = prev,
}
}
/// Link node `i` in at the MRU end. `i` must currently be unlinked.
fn push_front(&mut self, i: usize) {
self.nodes[i].prev = None;
self.nodes[i].next = self.head;
match self.head {
Some(h) => self.nodes[h].prev = Some(i),
None => self.tail = Some(i), // list was empty
}
self.head = Some(i);
}
/// Promote an already-linked node to MRU.
fn promote_to_front(&mut self, i: usize) {
if self.head == Some(i) {
return; // already MRU
}
self.detach(i);
self.push_front(i);
}
// ...
}
detachunlinks a node. Its precondition is that the node is in the list.push_frontlinks a node at the head. Its precondition is that the node is not in the list.promote_to_frontcombines them for a node that is in the list. If the node is already the head, it does nothing.
Each method now does one thing, with no linked check. put calls push_front for a new node and
promote_to_front for an existing one, so each call satisfies its precondition.
$ cargo run --bin lru_cache_modular
get 1 = Some(10)
get 2 = None
get 1 = Some(10)
get 3 = Some(30)
get 1 = Some(99)
13.5 Version 4: measuring the arena against Rc<RefCell<_>>
Is the arena faster than a list of Rc<RefCell<_>> nodes, and by how much? The only reliable answer is to
measure. This section builds both caches behind one trait, and runs the same requests through each.
13.5.1 One trait, two caches
Listing 13.9 The trait. benchmarking_examples/cache/mod.rs
//! Two LRU caches of the same design, stored differently, so one benchmark loop
//! can cover both.
//!
//! [`arena`] keeps nodes in a `Vec` and links them by `usize` index.
//! [`rc_list`] allocates an `Rc<RefCell<_>>` per node and links them by pointer.
pub mod arena;
pub mod rc_list;
use std::hash::Hash;
/// The operations every cache supports.
pub trait Cache<K, V>
where
K: Hash + Eq + Clone,
V: Copy,
{
/// A cache that holds at most `capacity` entries.
///
/// # Panics
/// Panics if `capacity == 0`, since a zero-capacity cache cannot store
/// anything.
fn new(capacity: usize) -> Self;
/// Looks up `key`, promoting it to most recently used on a hit.
fn get(&mut self, key: &K) -> Option<V>;
/// Inserts or updates `key`, evicting the least recently used entry when
/// the cache is full.
fn put(&mut self, key: K, value: V);
/// How many entries are cached.
fn len(&self) -> usize;
/// Whether the cache holds no entries.
fn is_empty(&self) -> bool {
self.len() == 0
}
/// The variant's name, for benchmark output.
fn variant() -> &'static str;
}
The trait Cache<K, V> lists what the benchmark needs. new is part of the trait, so generic code can create
a cache with C::new(capacity) without knowing which type C is. variant has no self parameter. It is
called on the type, and gives each cache a name for the output.
V: Copy lets get return the value itself, Option<V>, instead of a reference. The benchmark stores u64
values, which are Copy.
The arena cache is version 2, split into two impl blocks. touch stays in an ordinary impl, because it is
not part of the trait. The trait methods go in impl<K, V> Cache<K, V> for LruCache<K, V>, and their bodies are
the get and put of section 13.3.2:
Listing 13.10 The arena cache's trait methods (lines 91 to 154). benchmarking_examples/cache/arena.rs
impl<K, V> Cache<K, V> for LruCache<K, V>
where
K: Hash + Eq + Clone,
V: Copy,
{
fn new(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be > 0");
Self {
cap: capacity,
lookup_table: HashMap::new(),
nodes: Vec::new(),
head: None,
tail: None,
}
}
fn get(&mut self, key: &K) -> Option<V> {
let i = *self.lookup_table.get(key)?;
self.touch(i);
Some(self.nodes[i].value)
}
fn put(&mut self, key: K, value: V) {
// Case 1: key already present, update the value and promote.
if let Some(&i) = self.lookup_table.get(&key) {
self.nodes[i].value = value;
self.touch(i);
return;
}
// Case 2: room to grow, append a fresh node.
if self.nodes.len() < self.cap {
let i = self.nodes.len();
self.nodes.push(Node {
key: key.clone(),
value,
prev: None,
next: None,
});
self.lookup_table.insert(key, i);
self.touch(i);
return;
}
// Case 3: at capacity, reuse the LRU slot. `tail` is `Some` here because
// `cap >= 1` and the arena is full, but handle `None` anyway.
if let Some(i) = self.tail {
let old_key = std::mem::replace(&mut self.nodes[i].key, key);
self.nodes[i].value = value;
self.lookup_table.remove(&old_key);
self.lookup_table
.insert(self.nodes[i].key.clone(), i);
self.touch(i);
}
}
fn len(&self) -> usize {
self.lookup_table.len()
}
fn variant() -> &'static str {
"arena (Vec + indices)"
}
}
Listing 13.20, at the end of the chapter, has the whole file.
13.5.2 The Rc<RefCell<_>> list
Listing 13.11 The node and the cache (lines 10 to 33). benchmarking_examples/cache/rc_list.rs
use std::{
cell::RefCell,
collections::HashMap,
hash::Hash,
rc::{Rc, Weak},
};
use super::Cache;
type Link<K, V> = Option<Rc<RefCell<Node<K, V>>>>;
struct Node<K, V> {
key: K,
value: V,
prev: Weak<RefCell<Node<K, V>>>,
next: Link<K, V>,
}
pub struct LruCache<K, V> {
cap: usize,
map: HashMap<K, Rc<RefCell<Node<K, V>>>>,
head: Link<K, V>,
tail: Link<K, V>,
}
This is the doubly linked list of chapter 9. A node is Rc<RefCell<Node>>: Rc so several owners can hold it,
and RefCell so it can be changed through those shared handles. next is a strong link, and prev is a Weak
link, so two neighbors do not keep each other alive forever. The map holds one more Rc to every node.
Listing 13.12 detach and push_front (lines 35 to 69).
impl<K, V> LruCache<K, V>
where
K: Hash + Eq + Clone,
V: Copy,
{
fn detach(&mut self, node: &Rc<RefCell<Node<K, V>>>) {
let (prev, next) = {
let entry = node.borrow();
(entry.prev.clone(), entry.next.clone())
};
match prev.upgrade() {
Some(previous) => previous.borrow_mut().next = next.clone(),
None => self.head = next.clone(),
}
match &next {
Some(following) => following.borrow_mut().prev = prev,
None => self.tail = prev.upgrade(),
}
}
fn push_front(&mut self, node: Rc<RefCell<Node<K, V>>>) {
let head = self.head.clone();
{
let mut entry = node.borrow_mut();
entry.prev = Weak::new();
entry.next = head.clone();
}
match &head {
Some(previous) => previous.borrow_mut().prev = Rc::downgrade(&node),
None => self.tail = Some(node.clone()),
}
self.head = Some(node);
}
}
The logic matches listing 13.8, with pointers instead of indices. The difference is in the ceremony around each step:
detachreads the node’s links inside a small block.node.borrow()returns a guard that holds a shared borrow of theRefCell. The block ends before any neighbor is changed, so the guard is dropped early. ARefCellpanics if aborrow_muthappens while another borrow of the same cell is alive.prev.upgrade()turns theWeaklink into anRc, if the node still exists.- Each
.clone()of anRcor aWeakincreases a reference count, and each drop decreases one.
Listing 13.13 The trait methods and Drop (lines 71 to 139).
impl<K, V> Cache<K, V> for LruCache<K, V>
where
K: Hash + Eq + Clone,
V: Copy,
{
fn new(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be > 0");
Self {
cap: capacity,
map: HashMap::new(),
head: None,
tail: None,
}
}
fn get(&mut self, key: &K) -> Option<V> {
let node = self.map.get(key)?.clone();
self.detach(&node);
self.push_front(node.clone());
Some(node.borrow().value)
}
fn put(&mut self, key: K, value: V) {
if let Some(node) = self.map.get(&key).cloned() {
node.borrow_mut().value = value;
self.detach(&node);
self.push_front(node);
return;
}
if self.map.len() >= self.cap
&& let Some(victim) = self.tail.clone()
{
let old_key = victim.borrow().key.clone();
self.map.remove(&old_key);
self.detach(&victim);
}
let node = Rc::new(RefCell::new(Node {
key: key.clone(),
value,
prev: Weak::new(),
next: None,
}));
self.map.insert(key, node.clone());
self.push_front(node);
}
fn len(&self) -> usize {
self.map.len()
}
fn variant() -> &'static str {
"Rc<RefCell> list"
}
}
impl<K, V> Drop for LruCache<K, V> {
fn drop(&mut self) {
// Unlink iteratively: dropping a million-node chain recursively overflows
// the stack.
let mut current = self.head.take();
while let Some(node) = current {
let next = node.borrow_mut().next.take();
node.borrow_mut().prev = Weak::new();
current = next;
}
}
}
On a miss with a full cache, put removes the tail node from the map and detaches it. When victim goes out of
scope, the last Rc to that node is dropped, and its memory is freed. Then Rc::new allocates a new node for the
new key. So every eviction frees one heap block and allocates another.
The Drop implementation is the fix from chapter 9. Dropping a long chain of next links recursively uses one
stack frame per node, and a large cache would overflow the stack. The loop breaks the links one node at a time.
The map drops its Rc handles afterward, and each drop then frees a single node.
Figure 13.5 compares where the two caches keep their nodes.
u64 keys and values. The sizes were measured with std::mem::size_of.The arena’s node is 48 bytes: the key and value take 8 bytes each, and each Option<usize> takes 16. The Rc node is 32 bytes. The heap block around it adds a strong count, a weak count, and the RefCell
flag, for 56 bytes. The memory sizes are close. What differs is the number of allocations and where the nodes sit in memory.
13.5.3 The benchmark
Listing 13.14 The request stream and the timed loop (lines 52 to 106). benchmarking_examples/benchmark.rs
use std::{
hint::black_box,
time::{Duration, Instant},
};
use systems_lab::{
cache::{Cache, arena::LruCache as ArenaCache, rc_list::LruCache as RcCache},
lists::{SinglyList, boxed, boxed_drop, enum_drop, enum_node},
};
// ---------------------------------------------------------------------------
// Cache: arena vs Rc<RefCell> linked list
// ---------------------------------------------------------------------------
/// Request stream with realistic locality: four requests in five come from a hot
/// set of 1% of the keys, the rest from the whole space.
fn key_at(state: u64) -> u64 {
let mixed = state >> 17;
if (state >> 33) % 100 < 80 {
mixed % (KEY_SPACE / 100)
} else {
mixed % KEY_SPACE
}
}
/// The fastest of `CACHE_RUNS` timed passes, with the hit count and final size
/// from the last one. Each pass starts from an empty cache, so the stream is the
/// same every time.
fn best_cache_run<C: Cache<u64, u64>>() -> (Duration, u64, usize) {
let mut best = Duration::MAX;
let mut hits = 0;
let mut len = 0;
for _ in 0..CACHE_RUNS {
let mut cache = C::new(CACHE_CAPACITY);
let (elapsed, run_hits) = cache_workload(&mut cache);
best = best.min(elapsed);
hits = run_hits;
len = cache.len();
}
(best, hits, len)
}
/// The same request stream against any cache.
fn cache_workload<C: Cache<u64, u64>>(cache: &mut C) -> (Duration, u64) {
let mut state = 0x9e37_79b9_7f4a_7c15u64;
let mut hits = 0u64;
let start = Instant::now();
for _ in 0..OPERATIONS {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let key = key_at(state);
match cache.get(&key) {
Some(value) => {
hits += 1;
black_box(value);
}
None => cache.put(key, key.rotate_left(17)),
}
}
(start.elapsed(), hits)
}
A fair comparison needs both caches to see exactly the same requests. The program makes its own request stream
from a number called state. Each step multiplies and adds two fixed constants, so the same starting state
always produces the same sequence. A generator of this kind is called a linear congruential generator.
key_at shapes the stream to look like real traffic. Real caches see some keys far more often than others. Here,
80% of requests pick from a hot set of 1% of the keys, and the rest pick from all 131,072 keys. The capacity,
65,536, is half the key space.
Each request calls get. On a miss, it calls put, the way a program fills a cache after it computes a missing
value. black_box tells the compiler to treat the value as used, so the optimizer cannot remove the work.
Listing 13.15 Running and checking both caches (lines 108 to 158).
fn cache_benchmark() {
println!(
"LRU cache: {} entries, {} requests over {} keys",
with_thousands(CACHE_CAPACITY as u64),
with_thousands(OPERATIONS),
with_thousands(KEY_SPACE)
);
println!("A miss inserts and evicts the least recently used entry.");
println!("Best of {CACHE_RUNS} runs per cache.\n");
let (arena_time, arena_hits, arena_len) = best_cache_run::<ArenaCache<u64, u64>>();
let (list_time, list_hits, list_len) = best_cache_run::<RcCache<u64, u64>>();
// Same population and same hit count, or this compares nothing.
assert_eq!(arena_len, list_len);
assert_eq!(
arena_hits, list_hits,
"the two runs saw different request streams"
);
println!(
" hit rate: {:.1}% ({} hits, {} inserts)\n",
arena_hits as f64 * 100.0 / OPERATIONS as f64,
with_thousands(arena_hits),
with_thousands(OPERATIONS - arena_hits)
);
let per_request = |elapsed: Duration| elapsed.as_nanos() as f64 / OPERATIONS as f64;
// `{:.2?}` uses Duration's own Debug format, rounded to two decimals.
println!(" {:<22} {:>12} {:>13}", "storage", "total", "per request");
println!(" {}", "-".repeat(48));
for (name, elapsed) in [
(
<ArenaCache<u64, u64> as Cache<u64, u64>>::variant(),
arena_time,
),
(<RcCache<u64, u64> as Cache<u64, u64>>::variant(), list_time),
] {
println!(
" {name:<22} {elapsed:>12.2?} {:>8.0} ns",
per_request(elapsed)
);
}
let speedup = list_time.as_secs_f64() / arena_time.as_secs_f64();
println!(
"\n the arena is {speedup:.2}x faster on the same requests: {arena_time:.2?} against {list_time:.2?}"
);
println!(" the arena reuses the evicted slot; the list frees a node and allocates another");
}
best_cache_run::<ArenaCache<u64, u64>>() runs the generic function with the arena cache, and the next line runs
it with the Rc cache. The ::<...> syntax, called the turbofish, names the type parameter explicitly.
Before printing any times, the function checks that both caches finished with the same size and the same number of hits. Two correct LRU caches given the same requests make the same decisions. If the counts differed, one cache would have a bug, and its time would mean nothing.
<ArenaCache<u64, u64> as Cache<u64, u64>>::variant() is the fully qualified way to call a trait function that
has no self. It names both the type and the trait.
Listing 13.16 The complete benchmark program. benchmarking_examples/benchmark.rs
//! One main for every benchmark in the lab.
//!
//! Usage:
//! cargo run --release --bin benchmark # cache, then lists
//! cargo run --release --bin benchmark cache # cache only
//! cargo run --release --bin benchmark list # all four variants
//! cargo run --release --bin benchmark list enum 500000 # one variant, one size
//!
//! The recursive list variants abort with a stack overflow past roughly 265,000
//! nodes, so run them below that size or on their own.
use std::{
hint::black_box,
time::{Duration, Instant},
};
use systems_lab::{
cache::{Cache, arena::LruCache as ArenaCache, rc_list::LruCache as RcCache},
lists::{SinglyList, boxed, boxed_drop, enum_drop, enum_node},
};
const CACHE_CAPACITY: usize = 65_536;
const KEY_SPACE: u64 = 131_072;
const OPERATIONS: u64 = 10_000_000;
const DEFAULT_LIST_ELEMENTS: usize = 200_000;
/// Timed passes per cache; the fastest is reported, since a slow pass is noise.
const CACHE_RUNS: usize = 3;
/// Timed passes per list variant.
const LIST_RUNS: usize = 3;
fn main() {
let arguments: Vec<String> = std::env::args().skip(1).collect();
match arguments.first().map(String::as_str) {
Some("cache") => cache_benchmark(),
Some("list") => {
let variant = arguments.get(1).map(String::as_str);
let elements = arguments
.get(2)
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_LIST_ELEMENTS);
list_benchmark(variant, elements);
}
_ => {
cache_benchmark();
println!();
list_benchmark(None, DEFAULT_LIST_ELEMENTS);
}
}
}
// ---------------------------------------------------------------------------
// Cache: arena vs Rc<RefCell> linked list
// ---------------------------------------------------------------------------
/// Request stream with realistic locality: four requests in five come from a hot
/// set of 1% of the keys, the rest from the whole space.
fn key_at(state: u64) -> u64 {
let mixed = state >> 17;
if (state >> 33) % 100 < 80 {
mixed % (KEY_SPACE / 100)
} else {
mixed % KEY_SPACE
}
}
/// The fastest of `CACHE_RUNS` timed passes, with the hit count and final size
/// from the last one. Each pass starts from an empty cache, so the stream is the
/// same every time.
fn best_cache_run<C: Cache<u64, u64>>() -> (Duration, u64, usize) {
let mut best = Duration::MAX;
let mut hits = 0;
let mut len = 0;
for _ in 0..CACHE_RUNS {
let mut cache = C::new(CACHE_CAPACITY);
let (elapsed, run_hits) = cache_workload(&mut cache);
best = best.min(elapsed);
hits = run_hits;
len = cache.len();
}
(best, hits, len)
}
/// The same request stream against any cache.
fn cache_workload<C: Cache<u64, u64>>(cache: &mut C) -> (Duration, u64) {
let mut state = 0x9e37_79b9_7f4a_7c15u64;
let mut hits = 0u64;
let start = Instant::now();
for _ in 0..OPERATIONS {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let key = key_at(state);
match cache.get(&key) {
Some(value) => {
hits += 1;
black_box(value);
}
None => cache.put(key, key.rotate_left(17)),
}
}
(start.elapsed(), hits)
}
fn cache_benchmark() {
println!(
"LRU cache: {} entries, {} requests over {} keys",
with_thousands(CACHE_CAPACITY as u64),
with_thousands(OPERATIONS),
with_thousands(KEY_SPACE)
);
println!("A miss inserts and evicts the least recently used entry.");
println!("Best of {CACHE_RUNS} runs per cache.\n");
let (arena_time, arena_hits, arena_len) = best_cache_run::<ArenaCache<u64, u64>>();
let (list_time, list_hits, list_len) = best_cache_run::<RcCache<u64, u64>>();
// Same population and same hit count, or this compares nothing.
assert_eq!(arena_len, list_len);
assert_eq!(
arena_hits, list_hits,
"the two runs saw different request streams"
);
println!(
" hit rate: {:.1}% ({} hits, {} inserts)\n",
arena_hits as f64 * 100.0 / OPERATIONS as f64,
with_thousands(arena_hits),
with_thousands(OPERATIONS - arena_hits)
);
let per_request = |elapsed: Duration| elapsed.as_nanos() as f64 / OPERATIONS as f64;
// `{:.2?}` uses Duration's own Debug format, rounded to two decimals.
println!(" {:<22} {:>12} {:>13}", "storage", "total", "per request");
println!(" {}", "-".repeat(48));
for (name, elapsed) in [
(
<ArenaCache<u64, u64> as Cache<u64, u64>>::variant(),
arena_time,
),
(<RcCache<u64, u64> as Cache<u64, u64>>::variant(), list_time),
] {
println!(
" {name:<22} {elapsed:>12.2?} {:>8.0} ns",
per_request(elapsed)
);
}
let speedup = list_time.as_secs_f64() / arena_time.as_secs_f64();
println!(
"\n the arena is {speedup:.2}x faster on the same requests: {arena_time:.2?} against {list_time:.2?}"
);
println!(" the arena reuses the evicted slot; the list frees a node and allocates another");
}
// ---------------------------------------------------------------------------
// Lists: four variants, one loop
// ---------------------------------------------------------------------------
fn list_benchmark(variant: Option<&str>, elements: usize) {
println!(
"Singly linked list: push {} values, pop them back in order, then drop a full list",
with_thousands(elements as u64)
);
println!("Best of {LIST_RUNS} runs per variant, allocator warmed up.\n");
println!(
" {:<10} {:>12} {:>13} {:>13}",
"variant", "elements", "push", "drop"
);
println!(" {}", "-".repeat(52));
match variant {
Some("box") => run_list::<boxed::LinkedList<u64>>(elements),
Some("enum") => run_list::<enum_node::LinkedList<u64>>(elements),
Some("box+drop") => run_list::<boxed_drop::LinkedList<u64>>(elements),
Some("enum+drop") => run_list::<enum_drop::LinkedList<u64>>(elements),
_ => {
run_list::<boxed::LinkedList<u64>>(elements);
run_list::<enum_node::LinkedList<u64>>(elements);
run_list::<boxed_drop::LinkedList<u64>>(elements);
run_list::<enum_drop::LinkedList<u64>>(elements);
}
}
}
fn run_list<L: SinglyList<u64>>(elements: usize) {
// Warm the allocator first: without this the first variant in a process pays
// for fresh heap pages and looks several times slower than an identical one.
let mut warm_up = L::new();
for value in 0..elements as u64 {
warm_up.push_front(value);
}
drop(warm_up);
let mut best_push = Duration::MAX;
let mut best_drop = Duration::MAX;
for _ in 0..LIST_RUNS {
let mut list = L::new();
let start = Instant::now();
for value in 0..elements as u64 {
list.push_front(value);
}
best_push = best_push.min(start.elapsed());
// Pop every value back and check the order, so a broken list cannot post
// a fast time.
let mut expected = elements as u64;
while let Some(value) = list.pop_front() {
expected -= 1;
assert_eq!(value, expected, "{} lost LIFO order", L::variant());
}
assert_eq!(expected, 0);
black_box(expected);
// Rebuild, then time the drop of a full list. This is where the
// recursive variants overflow the stack.
for value in 0..elements as u64 {
list.push_front(value);
}
let start = Instant::now();
drop(list);
best_drop = best_drop.min(start.elapsed());
}
println!(
" {:<10} {:>12} {best_push:>13.2?} {best_drop:>13.2?}",
L::variant(),
with_thousands(elements as u64)
);
}
fn with_thousands(value: u64) -> String {
let digits = value.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(',');
}
out.push(digit);
}
out
}
The list half of this file was explained in section 9.8. Run only the cache half with the cache argument, and
always with --release. A debug build is not optimized, and its times do not show how the code performs.
$ cargo run --release --bin benchmark cache
LRU cache: 65,536 entries, 10,000,000 requests over 131,072 keys
A miss inserts and evicts the least recently used entry.
Best of 3 runs per cache.
hit rate: 89.8% (8,979,314 hits, 1,020,686 inserts)
storage total per request
------------------------------------------------
arena (Vec + indices) 587.49ms 59 ns
Rc<RefCell> list 1.86s 186 ns
the arena is 3.16x faster on the same requests: 587.49ms against 1.86s
the arena reuses the evicted slot; the list frees a node and allocates another
On this machine, the arena handled a request in 59 ns on average, and the Rc list took 186 ns. Your numbers
will differ, but the ratio should be similar on most machines.
The two caches do the same O(1) work per request. Three differences in how they do it add up:
- Allocation. About one request in ten is a miss, and each miss in the
Rclist frees a node and allocates another. The arena reuses the tail slot. - Bookkeeping. The
Rclist updates reference counts on every clone and drop, and checks theRefCellborrow flag on every access. The arena reads and writes plain integers. - Location. The arena’s nodes sit side by side in one block. The
Rcnodes sit wherever the allocator put them. Chapter 15 explains why nearby memory is faster to reach.
This benchmark measures the total, and does not separate the three costs. Exercise 4 suggests a way to separate them.
13.6 The complete files
The sections above showed these files in excerpts. Here each one is whole.
Listing 13.17 Version 1, stamped events, with tests. src/problems/lru_cache.rs
//! LRU cache without a linked list.
//!
//! Design: `HashMap<K, (V, generation)>` + `VecDeque<(K, generation)>`.
//!
//! Each access pushes a new `(key, generation)` event to the back. On eviction
//! we pop old events until we find the newest generation for a key, then remove
//! it. Stale events are skipped. This keeps amortized O(1) operations while
//! preserving true LRU semantics.
use std::{
collections::{HashMap, VecDeque},
hash::Hash,
};
pub struct LruCache<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
capacity: usize,
map: HashMap<K, (V, u64)>,
recency: VecDeque<(K, u64)>,
generation: u64,
}
impl<K, V> LruCache<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0, "LRU cache capacity must be > 0");
Self {
capacity,
map: HashMap::new(),
recency: VecDeque::new(),
generation: 0,
}
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn get(&mut self, key: &K) -> Option<V> {
let (value, _) = self.map.get(key)?.clone();
self.generation += 1;
self.recency
.push_back((key.clone(), self.generation));
self.map
.insert(key.clone(), (value.clone(), self.generation));
Some(value)
}
pub fn put(&mut self, key: K, value: V) {
self.generation += 1;
let is_new = self
.map
.insert(key.clone(), (value, self.generation))
.is_none();
self.recency
.push_back((key, self.generation));
if is_new {
self.evict_if_needed();
}
}
fn evict_if_needed(&mut self) {
while self.map.len() > self.capacity {
let Some((key, generation)) = self.recency.pop_front() else {
break;
};
match self.map.get(&key) {
Some((_, current)) if *current == generation => {
self.map.remove(&key);
break;
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn put_get_and_len() {
let mut cache = LruCache::new(2);
cache.put("a".to_string(), 1);
cache.put("b".to_string(), 2);
assert_eq!(cache.len(), 2);
assert_eq!(cache.get(&"a".to_string()), Some(1));
assert_eq!(cache.get(&"missing".to_string()), None);
}
#[test]
fn evicts_least_recently_used() {
let mut cache = LruCache::new(2);
cache.put("a".to_string(), 1);
cache.put("b".to_string(), 2);
cache.put("c".to_string(), 3);
assert_eq!(cache.get(&"a".to_string()), None);
assert_eq!(cache.get(&"b".to_string()), Some(2));
assert_eq!(cache.get(&"c".to_string()), Some(3));
}
#[test]
fn get_updates_recency() {
let mut cache = LruCache::new(2);
cache.put("a".to_string(), 1);
cache.put("b".to_string(), 2);
// Access "a" so it becomes most recently used.
assert_eq!(cache.get(&"a".to_string()), Some(1));
cache.put("c".to_string(), 3); // evicts "b"
assert_eq!(cache.get(&"b".to_string()), None);
assert_eq!(cache.get(&"a".to_string()), Some(1));
assert_eq!(cache.get(&"c".to_string()), Some(3));
}
#[test]
fn updating_existing_key_moves_it_to_mru() {
let mut cache = LruCache::new(2);
cache.put("a".to_string(), 1);
cache.put("b".to_string(), 2);
// Update "a"; it becomes MRU, so "b" should be evicted next.
cache.put("a".to_string(), 10);
cache.put("c".to_string(), 3);
assert_eq!(cache.get(&"a".to_string()), Some(10));
assert_eq!(cache.get(&"b".to_string()), None);
assert_eq!(cache.get(&"c".to_string()), Some(3));
}
}
The tests cover the cases of figure 13.1. They check plain eviction, a get that saves an entry, and a
put to an existing key, which also counts as a use.
Listing 13.18 Version 2, the arena, with tests. src/problems/lru_cache_easy.rs
//! A fixed-capacity LRU (Least Recently Used) cache.
//!
//! ## Design
//!
//! Two structures cooperate to give O(1) `get` and `put`:
//!
//! * `map: HashMap<K, usize>` — key → index into the arena.
//! * `nodes: Vec<Node<K, V>>` — an **arena** of nodes; indices play the role
//! of pointers, sidestepping `Rc<RefCell<…>>` and its borrow-checker pain.
//!
//! The arena is stitched into a doubly linked list through `head`/`tail`:
//!
//! ```text
//! MRU → head → … → tail → LRU
//! ```
//!
//! On eviction we **reuse the tail slot** instead of removing it, so no node
//! is ever deallocated mid-life. That keeps the arena dense and index-stable.
use std::{collections::HashMap, hash::Hash};
/// A single cache entry, linked into the recency list by index.
struct Node<K, V> {
/// Retained so we can delete the corresponding map entry when evicting.
key: K,
value: V,
/// Neighbour closer to the MRU end (or `None` if this is `head`).
prev: Option<usize>,
/// Neighbour closer to the LRU end (or `None` if this is `tail`).
next: Option<usize>,
}
/// Fixed-capacity LRU cache.
pub struct LruCache<K, V> {
/// Maximum number of entries; `>= 1` (enforced by `new`).
cap: usize,
/// Key → arena index. The single source of truth for "is this key cached?".
lookup_table: HashMap<K, usize>,
/// Arena of nodes; indices are the "pointers" of the linked list.
nodes: Vec<Node<K, V>>,
/// Arena index of the most recently used entry.
head: Option<usize>,
/// Arena index of the least recently used entry (next to be evicted).
tail: Option<usize>,
}
impl<K, V> LruCache<K, V>
where
K: Hash + Eq + Clone,
{
/// Create a cache holding at most `cap` entries.
///
/// # Panics
/// Panics if `cap == 0`, since a zero-capacity cache cannot store anything.
pub fn new(cap: usize) -> Self {
assert!(cap > 0, "capacity must be > 0");
Self {
cap,
lookup_table: HashMap::new(),
nodes: Vec::new(),
head: None,
tail: None,
}
}
/// Promote node `i` to `head` (MRU).
///
/// Handles both cases in one pass:
/// * **Already linked** — detach from its current neighbours first.
/// * **Brand new** — the node has `prev = next = None` and is not yet
/// referenced by `head`/`tail`, so the detach step is skipped.
fn touch(&mut self, i: usize) {
let (prev, next) = (self.nodes[i].prev, self.nodes[i].next);
// A node is "linked" if any of its four references exist.
let linked =
prev.is_some() || next.is_some() || self.head == Some(i) || self.tail == Some(i);
if linked {
// Splice `i` out of the list.
match prev {
Some(p) => self.nodes[p].next = next,
None => self.head = next,
}
match next {
Some(n) => self.nodes[n].prev = prev,
None => self.tail = prev,
}
}
// Splice `i` in at the front.
self.nodes[i].prev = None;
self.nodes[i].next = self.head;
if let Some(h) = self.head {
self.nodes[h].prev = Some(i);
}
self.head = Some(i);
if self.tail.is_none() {
self.tail = Some(i);
}
}
/// Look up `key`, promoting it to MRU on a hit.
///
/// Returns `None` if the key is not cached.
pub fn get(&mut self, key: &K) -> Option<&V> {
let i = *self.lookup_table.get(key)?;
self.touch(i);
Some(&self.nodes[i].value)
}
/// Insert or update `key`. Promotes it to MRU. Evicts the LRU entry if
/// the cache is full.
pub fn put(&mut self, key: K, value: V) {
// Case 1: key already present → update value, promote.
if let Some(&i) = self.lookup_table.get(&key) {
self.nodes[i].value = value;
self.touch(i);
return;
}
// Case 2: room to grow → append a fresh node.
if self.nodes.len() < self.cap {
let i = self.nodes.len();
self.nodes.push(Node {
key: key.clone(),
value,
prev: None,
next: None,
});
self.lookup_table.insert(key, i);
self.touch(i);
return;
}
// Case 3: at capacity → reuse the LRU slot.
// `tail` is guaranteed to be `Some` here because `cap >= 1` and the
// arena is full, but we still handle `None` gracefully.
if let Some(i) = self.tail {
let old_key = std::mem::replace(&mut self.nodes[i].key, key);
self.nodes[i].value = value;
self.lookup_table.remove(&old_key);
self.lookup_table
.insert(self.nodes[i].key.clone(), i);
self.touch(i);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_eviction() {
let mut c = LruCache::new(2);
c.put("A", 1);
c.put("B", 2);
assert_eq!(c.get(&"A"), Some(&1)); // A is now MRU
c.put("C", 3); // evicts B
assert_eq!(c.get(&"B"), None);
assert_eq!(c.get(&"A"), Some(&1));
assert_eq!(c.get(&"C"), Some(&3));
}
#[test]
fn updating_existing_key_keeps_it() {
let mut c = LruCache::new(2);
c.put("A", 1);
c.put("B", 2);
c.put("A", 100); // update A, A becomes MRU
c.put("C", 3); // evicts B
assert_eq!(c.get(&"A"), Some(&100));
assert_eq!(c.get(&"B"), None);
assert_eq!(c.get(&"C"), Some(&3));
}
#[test]
fn get_refreshes_recency() {
let mut c = LruCache::new(2);
c.put("A", 1);
c.put("B", 2);
assert_eq!(c.get(&"A"), Some(&1)); // A refreshed
c.put("C", 3); // evicts B
assert_eq!(c.get(&"B"), None);
assert_eq!(c.get(&"A"), Some(&1));
}
#[test]
fn capacity_one() {
let mut c = LruCache::new(1);
c.put("A", 1);
c.put("B", 2); // evicts A
assert_eq!(c.get(&"A"), None);
assert_eq!(c.get(&"B"), Some(&2));
}
#[test]
fn missing_key_returns_none() {
let mut c: LruCache<&str, i32> = LruCache::new(2);
assert_eq!(c.get(&"nope"), None);
}
#[test]
#[should_panic(expected = "capacity must be > 0")]
fn zero_capacity_panics() {
let _: LruCache<&str, i32> = LruCache::new(0);
}
}
The tests include a capacity of 1, where the single node is the head and the tail at once. Another test checks that a capacity of 0 panics.
Listing 13.19 Version 3, one operation per method. src/bin/lru_cache_modular.rs
//! A fixed-capacity LRU (Least Recently Used) cache.
//!
//! ## Design
//!
//! Two structures cooperate to give O(1) `get` and `put`:
//!
//! * `lookup_table: HashMap<i32, usize>` — key → index into the arena.
//! * `nodes: Vec<Node>` — an **arena** of nodes; indices play the role
//! of pointers, sidestepping `Rc<RefCell<…>>` and its borrow-checker pain.
//!
//! The arena is stitched into a doubly linked list through `head`/`tail`:
//!
//! ```text
//! MRU → head → … → tail → LRU
//! ```
//!
//! On eviction we **reuse the tail slot** instead of removing it, so no node
//! is ever deallocated mid-life. That keeps the arena dense and index-stable.
use std::collections::HashMap;
/// A single cache entry, linked into the recency list by index.
struct Node {
/// Retained so we can delete the corresponding map entry when evicting.
key: i32,
value: i32,
/// Neighbour closer to the MRU end (or `None` if this is `head`).
prev: Option<usize>,
/// Neighbour closer to the LRU end (or `None` if this is `tail`).
next: Option<usize>,
}
/// Fixed-capacity LRU cache.
pub struct LruCache {
/// Maximum number of entries; `>= 1` (enforced by `new`).
cap: usize,
/// Key → arena index. The single source of truth for "is this key cached?".
lookup_table: HashMap<i32, usize>,
/// Arena of nodes; indices are the "pointers" of the linked list.
nodes: Vec<Node>,
/// Arena index of the most recently used entry.
head: Option<usize>,
/// Arena index of the least recently used entry (next to be evicted).
tail: Option<usize>,
}
impl LruCache {
/// Create a cache holding at most `cap` entries.
///
/// # Panics
/// Panics if `cap == 0`, since a zero-capacity cache cannot store anything.
pub fn new(cap: usize) -> Self {
assert!(cap > 0, "capacity must be > 0");
Self {
cap,
lookup_table: HashMap::new(),
nodes: Vec::new(),
head: None,
tail: None,
}
}
/// Unlink node `i` from the list. `i` must currently be linked.
fn detach(&mut self, i: usize) {
let (prev, next) = (self.nodes[i].prev, self.nodes[i].next);
match prev {
Some(p) => self.nodes[p].next = next,
None => self.head = next,
}
match next {
Some(n) => self.nodes[n].prev = prev,
None => self.tail = prev,
}
}
/// Link node `i` in at the MRU end. `i` must currently be unlinked.
fn push_front(&mut self, i: usize) {
self.nodes[i].prev = None;
self.nodes[i].next = self.head;
match self.head {
Some(h) => self.nodes[h].prev = Some(i),
None => self.tail = Some(i), // list was empty
}
self.head = Some(i);
}
/// Promote an already-linked node to MRU.
fn promote_to_front(&mut self, i: usize) {
if self.head == Some(i) {
return; // already MRU
}
self.detach(i);
self.push_front(i);
}
/// Look up `key`, promoting it to MRU on a hit.
///
/// Returns `None` if the key is not cached.
pub fn get(&mut self, key: i32) -> Option<i32> {
let i = *self.lookup_table.get(&key)?;
self.promote_to_front(i);
Some(self.nodes[i].value)
}
/// Insert or update `key`. Promotes it to MRU. Evicts the LRU entry if
/// the cache is full.
pub fn put(&mut self, key: i32, value: i32) {
// Case 1: key already present → update value, promote.
if let Some(&i) = self.lookup_table.get(&key) {
self.nodes[i].value = value;
self.promote_to_front(i);
return;
}
// Case 2: room to grow → append a fresh (unlinked) node.
if self.nodes.len() < self.cap {
let i = self.nodes.len();
self.nodes.push(Node {
key,
value,
prev: None,
next: None,
});
self.lookup_table.insert(key, i);
self.push_front(i);
return;
}
// Case 3: at capacity → reuse the LRU slot.
// `tail` is guaranteed to be `Some` here because `cap >= 1` and the
// arena is full, but we still handle `None` gracefully.
if let Some(i) = self.tail {
// Swap the new key into the node and hand back the old one it held.
let old_key = std::mem::replace(&mut self.nodes[i].key, key);
self.nodes[i].value = value;
self.lookup_table.remove(&old_key);
self.lookup_table.insert(key, i);
self.promote_to_front(i);
}
}
}
fn main() {
let mut cache = LruCache::new(2);
cache.put(1, 10);
cache.put(2, 20);
println!("get 1 = {:?}", cache.get(1)); // Some(10), 1 now MRU
cache.put(3, 30); // evicts 2 (LRU)
println!("get 2 = {:?}", cache.get(2)); // None
println!("get 1 = {:?}", cache.get(1)); // Some(10)
println!("get 3 = {:?}", cache.get(3)); // Some(30)
cache.put(1, 99);
println!("get 1 = {:?}", cache.get(1)); // Some(99)
}
#[cfg(test)]
mod tests_for_lru {
use super::*;
#[test]
fn basic_eviction() {
let mut c = LruCache::new(2);
c.put(1, 10);
c.put(2, 20);
assert_eq!(c.get(1), Some(10)); // 1 is now MRU
c.put(3, 30); // evicts 2
assert_eq!(c.get(2), None);
assert_eq!(c.get(1), Some(10));
assert_eq!(c.get(3), Some(30));
}
#[test]
fn updating_existing_key_keeps_it() {
let mut c = LruCache::new(2);
c.put(1, 10);
c.put(2, 20);
c.put(1, 100); // update 1, 1 becomes MRU
c.put(3, 30); // evicts 2
assert_eq!(c.get(1), Some(100));
assert_eq!(c.get(2), None);
assert_eq!(c.get(3), Some(30));
}
#[test]
fn get_refreshes_recency() {
let mut c = LruCache::new(2);
c.put(1, 10);
c.put(2, 20);
assert_eq!(c.get(1), Some(10)); // 1 refreshed
c.put(3, 30); // evicts 2
assert_eq!(c.get(2), None);
assert_eq!(c.get(1), Some(10));
}
#[test]
fn capacity_one() {
let mut c = LruCache::new(1);
c.put(1, 10);
c.put(2, 20); // evicts 1
assert_eq!(c.get(1), None);
assert_eq!(c.get(2), Some(20));
}
#[test]
fn missing_key_returns_none() {
let mut c = LruCache::new(2);
assert_eq!(c.get(42), None);
}
#[test]
#[should_panic(expected = "capacity must be > 0")]
fn zero_capacity_panics() {
let _ = LruCache::new(0);
}
}
Listing 13.20 The arena LRU cache. src/bin/lru_cache_arena.rs
//! A fixed-capacity LRU (Least Recently Used) cache.
//!
//! ## Design
//!
//! Two structures cooperate to give O(1) `get` and `put`:
//!
//! * `map: HashMap<K, usize>` — key → index into the arena.
//! * `nodes: Vec<Node<K, V>>` — an **arena** of nodes; indices play the role
//! of pointers, sidestepping `Rc<RefCell<…>>` and its borrow-checker pain.
//!
//! The arena is stitched into a doubly linked list through `head`/`tail`:
//!
//! ```text
//! MRU → head → … → tail → LRU
//! ```
//!
//! On eviction we **reuse the tail slot** instead of removing it, so no node
//! is ever deallocated mid-life. That keeps the arena dense and index-stable.
use std::{collections::HashMap, hash::Hash};
/// A single cache entry, linked into the recency list by index.
struct Node<K, V> {
/// Retained so we can delete the corresponding map entry when evicting.
key: K,
value: V,
/// Neighbour closer to the MRU end (or `None` if this is `head`).
prev: Option<usize>,
/// Neighbour closer to the LRU end (or `None` if this is `tail`).
next: Option<usize>,
}
/// Fixed-capacity LRU cache.
pub struct LruCache<K, V> {
/// Maximum number of entries; `>= 1` (enforced by `new`).
cap: usize,
/// Key → arena index. The single source of truth for "is this key cached?".
lookup_table: HashMap<K, usize>,
/// Arena of nodes; indices are the "pointers" of the linked list.
nodes: Vec<Node<K, V>>,
/// Arena index of the most recently used entry.
head: Option<usize>,
/// Arena index of the least recently used entry (next to be evicted).
tail: Option<usize>,
}
impl<K, V> LruCache<K, V>
where
K: Hash + Eq + Clone,
{
/// Create a cache holding at most `cap` entries.
///
/// # Panics
/// Panics if `cap == 0`, since a zero-capacity cache cannot store anything.
pub fn new(cap: usize) -> Self {
assert!(cap > 0, "capacity must be > 0");
Self {
cap,
lookup_table: HashMap::new(),
nodes: Vec::new(),
head: None,
tail: None,
}
}
/// Promote node `i` to `head` (MRU).
///
/// Handles both cases in one pass:
/// * **Already linked** — detach from its current neighbours first.
/// * **Brand new** — the node has `prev = next = None` and is not yet
/// referenced by `head`/`tail`, so the detach step is skipped.
fn touch(&mut self, i: usize) {
let (prev, next) = (self.nodes[i].prev, self.nodes[i].next);
// A node is "linked" if any of its four references exist.
let linked =
prev.is_some() || next.is_some() || self.head == Some(i) || self.tail == Some(i);
if linked {
// Splice `i` out of the list.
match prev {
Some(p) => self.nodes[p].next = next,
None => self.head = next,
}
match next {
Some(n) => self.nodes[n].prev = prev,
None => self.tail = prev,
}
}
// Splice `i` in at the front.
self.nodes[i].prev = None;
self.nodes[i].next = self.head;
if let Some(h) = self.head {
self.nodes[h].prev = Some(i);
}
self.head = Some(i);
if self.tail.is_none() {
self.tail = Some(i);
}
}
/// Look up `key`, promoting it to MRU on a hit.
///
/// Returns `None` if the key is not cached.
pub fn get(&mut self, key: &K) -> Option<&V> {
let i = *self.lookup_table.get(key)?;
self.touch(i);
Some(&self.nodes[i].value)
}
/// Insert or update `key`. Promotes it to MRU. Evicts the LRU entry if
/// the cache is full.
pub fn put(&mut self, key: K, value: V) {
// Case 1: key already present → update value, promote.
if let Some(&i) = self.lookup_table.get(&key) {
self.nodes[i].value = value;
self.touch(i);
return;
}
// Case 2: room to grow → append a fresh node.
if self.nodes.len() < self.cap {
let i = self.nodes.len();
self.nodes.push(Node {
key: key.clone(),
value,
prev: None,
next: None,
});
self.lookup_table.insert(key, i);
self.touch(i);
return;
}
// Case 3: at capacity → reuse the LRU slot.
// `tail` is guaranteed to be `Some` here because `cap >= 1` and the
// arena is full, but we still handle `None` gracefully.
if let Some(i) = self.tail {
let old_key = std::mem::replace(&mut self.nodes[i].key, key);
self.nodes[i].value = value;
self.lookup_table.remove(&old_key);
self.lookup_table
.insert(self.nodes[i].key.clone(), i);
self.touch(i);
}
}
}
fn main() {
let mut cache = LruCache::new(2);
cache.put("A", 10);
cache.put("B", 20);
println!("get A = {:?}", cache.get(&"A")); // Some(10), A now MRU
cache.put("C", 30); // evicts B (LRU)
println!("get B = {:?}", cache.get(&"B")); // None
println!("get A = {:?}", cache.get(&"A")); // Some(10)
println!("get C = {:?}", cache.get(&"C")); // Some(30)
cache.put("A", 99);
println!("get A = {:?}", cache.get(&"A")); // Some(99)
}
#[cfg(test)]
mod tests_for_lru {
use super::*;
#[test]
fn basic_eviction() {
let mut c = LruCache::new(2);
c.put("A", 1);
c.put("B", 2);
assert_eq!(c.get(&"A"), Some(&1)); // A is now MRU
c.put("C", 3); // evicts B
assert_eq!(c.get(&"B"), None);
assert_eq!(c.get(&"A"), Some(&1));
assert_eq!(c.get(&"C"), Some(&3));
}
#[test]
fn updating_existing_key_keeps_it() {
let mut c = LruCache::new(2);
c.put("A", 1);
c.put("B", 2);
c.put("A", 100); // update A, A becomes MRU
c.put("C", 3); // evicts B
assert_eq!(c.get(&"A"), Some(&100));
assert_eq!(c.get(&"B"), None);
assert_eq!(c.get(&"C"), Some(&3));
}
#[test]
fn get_refreshes_recency() {
let mut c = LruCache::new(2);
c.put("A", 1);
c.put("B", 2);
assert_eq!(c.get(&"A"), Some(&1)); // A refreshed
c.put("C", 3); // evicts B
assert_eq!(c.get(&"B"), None);
assert_eq!(c.get(&"A"), Some(&1));
}
#[test]
fn capacity_one() {
let mut c = LruCache::new(1);
c.put("A", 1);
c.put("B", 2); // evicts A
assert_eq!(c.get(&"A"), None);
assert_eq!(c.get(&"B"), Some(&2));
}
#[test]
fn missing_key_returns_none() {
let mut c: LruCache<&str, i32> = LruCache::new(2);
assert_eq!(c.get(&"nope"), None);
}
#[test]
#[should_panic(expected = "capacity must be > 0")]
fn zero_capacity_panics() {
let _: LruCache<&str, i32> = LruCache::new(0);
}
}
Listing 13.21 The Rc<RefCell<_>> LRU cache used by the benchmark. benchmarking_examples/cache/rc_list.rs
//! LRU cache built from an `Rc<RefCell<_>>` doubly linked list.
//!
//! The textbook version: `Rc` forward, `Weak` back, and a `HashMap` from key to
//! node. Every node is its own heap allocation, and every move to the front
//! clones an `Rc`, upgrades a `Weak`, and borrows the `RefCell`.
//!
//! The default drop would free a node, which frees its `next`, one stack frame
//! per element, so `Drop` is written to unlink iteratively.
use std::{
cell::RefCell,
collections::HashMap,
hash::Hash,
rc::{Rc, Weak},
};
use super::Cache;
type Link<K, V> = Option<Rc<RefCell<Node<K, V>>>>;
struct Node<K, V> {
key: K,
value: V,
prev: Weak<RefCell<Node<K, V>>>,
next: Link<K, V>,
}
pub struct LruCache<K, V> {
cap: usize,
map: HashMap<K, Rc<RefCell<Node<K, V>>>>,
head: Link<K, V>,
tail: Link<K, V>,
}
impl<K, V> LruCache<K, V>
where
K: Hash + Eq + Clone,
V: Copy,
{
fn detach(&mut self, node: &Rc<RefCell<Node<K, V>>>) {
let (prev, next) = {
let entry = node.borrow();
(entry.prev.clone(), entry.next.clone())
};
match prev.upgrade() {
Some(previous) => previous.borrow_mut().next = next.clone(),
None => self.head = next.clone(),
}
match &next {
Some(following) => following.borrow_mut().prev = prev,
None => self.tail = prev.upgrade(),
}
}
fn push_front(&mut self, node: Rc<RefCell<Node<K, V>>>) {
let head = self.head.clone();
{
let mut entry = node.borrow_mut();
entry.prev = Weak::new();
entry.next = head.clone();
}
match &head {
Some(previous) => previous.borrow_mut().prev = Rc::downgrade(&node),
None => self.tail = Some(node.clone()),
}
self.head = Some(node);
}
}
impl<K, V> Cache<K, V> for LruCache<K, V>
where
K: Hash + Eq + Clone,
V: Copy,
{
fn new(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be > 0");
Self {
cap: capacity,
map: HashMap::new(),
head: None,
tail: None,
}
}
fn get(&mut self, key: &K) -> Option<V> {
let node = self.map.get(key)?.clone();
self.detach(&node);
self.push_front(node.clone());
Some(node.borrow().value)
}
fn put(&mut self, key: K, value: V) {
if let Some(node) = self.map.get(&key).cloned() {
node.borrow_mut().value = value;
self.detach(&node);
self.push_front(node);
return;
}
if self.map.len() >= self.cap
&& let Some(victim) = self.tail.clone()
{
let old_key = victim.borrow().key.clone();
self.map.remove(&old_key);
self.detach(&victim);
}
let node = Rc::new(RefCell::new(Node {
key: key.clone(),
value,
prev: Weak::new(),
next: None,
}));
self.map.insert(key, node.clone());
self.push_front(node);
}
fn len(&self) -> usize {
self.map.len()
}
fn variant() -> &'static str {
"Rc<RefCell> list"
}
}
impl<K, V> Drop for LruCache<K, V> {
fn drop(&mut self) {
// Unlink iteratively: dropping a million-node chain recursively overflows
// the stack.
let mut current = self.head.take();
while let Some(node) = current {
let next = node.borrow_mut().next.take();
node.borrow_mut().prev = Weak::new();
current = next;
}
}
}
Summary
- An LRU cache evicts the entry that has gone longest without use. It needs fast lookup by key and a recency order that changes on every call.
- Version 1 records each use as a stamped event in a queue, and skips stale events during eviction. It is amortized O(1), but its queue grows with the number of calls.
- Version 2 keeps a doubly linked list in a
Vec, with indices as links. Eviction reuses the tail slot, so the cache stops allocating once it is full. - Version 3 splits the list operations into
detach,push_front, andpromote_to_front, each with one job and a stated precondition. - Behind a common trait, the arena cache handled requests about three times faster than an
Rc<RefCell<_>>list in this benchmark. - A fair benchmark feeds both versions the same input, checks that their results agree, and runs in release mode.
Chapter 14 spreads a cache across several machines, and asks how to decide which machine holds which key.
Exercises
- Bound the queue in version 1. When
recencygrows longer than twice the capacity, rebuild it with only the current events. Check that the cost stays amortized O(1). - Add
peek(&self, key: &K) -> Option<&V>to version 2, which returns a value without changing the order. - Add
remove(&mut self, key: &K) -> Option<V>to version 3. The removed slot must be reused by a laterput. Keep a list of free slots. - Change the benchmark’s
CACHE_CAPACITYto the full key space, so no request evicts anything. Compare the ratio with the one above, and explain what the change tells you about the cost of allocation.
Shards and consistent hashing
This chapter covers
- Why one lock around a whole map makes threads wait, and splitting the map into shards
- Choosing a shard from a hash with a bit mask
- Reading a value under a lock without cloning it, by passing a closure
- Why
hash % Nmoves almost every key when N changes - Consistent hashing: a ring of virtual nodes, searched with
partition_point
The caches of chapter 13 serve one thread at a time. This chapter asks what happens when many threads, or many machines, share one set of keys.
Both halves of the chapter use the same idea. You split the keys into groups by their hash, and each group is handled separately. A group of keys handled separately is called a shard.
Section 14.1 splits a map inside one program, so threads that use different keys do not wait for each other. Section 14.2 splits keys across machines, so that adding or removing a machine moves as few keys as possible.
14.1 A map split into shards
Section 8.6 showed how to share a value between threads: wrap it in a Mutex, and share the Mutex with an
Arc. A thread calls lock() before it touches the value. While one thread holds the lock, every other thread
that calls lock() waits.
A single Mutex<HashMap<K, V>> is safe, but it has a cost. If four threads use the map at once, three of them
wait, even when each thread wants a different key. Waiting for a lock that another thread holds is called
lock contention.
The fix is to give the map several locks. Split it into, say, eight smaller maps, each with its own Mutex
(figure 14.1). A key always goes to the same small map. Two threads then wait for each other only when their
keys land in the same shard.
Animation 14.1 sends one key and value from each thread into the map. Under the single lock the pairs go in one at a time and stay. Under eight shards they go in together, each into the shard that owns its key.
14.1.1 The type
The cache holds a list of shards, each a HashMap behind its own Mutex. It also holds the hasher that decides which shard a key belongs to.
Listing 14.1 The type and the constructor (lines 16 to 42). src/problems/sharded_cache.rs
use std::{
collections::{HashMap, hash_map::RandomState},
hash::{BuildHasher, Hash},
sync::Mutex,
};
/// A fixed-size map split across independently locked shards.
///
/// The shard count is rounded up to a power of two so the index is a mask instead
/// of a modulo.
pub struct ShardedCache<K, V> {
shards: Vec<Mutex<HashMap<K, V>>>,
hasher: RandomState,
mask: usize,
}
impl<K, V> ShardedCache<K, V>
where
K: Hash + Eq,
{
/// Create a cache with at least `shard_count` shards (rounded to a power of two).
pub fn new(shard_count: usize) -> Self {
let shard_count = shard_count.max(1).next_power_of_two();
let shards = (0..shard_count)
.map(|_| Mutex::new(HashMap::new()))
.collect();
Self {
shards,
hasher: RandomState::new(),
mask: shard_count - 1,
}
}
// ...
}
ShardedCache has three fields:
shardsis aVecof maps, each inside its ownMutex.hasherproduces the hash that picks a shard for a key.maskturns a hash into a shard number, as the next section explains.
new builds the Vec with an iterator. (0..shard_count).map(|_| Mutex::new(HashMap::new())) makes one empty,
locked map per shard, and collect() gathers them. The _ in the closure ignores the shard number, which is
not needed.
The first line of new adjusts the requested count. shard_count.max(1) turns a request for 0 shards into 1.
.next_power_of_two() rounds up to the next power of two, so a request for 6 shards gives 8.
14.1.2 Picking a shard with a mask
A shard count that is a power of two lets the code pick a shard cheaply. For 8 shards, the mask is 8 − 1 = 7,
which is 0b111 in binary. The operation hash & mask keeps the lowest three bits of the hash and clears the
rest. The result is a number from 0 to 7 (figure 14.2).
hash & mask keeps the low bits. For a power-of-two count, it gives the same result as hash % count.hash % 8 would give the same answer. The & is one simple CPU instruction, while % is a division, which
takes more time. The difference is small, but this code runs on every call.
Listing 14.2 Picking a shard (lines 45 to 48).
impl<K, V> ShardedCache<K, V>
where
K: Hash + Eq,
{
// ...
fn shard(&self, key: &K) -> &Mutex<HashMap<K, V>> {
let index = (self.hasher.hash_one(key) as usize) & self.mask;
&self.shards[index]
}
// ...
}
hash_one hashes the key with the cache’s RandomState and returns a u64. The mask keeps its low bits as the shard index. It returns a reference to the chosen shard’s Mutex.
The cache keeps one RandomState for its whole life. RandomState::new() picks random secret keys for the hash
function when the cache is created. Every lookup in the same cache uses the same keys, so a key always maps to the
same shard. A different cache, or the same program run again, would place the keys differently.
HashMap uses RandomState for the same reason. Random keys stop an attacker from choosing inputs that all hash
to the same place and slow the map down.
WARNING: the mask reads the low three bits of the hash and nothing else. A hash whose low bits repeat sends every key to one shard. That shard then carries the traffic of the whole map, its lock serializes every call, and the seven other shards sit idle. The work looks sharded and behaves like the single map of section 14.1.
Three short hashes show how this happens. Each one takes the low three bits at the end, the mask for eight shards.
// 1. The bytes added up. Every order of the same bytes gives one number.
fn hash_sum(bytes: &[u8]) -> u64 {
bytes.iter().map(|b| u64::from(*b)).sum()
}
// 2. The first byte only. Every key with the same first letter collides.
fn hash_first(bytes: &[u8]) -> u64 {
u64::from(bytes.first().copied().unwrap_or(0))
}
// 3. The key itself, for a numeric key. The low bits repeat every eight.
fn hash_identity(key: u64) -> u64 {
key
}
The keys "ab" and "ba" hold the same two bytes, so hash_sum returns 195 for both. The mask computes
195 & 7 = 3, so both keys land in shard 3. The keys "abc" and "cab" both return 294, so both land in shard 6.
Under hash_first, the keys "user-1", "user-2", and "user-3" all begin with u, which is 117, so all
three land in shard 5. Under hash_identity, the keys 1000, 1008, 1016, and 1024 are all multiples of 8. Their
low three bits are 0, so all four land in shard 0.
The repair is a hash that mixes the whole key, not a piece of it. SipHash, which RandomState uses, flips about
half of the output bits when one input bit changes. Keys that arrive from outside, such as user names or header
values, are chosen by whoever sends them. A weak hash lets that caller fill one shard on purpose, and one busy
shard can take the whole service down. Use a reviewed hash, and keep the hash seed secret where the input is
untrusted. Animation 14.1 ends on this case: every key gives index 0, and the four pairs queue on one shard.
14.1.3 Each method locks one shard
A single-key operation touches one shard only. It hashes the key, locks that shard, and leaves every other shard free for other threads.
Listing 14.3 insert, get, and with (lines 50 to 80).
impl<K, V> ShardedCache<K, V>
where
K: Hash + Eq,
{
// ...
/// Insert a value, returning the previous one if the key was present.
pub fn insert(&self, key: K, value: V) -> Option<V> {
self.shard(&key)
.lock()
.expect("shard mutex poisoned")
.insert(key, value)
}
/// Read a value by cloning it out of the shard.
pub fn get(&self, key: &K) -> Option<V>
where
V: Clone,
{
self.shard(key)
.lock()
.expect("shard mutex poisoned")
.get(key)
.cloned()
}
/// Run `inspect` against the value under the shard lock, without cloning.
///
/// Use this for read-modify-write on a single key; the closure holds the lock
/// for its duration, so keep it short and do not call back into the cache.
pub fn with<R>(&self, key: &K, inspect: impl FnOnce(Option<&V>) -> R) -> R {
let guard = self
.shard(key)
.lock()
.expect("shard mutex poisoned");
inspect(guard.get(key))
}
// ...
}
Each method follows the same three steps: pick the shard, lock it, and call the matching HashMap method. The
methods take &self, not &mut self. The Mutex provides the permission to change the map, so a shared
reference to the cache is enough. Several threads can then share one cache through an Arc.
lock() returns a Result. It is an error only if another thread panicked while it held the same lock. Such a
lock is called poisoned, and chapter 16 covers it. Here .expect(...) turns that error into a panic with a
clear message.
The lock stays held until the guard returned by lock() is dropped. In insert, the guard is a temporary value
in one expression, so it is dropped at the end of the statement.
get must return a clone of the value, which is why it asks for V: Clone. A reference to the value would point
into the map, and the map is only reachable through the guard. When get returns, the guard is dropped and the
lock released. A reference could then be read while another thread changes the map, so the compiler does not
allow it to escape.
14.1.4 Reading under the lock with a closure
Cloning a large value only to read one field of it wastes work. with avoids the clone. You pass it a closure,
and with runs the closure while it holds the lock:
pub fn with<R>(&self, key: &K, inspect: impl FnOnce(Option<&V>) -> R) -> R
inspect: impl FnOnce(Option<&V>) -> Raccepts any closure that takes anOption<&V>and returns some typeR.FnOncemeanswithcalls it at most once.- The closure receives a reference into the map, valid while the lock is held.
- Only the closure’s result, of type
R, leaves the function.Rcannot borrow from the map.
The test with_exposes_the_value_without_cloning uses it to read the length of a String:
let observed = cache.with(&"node", |value| value.map(|v| v.len()));
The doc comment of with gives two rules. Keep the closure short, because every other thread that needs the same
shard waits while it runs. And do not call the cache from inside the closure. A call that lands on the same shard
would try to lock a Mutex that this thread already holds. The standard Mutex does not allow that: the thread
blocks forever or panics. A thread waiting forever for a lock is called a deadlock.
14.1.5 Operations on every shard
Some operations need every shard: the total size, and clearing the cache. They visit the shards one at a time.
Listing 14.4 len and clear (lines 96 to 119).
impl<K, V> ShardedCache<K, V>
where
K: Hash + Eq,
{
// ...
pub fn len(&self) -> usize {
self.shards
.iter()
.map(|shard| {
shard
.lock()
.expect("shard mutex poisoned")
.len()
})
.sum()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn clear(&self) {
for shard in &self.shards {
shard
.lock()
.expect("shard mutex poisoned")
.clear();
}
}
// ...
}
len locks each shard in turn, reads its size, releases the lock, and adds up the sizes. clear also visits the
shards one at a time.
This is the price of sharding, and the comment at the top of the file states it. No moment exists when all
shards are locked together. While len runs, other threads can insert into shards it has already counted. The
total is correct if no other thread is writing, and approximate if one is.
14.1.6 The complete file
Listing 14.5 The complete file, with tests. src/problems/sharded_cache.rs
//! Sharded concurrent map to reduce lock contention.
//!
//! A single `Mutex<HashMap>` serializes every access. Splitting the map into N
//! shards, each with its own lock, lets independent keys proceed in parallel. The
//! cost is that `len` and `clear` must visit every shard, and there is no global
//! atomic snapshot.
//!
//! Typical uses: per-account token caches, metadata caches, and request
//! de-duplication tables.
use std::{
collections::{HashMap, hash_map::RandomState},
hash::{BuildHasher, Hash},
sync::Mutex,
};
/// A fixed-size map split across independently locked shards.
///
/// The shard count is rounded up to a power of two so the index is a mask instead
/// of a modulo.
pub struct ShardedCache<K, V> {
shards: Vec<Mutex<HashMap<K, V>>>,
hasher: RandomState,
mask: usize,
}
impl<K, V> ShardedCache<K, V>
where
K: Hash + Eq,
{
/// Create a cache with at least `shard_count` shards (rounded to a power of two).
pub fn new(shard_count: usize) -> Self {
let shard_count = shard_count.max(1).next_power_of_two();
let shards = (0..shard_count)
.map(|_| Mutex::new(HashMap::new()))
.collect();
Self {
shards,
hasher: RandomState::new(),
mask: shard_count - 1,
}
}
fn shard(&self, key: &K) -> &Mutex<HashMap<K, V>> {
let index = (self.hasher.hash_one(key) as usize) & self.mask;
&self.shards[index]
}
/// Insert a value, returning the previous one if the key was present.
pub fn insert(&self, key: K, value: V) -> Option<V> {
self.shard(&key)
.lock()
.expect("shard mutex poisoned")
.insert(key, value)
}
/// Read a value by cloning it out of the shard.
pub fn get(&self, key: &K) -> Option<V>
where
V: Clone,
{
self.shard(key)
.lock()
.expect("shard mutex poisoned")
.get(key)
.cloned()
}
/// Run `inspect` against the value under the shard lock, without cloning.
///
/// Use this for read-modify-write on a single key; the closure holds the lock
/// for its duration, so keep it short and do not call back into the cache.
pub fn with<R>(&self, key: &K, inspect: impl FnOnce(Option<&V>) -> R) -> R {
let guard = self
.shard(key)
.lock()
.expect("shard mutex poisoned");
inspect(guard.get(key))
}
pub fn remove(&self, key: &K) -> Option<V> {
self.shard(key)
.lock()
.expect("shard mutex poisoned")
.remove(key)
}
pub fn contains_key(&self, key: &K) -> bool {
self.shard(key)
.lock()
.expect("shard mutex poisoned")
.contains_key(key)
}
pub fn len(&self) -> usize {
self.shards
.iter()
.map(|shard| {
shard
.lock()
.expect("shard mutex poisoned")
.len()
})
.sum()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn clear(&self) {
for shard in &self.shards {
shard
.lock()
.expect("shard mutex poisoned")
.clear();
}
}
pub fn shard_count(&self) -> usize {
self.shards.len()
}
}
#[cfg(test)]
mod tests {
use std::{sync::Arc, thread};
use super::*;
#[test]
fn insert_get_remove() {
let cache: ShardedCache<String, u32> = ShardedCache::new(4);
assert_eq!(cache.insert("server-1".into(), 8), None);
assert_eq!(cache.get(&"server-1".into()), Some(8));
assert_eq!(cache.insert("server-1".into(), 16), Some(8));
assert_eq!(cache.get(&"server-1".into()), Some(16));
assert_eq!(cache.remove(&"server-1".into()), Some(16));
assert!(!cache.contains_key(&"server-1".into()));
assert!(cache.is_empty());
}
#[test]
fn with_exposes_the_value_without_cloning() {
let cache: ShardedCache<&str, String> = ShardedCache::new(2);
cache.insert("node", "ABCD".to_string());
let observed = cache.with(&"node", |value| value.map(|v| v.len()));
assert_eq!(observed, Some(4));
}
#[test]
fn concurrent_writers_all_land() {
let cache = Arc::new(ShardedCache::new(16));
let mut handles = Vec::new();
for writer in 0..8 {
let cache = Arc::clone(&cache);
handles.push(thread::spawn(move || {
for key in 0..500 {
cache.insert(format!("{writer}-{key}"), key);
}
}));
}
for handle in handles {
handle.join().expect("writer panicked");
}
assert_eq!(cache.len(), 8 * 500);
}
#[test]
fn keys_spread_across_shards() {
let cache: ShardedCache<u32, u32> = ShardedCache::new(8);
for key in 0..4_096 {
cache.insert(key, key);
}
// Every shard should hold roughly 1/8 of the keys; assert none is starved.
let smallest = cache
.shards
.iter()
.map(|shard| {
shard
.lock()
.expect("shard mutex poisoned")
.len()
})
.min()
.expect("at least one shard");
assert!(smallest > 0, "at least one shard was empty");
}
}
The test concurrent_writers_all_land is the one that uses threads. It wraps the cache in an Arc, and starts
eight threads with thread::spawn. Each thread gets its own clone of the Arc and inserts 500 keys. join()
waits for each thread to finish. The final len must be 8 × 500 = 4,000, which shows that no insert was lost.
The test keys_spread_across_shards inserts 4,096 keys into 8 shards and checks that no shard is empty. It
reaches the private shards field directly. A test module inside the file can see private items of its parent
module.
$ cargo test --lib problems::sharded_cache
running 4 tests
test problems::sharded_cache::tests::with_exposes_the_value_without_cloning ... ok
test problems::sharded_cache::tests::insert_get_remove ... ok
test problems::sharded_cache::tests::concurrent_writers_all_land ... ok
test problems::sharded_cache::tests::keys_spread_across_shards ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 133 filtered out
14.2 Spreading keys across machines
Now move from threads to machines. Suppose a cache is spread over several servers, and each key must live on exactly one of them. Every client must compute the same server for the same key, with no central directory to ask.
14.2.1 The problem with hash % N
The obvious rule is hash(key) % N, where N is the number of servers. It spreads keys evenly and needs no
storage. It breaks down when N changes.
Figure 14.3 shows keys with hashes 0 to 11 when a fourth server joins three others. A key stays in place only if
hash % 3 equals hash % 4.
hash % N. Three keys in twelve stay, and the rest move.The pattern repeats every 12 hashes, so 75% of all keys change servers. For a cache, a moved key is a miss: the new server does not have it. Adding one server would empty most of the cache at once.
14.2.2 The ring
Consistent hashing places both servers and keys on a circle of hash values, called the ring. The ring
covers every u64 value. After the largest value, it wraps around to 0.
Each server is hashed to a point on the ring. A key is hashed too, and it belongs to the first server point clockwise from it. In practice, “clockwise” means “the next larger hash”.
When a server joins, it takes over only the keys between its point and the point before it. Every other key keeps its server. When a server leaves, only its own keys move, to the next point clockwise.
With one point per server, the arcs between points are uneven, so some servers get much more of the ring than others. The fix is to place each server at many points, called virtual nodes. With many small arcs per server, the shares even out (figure 14.4).
Figure 14.5 shows the ring cut open and laid flat, before and after a third node joins.
Animation 14.2 follows the join through the whole system. It shows the ring, the keys each node holds, the key that moves, and the clients that learn about it.
c moves one key from b to c. An event carries the new ring to every client; a client that misses it asks the wrong node.Each node keeps the keys it owns. A join moves the keys in the new arc from the old owner to the new one.
Every client also keeps a ConsistentHash of its own and computes the owner locally, so no lookup needs a central
directory. When a node joins or leaves, each client has to learn the new ring. Until it does, it sends keys to the
old owner. A client that still points k30 at node b asks a node that has given that key up.
With n servers, a new server takes over about 1/(n + 1) of the keys. Going from 3 servers to 4 moves about 25% of
the keys, where hash % N moved 75%.
14.2.3 The ring as a sorted Vec
The ring of figure 14.4 can be stored as a vector of points, kept sorted by hash. Each point records its position on the ring and the server it belongs to.
Listing 14.6 The type and add_node (lines 17 to 55). src/problems/consistent_hash.rs
use std::{
collections::{HashSet, hash_map::DefaultHasher},
hash::{Hash, Hasher},
sync::Arc,
};
/// A hash ring mapping keys to node names.
pub struct ConsistentHash {
replicas: usize,
/// Sorted `(hash, node)` pairs. The node name is an `Arc<str>` so that the
/// many virtual nodes for one physical node share a single allocation.
ring: Vec<(u64, Arc<str>)>,
}
impl ConsistentHash {
/// Create an empty ring with `replicas` virtual nodes per physical node.
/// Panics if `replicas` is zero.
pub fn new(replicas: usize) -> Self {
assert!(replicas > 0, "replicas must be > 0");
Self {
replicas,
ring: Vec::new(),
}
}
/// Add a node and its virtual nodes. Adding a node that already exists is a
/// no-op.
pub fn add_node(&mut self, node: &str) {
if self
.ring
.iter()
.any(|(_, name)| name.as_ref() == node)
{
return;
}
// One allocation for the name, then a refcount bump per virtual node.
let name: Arc<str> = Arc::from(node);
for replica in 0..self.replicas {
let hash = virtual_node_hash(node, replica);
self.ring
.push((hash, Arc::clone(&name)));
}
self.ring.sort_by_key(|(hash, _)| *hash);
}
/// Remove a node and all of its virtual nodes.
pub fn remove_node(&mut self, node: &str) {
self.ring
.retain(|(_, name)| name.as_ref() != node);
}
// ...
}
The ring is a Vec<(u64, Arc<str>)>, kept sorted by hash. Each entry is one virtual node: its position on the
ring, and the name of the server it belongs to.
With 128 virtual nodes per server, each name would be stored 128 times as a String. The code stores it as
Arc<str> instead. Arc::from(node) copies the name into one heap block once. Each Arc::clone(&name) then
adds a pointer to that same block and increases its reference count. No text is copied.
add_node does three things:
- It checks whether the server is already on the ring, by scanning the entries. If so, it returns.
- It pushes one entry per virtual node, with the hash from
virtual_node_hash. - It sorts the ring by hash again.
remove_node keeps every entry that belongs to another server, with retain. Removing entries does not
change the order of the rest, so the ring stays sorted.
Listing 14.7 The hash functions (lines 94 to 109).
fn hash_of(input: &str) -> u64 {
let mut hasher = DefaultHasher::new();
input.hash(&mut hasher);
hasher.finish()
}
/// Hash of the `replica`-th virtual node for `node`.
///
/// Hashing the two fields separately avoids allocating a `"{node}#{replica}"`
/// string for every virtual node on the ring.
fn virtual_node_hash(node: &str, replica: usize) -> u64 {
let mut hasher = DefaultHasher::new();
node.hash(&mut hasher);
replica.hash(&mut hasher);
hasher.finish()
}
virtual_node_hash feeds both the server name and the replica number into one hasher. Each replica number gives a
different point for the same server. Feeding the two values separately avoids building a string such as
"node-a#17" for every virtual node.
WARNING: DefaultHasher::new() gives the same result every time within one program, which the ring needs.
The standard library does not promise that it stays the same across Rust versions. Servers and clients built with
different compilers could then disagree about who owns a key. A real deployment should use a hash function with a
fixed, documented algorithm.
14.2.4 Finding a key’s server
To find a key’s server, hash the key and find the first point on the ring after that hash. If there is none, wrap around to the first point.
Listing 14.8 get (lines 64 to 77).
impl ConsistentHash {
// ...
/// Return the node responsible for `key`, or `None` when the ring is empty.
pub fn get(&self, key: &str) -> Option<&str> {
if self.ring.is_empty() {
return None;
}
let hash = hash_of(key);
// First virtual node strictly clockwise from the key.
let index = self
.ring
.partition_point(|(point, _)| *point <= hash);
let index = if index == self.ring.len() { 0 } else { index };
Some(self.ring[index].1.as_ref())
}
// ...
}
get must find the first ring entry with a hash larger than the key’s hash. The ring is sorted, so a binary
search can find it in O(log n) steps, as in section 3.10.
partition_point performs that binary search. It takes a condition that is true for a first part of the slice and false for the rest. It returns the
index where the condition turns false (figure 14.6). Here the condition is
point <= hash, so the result is the index of the first point larger than the key’s hash.
partition_point returns the first index where the condition is false.If the key’s hash is larger than every point, partition_point returns the length of the ring. The ring wraps
around there, so the code uses index 0 instead.
get returns Option<&str>, a reference into the ring. .as_ref() turns the Arc<str> into a &str. An empty
ring has no servers, so the function returns None for it.
14.2.5 The complete file
Listing 14.9 The complete file, with tests. src/problems/consistent_hash.rs
//! Consistent hashing ring.
//!
//! When a distributed gateway shards work across N backends, a plain
//! `hash(key) % N` remaps nearly every key the moment N changes. Consistent
//! hashing places each node at several points on a hash ring, so adding or
//! removing one node only moves the keys that node owns. That property is the
//! reason it shows up in caches, sharded queues, and load balancers.
//!
//! Each physical node is placed at `replicas` points (virtual nodes) to smooth the
//! key distribution; more replicas means a less lumpy ring.
use std::{
collections::{HashSet, hash_map::DefaultHasher},
hash::{Hash, Hasher},
sync::Arc,
};
/// A hash ring mapping keys to node names.
pub struct ConsistentHash {
replicas: usize,
/// Sorted `(hash, node)` pairs. The node name is an `Arc<str>` so that the
/// many virtual nodes for one physical node share a single allocation.
ring: Vec<(u64, Arc<str>)>,
}
impl ConsistentHash {
/// Create an empty ring with `replicas` virtual nodes per physical node.
/// Panics if `replicas` is zero.
pub fn new(replicas: usize) -> Self {
assert!(replicas > 0, "replicas must be > 0");
Self {
replicas,
ring: Vec::new(),
}
}
/// Add a node and its virtual nodes. Adding a node that already exists is a
/// no-op.
pub fn add_node(&mut self, node: &str) {
if self
.ring
.iter()
.any(|(_, name)| name.as_ref() == node)
{
return;
}
// One allocation for the name, then a refcount bump per virtual node.
let name: Arc<str> = Arc::from(node);
for replica in 0..self.replicas {
let hash = virtual_node_hash(node, replica);
self.ring
.push((hash, Arc::clone(&name)));
}
self.ring.sort_by_key(|(hash, _)| *hash);
}
/// Remove a node and all of its virtual nodes.
pub fn remove_node(&mut self, node: &str) {
self.ring
.retain(|(_, name)| name.as_ref() != node);
}
/// Return the node responsible for `key`, or `None` when the ring is empty.
pub fn get(&self, key: &str) -> Option<&str> {
if self.ring.is_empty() {
return None;
}
let hash = hash_of(key);
// First virtual node strictly clockwise from the key.
let index = self
.ring
.partition_point(|(point, _)| *point <= hash);
let index = if index == self.ring.len() { 0 } else { index };
Some(self.ring[index].1.as_ref())
}
/// Number of distinct physical nodes.
pub fn node_count(&self) -> usize {
self.ring
.iter()
.map(|(_, name)| name.as_ref())
.collect::<HashSet<_>>()
.len()
}
/// Number of virtual nodes on the ring.
pub fn ring_len(&self) -> usize {
self.ring.len()
}
}
fn hash_of(input: &str) -> u64 {
let mut hasher = DefaultHasher::new();
input.hash(&mut hasher);
hasher.finish()
}
/// Hash of the `replica`-th virtual node for `node`.
///
/// Hashing the two fields separately avoids allocating a `"{node}#{replica}"`
/// string for every virtual node on the ring.
fn virtual_node_hash(node: &str, replica: usize) -> u64 {
let mut hasher = DefaultHasher::new();
node.hash(&mut hasher);
replica.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_ring_has_no_owner() {
let ring = ConsistentHash::new(64);
assert_eq!(ring.get("workload-1"), None);
assert_eq!(ring.node_count(), 0);
}
#[test]
fn lookup_is_stable_for_a_fixed_ring() {
let mut ring = ConsistentHash::new(64);
ring.add_node("node-a");
ring.add_node("node-b");
let first = ring.get("job-42").map(str::to_string);
for _ in 0..100 {
assert_eq!(ring.get("job-42").map(str::to_string), first);
}
assert!(first.is_some());
}
#[test]
fn keys_spread_across_nodes() {
let mut ring = ConsistentHash::new(128);
for node in ["node-a", "node-b", "node-c"] {
ring.add_node(node);
}
let mut counts = std::collections::HashMap::new();
for key in 0..3_000 {
let owner = ring
.get(&format!("key-{key}"))
.expect("ring is populated");
*counts
.entry(owner.to_string())
.or_insert(0) += 1;
}
assert_eq!(counts.len(), 3, "every node should own some keys");
let smallest = *counts
.values()
.min()
.expect("non-empty");
assert!(smallest > 300, "distribution is too lumpy: {counts:?}");
}
#[test]
fn adding_a_node_only_moves_keys_to_the_new_node() {
let keys: Vec<String> = (0..2_000)
.map(|i| format!("workload-{i}"))
.collect();
let mut ring = ConsistentHash::new(128);
ring.add_node("node-a");
ring.add_node("node-b");
let before: Vec<String> = keys
.iter()
.map(|key| {
ring.get(key)
.expect("ring is populated")
.to_string()
})
.collect();
ring.add_node("node-c");
let after: Vec<String> = keys
.iter()
.map(|key| {
ring.get(key)
.expect("ring is populated")
.to_string()
})
.collect();
for (old, new) in before.iter().zip(after.iter()) {
assert!(
old == new || new == "node-c",
"adding a node moved a key from {old} to {new}"
);
}
}
#[test]
fn removing_a_node_drops_it_from_the_ring() {
let mut ring = ConsistentHash::new(32);
ring.add_node("node-a");
ring.add_node("node-b");
assert_eq!(ring.node_count(), 2);
assert_eq!(ring.ring_len(), 64);
ring.remove_node("node-a");
assert_eq!(ring.node_count(), 1);
for _ in 0..500 {
assert!(matches!(ring.get("any-key"), Some("node-b")));
}
}
}
node_count counts distinct servers by collecting the names into a HashSet, which keeps one copy of each.
The tests check the properties this section described:
keys_spread_across_nodesplaces 3,000 keys on three servers with 128 virtual nodes each. Each server must own more than 300 keys. An even split would give each one 1,000.adding_a_node_only_moves_keys_to_the_new_noderecords the owner of 2,000 keys, adds a third server, and looks again. Every key must either keep its owner or move to the new server.removing_a_node_drops_it_from_the_ringchecks that 2 servers with 32 virtual nodes give 64 ring entries, and that removing one leaves every key with the other.
$ cargo test --lib problems::consistent_hash
running 5 tests
test problems::consistent_hash::tests::empty_ring_has_no_owner ... ok
test problems::consistent_hash::tests::lookup_is_stable_for_a_fixed_ring ... ok
test problems::consistent_hash::tests::removing_a_node_drops_it_from_the_ring ... ok
test problems::consistent_hash::tests::adding_a_node_only_moves_keys_to_the_new_node ... ok
test problems::consistent_hash::tests::keys_spread_across_nodes ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 132 filtered out
Summary
- A single
Mutexaround a map makes every thread wait for every other. Sharding gives each group of keys its own lock, so threads wait only when their keys share a shard. - With a power-of-two shard count,
hash & (count - 1)picks the shard with one AND instruction. - A value inside a
Mutexcan leave the lock only as a clone. A closure passed towithcan read the value in place while the lock is held. - Operations over all shards, such as
len, lock one shard at a time and are not a single snapshot. hash % Nmoves most keys when N changes. Consistent hashing moves only the keys the new server takes over, about 1/(n + 1) of them.- Virtual nodes even out each server’s share of the ring.
Arc<str>lets all of a server’s entries share one copy of its name. - The ring is a sorted
Vec, andpartition_pointfinds a key’s server by binary search.
Chapter 15 looks below the data structures, at how the CPU caches memory and how the operating system hands it out.
Exercises
- Add
update(&self, key: &K, change: impl FnOnce(&mut V))toShardedCache, which changes a value in place under the lock. - Write a test that counts how many keys change owner when a fourth server joins a ring of three. Compare the result with 25%.
- Measure how even the ring is. For 10, 50, and 200 virtual nodes per server, place 100,000 keys and report the largest and smallest share.
add_nodesorts the whole ring after every call. Rewrite it to insert each new entry at its sorted position withpartition_pointandVec::insert, and compare the two for 100 servers.
Memory, CPU caches, and the operating system
This chapter covers
- How long it takes the CPU to read memory, measured at five data sizes
- A bump allocator, and what alignment means
- Why the order in which you read an array changes the time, and a benchmark that measured something else
- False sharing: two threads that slow each other down without sharing any data
- Pages, page tables, and the kinds of page fault
- Round-robin scheduling, and what a system call costs
The earlier chapters counted steps: O(1), O(log n), O(n). Step counts predict how a program grows, but not how long one step takes. On a real machine, one memory read can take 2 ns or 150 ns, depending on where the data is. Chapter 13 already saw a factor of three between two caches with the same step count.
This chapter looks at the machine under the data structures. Section 15.1 measures the memory system. Sections 15.2 to 15.4 show three programs whose speed depends on how they lay out and touch memory. Sections 15.5 to 15.7 look at three services of the operating system: virtual memory, scheduling, and system calls.
Most programs in this chapter print times. Run them with --release, and expect your numbers to differ from mine.
All the numbers here come from one laptop with an Intel Core i5-1038NG7 CPU, running macOS.
15.1 How far away is memory?
Main memory, the RAM, is large and slow compared with the CPU. To hide the difference, the CPU keeps copies of recently used memory in small, fast memories called caches. There are usually three levels:
- L1 is the smallest and fastest, and each core has its own. On this CPU it holds 48 KiB of data per core.
- L2 is larger and slower, also per core: 512 KiB here.
- L3 is shared by all cores: 6 MiB here.
Caches do not copy single bytes. They copy blocks of 64 bytes, called cache lines. Reading one u64 brings
in the line around it, so the next seven u64 values in memory arrive at no extra cost.
To see the cost of each level, I measured a pointer chase. The program fills an array with the indexes of a
random cycle, so each element holds the index of the next one to read. The loop then runs
i = next[i] many times. Each read needs the result of the previous one, so the reads cannot overlap. The time per step is the time
of one read. Figure 15.1 shows the result for five array sizes.
A read from L1 took 2.2 ns. A read that had to go to main memory took 120 to 146 ns. In 120 ns, this CPU can run several hundred simple instructions. The larger sizes also include the cost of translating addresses, which section 15.5 explains.
Those five numbers describe one hierarchy, not five separate machines. Data in L1 answers a dependent read in 2.2 ns. The same read against 256 KiB costs 5.6 ns. That array no longer fits in a 48 KiB L1, so the line comes from L2. At 2 MiB the cost is 42.3 ns, having left L2 as well. From 32 MiB upward it is 120 ns or more, which is main memory. A larger level holds several times more than the one above it, and answers several times more slowly. The trade is forced. A store that answers in 2 ns has to be small, because reaching a location costs more as the store grows.
A cache line is 64 bytes, and it arrives as one unit. A program that reads one u64 and then the next seven
pays for a single line and uses all of it. A program that jumps to a new random line on every read pays the
same 120 ns. It uses one eighth of what it fetched.
Animation 15.1 follows single reads through the hierarchy, then a line read in order and lines read scattered.
The rest of the chapter comes back to one fact. Data that is close together in memory, and read in order, is much cheaper than data scattered around. Keeping related data close together is called locality.
15.2 A bump allocator
Programs get heap memory from an allocator. Every Box::new and every growing Vec asks the allocator for
a block, and every drop gives one back. A general-purpose allocator must handle blocks of any size, freed in any
order, from any thread. That takes bookkeeping.
A bump allocator is the simplest allocator. It owns one large buffer and an offset, the position of the
first free byte. To allocate, it hands out the bytes at the offset and moves the offset forward. It never frees a
single block. Instead, reset frees everything at once by setting the offset back to 0.
This fits work that allocates many small pieces and throws them all away together. A parser can allocate its syntax tree in an arena and drop it after compiling. A server can allocate per request and reset after sending the response.
15.2.1 Alignment
Most types must start at an address that is a multiple of some number, called their alignment. A u64 has
alignment 8, so it must start at an address divisible by 8. The CPU reads aligned values in one step, and some
instructions refuse unaligned ones.
So an allocator must sometimes skip bytes before a block, to reach an address with the right alignment. The skipped bytes are called padding. Figure 15.2 shows two allocations and the padding between them.
allocations_are_aligned. Aligning the second block to 16 costs 15 bytes of padding.15.2.2 The code
Listing 15.1 alloc (lines 27 to 42) and align_up (lines 65 to 70). src/problems/bump_allocator.rs
impl BumpArena {
// ...
/// Allocate `size` bytes aligned to `align`.
///
/// Returns `None` when the request does not fit. `align` must be a power of
/// two.
pub fn alloc(&mut self, size: usize, align: usize) -> Option<&mut [u8]> {
assert!(align.is_power_of_two(), "align must be a power of two");
let start = align_up(self.offset, align)?;
let end = start.checked_add(size)?;
if end > self.buffer.len() {
return None;
}
self.offset = end;
Some(&mut self.buffer[start..end])
}
// ...
}
fn align_up(offset: usize, align: usize) -> Option<usize> {
let mask = align - 1;
offset
.checked_add(mask)
.map(|value| value & !mask)
}
align_up rounds the offset up to the next multiple of align. The alignment must be a power of two, which the
assert! in alloc checks. A power of two has exactly one bit set, so align - 1 is a mask with all the bits
below it set. For align = 16, the mask is 0b1111.
The rounding happens in two steps. Adding the mask pushes the offset past the next multiple, unless it is already
on one. Then & !mask clears the low bits, which rounds down to a multiple. Work through the second allocation of
figure 15.2 by hand:
- The offset is 1, and the alignment is 16, so the mask is 15.
- 1 + 15 = 16.
- 16 with the low four bits cleared is 16. The block starts at 16.
An offset of 16 would give 16 + 15 = 31, which rounds down to 16 again. An offset already aligned stays where it is.
alloc uses checked_add twice, and the ? operator returns None if either addition overflows. It also
returns None if the block would end past the buffer. Only after every check passes does it move the offset.
So a failed allocation leaves the arena unchanged, which the test exhaustion_returns_none_and_keeps_state
checks.
The block is returned as &mut [u8], a slice of the buffer. That reference borrows the arena mutably, so you
cannot call alloc again while you hold it. A production arena allocator hands out many live references at
once, and needs unsafe code or interior mutability to do so.
WARNING: align_up aligns the offset, not the memory address. The address of a block is the buffer’s address plus the offset. So the block is aligned only if the buffer
itself starts at a multiple of align. The buffer is a
Vec<u8>, which only promises alignment 1. The tests pass because the macOS allocator returns 16-byte aligned
blocks. A request for alignment 64 could return a misaligned address.
Listing 15.2 The complete file, with tests. src/problems/bump_allocator.rs
//! A bump (arena) allocator over a fixed byte buffer.
//!
//! Bump allocation is the simplest allocator: hand out bytes from a cursor and
//! never free an individual block. It is O(1) per allocation, has no fragmentation
//! bookkeeping, and is freed all at once with [`BumpArena::reset`]. Parsers,
//! request handlers, and per-frame buffers in games use exactly this shape.
//!
//! This version does not try to beat the system allocator. It shows alignment,
//! the memory layout you hand to hardware, and why "free everything at once" is
//! both the strength and the limitation.
/// A fixed-capacity bump allocator.
pub struct BumpArena {
buffer: Vec<u8>,
offset: usize,
}
impl BumpArena {
/// Create an arena backed by `capacity` zeroed bytes.
pub fn with_capacity(capacity: usize) -> Self {
Self {
buffer: vec![0; capacity],
offset: 0,
}
}
/// Allocate `size` bytes aligned to `align`.
///
/// Returns `None` when the request does not fit. `align` must be a power of
/// two.
pub fn alloc(&mut self, size: usize, align: usize) -> Option<&mut [u8]> {
assert!(align.is_power_of_two(), "align must be a power of two");
let start = align_up(self.offset, align)?;
let end = start.checked_add(size)?;
if end > self.buffer.len() {
return None;
}
self.offset = end;
Some(&mut self.buffer[start..end])
}
/// Bytes handed out so far, including alignment padding.
pub fn used(&self) -> usize {
self.offset
}
/// Bytes still available before the next allocation.
pub fn remaining(&self) -> usize {
self.buffer.len() - self.offset
}
/// Total capacity.
pub fn capacity(&self) -> usize {
self.buffer.len()
}
/// Free everything at once, as an arena does.
pub fn reset(&mut self) {
self.offset = 0;
}
}
fn align_up(offset: usize, align: usize) -> Option<usize> {
let mask = align - 1;
offset
.checked_add(mask)
.map(|value| value & !mask)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allocations_are_aligned() {
let mut arena = BumpArena::with_capacity(64);
let first = arena.alloc(1, 8).expect("fits");
assert_eq!(first.as_ptr() as usize % 8, 0);
assert_eq!(first.len(), 1);
let second = arena.alloc(4, 16).expect("fits");
assert_eq!(second.as_ptr() as usize % 16, 0);
// The first block occupies 1 byte. Aligning the next block to 16 adds 15
// bytes of padding, so the arena has consumed 16 + 4 = 20 bytes.
assert_eq!(arena.used(), 20);
assert_eq!(arena.remaining(), 44);
}
#[test]
fn exhaustion_returns_none_and_keeps_state() {
let mut arena = BumpArena::with_capacity(16);
assert!(arena.alloc(16, 1).is_some());
let used_before = arena.used();
assert!(arena.alloc(1, 1).is_none());
assert_eq!(arena.used(), used_before, "a failed alloc must not advance");
}
#[test]
fn reset_reclaims_everything() {
let mut arena = BumpArena::with_capacity(32);
assert!(arena.alloc(20, 4).is_some());
assert_eq!(arena.remaining(), 12);
arena.reset();
assert_eq!(arena.used(), 0);
assert_eq!(arena.remaining(), arena.capacity());
assert!(arena.alloc(32, 1).is_some());
}
#[test]
fn zero_sized_allocation_is_allowed() {
let mut arena = BumpArena::with_capacity(8);
let empty = arena.alloc(0, 8).expect("fits");
assert!(empty.is_empty());
assert_eq!(arena.used(), 0);
}
#[test]
fn writes_are_isolated_between_allocations() {
let mut arena = BumpArena::with_capacity(16);
arena
.alloc(4, 4)
.expect("fits")
.copy_from_slice(&[1, 2, 3, 4]);
let second = arena.alloc(4, 4).expect("fits");
assert!(second.iter().all(|&byte| byte == 0));
second.copy_from_slice(&[9, 9, 9, 9]);
let view = arena.buffer.as_slice();
assert_eq!(&view[0..4], &[1, 2, 3, 4]);
assert_eq!(&view[4..8], &[9, 9, 9, 9]);
}
}
$ cargo test --lib problems::bump_allocator
running 5 tests
test problems::bump_allocator::tests::allocations_are_aligned ... ok
test problems::bump_allocator::tests::exhaustion_returns_none_and_keeps_state ... ok
test problems::bump_allocator::tests::reset_reclaims_everything ... ok
test problems::bump_allocator::tests::zero_sized_allocation_is_allowed ... ok
test problems::bump_allocator::tests::writes_are_isolated_between_allocations ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 132 filtered out
15.3 Reading in order, and reading scattered
Figure 15.3 shows how the order of reads changes the work. Reading a u64 array in order uses every value of each cache
line it loads. The CPU also notices the pattern and fetches the next lines before they are needed, which is called
prefetching. Reading scattered values loads a whole line for each value and uses one eighth of it.
15.3.1 The program
Listing 15.3 The two loops (lines 9 to 30). src/bin/cs_locality.rs
use std::time::{Duration, Instant};
/// A power of two, so a stride must be odd to visit every index.
const ELEMENTS: usize = 4 * 1024 * 1024;
/// Odd, and therefore coprime with `ELEMENTS`: the walk visits every slot once.
const STRIDE: usize = 1_000_003;
fn sequential_sum(data: &[u64]) -> (u64, Duration) {
let start = Instant::now();
let sum = data.iter().sum();
(sum, start.elapsed())
}
fn strided_sum(data: &[u64]) -> (u64, Duration) {
let start = Instant::now();
let mut index = 0usize;
let mut sum = 0u64;
for _ in 0..data.len() {
index = (index + STRIDE) % data.len();
sum = sum.wrapping_add(data[index]);
}
(sum, start.elapsed())
}
Both functions add up every element of the same 32 MiB array, which is too large for any cache.
sequential_sum reads it in order with data.iter().sum(). strided_sum jumps 1,000,003 elements forward at
each step, wrapping around with %.
The jump must eventually visit every element exactly once. The array has 4,194,304 elements, which is 2 to the power 22. The stride is odd, so it shares no factor with a power of two. A walk whose step shares no factor with the length visits every position once before it returns to the start.
Listing 15.4 The complete program. src/bin/cs_locality.rs
//! Cache locality: the same data is much faster to walk in order than to jump
//! around, because memory moves in cache lines and the prefetcher helps only the
//! sequential case.
//!
//! Run with: cargo run --bin cs_locality
use std::time::{Duration, Instant};
/// A power of two, so a stride must be odd to visit every index.
const ELEMENTS: usize = 4 * 1024 * 1024;
/// Odd, and therefore coprime with `ELEMENTS`: the walk visits every slot once.
const STRIDE: usize = 1_000_003;
fn sequential_sum(data: &[u64]) -> (u64, Duration) {
let start = Instant::now();
let sum = data.iter().sum();
(sum, start.elapsed())
}
fn strided_sum(data: &[u64]) -> (u64, Duration) {
let start = Instant::now();
let mut index = 0usize;
let mut sum = 0u64;
for _ in 0..data.len() {
index = (index + STRIDE) % data.len();
sum = sum.wrapping_add(data[index]);
}
(sum, start.elapsed())
}
fn main() {
let data: Vec<u64> = (0..ELEMENTS as u64).collect();
println!(
"{} elements, {} MiB",
data.len(),
data.len() * 8 / (1024 * 1024)
);
let (sequential, sequential_time) = sequential_sum(&data);
let (strided, strided_time) = strided_sum(&data);
println!("\n sequential: {sequential} in {sequential_time:?}");
println!(" strided: {strided} in {strided_time:?}");
// Both touch every element exactly once, so the sums must match; only the
// access pattern differs.
assert_eq!(sequential, strided);
assert_eq!(sequential, ELEMENTS as u64 * (ELEMENTS as u64 - 1) / 2);
println!("\nequal work, different pattern; timings vary by machine");
}
main checks that both sums are equal, and that they match the formula for 0 + 1 + … + (n − 1). The two loops
did the same additions. Only the order of reading differs.
$ cargo run --release --bin cs_locality
4194304 elements, 32 MiB
sequential: 8796090925056 in 3.127311ms
strided: 8796090925056 in 37.375274ms
equal work, different pattern; timings vary by machine
15.3.2 What the strided loop measured
The strided loop took twelve times as long. It is tempting to credit all of that to the cache, but the two loops differ in more than their reading order.
strided_sum computes (index + STRIDE) % data.len() at every step. data.len() is not known at compile time,
so % is compiled as a real division instruction, one of the slowest integer instructions. Each step’s index
depends on the previous one, so the divisions run one after another.
To separate the two costs, I ran four variants of the loop:
Table 15.1 Variants of the 32 MiB sum, release build
| Loop | Time |
|---|---|
data.iter().sum(), in order | 3.1 ms |
strided_sum as written, stride 1,000,003 | 37.4 ms |
strided_sum with stride 1, reading in order | 36 ms |
| sum through a list of indexes, in order | 4.4 ms |
| sum through a list of indexes, in random order | 31 ms |
| pointer chase in random order, each read waits for the last | 478 ms |
The third row settles it. With stride 1, the loop reads memory in perfect order, and it still takes 36 ms. The
division sets the pace of strided_sum, not the memory.
The last three rows remove the division and show the real cost of scattered reads. Reading through a list of indexes in random order took about seven times as long as reading through the same list in order. The reads are independent of each other, so the CPU keeps several of them in flight at once. In the pointer chase of section 15.1, each read must wait for the previous one. There, the same 4 million reads take 478 ms, about 114 ns each.
Two lessons come out of this. Scattered memory access is expensive, from a few times slower to a hundred times, depending on whether reads can overlap. And a benchmark measures everything in its loop. Before you explain a number, change one thing at a time and check that the number moves.
15.4 False sharing
Chapter 16 covers threads in depth. This section needs only two facts about them:
thread::scope(|scope| { ... })creates a scope. Threads started inside it withscope.spawnmay borrow local variables, because the scope waits for all of them to finish before it returns.- An atomic integer such as
AtomicUsizecan be changed by several threads at once without a lock.fetch_add(1, Ordering::Relaxed)adds 1 in one indivisible step. TheOrderingargument is explained in chapter 16.Relaxedis enough for a counter that nothing else depends on.
Now the problem. Each core has its own L1 cache. Before a core writes to a cache line, it must have the only copy of that line. Every other core’s copy is thrown away. If two cores write to the same line in turn, the line moves back and forth between them on almost every write.
That is expected when two threads write the same variable. False sharing is the same slowdown when the two threads write different variables that happen to sit in the same cache line (figure 15.4). The threads share no data, but the hardware moves memory in whole lines, so they share a line.
Animation 15.2 shows the line moving between the cores, then the same writes with one line per counter.
The fix is to place each counter in its own cache line. #[repr(align(64))] on a struct makes its alignment 64,
so every value of the type starts on a new line. The size rounds up to a multiple of the alignment, so the value
fills the line.
15.4.1 Four counters
Listing 15.5 Two counter types (lines 16 to 41). src/bin/concurrency_false_sharing.rs
use std::{
mem::size_of,
sync::atomic::{AtomicUsize, Ordering},
thread,
time::{Duration, Instant},
};
/// Packed by default: consecutive counters share a cache line.
struct Unpadded(AtomicUsize);
impl Unpadded {
fn new() -> Self {
Self(AtomicUsize::new(0))
}
fn increment(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
/// Padded to a cache line, so no two counters share one.
#[repr(align(64))]
struct Padded(AtomicUsize);
impl Padded {
fn new() -> Self {
Self(AtomicUsize::new(0))
}
fn increment(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
Unpadded is 8 bytes, so a Vec of four of them fits in 32 bytes, inside one or two cache lines. Padded is 64
bytes, and each one gets a line.
Listing 15.6 The timed run (lines 43 to 59).
/// Scoped threads borrow the vector directly, so there is no `Arc` and no clone.
fn run_unpadded() -> Duration {
let counters: Vec<Unpadded> = (0..THREADS)
.map(|_| Unpadded::new())
.collect();
let start = Instant::now();
thread::scope(|scope| {
for counter in &counters {
scope.spawn(move || {
for _ in 0..ITERATIONS {
counter.increment();
}
});
}
});
start.elapsed()
}
The loop for counter in &counters gives each thread a reference to its own counter. move || moves that
reference into the thread’s closure. Each thread increments only its own counter, two million times.
Listing 15.7 The complete program. src/bin/concurrency_false_sharing.rs
//! False sharing: two independent counters that land on the same cache line make
//! each other slow, because every write moves the line between cores.
//!
//! Run with: cargo run --bin concurrency_false_sharing
use std::{
mem::size_of,
sync::atomic::{AtomicUsize, Ordering},
thread,
time::{Duration, Instant},
};
const THREADS: usize = 4;
const ITERATIONS: usize = 2_000_000;
/// Packed by default: consecutive counters share a cache line.
struct Unpadded(AtomicUsize);
impl Unpadded {
fn new() -> Self {
Self(AtomicUsize::new(0))
}
fn increment(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
/// Padded to a cache line, so no two counters share one.
#[repr(align(64))]
struct Padded(AtomicUsize);
impl Padded {
fn new() -> Self {
Self(AtomicUsize::new(0))
}
fn increment(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
/// Scoped threads borrow the vector directly, so there is no `Arc` and no clone.
fn run_unpadded() -> Duration {
let counters: Vec<Unpadded> = (0..THREADS)
.map(|_| Unpadded::new())
.collect();
let start = Instant::now();
thread::scope(|scope| {
for counter in &counters {
scope.spawn(move || {
for _ in 0..ITERATIONS {
counter.increment();
}
});
}
});
start.elapsed()
}
fn run_padded() -> Duration {
let counters: Vec<Padded> = (0..THREADS)
.map(|_| Padded::new())
.collect();
let start = Instant::now();
thread::scope(|scope| {
for counter in &counters {
scope.spawn(move || {
for _ in 0..ITERATIONS {
counter.increment();
}
});
}
});
start.elapsed()
}
fn main() {
println!("atomic: {} bytes", size_of::<AtomicUsize>());
println!(
"unpadded: {} bytes (several counters per 64 byte line)",
size_of::<Unpadded>()
);
println!(
"padded: {} bytes (one counter per 64 byte line)",
size_of::<Padded>()
);
let unpadded = run_unpadded();
let padded = run_padded();
println!("\n{THREADS} threads x {ITERATIONS} increments, each on its own counter");
println!(" unpadded (shared lines): {unpadded:?}");
println!(" padded (own line): {padded:?}");
assert!(size_of::<Padded>() >= 64, "padding must fill a cache line");
assert!(unpadded.as_nanos() > 0 && padded.as_nanos() > 0);
println!("\ntimings vary by machine; the padded run is normally the faster one");
}
$ cargo run --release --bin concurrency_false_sharing
atomic: 8 bytes
unpadded: 8 bytes (several counters per 64 byte line)
padded: 64 bytes (one counter per 64 byte line)
4 threads x 2000000 increments, each on its own counter
unpadded (shared lines): 205.466825ms
padded (own line): 20.667095ms
timings vary by machine; the padded run is normally the faster one
Adding 56 bytes of padding to each counter made the same work ten times faster.
15.4.2 Two counters
The second program makes the sharing certain instead of likely. SameLine holds both counters and is aligned to
64, so both are guaranteed to be in one line.
#[repr(align(64))] makes every value of the type start at an address that is a multiple of 64. That is the size of a cache line. SameLine puts two counters in one line on purpose. Padded gives one counter a whole line:
/// Both counters are guaranteed to live inside one 64-byte cache line.
#[repr(align(64))]
struct SameLine {
a: AtomicU64,
b: AtomicU64,
}
/// Each of these occupies its own 64-byte block.
#[repr(align(64))]
struct Padded(AtomicU64);
Listing 15.8 The complete program. src/bin/false_sharing.rs
use std::{
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
const ITERS: u64 = 20_000_000;
/// Both counters are guaranteed to live inside one 64-byte cache line.
#[repr(align(64))]
struct SameLine {
a: AtomicU64,
b: AtomicU64,
}
/// Each of these occupies its own 64-byte block.
#[repr(align(64))]
struct Padded(AtomicU64);
fn time<F: FnOnce()>(f: F) -> Duration {
let t = Instant::now();
f();
t.elapsed()
}
fn main() {
// ---- 1. False sharing: two counters on the same cache line ----
let same = SameLine {
a: AtomicU64::new(0),
b: AtomicU64::new(0),
};
let d1 = time(|| {
thread::scope(|s| {
s.spawn(|| {
for _ in 0..ITERS {
same.a.fetch_add(1, Ordering::Relaxed);
}
});
s.spawn(|| {
for _ in 0..ITERS {
same.b.fetch_add(1, Ordering::Relaxed);
}
});
});
});
// ---- 2. Padded: same work, but counters are on separate cache lines ----
let p0 = Padded(AtomicU64::new(0));
let p1 = Padded(AtomicU64::new(0));
let d2 = time(|| {
thread::scope(|s| {
s.spawn(|| {
for _ in 0..ITERS {
p0.0.fetch_add(1, Ordering::Relaxed);
}
});
s.spawn(|| {
for _ in 0..ITERS {
p1.0.fetch_add(1, Ordering::Relaxed);
}
});
});
});
println!("same cache line (false sharing): {:?}", d1);
println!("padded (separate lines): {:?}", d2);
}
time is a small generic helper. It takes any closure that can be called once, F: FnOnce(), runs it, and
returns how long it took.
The spawned closures have no move. They borrow same, p0, and p1 directly, which thread::scope allows.
$ cargo run --release --bin false_sharing
same cache line (false sharing): 838.152232ms
padded (separate lines): 170.243616ms
Here the difference is about five times.
NOTE: A cache line is 64 bytes on this Intel CPU. Apple’s M-series CPUs use 128-byte lines. Some Intel CPUs also fetch lines in adjacent pairs, so some libraries pad shared counters to 128 bytes to be safe on both.
15.5 Pages and page faults
Every address your program uses is a virtual address. The operating system gives each process its own private range of addresses. The CPU translates each virtual address into a physical address, a location in the actual RAM.
Translation works on fixed-size blocks called pages. On this machine a page is 4,096 bytes. Apple’s ARM Macs use 16 KiB pages. The operating system keeps a page table for each process, which maps each virtual page to a physical block of RAM, called a frame.
An address splits into two parts (figure 15.5). The high part is the page number, which the page table translates. The low part is the offset inside the page, which stays the same. With 4,096-byte pages, the offset is the low 12 bits, because 2 to the power 12 is 4,096.
Looking up the page table on every access would be slow. The CPU keeps recent translations in a small cache called the TLB (translation lookaside buffer). Scattered reads over a large array need many different pages, miss the TLB, and pay for a table lookup. That cost is part of the larger numbers in figure 15.1.
When the translation cannot complete, the CPU stops the program and hands control to the kernel. This is a page fault. The kernel decides what kind it is:
- Invalid: the address is not mapped at all. The program is stopped, usually with a segmentation fault.
- Protection: the page is mapped, but the access is not allowed, such as a write to a read-only page.
- Major: the page is valid, but its contents are on disk. The kernel reads it in and restarts the access. This takes milliseconds.
- Minor: the kernel can fix the mapping without reading the disk. Examples are the first write to a freshly
allocated page, or a write to a page shared after
fork(copy-on-write). This takes microseconds.
Listing 15.9 The address arithmetic and the fault model (lines 6 to 35). src/bin/os_paging.rs
const PAGE_SIZE: u64 = 4096;
fn split_page(address: u64) -> (u64, u64) {
(address / PAGE_SIZE, address % PAGE_SIZE)
}
fn pages_needed(bytes: u64) -> u64 {
bytes.div_ceil(PAGE_SIZE)
}
#[derive(Debug, PartialEq, Eq)]
enum Fault {
/// The page is resident; a copy-on-write write or permission upgrade.
Minor,
/// The mapping is valid but the page must be read from backing store.
Major,
/// The mapping is valid and resident, but the access permission is wrong.
Protection,
/// No valid mapping at this address.
Invalid,
}
fn classify_fault(mapped: bool, present: bool, writable: bool) -> Fault {
match (mapped, present, writable) {
(false, _, _) => Fault::Invalid,
(true, false, _) => Fault::Major,
(true, true, false) => Fault::Protection,
(true, true, true) => Fault::Minor,
}
}
split_page divides by the page size for the page number, and takes the remainder for the offset.
pages_needed rounds up with div_ceil, because 4,097 bytes need two pages, not one.
classify_fault matches on three facts at once, written as a tuple. They are: is the address mapped, is
the page present in memory, and does the mapping allow the access. The arms are tried in order, so the first arm that fits wins.
(false, _, _) catches every unmapped address, whatever the other two values are.
The model is a simplification. It treats every page that is not in memory as a major fault. A real kernel also counts some of those as minor, because no disk read is needed. Examples are a first touch of new memory, and a file page already in the kernel’s file cache.
Listing 15.10 The complete program. src/bin/os_paging.rs
//! Virtual memory arithmetic: address to page and offset, page counts, and how a
//! page fault is classified.
//!
//! Run with: cargo run --bin os_paging
const PAGE_SIZE: u64 = 4096;
fn split_page(address: u64) -> (u64, u64) {
(address / PAGE_SIZE, address % PAGE_SIZE)
}
fn pages_needed(bytes: u64) -> u64 {
bytes.div_ceil(PAGE_SIZE)
}
#[derive(Debug, PartialEq, Eq)]
enum Fault {
/// The page is resident; a copy-on-write write or permission upgrade.
Minor,
/// The mapping is valid but the page must be read from backing store.
Major,
/// The mapping is valid and resident, but the access permission is wrong.
Protection,
/// No valid mapping at this address.
Invalid,
}
fn classify_fault(mapped: bool, present: bool, writable: bool) -> Fault {
match (mapped, present, writable) {
(false, _, _) => Fault::Invalid,
(true, false, _) => Fault::Major,
(true, true, false) => Fault::Protection,
(true, true, true) => Fault::Minor,
}
}
fn main() {
println!("page size: {PAGE_SIZE} bytes");
println!("\naddress -> page, offset");
for address in [0u64, 0x1234, 0x1fff, 0x2000] {
let (page, offset) = split_page(address);
println!(" 0x{address:04x} -> page {page:>2}, offset 0x{offset:03x}");
}
println!("\npages needed");
for bytes in [0u64, 1, PAGE_SIZE, PAGE_SIZE + 1] {
println!(" {bytes:>5} bytes -> {}", pages_needed(bytes));
}
println!("\nfault classification");
println!(
" unmapped -> {:?}",
classify_fault(false, false, false)
);
println!(
" mapped, not in ram -> {:?}",
classify_fault(true, false, true)
);
println!(
" resident, read-only-> {:?}",
classify_fault(true, true, false)
);
println!(
" resident, writable -> {:?}",
classify_fault(true, true, true)
);
assert_eq!(split_page(0x1234), (1, 0x234));
assert_eq!(pages_needed(0), 0);
assert_eq!(pages_needed(PAGE_SIZE + 1), 2);
assert_eq!(classify_fault(true, false, true), Fault::Major);
assert_eq!(classify_fault(true, true, false), Fault::Protection);
assert_eq!(classify_fault(false, true, true), Fault::Invalid);
println!("\nall checks passed");
}
$ cargo run --bin os_paging
page size: 4096 bytes
address -> page, offset
0x0000 -> page 0, offset 0x000
0x1234 -> page 1, offset 0x234
0x1fff -> page 1, offset 0xfff
0x2000 -> page 2, offset 0x000
pages needed
0 bytes -> 0
1 bytes -> 1
4096 bytes -> 1
4097 bytes -> 2
fault classification
unmapped -> Invalid
mapped, not in ram -> Major
resident, read-only-> Protection
resident, writable -> Minor
all checks passed
0x1fff is the last byte of page 1, and 0x2000 is the first byte of page 2.
15.6 Taking turns on the CPU
A machine runs more threads than it has cores. The operating system’s scheduler decides which thread runs on each core, and switches between them many times per second. A context switch saves one thread’s registers and loads another’s.
Round robin is the simplest fair policy. The runnable tasks wait in a line. The first runs for a fixed time, called the quantum or time slice, and then goes to the back of the line (figure 15.6). No task can hold a core forever, so none starves.
Listing 15.11 The complete program. src/bin/os_scheduler.rs
//! Round-robin scheduling: each runnable task gets one fixed time slice in turn,
//! so no task starves. This is the simplest fair policy a scheduler can use.
//!
//! Run with: cargo run --bin os_scheduler
const QUANTUM_MS: u64 = 5;
/// The task chosen on each of `ticks` time slices.
fn schedule<'a>(tasks: &[&'a str], ticks: usize) -> Vec<&'a str> {
if tasks.is_empty() {
return Vec::new();
}
(0..ticks)
.map(|tick| tasks[tick % tasks.len()])
.collect()
}
fn main() {
let tasks = ["encoder", "decoder", "gc"];
println!("{} runnable tasks, {QUANTUM_MS} ms quantum", tasks.len());
for (tick, task) in schedule(&tasks, 7).iter().enumerate() {
let start = tick as u64 * QUANTUM_MS;
println!(
" slice {tick}: {start:>2}-{:>2} ms {task}",
start + QUANTUM_MS
);
}
let order = schedule(&tasks, 7);
assert_eq!(
order,
[
"encoder", "decoder", "gc", "encoder", "decoder", "gc", "encoder"
]
);
assert!(schedule(&[], 3).is_empty());
// A long task is preempted, not run to completion, which is what bounds its
// worst-case latency to one quantum times the number of tasks.
println!(
"\nworst-case wait for one slice: {} ms",
QUANTUM_MS * tasks.len() as u64
);
println!("all checks passed");
}
schedule returns the task for each slice: slice tick goes to task tick % tasks.len(). The early return
avoids a division by zero when there are no tasks.
The signature fn schedule<'a>(tasks: &[&'a str], ticks: usize) -> Vec<&'a str> has one named lifetime. It
says that the returned names borrow from the same strings as the names in tasks, not from the slice that holds
them. The caller can drop the slice and keep the result.
$ cargo run --bin os_scheduler
3 runnable tasks, 5 ms quantum
slice 0: 0- 5 ms encoder
slice 1: 5-10 ms decoder
slice 2: 10-15 ms gc
slice 3: 15-20 ms encoder
slice 4: 20-25 ms decoder
slice 5: 25-30 ms gc
slice 6: 30-35 ms encoder
worst-case wait for one slice: 15 ms
all checks passed
Between two of its own slices, a task waits 10 ms, while the other two tasks run. From an arbitrary moment, it waits at most one full round of 15 ms, which is the number the program prints. A shorter quantum makes tasks wait less, but switches more often, and each switch costs time.
15.7 The cost of a system call
A program cannot touch hardware or other processes directly. It asks the kernel, with a system call: opening a file, reading a socket, starting a thread. The CPU switches from user mode into kernel mode, the kernel does the work, and the CPU switches back (figure 15.7). The switch itself costs time, even when the work is tiny.
15.7.1 Calling C from Rust
The program calls getpid, which returns the process ID. getpid is a C function from the system’s C library.
The libc crate declares it for Rust.
Calling a function written in another language is called a foreign function interface (FFI) call. Rust
cannot check what C code does, so every call to it must be inside an unsafe block. Each block here carries a
SAFETY comment explaining why the call is sound. getpid takes no pointers and changes no memory.
Listing 15.12 The timed loop (lines 27 to 47). src/bin/syscall_overhead.rs
use std::time::Instant;
/// Calls `getpid()` `iterations` times and returns the average cost in
/// nanoseconds per call.
fn measure_getpid_ns_per_call(iterations: u64) -> f64 {
let start = Instant::now();
for _ in 0..iterations {
// SAFETY: `libc::getpid` is a pure read-only system call with no
// pointer arguments and no memory side effects. The FFI declaration in
// the `libc` crate matches the Linux kernel ABI.
//
// `black_box` prevents the optimizer from assuming the loop has no
// observable effect, which matters because the syscall result is
// otherwise unused.
unsafe {
libc::getpid();
}
}
let elapsed = start.elapsed();
elapsed.as_nanos() as f64 / iterations as f64
}
The comment inside the loop mentions black_box, but the loop does not use it, and does not need it. The compiler
cannot see inside an external C function, so it must assume each call has an effect, and it cannot remove the
loop.
Listing 15.13 The complete program. src/bin/syscall_overhead.rs
// ============================================================================
// Syscall overhead measurement
//
// Ported from the Rust playground repo (`mkdir_syscall.rs`).
//
// What this measures:
// - Every syscall crosses the user/kernel boundary: argument validation,
// kernel work, scheduling checks, and return to user space.
// - This program calls `getpid()` in a tight loop and estimates the average
// per-call cost. `getpid()` is a tiny syscall, so the measured cost is
// close to the irreducible syscall overhead on this kernel/hardware.
//
// Where this appears in practice:
// - High-performance data planes care about per-packet or per-I/O syscall
// cost. The difference between one syscall per event and batching via
// io_uring/recvmmsg can be enormous.
// - It also demonstrates unsafe FFI, safety comments, warm-up loops, and
// benchmark methodology: warm the code path, run enough iterations, and
// report average cost rather than one lucky sample.
//
// Run:
// cargo run --bin syscall_overhead
// ============================================================================
use std::time::Instant;
/// Calls `getpid()` `iterations` times and returns the average cost in
/// nanoseconds per call.
fn measure_getpid_ns_per_call(iterations: u64) -> f64 {
let start = Instant::now();
for _ in 0..iterations {
// SAFETY: `libc::getpid` is a pure read-only system call with no
// pointer arguments and no memory side effects. The FFI declaration in
// the `libc` crate matches the Linux kernel ABI.
//
// `black_box` prevents the optimizer from assuming the loop has no
// observable effect, which matters because the syscall result is
// otherwise unused.
unsafe {
libc::getpid();
}
}
let elapsed = start.elapsed();
elapsed.as_nanos() as f64 / iterations as f64
}
fn main() {
const ITERATIONS: u64 = 10_000_000;
// Warm up the code path before timing. The first calls may fault in the
// libc/PLT, cache lines, or CPU frequency may still be ramping.
for _ in 0..100_000 {
// SAFETY: same reasoning as above; read-only syscall.
unsafe { libc::getpid() };
}
let ns_per_call = measure_getpid_ns_per_call(ITERATIONS);
println!("Iterations: {}", ITERATIONS);
println!(
"Total time: {:.3}s",
ns_per_call * ITERATIONS as f64 / 1e9
);
println!("Per syscall: {:.1} ns", ns_per_call);
// Example framing: if a service processed 10M packets/s and used one
// getpid-style syscall per packet, this fraction of each second would be
// spent crossing into the kernel just for the syscall overhead.
let cpu_seconds_per_second = ns_per_call * 10_000_000.0 / 1_000_000_000.0;
println!(
"At 10M calls/s, syscall overhead would consume: {:.2} CPU-seconds per second",
cpu_seconds_per_second
);
}
// ============================================================================
// Tests (cargo test --bin syscall_overhead)
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measurement_returns_a_positive_finite_number() {
let ns = measure_getpid_ns_per_call(10_000);
assert!(ns.is_finite(), "nanoseconds per call must be finite");
assert!(ns > 0.0, "a syscall cannot cost zero time");
// On any real kernel this is microseconds or tens/hundreds of ns, not
// seconds per call.
assert!(
ns < 1_000_000.0,
"suspiciously slow syscall measurement: {ns} ns"
);
}
#[test]
fn getpid_returns_nonzero_pid() {
// SAFETY: read-only syscall, no pointers, no side effects.
let pid = unsafe { libc::getpid() };
assert!(pid > 0);
}
}
main warms up with 100,000 untimed calls, then times ten million. Warming up lets the first-call costs, such as
loading the function’s code into the cache, happen outside the measurement.
$ cargo run --release --bin syscall_overhead
Iterations: 10000000
Total time: 0.031s
Per syscall: 3.1 ns
At 10M calls/s, syscall overhead would consume: 0.03 CPU-seconds per second
15.7.2 What the 3.1 ns measured
3.1 ns is far too fast for a trip into the kernel. It is about the time of two L1 reads. The program measured something other than its title says.
The C library on macOS stores the process ID the first time it is asked, and answers later getpid calls from
that stored value. None of the ten million calls entered the kernel.
To measure a real round trip, the call must be one the library cannot answer by itself. On the same machine, I
timed getppid, which returns the parent’s process ID, and a raw syscall(SYS_getpid). Both took about 594 ns
per call, nearly 200 times the cached getpid.
On Linux, the answer is different again. The GNU C library stopped caching getpid in version 2.25, so there the
same program does measure a real system call.
The lesson matches section 15.3. Before you trust a number, check that the code did the work you meant to time.
The comment block at the top of the file names the practical point. A program that handles millions of events
per second cannot afford one system call per event. Interfaces such as Linux’s recvmmsg and io_uring exist to
handle many events per system call.
Summary
- Reading memory on this laptop took 2.2 ns from L1 and up to 146 ns from main memory. Caches move 64-byte lines.
- A bump allocator hands out bytes from an offset and frees everything at once.
(offset + align - 1) & !(align - 1)rounds an offset up to a power-of-two alignment. - Reading in order uses whole cache lines and lets the CPU prefetch. Scattered reads were about seven times slower when independent, and about 114 ns each when each depended on the last.
- The
cs_localitybenchmark mostly measured a division instruction. Change one thing at a time before explaining a number. - False sharing slows threads that write different variables in one cache line.
#[repr(align(64))]gave each counter its own line and made the loop up to ten times faster. - A virtual address is a page number and an offset. The page table maps pages to frames, the TLB caches the mappings, and a page fault hands control to the kernel.
- Round robin gives each task one quantum in turn. A shorter quantum lowers waiting but adds switches.
- A system call costs hundreds of nanoseconds here. macOS answers
getpidwithout one, so the benchmark measured a cached value.
Chapter 16 starts the concurrency part of the book: threads, atomics, and locks, and the rules that keep them correct.
Exercises
- Change
strided_sumto use& (data.len() - 1)instead of% data.len(), and compare its time with stride 1 and stride 1,000,003. - Write the pointer chase of section 15.1, and measure it on your machine for sizes from 4 KiB to 512 MiB. Find where the time jumps, and compare with your CPU’s cache sizes.
- Fix the warning in section 15.2. Make
BumpArenaallocate its buffer withstd::alloc::allocand aLayoutaligned to 64. Add a test that requests alignment 64. - Change
concurrency_false_sharing.rsto use#[repr(align(128))], and check whether the time changes on your machine. - Time
getppidin a loop on your system, and compare it withgetpid.
Threads, atomics, and locks
This chapter covers
- Starting threads with
thread::spawnandthread::scope, and collecting their results - The
SendandSynctraits, which let the compiler reject data races - Atomic integers, and why
count += 1from 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
Mutexand aCondvar - What happens to a
Mutexwhen 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).
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).
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
Sendif a value of it can be moved to another thread. - A type is
Syncif a reference to it,&T, can be shared with other threads. Put another way,TisSyncexactly when&TisSend.
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).
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.
Relaxedmakes 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, andAcquire, used when reading, work as a pair. Section 16.4 uses them to build a lock.SeqCstis the strongest. AllSeqCstoperations 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
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).
unlock pairs with the acquire exchange in the next lock.unlockstoresfalsewithRelease. 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.lockexchanges withAcquire. 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.
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:
Dropreleases 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.Dereflets*guardand method calls on the guard reach the value, as&T.DerefMutgives&mut T, so*lock.lock() += 1changes 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.
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:
- The waiting thread locks the mutex and checks the condition.
- If the condition is false, it calls
condvar.wait(guard). That call releases the mutex and puts the thread to sleep, in one step. - Another thread locks the mutex, changes the state, and calls
notify_one(). - 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:
acquirecallsacquire_guardand stores the guard in_guard. The guard is dropped whenacquirereturns, so the permit is released at once. A caller ofacquirenever actually holds a permit.acquire_guardis the method to use.try_acquire_timeoutwaits once. If it wakes early, from a spurious wakeup or because another thread took the permit first, it returnsNonebefore 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).
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.
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:
- Mutual exclusion: a lock can be held by only one thread.
- Hold and wait: a thread holds one lock while waiting for another.
- No preemption: a lock cannot be taken away from its holder.
- 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.
m1 first, the waiting thread holds nothing, and no cycle can form.Summary
thread::spawnneeds closures that own their data.thread::scopelets threads borrow local data, and waits for them.- Collect the join handles before joining, or lazy iterators run the threads one at a time.
SendandSyncare auto traits.RcandRefCelllack them, so the compiler rejects sharing them across threads.- An atomic performs read, change, and write as one step.
Relaxedsuffices for a standalone counter.ReleaseandAcquirepair 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 / sfor a serial fractions. - A spin lock is an atomic flag, a compare-exchange loop, and a guard that releases on drop. Its
unsafe impl Syncis a promise the code must keep. - A semaphore built from a
Mutexand aCondvarwaits in awhileloop, because wakeups can be spurious. - A panic while holding a
MutexGuardpoisons the mutex, andinto_innergives 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
- Write a version of
parallel_sumfrom listing 16.1 as a single lazy chain,.map(spawn).map(join).sum(), and time both on a large slice. Explain the difference. - Make the three pairs of functions in
parallel_sum.rsone generic function overT: Sum<T> + Copy + Send + Sync. Which extra bound doessum::<T>()over&Titems need? - Close the gap in
SpinGuard. Add a field_marker: PhantomData<&'a mut T>, and explain why this makes the guardSynconly whenT: Sync. - Rewrite
try_acquire_timeoutto loop until a permit is free or the whole timeout has passed. UseInstant::now()to compute the time left for each wait. - Write a program that deadlocks with two
Mutexvalues and two threads. Then fix it by taking the locks in a fixed order.
Queues and bounded buffers
This chapter covers
- Passing work between threads through a shared queue
- Putting a waiting thread to sleep with a
Condvar, and waking it again - A bounded buffer, where producers wait when the queue is full
- Three ways to handle a poisoned lock inside a queue
- Closing a queue so that every waiting thread wakes up and every loop ends
- A ring buffer for one producer and one consumer, with no lock at all
Chapter 16 split one job across threads. Many programs have a different shape. Some threads produce work: they read requests, parse files, or receive messages. Other threads consume it: they process each item. The two groups run at different speeds, so they need a place to leave work for each other.
That place is a queue. Producers add items at the back, and consumers take them from the front. Items come out in the order they went in, which is called FIFO, for first in, first out.
This chapter builds the queue six times. Each version fixes a limitation of the one before, and the last one removes the lock.
17.1 Why a queue needs a limit
Suppose producers add items faster than consumers can handle them. If the queue can grow without limit, it grows until the machine runs out of memory, and the program is killed (figure 17.1).
A bounded queue has a fixed capacity. When it is full, push waits until a consumer makes room. The producer
slows down to the consumer’s speed. Slowing down the source of work when the rest of the system cannot keep up is
called backpressure. A slow system is better than a crashed one.
A bounded queue with blocking push and pop is also called a bounded buffer.
17.2 A shared queue
The first version is unbounded. It shows the two tools every version uses: a Mutex around a VecDeque, and a
Condvar for waiting.
VecDeque from chapter 3 adds at the back and removes from the front in O(1). The Mutex makes sure one thread
at a time touches it.
The Condvar is for consumers that find the queue empty. Section 16.5 introduced condition variables. A thread waits on one while holding a mutex, and the wait
releases the mutex and sleeps in one step. When another thread
calls notify_one, one sleeping thread wakes up with the mutex locked again.
Listing 17.1 The queue (lines 7 to 29). src/bin/thread_safe_queue.rs
use std::{
collections::VecDeque,
sync::{Arc, Condvar, Mutex},
thread,
};
#[derive(Default)]
struct SyncQueue {
items: Mutex<VecDeque<i32>>,
not_empty: Condvar,
}
impl SyncQueue {
fn push(&self, item: i32) {
if let Ok(mut q) = self.items.lock() {
q.push_back(item);
self.not_empty.notify_one();
}
}
fn pop(&self) -> Option<i32> {
let q = self.items.lock().ok()?;
let mut q = self
.not_empty
.wait_while(q, |q| q.is_empty())
.ok()?;
q.pop_front()
}
}
push locks the queue, adds the item, and wakes one waiting consumer. if let Ok(mut q) skips the push if the
lock is poisoned, so in that case the item is silently lost. Section 17.4 shows better ways to handle that.
pop uses wait_while. It takes the guard and a condition, and waits for as long as the condition is true:
let mut q = self.not_empty.wait_while(q, |q| q.is_empty()).ok()?;
wait_while contains the while loop that section 16.5 wrote by hand. It checks the condition, waits if it is
true, and checks again after each wakeup. It returns the guard when the queue is not empty. The .ok()? turns a
poisoned lock into None.
Animation 17.1 follows one item from a producer to a consumer that is asleep in wait_while. Then it takes out
notify_one.
wait_while sleeps with the lock released and checks again when woken. Without notify_one, the item waits in the queue and the consumer never wakes.Listing 17.2 main (lines 31 to 56). src/bin/thread_safe_queue.rs
fn main() {
let q = Arc::new(SyncQueue::default());
// producer
let producer = {
let q = Arc::clone(&q);
thread::spawn(move || {
for i in 0..5 {
q.push(i);
println!("Producer pushed {i}");
}
})
};
let consumer = {
let q = Arc::clone(&q);
thread::spawn(move || {
for _ in 0..5 {
println!("Cosnumer popped: {:?}", q.pop());
}
})
};
producer.join().unwrap();
consumer.join().unwrap();
}
main shares the queue through an Arc. Each thread is created inside a block that makes its own clone of the
Arc first:
let producer = {
let q = Arc::clone(&q);
thread::spawn(move || { ... })
};
The block keeps the clone’s name short, and the outer q stays available for the next thread.
$ cargo run --bin thread_safe_queue
Producer pushed 0
Producer pushed 1
Producer pushed 2
Producer pushed 3
Producer pushed 4
Cosnumer popped: Some(0)
Cosnumer popped: Some(1)
Cosnumer popped: Some(2)
Cosnumer popped: Some(3)
Cosnumer popped: Some(4)
In this run the producer finished before the consumer started. On another run the lines can interleave. The values always come out in the order 0 to 4, because the queue is FIFO.
17.3 A bounded buffer
The bounded version adds a capacity, and a second reason to wait: a producer must wait while the queue is full. So it has two condition variables, one for each kind of waiting thread (figure 17.2).
not_emptyis where consumers wait. A producer notifies it after adding an item.not_fullis where producers wait. A consumer notifies it after removing an item.
With a single condition variable and notify_one, a producer could wake another producer instead of a consumer.
The woken producer would find the queue still full and sleep again, while the consumer that could make progress
never woke. Every thread could end up asleep. notify_all on one condition variable avoids that, but wakes every
thread for each change. Two condition variables wake the right kind of thread directly.
Listing 17.3 The type and new (lines 19 to 38). src/bin/bounded_buffer.rs
use std::{
collections::VecDeque,
sync::{Arc, Condvar, Mutex},
thread,
time::Duration,
};
struct BoundedQueue<T> {
inner: Mutex<VecDeque<T>>,
capacity: usize,
not_empty: Condvar,
not_full: Condvar,
}
impl<T> BoundedQueue<T> {
fn new(capacity: usize) -> Self {
// Capacity 0 makes no sense: no producer could ever push,
// and every pop would block forever.
assert!(capacity > 0, "capacity must be > 0");
Self {
inner: Mutex::new(VecDeque::with_capacity(capacity)),
capacity,
not_empty: Condvar::new(),
not_full: Condvar::new(),
}
}
// ...
}
VecDeque::with_capacity(capacity) allocates room for every item at the start, so the deque never grows later.
A capacity of 0 is rejected, because nothing could ever be pushed.
Listing 17.4 push (lines 42 to 76).
impl<T> BoundedQueue<T> {
// ...
fn push(&self, item: T) {
// Acquire the shelf lock. `guard` is our exclusive handle to the deque.
let mut guard = self.inner.lock().unwrap();
// Wait while the shelf is full.
//
// Why `while` and not `if`:
// 1. Condvars are allowed to wake up for no reason (spurious wakeup).
// 2. Even on a real wakeup, another producer may have raced us and
// taken the free slot before we reacquired the lock.
// So we must re-check the condition every time we wake.
//
// Why `guard = ...wait(guard)`:
// `wait` consumes the guard (it releases the lock on our behalf),
// puts us to sleep, then re-locks and hands us a NEW guard.
// The reassignment just means "here's my fresh key to the shelf."
// The VecDeque inside is the same one — we're not replacing the queue.
while guard.len() == self.capacity {
guard = self.not_full.wait(guard).unwrap();
}
// We have room. Add the item to the back of the deque.
guard.push_back(item);
// Release the lock BEFORE ringing the bell.
// This is an optimization, not a correctness requirement.
// If we notify while still holding the lock, the thread we wake will
// immediately block on `lock()` until we release it — a wasted wakeup.
drop(guard);
// Ring the "not empty" bell: one consumer can now make progress.
// `notify_one` (not `notify_all`) because exactly ONE item was added,
// so only ONE consumer can succeed. Waking all of them = stampede.
self.not_empty.notify_one();
}
// ...
}
The comments in push explain each decision. Figure 17.3 puts the steps of a blocked push in order.
The line guard = self.not_full.wait(guard).unwrap(); looks odd at first. wait takes the guard by value,
because it must release the mutex. When the thread wakes, wait locks the mutex again and returns a new guard. The
assignment stores that new guard in the same variable.
After the push, the code drops the guard before calling notify_one. If it notified first, the woken consumer
would try to lock a mutex that is still held, and wait again at once. Unlocking first avoids that extra wait. The
program would be correct either way.
Listing 17.5 pop (lines 78 to 102).
impl<T> BoundedQueue<T> {
// ...
fn pop(&self) -> T {
// Acquire the shelf lock.
let mut guard = self.inner.lock().unwrap();
// Wait while the shelf is empty.
// Same `while` reasoning as push: spurious wakeups + races with other
// consumers who may have grabbed the item before we reacquired the lock.
while guard.is_empty() {
guard = self.not_empty.wait(guard).unwrap();
}
// Safe to unwrap: the `while` loop guarantees the deque is non-empty,
// and we hold the lock so nobody can change that between the check
// and this line.
let item = guard.pop_front().unwrap();
// Release before notifying (same optimization as in push).
drop(guard);
// Ring the "not full" bell: one producer can now make progress.
// Exactly one slot opened, so wake exactly one producer.
self.not_full.notify_one();
item
}
}
pop mirrors push. It waits on not_empty while the queue is empty, removes the front item, unlocks, and wakes
one producer through not_full. The unwrap() on pop_front() cannot fail. The loop ended because the queue was not empty, and the
thread has held the lock since that check.
Listing 17.6 main (lines 105 to 150). src/bin/bounded_buffer.rs
fn main() {
// Small capacity (3) so that with two slow consumers you can actually
// SEE producers block: the queue fills up and push() sleeps in `not_full`.
let queue = Arc::new(BoundedQueue::new(3));
let mut handles = vec![];
// ---- Producers ----
// Two producers, each pushes 5 items. `Arc::clone` bumps the refcount so
// each thread shares the same queue; ownership of the clone moves in.
for p in 0..2 {
let q = Arc::clone(&queue);
handles.push(thread::spawn(move || {
for i in 0..5 {
let item = format!("p{}:item{}", p, i);
println!(" [producer {}] pushing {}", p, item);
q.push(item);
// Slow down so we don't blast through the whole buffer instantly.
thread::sleep(Duration::from_millis(30));
}
}));
}
// ---- Consumers ----
// Two consumers, each pops 5 items. Consumers are SLOWER than producers
// here (60ms vs 30ms), so the queue fills up and you'll see producers
// stall on `not_full.wait(...)`.
for c in 0..2 {
let q = Arc::clone(&queue);
handles.push(thread::spawn(move || {
for _ in 0..5 {
let item = q.pop();
println!("[consumer {}] got {}", c, item);
thread::sleep(Duration::from_millis(60));
}
}));
}
// Join everything. If our synchronization is wrong, this is where you'd
// hang (a thread sleeping forever) or panic (unwrap on empty / on a
// poisoned mutex after another thread panicked while holding it).
for h in handles {
h.join().unwrap();
}
println!("done");
}
main starts two producers and two consumers on a queue with room for 3 items. Producers sleep 30 ms after each
push, and consumers sleep 60 ms after each pop, so the producers are faster.
$ cargo run --bin bounded_buffer
[producer 0] pushing p0:item0
[producer 1] pushing p1:item0
[consumer 1] got p0:item0
[consumer 0] got p1:item0
[producer 0] pushing p0:item1
[producer 1] pushing p1:item1
[producer 0] pushing p0:item2
[producer 1] pushing p1:item2
[consumer 1] got p0:item1
[consumer 0] got p1:item1
[producer 0] pushing p0:item3
[producer 1] pushing p1:item3
[consumer 1] got p0:item2
[consumer 0] got p1:item2
[producer 0] pushing p0:item4
[producer 1] pushing p1:item4
[consumer 1] got p1:item3
[consumer 0] got p0:item3
[consumer 1] got p0:item4
[consumer 0] got p1:item4
done
The order changes from run to run. Items from one producer always come out in that producer’s order. The
“pushing” line is printed before push is called, so the output does not show how long a push waited. Exercise 1
adds that.
17.4 When the lock is poisoned
bounded_buffer.rs calls .unwrap() on every lock() and wait(). If a thread panics while holding the lock,
the mutex is poisoned, as section 16.6 showed. Every other thread’s unwrap() then panics too. Two more versions
return an error instead (figure 17.4).
17.4.1 A custom error type
Listing 17.7 The error and an extension trait (lines 9 to 31). src/bin/bounded_buffer_with_error_handling.rs
use std::{
collections::VecDeque,
fmt,
sync::{Arc, Condvar, Mutex, PoisonError},
thread,
time::Duration,
};
#[derive(Debug)]
pub struct QueuePoisonedError;
impl fmt::Display for QueuePoisonedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"The queue's internal mutex was poisoned by a panicking thread"
)
}
}
impl std::error::Error for QueuePoisonedError {}
trait PoisonMap<T> {
fn map_poison(self) -> Result<T, QueuePoisonedError>;
}
impl<T> PoisonMap<T> for Result<T, PoisonError<T>> {
fn map_poison(self) -> Result<T, QueuePoisonedError> {
self.map_err(|_| QueuePoisonedError)
}
}
QueuePoisonedError is a unit struct: it carries no data. It implements Display for a readable message, and
std::error::Error so it works with ? and Box<dyn Error>.
PoisonMap adds a method to a type the code does not own. A trait defined in your crate can be implemented for
any type, including the standard library’s Result. A trait used this way is called an extension trait.
impl<T> PoisonMap<T> for Result<T, PoisonError<T>> gives every Result<T, PoisonError<T>> a map_poison
method, which replaces the PoisonError with QueuePoisonedError.
Listing 17.8 push and pop (lines 51 to 74).
impl<T> BoundedQueue<T> {
// ...
fn push(&self, item: T) -> Result<(), QueuePoisonedError> {
let mut guard = self.inner.lock().map_poison()?;
while guard.len() == self.capacity {
guard = self.not_full.wait(guard).map_poison()?;
}
guard.push_back(item);
drop(guard);
self.not_empty.notify_one();
Ok(())
}
fn pop(&self) -> Result<T, QueuePoisonedError> {
let mut guard = self.inner.lock().map_poison()?;
while guard.is_empty() {
guard = self
.not_empty
.wait(guard)
.map_poison()?;
}
let item = guard.pop_front().unwrap();
drop(guard);
self.not_full.notify_one();
Ok(item)
}
}
Each lock() and wait() call ends in .map_poison()?. The logic is the same as listing 17.4, and each method
now returns a Result.
The complete program is listing 17.19 at the end of the chapter.
The threads in main handle an error by printing it with dbg! and leaving their loop with break.
The second test poisons the queue on purpose. It spawns a thread that locks the inner mutex and panics while
holding it. A test in the same file can reach the private inner field. After that, both push and pop must
return Err(QueuePoisonedError).
$ cargo test --bin bounded_buffer_with_error_handling
running 2 tests
test tests::test_zero_capacity_panics - should panic ... ok
test tests::test_queue_poisoning_on_thread_panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
17.4.2 A boxed error
The third version returns a general error type:
use std::{
collections::VecDeque,
error::Error,
sync::{Arc, Condvar, Mutex},
thread,
time::Duration,
};
type RuntimeError = Box<dyn Error + Send + Sync>;
Box<dyn Error + Send + Sync> can hold any error type. + Send + Sync is needed because the error travels
between threads: each thread returns a Result, and join hands it to the main thread.
Listing 17.9 push (lines 29 to 46). src/bin/bounded_buffer_error_propagation.rs
impl<T> BoundedQueue<T> {
// ...
fn push(&self, item: T) -> Result<(), RuntimeError> {
let mut guard = self
.inner
.lock()
.map_err(|e| e.to_string())?;
while guard.len() == self.capacity {
guard = self
.not_full
.wait(guard)
.map_err(|e| e.to_string())?;
}
guard.push_back(item);
drop(guard);
self.not_empty.notify_one();
Ok(())
}
// ...
}
Why .map_err(|e| e.to_string()) instead of ? directly? A PoisonError contains the guard, and the guard
borrows the mutex. A Box<dyn Error> must not borrow anything local, so the poison error cannot go into one.
to_string() turns it into a String, which owns its text. Then ? converts the String into the boxed error,
through a From implementation in the standard library.
Listing 17.10 main (lines 72 to 111). src/bin/bounded_buffer_error_propagation.rs
fn main() -> Result<(), RuntimeError> {
let queue = Arc::new(BoundedQueue::new(3));
let producers = (0..2).map(|p| {
let q = Arc::clone(&queue);
thread::spawn(move || -> Result<(), RuntimeError> {
for i in 0..5 {
let item = format!("p{}:item{}", p, i);
println!(" [producer {}] pushing {}", p, item);
q.push(item)?;
thread::sleep(Duration::from_millis(30));
}
Ok(())
})
});
let consumers = (0..2).map(|c| {
let q = Arc::clone(&queue);
thread::spawn(move || -> Result<(), RuntimeError> {
for _ in 0..5 {
let item = q.pop()?;
println!("[consumer {}] got {}", c, item);
thread::sleep(Duration::from_millis(60));
}
Ok(())
})
});
let handles: Vec<_> = producers.chain(consumers).collect();
for h in handles {
match h.join() {
Ok(r) => println!("Thread joined : {:#?}", r),
Err(e) => eprintln!("Error occured : {:#?}", e),
}
}
println!("done");
Ok(())
}
main builds the threads with two iterator chains, producers and consumers. These are lazy: no thread starts
until something consumes them. producers.chain(consumers).collect() starts all four threads while building the
Vec, and only then does the loop join them.
Each thread’s closure is written move || -> Result<(), RuntimeError> { ... }, with its return type, so the ?
inside it knows what error type to convert to. join() returns Ok(r), where r is the thread’s own Result.
$ cargo run --bin bounded_buffer_error_propagation
[producer 1] pushing p1:item0
[producer 0] pushing p0:item0
[consumer 1] got p1:item0
[consumer 0] got p0:item0
[producer 0] pushing p0:item1
[producer 1] pushing p1:item1
[consumer 1] got p0:item1
[consumer 0] got p1:item1
[producer 0] pushing p0:item2
[producer 1] pushing p1:item2
[producer 0] pushing p0:item3
[producer 1] pushing p1:item3
[consumer 0] got p1:item2
[consumer 1] got p0:item2
[producer 0] pushing p0:item4
[producer 1] pushing p1:item4
Thread joined : Ok(
(),
)
[consumer 0] got p1:item3
[consumer 1] got p0:item3
Thread joined : Ok(
(),
)
[consumer 1] got p0:item4
[consumer 0] got p1:item4
Thread joined : Ok(
(),
)
Thread joined : Ok(
(),
)
done
The join messages appear between the consumer lines, because main joins the producers first, while the
consumers are still running.
17.5 Many producers and consumers with wait_while
The next version is the bounded buffer written more compactly, with wait_while for both waits. Its main uses
scoped threads, so no Arc is needed.
The type is the same as in section 17.3. It holds a VecDeque behind a Mutex, a capacity, and one Condvar
for each thing a thread can wait for:
use std::{
collections::VecDeque,
sync::{Condvar, Mutex},
thread,
};
pub struct BoundedBuffer<T> {
queue: Mutex<VecDeque<T>>,
capacity: usize,
not_empty: Condvar,
not_full: Condvar,
}
impl<T> BoundedBuffer<T> {
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be > 0");
Self {
queue: Mutex::new(VecDeque::with_capacity(capacity)),
capacity,
not_empty: Condvar::new(),
not_full: Condvar::new(),
}
}
// ...
}
push waits while the queue is full. wait_while takes the guard and a condition, and loops for you. It sleeps
while the condition is true, and returns the guard once it is false. The while loop of section 17.3 is inside
it, so a spurious wakeup is handled the same way.
impl<T> BoundedBuffer<T> {
// ...
/// Blocks while the buffer is full.
pub fn push(&self, item: T) {
let cap = self.capacity;
let mut queue = self.queue.lock().unwrap();
queue = self
.not_full
.wait_while(queue, |q| q.len() >= cap)
.unwrap();
queue.push_back(item);
drop(queue);
self.not_empty.notify_one();
}
// ...
}
push reads self.capacity into a local cap before the closure |q| q.len() >= cap. The closure then
captures a usize instead of borrowing self. After pushing, drop(queue) releases the lock before
notify_one, so the woken consumer does not wake only to wait for the lock.
pop is the mirror image: it waits while the queue is empty, takes the front item, and wakes one producer.
impl<T> BoundedBuffer<T> {
// ...
/// Blocks while the buffer is empty.
pub fn pop(&self) -> T {
let mut queue = self.queue.lock().unwrap();
queue = self
.not_empty
.wait_while(queue, |q| q.is_empty())
.unwrap();
let item = queue.pop_front().unwrap();
self.not_full.notify_one();
item
}
}
MPMC stands for multiple producers, multiple consumers. In main, four producers push 10 items each, and two
consumers pop 20 each:
fn main() {
let buffer = BoundedBuffer::<(usize, usize)>::new(8);
let buffer = &buffer;
const PRODUCERS: usize = 4;
const CONSUMERS: usize = 2;
const PER_PRODUCER: usize = 10;
const PER_CONSUMER: usize = PRODUCERS * PER_PRODUCER / CONSUMERS;
thread::scope(|s| {
for p in 0..PRODUCERS {
s.spawn(move || {
for i in 0..PER_PRODUCER {
buffer.push((p, i));
}
});
}
for c in 0..CONSUMERS {
s.spawn(move || {
for _ in 0..PER_CONSUMER {
let item = buffer.pop();
println!("consumer {c}: {item:?}");
}
});
}
});
}
PER_CONSUMER is computed from the other constants, so every pushed item is popped and every thread finishes.
The complete program is listing 17.21 at the end of the chapter.
let buffer = &buffer; replaces the buffer with a reference to it. Each move closure then copies the reference
into its thread. The scope guarantees that the buffer outlives all the threads.
$ cargo run --bin mpmc_bounded_buffer
consumer 1: (3, 0)
consumer 1: (2, 0)
consumer 0: (1, 0)
consumer 0: (3, 2)
consumer 0: (3, 3)
...
consumer 0: (3, 8)
consumer 0: (0, 9)
consumer 0: (2, 9)
The full output has 40 lines. Each item is a (producer, index) pair. Across the whole output, each producer’s
indexes appear in increasing order, but the producers are mixed together. Which consumer prints an item depends
on which one the operating system happened to wake.
17.6 Closing a queue
Every version so far has one problem. A consumer loop needs to know when to stop. The demos stop after a fixed
count, but a real consumer does not know how many items will come. It calls pop in a loop, and when the
producers finish, pop waits forever.
The library version adds a way to close the queue (figure 17.5).
close.Listing 17.11 The state (lines 16 to 27). src/problems/bounded_queue.rs
use std::{
collections::VecDeque,
sync::{Condvar, Mutex},
};
struct State<T> {
items: VecDeque<T>,
capacity: usize,
closed: bool,
}
/// A bounded MPMC queue. Share it behind `Arc`; all methods take `&self`.
pub struct BoundedQueue<T> {
state: Mutex<State<T>>,
not_empty: Condvar,
not_full: Condvar,
}
The mutex now protects a small struct: the items, the capacity, and a closed flag. The flag must be inside the
mutex, because threads check it together with the items.
Listing 17.12 push and pop (lines 47 to 94).
impl<T> BoundedQueue<T> {
// ...
/// Push an item, blocking while the queue is full.
///
/// Returns `Err(item)` if the queue is closed, so the caller keeps ownership
/// instead of losing the value.
pub fn push(&self, item: T) -> Result<(), T> {
let mut state = self
.state
.lock()
.expect("queue mutex poisoned");
loop {
if state.closed {
return Err(item);
}
if state.items.len() < state.capacity {
state.items.push_back(item);
self.not_empty.notify_one();
return Ok(());
}
state = self
.not_full
.wait(state)
.expect("queue mutex poisoned");
}
}
/// Pop an item, blocking while the queue is empty.
///
/// Returns `None` once the queue is closed and drained, which is how a
/// consumer loop terminates cleanly.
pub fn pop(&self) -> Option<T> {
let mut state = self
.state
.lock()
.expect("queue mutex poisoned");
loop {
if let Some(item) = state.items.pop_front() {
self.not_full.notify_one();
return Some(item);
}
if state.closed {
return None;
}
state = self
.not_empty
.wait(state)
.expect("queue mutex poisoned");
}
}
// ...
}
Both methods use a loop that checks every condition after each wakeup.
push returns Result<(), T>. If the queue is closed, it returns Err(item), handing the item back. The value
is not lost, and the caller decides what to do with it.
pop returns Option<T>. It checks for an item before it checks closed, so a closed queue still gives up the
items already in it. Only a closed queue that is empty returns None. A consumer can therefore loop with
while let Some(item) = queue.pop(), and the loop ends after the last item.
Listing 17.13 close (lines 109 to 122).
impl<T> BoundedQueue<T> {
// ...
/// Close the queue.
///
/// Pending and future `pop` calls return `None` after the queue drains, and
/// future `push` calls fail. Idempotent.
pub fn close(&self) {
let mut state = self
.state
.lock()
.expect("queue mutex poisoned");
state.closed = true;
drop(state);
self.not_empty.notify_all();
self.not_full.notify_all();
}
// ...
}
close sets the flag and wakes every waiting thread on both condition variables with notify_all. Each woken
thread sees closed in its loop and returns. Calling close twice does no harm. An operation that gives the same
result when repeated is called idempotent.
Animation 17.3 closes a full queue while a producer waits in push. Then it closes a queue without waking
anyone.
close sets the flag and wakes every waiter. push hands its item back, and pop drains the queue before it returns None. A close that wakes nobody hangs the shutdown.The complete file, with its tests, is listing 17.22 at the end of the chapter.
The tests check the four promises:
preserves_order_across_threadspushes 0 to 999 from one thread and pops them on another. They must arrive in order, and the consumer’swhile letloop must end afterclose.never_exceeds_capacityrecords the largest length seen, with a slow consumer. It must never pass 4.close_wakes_a_blocked_consumerstarts a consumer on an empty queue, waits 20 ms so it is asleep, and closes the queue. The consumer must returnNoneinstead of sleeping forever.push_after_close_returns_the_itemchecks thatpushhands the item back.
$ cargo test --lib problems::bounded_queue
running 4 tests
test problems::bounded_queue::tests::push_after_close_returns_the_item ... ok
test problems::bounded_queue::tests::preserves_order_across_threads ... ok
test problems::bounded_queue::tests::never_exceeds_capacity ... ok
test problems::bounded_queue::tests::close_wakes_a_blocked_consumer ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 133 filtered out
17.7 A ring buffer without a lock
Every queue so far takes a lock for each operation. With exactly one producer thread and one consumer thread, the lock can go. This case is called SPSC, for single producer, single consumer.
17.7.1 The idea
A ring buffer is a fixed array used in a circle (figure 17.6). Two counters track it:
headcounts the items taken so far. The consumer reads the next item athead % N.tailcounts the items added so far. The producer writes the next item attail % N.
The number of items in the ring is tail - head. The ring is empty when the two are equal, and full when the
difference is N.
% 8 maps them to slots.No lock is needed because each counter has one writer. Only the producer changes tail, and only the consumer
changes head. Each side only reads the other’s counter.
The ordering of each store makes the handoff safe. The producer writes the item into its slot first, and then
stores the new tail with Release. The consumer loads tail with Acquire before it reads the slot. This is
the release and acquire pairing from section 16.4. If the consumer sees the new tail, it also sees the item
written before it.
17.7.2 The type
Listing 17.14 The ring (lines 25 to 38). src/problems/ring_buffer.rs
use std::{
cell::UnsafeCell,
mem::MaybeUninit,
sync::atomic::{AtomicUsize, Ordering},
};
/// Ring buffer with capacity `N` (the maximum number of queued items).
pub struct SpscRing<T, const N: usize> {
slots: [UnsafeCell<MaybeUninit<T>>; N],
/// Next index to read. Written only by the consumer.
head: AtomicUsize,
/// Next index to write. Written only by the producer.
tail: AtomicUsize,
}
// SAFETY: at most one thread mutates `head` and one mutates `tail`. A slot is
// initialized before `tail` is published with a release store, and the consumer
// performs an acquire load of `tail` before reading that slot, so there is no
// data race and no read of uninitialized memory under the SPSC discipline.
unsafe impl<T: Send, const N: usize> Sync for SpscRing<T, N> {}
Three new pieces of Rust appear here:
const N: usizeis a const generic: a type parameter that is a number instead of a type.SpscRing<u64, 256>is a ring of 256 slots, and the array has that length at compile time.MaybeUninit<T>is memory that may or may not hold a validT. An empty slot holds no value, and Rust does not allow a variable of typeTto hold garbage.MaybeUninitmakes the uninitialized state explicit. Reading it as aTisunsafe, because the code must know that the slot holds a value.UnsafeCelllets the producer write a slot through&self, as in the spin lock of section 16.4.
new builds the array with std::array::from_fn, which calls a closure once per index.
17.7.3 Push and pop
Listing 17.15 push and pop (lines 71 to 107).
impl<T, const N: usize> SpscRing<T, N> {
// ...
/// Enqueue a value, returning `Err(value)` when the ring is full.
pub fn push(&self, value: T) -> Result<(), T> {
let tail = self.tail.load(Ordering::Relaxed);
let head = self.head.load(Ordering::Acquire);
if tail.wrapping_sub(head) >= N {
return Err(value);
}
let index = tail % N;
// SAFETY: the slot at `index` is free because the ring is not full, and
// only the producer writes it.
unsafe { (*self.slots[index].get()).write(value) };
// Publish the slot to the consumer.
self.tail
.store(tail.wrapping_add(1), Ordering::Release);
Ok(())
}
/// Dequeue a value, or `None` when the ring is empty.
pub fn pop(&self) -> Option<T> {
let head = self.head.load(Ordering::Relaxed);
let tail = self.tail.load(Ordering::Acquire);
if head == tail {
return None;
}
let index = head % N;
// SAFETY: `head != tail`, so the producer has published this slot and
// only the consumer reads it.
let value = unsafe { (*self.slots[index].get()).assume_init_read() };
// Publish the freed slot to the producer.
self.head
.store(head.wrapping_add(1), Ordering::Release);
Some(value)
}
}
push loads its own counter, tail, with Relaxed, because only this thread writes it. It loads the other
side’s counter, head, with Acquire. That load pairs with the consumer’s Release store of head, so the
consumer has finished reading a slot before the producer writes it again.
If the ring is full, push returns Err(value), handing the value back. Otherwise it writes the value into the
slot with MaybeUninit::write, and publishes it by storing tail + 1 with Release.
pop is the mirror image. It loads tail with Acquire, reads the value out of the slot with
assume_init_read, and publishes the free slot by storing head + 1 with Release.
The counters use wrapping_add and wrapping_sub. After 2 to the power 64 operations a counter would wrap around
to 0, and wrapping arithmetic keeps tail - head correct across that point.
Animation 17.4 runs the ring with 8 slots. The producer pushes and the consumer pops, the counters grow past 8,
and the ring fills. The last part stores tail before writing the slot.
tail; the consumer reads tail, then the slot. Publishing first lets the consumer read a slot that was never written.Listing 17.16 Drop (lines 116 to 121).
impl<T, const N: usize> Drop for SpscRing<T, N> {
fn drop(&mut self) {
// Drop any items that were never popped.
while self.pop().is_some() {}
}
}
MaybeUninit never drops its contents, because it cannot know whether a value is there. So when the ring is
dropped, its Drop pops every remaining item. Each popped value is a normal T, and it is dropped at the end of
the loop body. The test dropped_ring_drops_queued_items counts those drops.
WARNING: The safety of SpscRing depends on a rule the compiler does not check: one thread calls push, and
one thread calls pop. Both methods take &self, and the type is Sync. So safe code can share the ring with two
producer threads, and both can write the same slot at once. That is a data race. A sound design splits the ring
into a Producer handle and a Consumer handle that cannot be cloned. Exercise 5 asks you to build it.
The complete file, with its tests, is listing 17.23 at the end of the chapter.
The concurrent test sends 50,000 values through a ring of 256 slots. When the ring is full, the producer calls
thread::yield_now() and tries again. When it is empty, the consumer does the same. The producer ends with a
sentinel value, u64::MAX, which tells the consumer to stop. The consumer must receive every value, in order.
$ cargo test --lib problems::ring_buffer
running 4 tests
test problems::ring_buffer::tests::is_fifo_within_capacity ... ok
test problems::ring_buffer::tests::full_ring_returns_the_value ... ok
test problems::ring_buffer::tests::dropped_ring_drops_queued_items ... ok
test problems::ring_buffer::tests::producer_and_consumer_run_concurrently_in_order ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 133 filtered out
17.8 The complete files
Each file below is shown whole, in the order the chapter used it.
Listing 17.17 The shared queue and its main. src/bin/thread_safe_queue.rs
use std::{
collections::VecDeque,
sync::{Arc, Condvar, Mutex},
thread,
};
#[derive(Default)]
struct SyncQueue {
items: Mutex<VecDeque<i32>>,
not_empty: Condvar,
}
impl SyncQueue {
fn push(&self, item: i32) {
if let Ok(mut q) = self.items.lock() {
q.push_back(item);
self.not_empty.notify_one();
}
}
fn pop(&self) -> Option<i32> {
let q = self.items.lock().ok()?;
let mut q = self
.not_empty
.wait_while(q, |q| q.is_empty())
.ok()?;
q.pop_front()
}
}
fn main() {
let q = Arc::new(SyncQueue::default());
// producer
let producer = {
let q = Arc::clone(&q);
thread::spawn(move || {
for i in 0..5 {
q.push(i);
println!("Producer pushed {i}");
}
})
};
let consumer = {
let q = Arc::clone(&q);
thread::spawn(move || {
for _ in 0..5 {
println!("Cosnumer popped: {:?}", q.pop());
}
})
};
producer.join().unwrap();
consumer.join().unwrap();
}
Listing 17.18 The bounded buffer and its main. src/bin/bounded_buffer.rs
use std::{
collections::VecDeque,
sync::{Arc, Condvar, Mutex},
thread,
time::Duration,
};
// A thread-safe bounded buffer (a "shelf" that holds at most `capacity` items).
//
// Three fields do all the work:
// - `inner` : the actual VecDeque, wrapped in a Mutex so only one thread
// touches it at a time.
// - `not_empty` : waiting room for CONSUMERS. A consumer sleeps here when the
// shelf is empty. Producers ring this bell after pushing.
// - `not_full` : waiting room for PRODUCERS. A producer sleeps here when the
// shelf is full. Consumers ring this bell after popping.
//
// Two separate condvars so we always wake the RIGHT kind of thread.
// One shared condvar would cause livelock: consumers waking consumers,
// producers waking producers, everyone re-checking and going back to sleep.
struct BoundedQueue<T> {
inner: Mutex<VecDeque<T>>,
capacity: usize,
not_empty: Condvar,
not_full: Condvar,
}
impl<T> BoundedQueue<T> {
fn new(capacity: usize) -> Self {
// Capacity 0 makes no sense: no producer could ever push,
// and every pop would block forever.
assert!(capacity > 0, "capacity must be > 0");
Self {
inner: Mutex::new(VecDeque::with_capacity(capacity)),
capacity,
not_empty: Condvar::new(),
not_full: Condvar::new(),
}
}
fn push(&self, item: T) {
// Acquire the shelf lock. `guard` is our exclusive handle to the deque.
let mut guard = self.inner.lock().unwrap();
// Wait while the shelf is full.
//
// Why `while` and not `if`:
// 1. Condvars are allowed to wake up for no reason (spurious wakeup).
// 2. Even on a real wakeup, another producer may have raced us and
// taken the free slot before we reacquired the lock.
// So we must re-check the condition every time we wake.
//
// Why `guard = ...wait(guard)`:
// `wait` consumes the guard (it releases the lock on our behalf),
// puts us to sleep, then re-locks and hands us a NEW guard.
// The reassignment just means "here's my fresh key to the shelf."
// The VecDeque inside is the same one — we're not replacing the queue.
while guard.len() == self.capacity {
guard = self.not_full.wait(guard).unwrap();
}
// We have room. Add the item to the back of the deque.
guard.push_back(item);
// Release the lock BEFORE ringing the bell.
// This is an optimization, not a correctness requirement.
// If we notify while still holding the lock, the thread we wake will
// immediately block on `lock()` until we release it — a wasted wakeup.
drop(guard);
// Ring the "not empty" bell: one consumer can now make progress.
// `notify_one` (not `notify_all`) because exactly ONE item was added,
// so only ONE consumer can succeed. Waking all of them = stampede.
self.not_empty.notify_one();
}
fn pop(&self) -> T {
// Acquire the shelf lock.
let mut guard = self.inner.lock().unwrap();
// Wait while the shelf is empty.
// Same `while` reasoning as push: spurious wakeups + races with other
// consumers who may have grabbed the item before we reacquired the lock.
while guard.is_empty() {
guard = self.not_empty.wait(guard).unwrap();
}
// Safe to unwrap: the `while` loop guarantees the deque is non-empty,
// and we hold the lock so nobody can change that between the check
// and this line.
let item = guard.pop_front().unwrap();
// Release before notifying (same optimization as in push).
drop(guard);
// Ring the "not full" bell: one producer can now make progress.
// Exactly one slot opened, so wake exactly one producer.
self.not_full.notify_one();
item
}
}
fn main() {
// Small capacity (3) so that with two slow consumers you can actually
// SEE producers block: the queue fills up and push() sleeps in `not_full`.
let queue = Arc::new(BoundedQueue::new(3));
let mut handles = vec![];
// ---- Producers ----
// Two producers, each pushes 5 items. `Arc::clone` bumps the refcount so
// each thread shares the same queue; ownership of the clone moves in.
for p in 0..2 {
let q = Arc::clone(&queue);
handles.push(thread::spawn(move || {
for i in 0..5 {
let item = format!("p{}:item{}", p, i);
println!(" [producer {}] pushing {}", p, item);
q.push(item);
// Slow down so we don't blast through the whole buffer instantly.
thread::sleep(Duration::from_millis(30));
}
}));
}
// ---- Consumers ----
// Two consumers, each pops 5 items. Consumers are SLOWER than producers
// here (60ms vs 30ms), so the queue fills up and you'll see producers
// stall on `not_full.wait(...)`.
for c in 0..2 {
let q = Arc::clone(&queue);
handles.push(thread::spawn(move || {
for _ in 0..5 {
let item = q.pop();
println!("[consumer {}] got {}", c, item);
thread::sleep(Duration::from_millis(60));
}
}));
}
// Join everything. If our synchronization is wrong, this is where you'd
// hang (a thread sleeping forever) or panic (unwrap on empty / on a
// poisoned mutex after another thread panicked while holding it).
for h in handles {
h.join().unwrap();
}
println!("done");
}
Listing 17.19 The bounded buffer with a custom error type, with tests. src/bin/bounded_buffer_with_error_handling.rs
use std::{
collections::VecDeque,
fmt,
sync::{Arc, Condvar, Mutex, PoisonError},
thread,
time::Duration,
};
#[derive(Debug)]
pub struct QueuePoisonedError;
impl fmt::Display for QueuePoisonedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"The queue's internal mutex was poisoned by a panicking thread"
)
}
}
impl std::error::Error for QueuePoisonedError {}
trait PoisonMap<T> {
fn map_poison(self) -> Result<T, QueuePoisonedError>;
}
impl<T> PoisonMap<T> for Result<T, PoisonError<T>> {
fn map_poison(self) -> Result<T, QueuePoisonedError> {
self.map_err(|_| QueuePoisonedError)
}
}
struct BoundedQueue<T> {
inner: Mutex<VecDeque<T>>,
capacity: usize,
not_empty: Condvar,
not_full: Condvar,
}
impl<T> BoundedQueue<T> {
fn new(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be > 0");
Self {
inner: Mutex::new(VecDeque::with_capacity(capacity)),
capacity,
not_empty: Condvar::new(),
not_full: Condvar::new(),
}
}
fn push(&self, item: T) -> Result<(), QueuePoisonedError> {
let mut guard = self.inner.lock().map_poison()?;
while guard.len() == self.capacity {
guard = self.not_full.wait(guard).map_poison()?;
}
guard.push_back(item);
drop(guard);
self.not_empty.notify_one();
Ok(())
}
fn pop(&self) -> Result<T, QueuePoisonedError> {
let mut guard = self.inner.lock().map_poison()?;
while guard.is_empty() {
guard = self
.not_empty
.wait(guard)
.map_poison()?;
}
let item = guard.pop_front().unwrap();
drop(guard);
self.not_full.notify_one();
Ok(item)
}
}
fn main() {
let queue = Arc::new(BoundedQueue::new(3));
let mut handles = vec![];
// ---- Producers ----
for p in 0..2 {
let q = Arc::clone(&queue);
handles.push(thread::spawn(move || {
for i in 0..5 {
let item = format!("p{}:item{}", p, i);
println!(" [producer {}] pushing {}", p, item);
// Uses match, avoids naming an error variable, uses dbg!
match q.push(item) {
Ok(_) => {}
Err(err) => {
dbg!(err);
break;
}
}
thread::sleep(Duration::from_millis(30));
}
}));
}
// ---- Consumers ----
for c in 0..2 {
let q = Arc::clone(&queue);
handles.push(thread::spawn(move || {
for _ in 0..5 {
// Uses match, avoids naming an error variable, uses dbg!
match q.pop() {
Ok(item) => {
println!("[consumer {}] got {}", c, item);
}
Err(err) => {
dbg!(err);
break;
}
}
thread::sleep(Duration::from_millis(60));
}
}));
}
// Join everything.
for h in handles {
match h.join() {
Ok(_) => {}
Err(err) => {
dbg!(err);
}
}
}
println!("done");
}
#[cfg(test)]
mod tests {
use super::*;
// 1. Mark the test as one that MUST panic to pass
// 2. Optional: use 'expected' to verify it panics with the correct error message
#[test]
#[should_panic(expected = "capacity must be > 0")]
fn test_zero_capacity_panics() {
// This line invokes the assert!(capacity > 0) inside new()
let _queue: BoundedQueue<i32> = BoundedQueue::new(0);
}
#[test]
fn test_queue_poisoning_on_thread_panic() {
// 1. Create a queue shared via Arc so multiple threads can touch it
let queue = Arc::new(BoundedQueue::new(5));
let queue_clone = Arc::clone(&queue);
// 2. Spawn a thread designed to crash while holding the internal lock
let handle = thread::spawn(move || {
// Manually lock the inner mutex so this thread owns the lock guard
let _guard = queue_clone.inner.lock().unwrap();
// Trigger a serious runtime panic while holding the lock guard!
panic!("Intentional worker thread crash!");
});
// 3. Wait for the thread to die. It will return an Err because it panicked.
let join_result = handle.join();
assert!(join_result.is_err(), "The thread was supposed to panic");
// 4. Now try to interact with the queue.
// Because the thread panicked while holding the guard, the Mutex is poisoned.
// Our push and pop methods should cleanly catch this and return our custom error.
match queue.push("test_item") {
Err(QueuePoisonedError) => {
// Success! The queue correctly caught the poisoned state
// instead of panicking the main thread.
}
Ok(_) => {
panic!("Expected QueuePoisonedError, but push succeeded!");
}
}
// Verify pop behaves the same way
match queue.pop() {
Err(QueuePoisonedError) => {}
Ok(_) => panic!("Expected QueuePoisonedError, but pop succeeded!"),
}
}
}
Listing 17.20 The bounded buffer with a boxed error. src/bin/bounded_buffer_error_propagation.rs
use std::{
collections::VecDeque,
error::Error,
sync::{Arc, Condvar, Mutex},
thread,
time::Duration,
};
type RuntimeError = Box<dyn Error + Send + Sync>;
struct BoundedQueue<T> {
inner: Mutex<VecDeque<T>>,
capacity: usize,
not_empty: Condvar,
not_full: Condvar,
}
impl<T> BoundedQueue<T> {
fn new(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be > 0");
Self {
inner: Mutex::new(VecDeque::with_capacity(capacity)),
capacity,
not_empty: Condvar::new(),
not_full: Condvar::new(),
}
}
fn push(&self, item: T) -> Result<(), RuntimeError> {
let mut guard = self
.inner
.lock()
.map_err(|e| e.to_string())?;
while guard.len() == self.capacity {
guard = self
.not_full
.wait(guard)
.map_err(|e| e.to_string())?;
}
guard.push_back(item);
drop(guard);
self.not_empty.notify_one();
Ok(())
}
fn pop(&self) -> Result<T, RuntimeError> {
let mut guard = self
.inner
.lock()
.map_err(|e| e.to_string())?;
while guard.is_empty() {
guard = self
.not_empty
.wait(guard)
.map_err(|e| e.to_string())?;
}
let item = match guard.pop_front() {
Some(item) => item,
None => return Err("deque is non-empty by loop invariant".into()),
};
drop(guard);
self.not_full.notify_one();
Ok(item)
}
}
fn main() -> Result<(), RuntimeError> {
let queue = Arc::new(BoundedQueue::new(3));
let producers = (0..2).map(|p| {
let q = Arc::clone(&queue);
thread::spawn(move || -> Result<(), RuntimeError> {
for i in 0..5 {
let item = format!("p{}:item{}", p, i);
println!(" [producer {}] pushing {}", p, item);
q.push(item)?;
thread::sleep(Duration::from_millis(30));
}
Ok(())
})
});
let consumers = (0..2).map(|c| {
let q = Arc::clone(&queue);
thread::spawn(move || -> Result<(), RuntimeError> {
for _ in 0..5 {
let item = q.pop()?;
println!("[consumer {}] got {}", c, item);
thread::sleep(Duration::from_millis(60));
}
Ok(())
})
});
let handles: Vec<_> = producers.chain(consumers).collect();
for h in handles {
match h.join() {
Ok(r) => println!("Thread joined : {:#?}", r),
Err(e) => eprintln!("Error occured : {:#?}", e),
}
}
println!("done");
Ok(())
}
Listing 17.21 Many producers and consumers with wait_while. src/bin/mpmc_bounded_buffer.rs
use std::{
collections::VecDeque,
sync::{Condvar, Mutex},
thread,
};
pub struct BoundedBuffer<T> {
queue: Mutex<VecDeque<T>>,
capacity: usize,
not_empty: Condvar,
not_full: Condvar,
}
impl<T> BoundedBuffer<T> {
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be > 0");
Self {
queue: Mutex::new(VecDeque::with_capacity(capacity)),
capacity,
not_empty: Condvar::new(),
not_full: Condvar::new(),
}
}
/// Blocks while the buffer is full.
pub fn push(&self, item: T) {
let cap = self.capacity;
let mut queue = self.queue.lock().unwrap();
queue = self
.not_full
.wait_while(queue, |q| q.len() >= cap)
.unwrap();
queue.push_back(item);
drop(queue);
self.not_empty.notify_one();
}
/// Blocks while the buffer is empty.
pub fn pop(&self) -> T {
let mut queue = self.queue.lock().unwrap();
queue = self
.not_empty
.wait_while(queue, |q| q.is_empty())
.unwrap();
let item = queue.pop_front().unwrap();
self.not_full.notify_one();
item
}
}
fn main() {
let buffer = BoundedBuffer::<(usize, usize)>::new(8);
let buffer = &buffer;
const PRODUCERS: usize = 4;
const CONSUMERS: usize = 2;
const PER_PRODUCER: usize = 10;
const PER_CONSUMER: usize = PRODUCERS * PER_PRODUCER / CONSUMERS;
thread::scope(|s| {
for p in 0..PRODUCERS {
s.spawn(move || {
for i in 0..PER_PRODUCER {
buffer.push((p, i));
}
});
}
for c in 0..CONSUMERS {
s.spawn(move || {
for _ in 0..PER_CONSUMER {
let item = buffer.pop();
println!("consumer {c}: {item:?}");
}
});
}
});
}
Listing 17.22 The closable bounded queue, with tests. src/problems/bounded_queue.rs
//! Bounded blocking queue with backpressure.
//!
//! Unbounded queues are a classic production failure: a fast producer and a slow
//! consumer turn into an OOM kill instead of a slow response. A bounded queue makes
//! the producer wait, which is backpressure.
//!
//! This is an MPMC queue (many producers, many consumers) built from one `Mutex`
//! and two condition variables: `not_empty` for waiting consumers and `not_full`
//! for waiting producers. `close` unblocks everyone so shutdown cannot hang.
use std::{
collections::VecDeque,
sync::{Condvar, Mutex},
};
struct State<T> {
items: VecDeque<T>,
capacity: usize,
closed: bool,
}
/// A bounded MPMC queue. Share it behind `Arc`; all methods take `&self`.
pub struct BoundedQueue<T> {
state: Mutex<State<T>>,
not_empty: Condvar,
not_full: Condvar,
}
impl<T> BoundedQueue<T> {
/// Create a queue that holds at most `capacity` items.
///
/// Panics if `capacity` is zero, because a zero-capacity queue can never make
/// progress.
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0, "capacity must be > 0");
Self {
state: Mutex::new(State {
items: VecDeque::with_capacity(capacity),
capacity,
closed: false,
}),
not_empty: Condvar::new(),
not_full: Condvar::new(),
}
}
/// Push an item, blocking while the queue is full.
///
/// Returns `Err(item)` if the queue is closed, so the caller keeps ownership
/// instead of losing the value.
pub fn push(&self, item: T) -> Result<(), T> {
let mut state = self
.state
.lock()
.expect("queue mutex poisoned");
loop {
if state.closed {
return Err(item);
}
if state.items.len() < state.capacity {
state.items.push_back(item);
self.not_empty.notify_one();
return Ok(());
}
state = self
.not_full
.wait(state)
.expect("queue mutex poisoned");
}
}
/// Pop an item, blocking while the queue is empty.
///
/// Returns `None` once the queue is closed and drained, which is how a
/// consumer loop terminates cleanly.
pub fn pop(&self) -> Option<T> {
let mut state = self
.state
.lock()
.expect("queue mutex poisoned");
loop {
if let Some(item) = state.items.pop_front() {
self.not_full.notify_one();
return Some(item);
}
if state.closed {
return None;
}
state = self
.not_empty
.wait(state)
.expect("queue mutex poisoned");
}
}
/// Pop without blocking.
pub fn try_pop(&self) -> Option<T> {
let mut state = self
.state
.lock()
.expect("queue mutex poisoned");
let item = state.items.pop_front();
if item.is_some() {
self.not_full.notify_one();
}
item
}
/// Close the queue.
///
/// Pending and future `pop` calls return `None` after the queue drains, and
/// future `push` calls fail. Idempotent.
pub fn close(&self) {
let mut state = self
.state
.lock()
.expect("queue mutex poisoned");
state.closed = true;
drop(state);
self.not_empty.notify_all();
self.not_full.notify_all();
}
pub fn is_closed(&self) -> bool {
self.state
.lock()
.expect("queue mutex poisoned")
.closed
}
pub fn capacity(&self) -> usize {
self.state
.lock()
.expect("queue mutex poisoned")
.capacity
}
pub fn len(&self) -> usize {
self.state
.lock()
.expect("queue mutex poisoned")
.items
.len()
}
pub fn is_empty(&self) -> bool {
self.state
.lock()
.expect("queue mutex poisoned")
.items
.is_empty()
}
}
#[cfg(test)]
mod tests {
use std::{
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
thread,
time::Duration,
};
use super::*;
#[test]
fn preserves_order_across_threads() {
let queue = Arc::new(BoundedQueue::new(8));
let producer = {
let queue = Arc::clone(&queue);
thread::spawn(move || {
for value in 0..1_000 {
queue
.push(value)
.expect("queue stays open");
}
queue.close();
})
};
let mut received = Vec::new();
while let Some(value) = queue.pop() {
received.push(value);
}
producer
.join()
.expect("producer panicked");
assert_eq!(received, (0..1_000).collect::<Vec<_>>());
}
#[test]
fn never_exceeds_capacity() {
let queue = Arc::new(BoundedQueue::new(4));
let observed_peak = Arc::new(AtomicUsize::new(0));
let producer = {
let queue = Arc::clone(&queue);
let observed_peak = Arc::clone(&observed_peak);
thread::spawn(move || {
for value in 0..200u32 {
queue
.push(value)
.expect("queue stays open");
observed_peak.fetch_max(queue.len(), Ordering::SeqCst);
}
queue.close();
})
};
while queue.pop().is_some() {
thread::sleep(Duration::from_micros(50));
}
producer
.join()
.expect("producer panicked");
assert!(observed_peak.load(Ordering::SeqCst) <= 4);
}
#[test]
fn close_wakes_a_blocked_consumer() {
let queue = Arc::new(BoundedQueue::<u32>::new(1));
let consumer = {
let queue = Arc::clone(&queue);
thread::spawn(move || queue.pop())
};
thread::sleep(Duration::from_millis(20));
queue.close();
assert_eq!(
consumer
.join()
.expect("consumer panicked"),
None
);
}
#[test]
fn push_after_close_returns_the_item() {
let queue = BoundedQueue::new(1);
queue.close();
assert_eq!(
queue.push("workload".to_string()),
Err("workload".to_string())
);
}
}
Listing 17.23 The SPSC ring buffer, with tests. src/problems/ring_buffer.rs
//! Lock-free single-producer / single-consumer (SPSC) ring buffer.
//!
//! The classic producer/consumer exercise, but with no mutex: the producer owns
//! the `tail` index and the consumer owns the `head` index, and each publishes its
//! write with a release store so the other side sees the slot only after it is
//! initialized. This is the shape inside channel implementations, log shippers,
//! and kernel ring buffers.
//!
//! Two rules keep this sound:
//!
//! - exactly one thread calls [`SpscRing::push`] and exactly one calls
//! [`SpscRing::pop`];
//! - a slot is written before `tail` is published, and read only after `head`
//! reaches it.
//!
//! If you need many producers or consumers, use a mutex-backed queue such as
//! [`crate::problems::bounded_queue::BoundedQueue`].
use std::{
cell::UnsafeCell,
mem::MaybeUninit,
sync::atomic::{AtomicUsize, Ordering},
};
/// Ring buffer with capacity `N` (the maximum number of queued items).
pub struct SpscRing<T, const N: usize> {
slots: [UnsafeCell<MaybeUninit<T>>; N],
/// Next index to read. Written only by the consumer.
head: AtomicUsize,
/// Next index to write. Written only by the producer.
tail: AtomicUsize,
}
// SAFETY: at most one thread mutates `head` and one mutates `tail`. A slot is
// initialized before `tail` is published with a release store, and the consumer
// performs an acquire load of `tail` before reading that slot, so there is no
// data race and no read of uninitialized memory under the SPSC discipline.
unsafe impl<T: Send, const N: usize> Sync for SpscRing<T, N> {}
impl<T, const N: usize> SpscRing<T, N> {
/// Create an empty ring. Panics if `N` is zero.
pub fn new() -> Self {
assert!(N > 0, "ring capacity must be > 0");
Self {
slots: std::array::from_fn(|_| UnsafeCell::new(MaybeUninit::uninit())),
head: AtomicUsize::new(0),
tail: AtomicUsize::new(0),
}
}
/// Maximum number of items the ring can hold.
pub const fn capacity(&self) -> usize {
N
}
/// Number of queued items. Only meaningful to the owning side in a race.
pub fn len(&self) -> usize {
self.tail
.load(Ordering::Acquire)
.wrapping_sub(self.head.load(Ordering::Acquire))
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn is_full(&self) -> bool {
self.len() >= N
}
/// Enqueue a value, returning `Err(value)` when the ring is full.
pub fn push(&self, value: T) -> Result<(), T> {
let tail = self.tail.load(Ordering::Relaxed);
let head = self.head.load(Ordering::Acquire);
if tail.wrapping_sub(head) >= N {
return Err(value);
}
let index = tail % N;
// SAFETY: the slot at `index` is free because the ring is not full, and
// only the producer writes it.
unsafe { (*self.slots[index].get()).write(value) };
// Publish the slot to the consumer.
self.tail
.store(tail.wrapping_add(1), Ordering::Release);
Ok(())
}
/// Dequeue a value, or `None` when the ring is empty.
pub fn pop(&self) -> Option<T> {
let head = self.head.load(Ordering::Relaxed);
let tail = self.tail.load(Ordering::Acquire);
if head == tail {
return None;
}
let index = head % N;
// SAFETY: `head != tail`, so the producer has published this slot and
// only the consumer reads it.
let value = unsafe { (*self.slots[index].get()).assume_init_read() };
// Publish the freed slot to the producer.
self.head
.store(head.wrapping_add(1), Ordering::Release);
Some(value)
}
}
impl<T, const N: usize> Default for SpscRing<T, N> {
fn default() -> Self {
Self::new()
}
}
impl<T, const N: usize> Drop for SpscRing<T, N> {
fn drop(&mut self) {
// Drop any items that were never popped.
while self.pop().is_some() {}
}
}
#[cfg(test)]
mod tests {
use std::{
sync::{Arc, atomic::AtomicUsize},
thread,
};
use super::*;
#[test]
fn is_fifo_within_capacity() {
let ring = SpscRing::<u32, 4>::new();
assert!(ring.push(1).is_ok());
assert!(ring.push(2).is_ok());
assert!(ring.push(3).is_ok());
assert_eq!(ring.pop(), Some(1));
assert_eq!(ring.pop(), Some(2));
assert_eq!(ring.pop(), Some(3));
assert_eq!(ring.pop(), None);
}
#[test]
fn full_ring_returns_the_value() {
let ring = SpscRing::<u32, 2>::new();
assert!(ring.push(1).is_ok());
assert!(ring.push(2).is_ok());
assert!(ring.is_full());
assert_eq!(ring.push(3), Err(3));
}
#[test]
fn producer_and_consumer_run_concurrently_in_order() {
const ITEMS: u64 = 50_000;
const SENTINEL: u64 = u64::MAX;
let ring = Arc::new(SpscRing::<u64, 256>::new());
let producer = {
let ring = Arc::clone(&ring);
thread::spawn(move || {
for value in 0..ITEMS {
let mut pending = value;
loop {
match ring.push(pending) {
Ok(()) => break,
Err(returned) => {
pending = returned;
thread::yield_now();
}
}
}
}
let mut pending = SENTINEL;
loop {
match ring.push(pending) {
Ok(()) => break,
Err(returned) => {
pending = returned;
thread::yield_now();
}
}
}
})
};
let consumer = {
let ring = Arc::clone(&ring);
thread::spawn(move || {
let mut received = Vec::with_capacity(ITEMS as usize);
loop {
match ring.pop() {
Some(SENTINEL) => break,
Some(value) => received.push(value),
None => thread::yield_now(),
}
}
received
})
};
producer
.join()
.expect("producer panicked");
let received = consumer
.join()
.expect("consumer panicked");
assert_eq!(received, (0..ITEMS).collect::<Vec<_>>());
}
#[test]
fn dropped_ring_drops_queued_items() {
struct Tracker(Arc<AtomicUsize>);
impl Drop for Tracker {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
let dropped = Arc::new(AtomicUsize::new(0));
{
let ring = SpscRing::<Tracker, 4>::new();
assert!(
ring.push(Tracker(Arc::clone(&dropped)))
.is_ok()
);
assert!(
ring.push(Tracker(Arc::clone(&dropped)))
.is_ok()
);
assert!(
ring.push(Tracker(Arc::clone(&dropped)))
.is_ok()
);
}
assert_eq!(dropped.load(Ordering::SeqCst), 3);
}
}
Summary
- A queue connects threads that produce work with threads that consume it. A bounded queue makes producers wait when it is full, which is backpressure.
- A
Condvarputs a thread to sleep until another thread notifies it. The wait releases the mutex, and the thread holds it again when it wakes. - Always check the condition again after waking, with a
whileloop orwait_while. Wakeups can be spurious, and another thread may have acted first. - Two condition variables,
not_emptyandnot_full, wake only the kind of thread that can make progress. - A poisoned lock can panic the caller, become a custom error through an extension trait, or become a
Stringinside a boxed error. - A
closedflag,notify_all, andpopreturningNoneafter the queue drains let every consumer loop end. - An SPSC ring buffer needs no lock: each counter has one writer, and release and acquire publish each slot. Its one-producer rule must be enforced by the API, or it is unsound.
Chapter 18 puts a queue at the center of a thread pool: a fixed set of threads that take jobs from a shared queue.
Exercises
- In
bounded_buffer.rs, measure how long eachpushwaits and print it. Change the sleep times until the producers visibly block. - Add
try_push(&self, item: T) -> Result<(), T>to the libraryBoundedQueue. It should return at once when the queue is full. - Add
pop_timeout(&self, timeout: Duration) -> Option<T>usingCondvar::wait_timeout_while. - Replace the two condition variables in
bounded_buffer.rswith one, usingnotify_all. Check that it still works, and count the wakeups with an atomic counter. - Split
SpscRingintoProducer<T, N>andConsumer<T, N>handles that share the ring through anArc. Givepushandpopa&mut selfreceiver, so that each handle can be used by only one thread at a time.
Thread pools
This chapter covers
- A pool of worker threads that take jobs from a shared channel
- A measured bug: a lock held for too long turned four parallel jobs into a sequence
- Returning errors instead of panicking, and shutting down in
Drop - Keeping workers alive when a job panics, with
catch_unwind - A bounded job queue, results sent back through a channel, and many producers
- A parallel map that returns results in input order
Starting a thread costs time and memory. The operating system must create it, give it a stack, and schedule it. For a program that runs thousands of small jobs, starting a thread per job wastes most of its time on starting threads.
A thread pool starts a fixed number of threads once, and reuses them. The threads are called workers. Each worker waits for a job, runs it, and waits for the next. Jobs wait in a queue until a worker is free. This is the queue of chapter 17, with the consumers built in.
This chapter builds the pool several times. Each version fixes a problem found in the one before, so read them in order.
18.1 The parts of a pool
Every pool here has the same parts (figure 18.1).
execute, through a channel, to whichever worker takes it first.The job type. A job is any closure that the pool can run once on another thread:
type Job = Box<dyn FnOnce() + Send + 'static>;
Each closure has its own unnamed type, so a queue cannot hold them directly. Box<dyn FnOnce()> puts each closure
on the heap behind a pointer of one common type. dyn FnOnce() means “some type that can be called once with no
arguments”. Send lets the job move to a worker thread. 'static means the job borrows nothing that could be
dropped before the job runs.
The channel. std::sync::mpsc::channel() returns a Sender and a Receiver. Values sent through the sender
come out of the receiver in the same order. mpsc stands for multiple producer, single consumer: the Sender can
be cloned, but there is only one Receiver.
The shared receiver. Every worker needs to take jobs from the one receiver. So the pool wraps it in a Mutex,
and shares the mutex through an Arc. A worker locks the mutex, calls recv() to take the next job, and runs it.
Shutdown. When every Sender is dropped, the channel closes. recv() then returns Err once the remaining
jobs are taken. A worker that sees the Err leaves its loop, and the thread ends.
18.2 Version 1, and a bug you can measure
Version 1 writes the pool of figure 18.1 directly. new starts the workers, execute sends a job, and join
closes the channel and waits for every worker. Each worker locks the shared receiver to take a job. How long it
keeps that lock decides whether the workers run at the same time.
A Job is a boxed closure that runs once and can move to another thread. The pool holds the sending end of the
job channel and the handles of its workers:
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
sender: mpsc::Sender<Job>,
workers: Vec<thread::JoinHandle<()>>,
}
new builds the parts of figure 18.1 and starts the workers:
impl ThreadPool {
pub fn new(size: usize) -> Self {
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..size)
.map(|_| {
let rx = Arc::clone(&receiver);
thread::spawn(move || {
while let Ok(job) = rx.lock().unwrap().recv() {
job();
}
})
})
.collect();
Self { sender, workers }
}
// ...
}
mpsc::channel::<Job>() creates the channel. The receiver goes into Arc::new(Mutex::new(...)), so every worker
can share it and only one at a time can use it. For each worker, Arc::clone(&receiver) makes another handle to
the same mutex, and thread::spawn(move || ...) starts a thread that owns that handle. The move closure is the
worker’s whole life: take a job, run it, and repeat until recv fails. collect() gathers the JoinHandles
that spawn returned into the workers vector.
Submitting a job and shutting down are two short methods:
impl ThreadPool {
// ...
pub fn execute(&self, job: Job) {
self.sender.send(job).unwrap();
}
pub fn join(self) {
drop(self.sender);
for w in self.workers {
let _ = w.join();
}
}
}
execute sends the boxed job into the channel. It does not wait for a worker: the job sits in the channel until
a worker takes it. join takes self by value, so the pool cannot be used after it. drop(self.sender) closes
the channel. Each worker finishes the jobs still queued, gets Err from recv, and ends. w.join() waits for
each worker thread to finish.
main queues 10,000 jobs that each print a line, then joins:
fn main() {
let pool = ThreadPool::new(4);
for i in 0..10_000 {
pool.execute(Box::new(move || println!("task {i}")));
}
pool.join();
}
Listing 18.1 The complete program. src/bin/thread_pool.rs
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
sender: mpsc::Sender<Job>,
workers: Vec<thread::JoinHandle<()>>,
}
impl ThreadPool {
pub fn new(size: usize) -> Self {
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..size)
.map(|_| {
let rx = Arc::clone(&receiver);
thread::spawn(move || {
while let Ok(job) = rx.lock().unwrap().recv() {
job();
}
})
})
.collect();
Self { sender, workers }
}
pub fn execute(&self, job: Job) {
self.sender.send(job).unwrap();
}
pub fn join(self) {
drop(self.sender);
for w in self.workers {
let _ = w.join();
}
}
}
fn main() {
let pool = ThreadPool::new(4);
for i in 0..10_000 {
pool.execute(Box::new(move || println!("task {i}")));
}
pool.join();
}
The program prints 10,000 lines and looks correct. It has one bug, in the worker loop:
while let Ok(job) = rx.lock().unwrap().recv() {
job();
}
rx.lock().unwrap() creates a MutexGuard. The guard is a temporary value inside the while let condition. A
temporary in that position lives until the end of the loop body, so the guard is still alive while job() runs.
The worker holds the lock for the whole job, and no other worker can take a job in the meantime.
To see the effect, I ran four jobs that each sleep for 200 ms on four workers. I ran them once with this loop and once with the loop of version 2. Figure 18.2 shows the result.
With while let, the four jobs ran one after another and took 814 ms. With the lock released before the job, they
ran at the same time and took 205 ms. The first pool had four threads but used one at a time.
Animation 18.1 replays both runs. Watch the lock. In version 2 it is free again before each job starts. In version 1 it stays with the worker that is running.
The same bug has a second effect. If a job panics, the panic happens while the guard is alive, so it poisons the
mutex. When I piped this program’s output into head, which closes the pipe after five lines, println! panicked
inside a job. The other three workers then panicked on .unwrap() of the poisoned lock, and main panicked when
send found no worker left.
18.3 Version 2: release the lock before the job
Listing 18.2 The pool type, the worker loop, and execute (lines 10 to 64). src/problems/thread_pool_v2.rs
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
sender: mpsc::Sender<Job>,
workers: Vec<thread::JoinHandle<()>>,
}
impl ThreadPool {
/// Start `size` workers. Panics if `size` is zero.
pub fn new(size: usize) -> Self {
assert!(size > 0, "a thread pool needs at least one worker");
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..size)
.map(|_| {
let receiver = Arc::clone(&receiver);
thread::spawn(move || {
loop {
// The guard is a temporary of this `let` statement, so the
// lock is released here, before the job runs.
let job = receiver.lock().unwrap().recv();
match job {
Ok(job) => job(),
Err(_) => break, // every sender is gone: shut down
}
}
})
})
.collect();
Self { sender, workers }
}
/// Queue a closure to run on the next free worker.
pub fn execute<F>(&self, job: F)
where
F: FnOnce() + Send + 'static,
{
self.sender.send(Box::new(job)).unwrap();
}
/// Stop accepting jobs, let the workers drain the queue, and wait for them.
pub fn join(self) {
drop(self.sender);
for worker in self.workers {
let _ = worker.join();
}
}
}
Job is a type alias for the work a pool runs, a boxed closure. The closure runs once (FnOnce), can move
to another thread (Send), and borrows nothing short-lived ('static). ThreadPool holds the sending end of the
channel that carries jobs, and the handles of its worker threads.
The fix is one line. let job = receiver.lock().unwrap().recv(); is a separate statement, and the temporary guard
is dropped at the end of that statement. The lock is released before match runs the job.
Version 2 also changes execute. It is generic over F: FnOnce() + Send + 'static, and does the boxing itself.
Callers write pool.execute(|| ...) instead of pool.execute(Box::new(|| ...)).
Listing 18.3 The complete file, with tests. src/problems/thread_pool_v2.rs
//! Thread pool, version 2: workers really run in parallel.
//!
//! Version 1 (`src/bin/thread_pool.rs`) holds the receiver's lock while a job runs,
//! because the `MutexGuard` in `while let Ok(job) = rx.lock().unwrap().recv()` lives
//! until the end of the loop body. This version takes the job in its own `let`
//! statement, so the guard is dropped before the job starts.
//!
//! It also accepts any closure in `execute`, so callers no longer write `Box::new`.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
sender: mpsc::Sender<Job>,
workers: Vec<thread::JoinHandle<()>>,
}
impl ThreadPool {
/// Start `size` workers. Panics if `size` is zero.
pub fn new(size: usize) -> Self {
assert!(size > 0, "a thread pool needs at least one worker");
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..size)
.map(|_| {
let receiver = Arc::clone(&receiver);
thread::spawn(move || {
loop {
// The guard is a temporary of this `let` statement, so the
// lock is released here, before the job runs.
let job = receiver.lock().unwrap().recv();
match job {
Ok(job) => job(),
Err(_) => break, // every sender is gone: shut down
}
}
})
})
.collect();
Self { sender, workers }
}
/// Queue a closure to run on the next free worker.
pub fn execute<F>(&self, job: F)
where
F: FnOnce() + Send + 'static,
{
self.sender.send(Box::new(job)).unwrap();
}
/// Stop accepting jobs, let the workers drain the queue, and wait for them.
pub fn join(self) {
drop(self.sender);
for worker in self.workers {
let _ = worker.join();
}
}
}
#[cfg(test)]
mod tests {
use std::{
sync::{
Barrier,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use super::*;
#[test]
fn runs_every_job() {
let pool = ThreadPool::new(4);
let counter = Arc::new(AtomicUsize::new(0));
for _ in 0..100 {
let counter = Arc::clone(&counter);
pool.execute(move || {
counter.fetch_add(1, Ordering::Relaxed);
});
}
pool.join();
assert_eq!(counter.load(Ordering::Relaxed), 100);
}
#[test]
fn jobs_run_at_the_same_time() {
// Four jobs wait on a barrier for four parties. They can only all get
// through if four workers run them at once; version 1 would never finish.
let pool = ThreadPool::new(4);
let barrier = Arc::new(Barrier::new(4));
let (done_tx, done_rx) = mpsc::channel();
for _ in 0..4 {
let barrier = Arc::clone(&barrier);
let done_tx = done_tx.clone();
pool.execute(move || {
barrier.wait();
done_tx.send(()).unwrap();
});
}
for _ in 0..4 {
done_rx
.recv_timeout(Duration::from_secs(5))
.expect("jobs did not run in parallel");
}
pool.join();
}
#[test]
#[should_panic(expected = "at least one worker")]
fn zero_workers_is_rejected() {
let _ = ThreadPool::new(0);
}
}
The test jobs_run_at_the_same_time checks the fix without measuring time. It uses a Barrier for four threads.
A barrier makes each thread that calls wait() sleep until four threads have called it, then releases them all.
Four jobs wait on the barrier. They can only get through if four workers run them at the same moment. With
version 1’s loop, the first job would hold the lock and wait on the barrier forever.
The test waits for the results with recv_timeout(Duration::from_secs(5)), so a broken pool fails the test after
five seconds instead of hanging it.
18.4 Version 3: errors, and shutdown on drop
Version 2 still panics in three places: for a size of zero, when send fails, and when the lock is poisoned. And
if the caller forgets to call join, the pool is dropped without waiting for its jobs. Version 3 fixes both.
Listing 18.4 The job type and the error type (lines 8 to 32). src/problems/thread_pool_v3.rs
use std::{
fmt,
sync::{Arc, Mutex, mpsc},
thread,
};
type Job = Box<dyn FnOnce() + Send + 'static>;
/// Why the pool refused a request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PoolError {
NoWorkers,
ShutDown,
}
impl fmt::Display for PoolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoWorkers => write!(f, "a thread pool needs at least one worker"),
Self::ShutDown => write!(f, "the thread pool has shut down"),
}
}
}
impl std::error::Error for PoolError {}
PoolError names the two ways a request can fail. It implements Display for a message, and Error so callers
can use ? and Box<dyn Error> with it.
Listing 18.5 The pool, execute, shutdown, and Drop.
pub struct ThreadPool {
// `None` after shutdown: dropping the sender is what tells workers to stop.
sender: Option<mpsc::Sender<Job>>,
workers: Vec<thread::JoinHandle<()>>,
}
impl ThreadPool {
// ...
/// Queue a closure to run on the next free worker.
pub fn execute<F>(&self, job: F) -> Result<(), PoolError>
where
F: FnOnce() + Send + 'static,
{
self.sender
.as_ref()
.ok_or(PoolError::ShutDown)?
.send(Box::new(job))
.map_err(|_| PoolError::ShutDown)
}
/// Stop accepting jobs, drain the queue, and wait for every worker.
/// Calling it again does nothing.
pub fn shutdown(&mut self) {
drop(self.sender.take());
for worker in self.workers.drain(..) {
let _ = worker.join();
}
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
self.shutdown();
}
}
The sender is now an Option<Sender<Job>>. shutdown takes it out with self.sender.take(), which leaves None
behind, and drops it. execute checks for None with .ok_or(PoolError::ShutDown)?.
shutdown takes &mut self, not self, so that Drop can call it. self.workers.drain(..) removes the handles
from the vector one at a time and gives each to the loop. A second call finds no sender and no workers, so it does
nothing.
impl Drop for ThreadPool calls shutdown. When a pool goes out of scope, it closes its channel and waits for
every queued job to finish.
Listing 18.6 The worker loop (lines 91 to 104).
fn worker_loop(receiver: &Mutex<mpsc::Receiver<Job>>) {
loop {
// A poisoned lock means another worker panicked while holding it;
// stop this worker instead of panicking a second time.
let Ok(guard) = receiver.lock() else { return };
let job = guard.recv();
drop(guard);
match job {
Ok(job) => job(),
Err(_) => return,
}
}
}
The worker loop is now a named function. It uses let ... else to leave quietly if the lock is poisoned, and
drops the guard explicitly before running the job.
Listing 18.7 The complete file, with tests. src/problems/thread_pool_v3.rs
//! Thread pool, version 3: errors instead of panics, and shutdown on drop.
//!
//! - `new` returns `Err(PoolError::NoWorkers)` for a size of zero.
//! - `execute` returns `Err(PoolError::ShutDown)` once the pool no longer accepts work.
//! - Dropping the pool closes the queue and joins every worker, so a pool that goes
//! out of scope still finishes the jobs it was given.
use std::{
fmt,
sync::{Arc, Mutex, mpsc},
thread,
};
type Job = Box<dyn FnOnce() + Send + 'static>;
/// Why the pool refused a request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PoolError {
NoWorkers,
ShutDown,
}
impl fmt::Display for PoolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoWorkers => write!(f, "a thread pool needs at least one worker"),
Self::ShutDown => write!(f, "the thread pool has shut down"),
}
}
}
impl std::error::Error for PoolError {}
pub struct ThreadPool {
// `None` after shutdown: dropping the sender is what tells workers to stop.
sender: Option<mpsc::Sender<Job>>,
workers: Vec<thread::JoinHandle<()>>,
}
impl ThreadPool {
/// Start `size` workers.
pub fn new(size: usize) -> Result<Self, PoolError> {
if size == 0 {
return Err(PoolError::NoWorkers);
}
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..size)
.map(|_| {
let receiver = Arc::clone(&receiver);
thread::spawn(move || worker_loop(&receiver))
})
.collect();
Ok(Self {
sender: Some(sender),
workers,
})
}
/// Queue a closure to run on the next free worker.
pub fn execute<F>(&self, job: F) -> Result<(), PoolError>
where
F: FnOnce() + Send + 'static,
{
self.sender
.as_ref()
.ok_or(PoolError::ShutDown)?
.send(Box::new(job))
.map_err(|_| PoolError::ShutDown)
}
/// Stop accepting jobs, drain the queue, and wait for every worker.
/// Calling it again does nothing.
pub fn shutdown(&mut self) {
drop(self.sender.take());
for worker in self.workers.drain(..) {
let _ = worker.join();
}
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
self.shutdown();
}
}
fn worker_loop(receiver: &Mutex<mpsc::Receiver<Job>>) {
loop {
// A poisoned lock means another worker panicked while holding it;
// stop this worker instead of panicking a second time.
let Ok(guard) = receiver.lock() else { return };
let job = guard.recv();
drop(guard);
match job {
Ok(job) => job(),
Err(_) => return,
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
#[test]
fn zero_workers_is_an_error() {
assert_eq!(ThreadPool::new(0).err(), Some(PoolError::NoWorkers));
}
#[test]
fn dropping_the_pool_finishes_queued_jobs() {
let counter = Arc::new(AtomicUsize::new(0));
{
let pool = ThreadPool::new(2).unwrap();
for _ in 0..50 {
let counter = Arc::clone(&counter);
pool.execute(move || {
counter.fetch_add(1, Ordering::Relaxed);
})
.unwrap();
}
} // drop: close the queue and join
assert_eq!(counter.load(Ordering::Relaxed), 50);
}
#[test]
fn execute_after_shutdown_is_an_error() {
let mut pool = ThreadPool::new(1).unwrap();
pool.shutdown();
assert_eq!(pool.execute(|| {}), Err(PoolError::ShutDown));
pool.shutdown(); // idempotent
}
#[test]
fn errors_render_a_message() {
assert_eq!(
PoolError::NoWorkers.to_string(),
"a thread pool needs at least one worker"
);
assert_eq!(
PoolError::ShutDown.to_string(),
"the thread pool has shut down"
);
}
}
The test dropping_the_pool_finishes_queued_jobs creates a pool inside a block, queues 50 jobs, and lets the pool
go out of scope at the closing brace. After the block, all 50 jobs must have run.
18.5 Version 4: surviving a panicking job
Version 3 has one remaining weakness. If a job panics, the panic unwinds out of worker_loop and ends that
worker’s thread. The pool now has one worker fewer, and nothing reports it. After enough panics, no worker is left,
and jobs queue up forever.
Version 4 catches the panic (figure 18.3).
catch_unwind, and the worker lives on.Animation 18.2 shows the difference over many jobs. Version 4 counts the panic and keeps every worker. Version 3 loses one worker per panic, until no one is left to run the queue.
catch_unwind, a panic costs one job. Without it, each panic costs a worker, and the pool fails silently once all are gone.Listing 18.8 The types and the worker loop. src/problems/thread_pool_v4.rs
use std::{
fmt,
io,
panic::{self, AssertUnwindSafe},
sync::{
Arc,
Mutex,
atomic::{AtomicUsize, Ordering},
mpsc,
},
thread,
};
type Job = Box<dyn FnOnce() + Send + 'static>;
/// Why the pool could not be created or refused a job.
#[derive(Debug)]
pub enum PoolError {
NoWorkers,
Spawn(io::Error),
ShutDown,
}
impl fmt::Display for PoolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoWorkers => write!(f, "a thread pool needs at least one worker"),
Self::Spawn(error) => write!(f, "failed to spawn a worker thread: {error}"),
Self::ShutDown => write!(f, "the thread pool has shut down"),
}
}
}
impl std::error::Error for PoolError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Spawn(error) => Some(error),
_ => None,
}
}
}
/// What the pool did over its lifetime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Report {
pub completed: usize,
pub panicked: usize,
}
#[derive(Default)]
struct Counters {
completed: AtomicUsize,
panicked: AtomicUsize,
}
pub struct ThreadPool {
sender: Option<mpsc::Sender<Job>>,
workers: Vec<thread::JoinHandle<()>>,
counters: Arc<Counters>,
}
fn worker_loop(receiver: &Mutex<mpsc::Receiver<Job>>, counters: &Counters) {
loop {
let Ok(guard) = receiver.lock() else { return };
let job = guard.recv();
drop(guard);
let Ok(job) = job else { return };
// `AssertUnwindSafe` is sound here: the job owns everything it touches,
// and a panicking job's partial state is never observed by this loop.
match panic::catch_unwind(AssertUnwindSafe(job)) {
Ok(()) => counters
.completed
.fetch_add(1, Ordering::Relaxed),
Err(_) => counters
.panicked
.fetch_add(1, Ordering::Relaxed),
};
}
}
Version 4 adds three things to the types of version 3:
PoolError::Spawn(io::Error), for a worker thread the operating system refused to start. Itssourcemethod returns theio::Errorinside, so a caller can see the cause.Counters, two atomic counts that every worker updates. The pool and the workers share them through anArc.Report, a plain copy of the two counts, whichshutdownreturns.
panic::catch_unwind(f) runs f. If f panics, the unwinding stops at catch_unwind, which returns Err.
Otherwise it returns Ok with f’s result.
catch_unwind requires the closure to be UnwindSafe. The trait marks types that cannot leave broken data
visible after a panic. A boxed dyn FnOnce does not have it, because the compiler cannot see what the closure
captured. AssertUnwindSafe(job) is a wrapper that declares the closure safe anyway. The comment gives the reason:
the job owns everything it touches, and this loop never looks at its partial state.
Each outcome increments one of two atomic counters, completed or panicked.
Listing 18.9 Named workers (lines 73 to 105).
impl ThreadPool {
/// Start `size` named workers.
pub fn new(size: usize) -> Result<Self, PoolError> {
if size == 0 {
return Err(PoolError::NoWorkers);
}
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let counters = Arc::new(Counters::default());
let mut pool = Self {
sender: Some(sender),
workers: Vec::with_capacity(size),
counters,
};
for id in 0..size {
let receiver = Arc::clone(&receiver);
let counters = Arc::clone(&pool.counters);
let spawned = thread::Builder::new()
.name(format!("pool-worker-{id}"))
.spawn(move || worker_loop(&receiver, &counters));
match spawned {
Ok(handle) => pool.workers.push(handle),
// Dropping `pool` here closes the queue and joins the workers
// that did start, so none of them is leaked.
Err(error) => return Err(PoolError::Spawn(error)),
}
}
Ok(pool)
}
// ...
}
thread::Builder configures a thread before starting it. Here it gives each worker a name, pool-worker-0 and so
on, which appears in panic messages and debuggers. Builder::spawn returns a Result, because the operating system
can refuse to create a thread. thread::spawn panics in that case.
The pool is built before the workers are started. If starting the third worker fails, return Err(...) drops the
half-built pool. Its Drop closes the channel and joins the two workers that did start, so no thread is left
running.
Listing 18.10 shutdown and the report (lines 113 to 136).
impl ThreadPool {
// ...
/// Close the queue, wait for every queued job, and report the outcome.
pub fn shutdown(mut self) -> Report {
self.close_and_join();
Report {
completed: self
.counters
.completed
.load(Ordering::Relaxed),
panicked: self
.counters
.panicked
.load(Ordering::Relaxed),
}
}
fn close_and_join(&mut self) {
drop(self.sender.take());
for worker in self.workers.drain(..) {
// Jobs cannot unwind out of `worker_loop`, so a join error would mean
// a bug in the loop itself; there is nothing further to clean up.
let _ = worker.join();
}
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
self.close_and_join();
}
}
shutdown takes self by value and returns a Report with the two counts. The work is in close_and_join, which
Drop also calls. When shutdown returns, self is dropped, and Drop runs close_and_join a second time. The
second call finds nothing left to do.
Listing 18.11 The complete file, with tests. src/problems/thread_pool_v4.rs
//! Thread pool, version 4: survives panicking jobs and reports what happened.
//!
//! - Workers are named threads created with `thread::Builder`, and a failure to
//! spawn one is returned as `PoolError::Spawn` after the already started workers
//! are shut down.
//! - Each job runs inside `catch_unwind`, so a panicking job no longer kills its
//! worker and silently shrinks the pool.
//! - `shutdown` consumes the pool and returns a `Report` with the number of jobs
//! that completed and the number that panicked.
use std::{
fmt,
io,
panic::{self, AssertUnwindSafe},
sync::{
Arc,
Mutex,
atomic::{AtomicUsize, Ordering},
mpsc,
},
thread,
};
type Job = Box<dyn FnOnce() + Send + 'static>;
/// Why the pool could not be created or refused a job.
#[derive(Debug)]
pub enum PoolError {
NoWorkers,
Spawn(io::Error),
ShutDown,
}
impl fmt::Display for PoolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoWorkers => write!(f, "a thread pool needs at least one worker"),
Self::Spawn(error) => write!(f, "failed to spawn a worker thread: {error}"),
Self::ShutDown => write!(f, "the thread pool has shut down"),
}
}
}
impl std::error::Error for PoolError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Spawn(error) => Some(error),
_ => None,
}
}
}
/// What the pool did over its lifetime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Report {
pub completed: usize,
pub panicked: usize,
}
#[derive(Default)]
struct Counters {
completed: AtomicUsize,
panicked: AtomicUsize,
}
pub struct ThreadPool {
sender: Option<mpsc::Sender<Job>>,
workers: Vec<thread::JoinHandle<()>>,
counters: Arc<Counters>,
}
impl ThreadPool {
/// Start `size` named workers.
pub fn new(size: usize) -> Result<Self, PoolError> {
if size == 0 {
return Err(PoolError::NoWorkers);
}
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let counters = Arc::new(Counters::default());
let mut pool = Self {
sender: Some(sender),
workers: Vec::with_capacity(size),
counters,
};
for id in 0..size {
let receiver = Arc::clone(&receiver);
let counters = Arc::clone(&pool.counters);
let spawned = thread::Builder::new()
.name(format!("pool-worker-{id}"))
.spawn(move || worker_loop(&receiver, &counters));
match spawned {
Ok(handle) => pool.workers.push(handle),
// Dropping `pool` here closes the queue and joins the workers
// that did start, so none of them is leaked.
Err(error) => return Err(PoolError::Spawn(error)),
}
}
Ok(pool)
}
/// Queue a closure to run on the next free worker.
pub fn execute<F>(&self, job: F) -> Result<(), PoolError>
where
F: FnOnce() + Send + 'static,
{
self.sender
.as_ref()
.ok_or(PoolError::ShutDown)?
.send(Box::new(job))
.map_err(|_| PoolError::ShutDown)
}
/// Close the queue, wait for every queued job, and report the outcome.
pub fn shutdown(mut self) -> Report {
self.close_and_join();
Report {
completed: self
.counters
.completed
.load(Ordering::Relaxed),
panicked: self
.counters
.panicked
.load(Ordering::Relaxed),
}
}
fn close_and_join(&mut self) {
drop(self.sender.take());
for worker in self.workers.drain(..) {
// Jobs cannot unwind out of `worker_loop`, so a join error would mean
// a bug in the loop itself; there is nothing further to clean up.
let _ = worker.join();
}
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
self.close_and_join();
}
}
fn worker_loop(receiver: &Mutex<mpsc::Receiver<Job>>, counters: &Counters) {
loop {
let Ok(guard) = receiver.lock() else { return };
let job = guard.recv();
drop(guard);
let Ok(job) = job else { return };
// `AssertUnwindSafe` is sound here: the job owns everything it touches,
// and a panicking job's partial state is never observed by this loop.
match panic::catch_unwind(AssertUnwindSafe(job)) {
Ok(()) => counters
.completed
.fetch_add(1, Ordering::Relaxed),
Err(_) => counters
.panicked
.fetch_add(1, Ordering::Relaxed),
};
}
}
#[cfg(test)]
mod tests {
use std::{error::Error, sync::mpsc::channel, time::Duration};
use super::*;
#[test]
fn reports_completed_jobs() {
let pool = ThreadPool::new(4).unwrap();
for _ in 0..20 {
pool.execute(|| {}).unwrap();
}
assert_eq!(
pool.shutdown(),
Report {
completed: 20,
panicked: 0
}
);
}
#[test]
fn a_panicking_job_does_not_kill_its_worker() {
// One worker: if the panic killed it, the second job would never run.
let pool = ThreadPool::new(1).unwrap();
let (done_tx, done_rx) = channel();
pool.execute(|| panic!("job failed on purpose"))
.unwrap();
pool.execute(move || done_tx.send(()).unwrap())
.unwrap();
done_rx
.recv_timeout(Duration::from_secs(5))
.expect("the worker died with the panicking job");
assert_eq!(
pool.shutdown(),
Report {
completed: 1,
panicked: 1
}
);
}
#[test]
fn workers_are_named() {
let pool = ThreadPool::new(1).unwrap();
let (name_tx, name_rx) = channel();
pool.execute(move || {
let name = thread::current()
.name()
.map(str::to_string);
name_tx.send(name).unwrap();
})
.unwrap();
assert_eq!(name_rx.recv().unwrap().as_deref(), Some("pool-worker-0"));
}
#[test]
fn zero_workers_is_an_error() {
assert!(matches!(ThreadPool::new(0), Err(PoolError::NoWorkers)));
}
#[test]
fn spawn_errors_expose_their_source() {
let error = PoolError::Spawn(io::Error::other("no threads left"));
assert!(
error
.to_string()
.contains("no threads left")
);
assert!(error.source().is_some());
assert!(PoolError::ShutDown.source().is_none());
}
}
The test a_panicking_job_does_not_kill_its_worker uses a pool with one worker. The first job panics. If that
killed the worker, the second job would never run, and recv_timeout would fail after five seconds.
The test spawn_errors_expose_their_source checks the source method. Error::source returns the lower-level
error that caused this one, so a caller can print the whole chain.
$ cargo test --lib problems::thread_pool
running 12 tests
test problems::thread_pool_v3::tests::zero_workers_is_an_error ... ok
test problems::thread_pool_v3::tests::errors_render_a_message ... ok
test problems::thread_pool_v2::tests::zero_workers_is_rejected - should panic ... ok
test problems::thread_pool_v3::tests::execute_after_shutdown_is_an_error ... ok
test problems::thread_pool_v3::tests::dropping_the_pool_finishes_queued_jobs ... ok
test problems::thread_pool_v2::tests::runs_every_job ... ok
test problems::thread_pool_v4::tests::spawn_errors_expose_their_source ... ok
test problems::thread_pool_v2::tests::jobs_run_at_the_same_time ... ok
test problems::thread_pool_v4::tests::zero_workers_is_an_error ... ok
test problems::thread_pool_v4::tests::reports_completed_jobs ... ok
test problems::thread_pool_v4::tests::a_panicking_job_does_not_kill_its_worker ... ok
test problems::thread_pool_v4::tests::workers_are_named ... ok
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 125 filtered out
The filter problems::thread_pool matches all three versions. cargo test hides what passing tests print,
including the message of the job that panics on purpose. Add -- --nocapture to see it. The message names the
thread, pool-worker-0, which shows the thread names at work.
18.6 Shorter pools
Three more pools take the lessons of versions 2 and 3 in a more compact form, and each adds one feature.
18.6.1 Shutdown in Drop, compactly
This pool shuts itself down when it goes out of scope. Its Drop closes the channel and joins the workers, so
the caller never calls a shutdown method.
The sender is wrapped in an Option, as in version 3, so Drop can close the channel by setting it to None:
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
workers: Vec<JoinHandle<()>>,
job_sender: Option<mpsc::Sender<Job>>,
}
Listing 18.12 The complete program. src/bin/thread_pool_bruce_lee.rs
use std::{
sync::{Arc, Mutex, mpsc},
thread::{self, JoinHandle},
};
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
workers: Vec<JoinHandle<()>>,
job_sender: Option<mpsc::Sender<Job>>,
}
impl ThreadPool {
pub fn new(size: usize) -> Self {
assert!(size > 0, "pool size must be > 0");
let (job_sender, job_receiver) = mpsc::channel();
let job_receiver = Arc::new(Mutex::new(job_receiver));
let workers = (0..size)
.map(|_| Self::spawn_worker(Arc::clone(&job_receiver)))
.collect();
Self {
workers,
job_sender: Some(job_sender),
}
}
fn spawn_worker(job_receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> JoinHandle<()> {
thread::spawn(move || {
loop {
let message = job_receiver.lock().unwrap().recv();
match message {
Ok(job) => job(),
Err(_) => break,
}
}
})
}
pub fn execute(&self, task: impl FnOnce() + Send + 'static) {
if let Some(job_sender) = &self.job_sender {
job_sender.send(Box::new(task)).unwrap();
}
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
self.job_sender = None; // close channel so workers stop
for worker in self.workers.drain(..) {
worker.join().unwrap(); // Wait for workers to finish... This will ensure gracefull shutdown..
}
}
}
fn main() {
let pool = ThreadPool::new(4);
for i in 0..8 {
pool.execute(move || println!("task {i} on {:?}", thread::current().id()));
}
}
This pool keeps version 2’s worker loop and version 3’s Option sender and Drop. self.job_sender = None
drops the sender by overwriting it. thread::spawn(move || loop { ... }) passes a closure whose whole body is the
loop.
main has no explicit shutdown. The pool is dropped at the end of main, and Drop waits for all eight jobs.
$ cargo run --bin thread_pool_bruce_lee
task 0 on ThreadId(4)
task 4 on ThreadId(4)
task 5 on ThreadId(4)
task 6 on ThreadId(4)
task 7 on ThreadId(4)
task 1 on ThreadId(3)
task 2 on ThreadId(2)
task 3 on ThreadId(5)
In this run, one worker took five of the eight jobs. Printing a line is so quick that the first worker to wake could take the next job before the others were scheduled. The pool does not promise an even split.
Drop calls worker.join().unwrap(). If a job panicked, its worker’s join returns Err, and the unwrap panics
inside Drop. The later versions use let _ = worker.join(); instead.
18.6.2 A bounded queue and results
This pool adds two features. The queue has a fixed size, so a caller that submits jobs faster than the workers run them has to wait. And each job’s return value comes back to the caller.
The sender is now a SyncSender, the sending end of a channel with a fixed capacity:
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
workers: Vec<JoinHandle<()>>,
job_sender: Option<mpsc::SyncSender<Job>>, // changed: bounded sender
}
Listing 18.13 The complete program. src/bin/thread_pool_with_back_pressure.rs
use std::{
sync::{Arc, Mutex, mpsc},
thread::{self, JoinHandle},
};
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
workers: Vec<JoinHandle<()>>,
job_sender: Option<mpsc::SyncSender<Job>>, // changed: bounded sender
}
impl ThreadPool {
pub fn new(size: usize, queue_size: usize) -> Self {
// changed: queue_size
assert!(size > 0, "pool size must be > 0");
let (job_sender, job_receiver) = mpsc::sync_channel(queue_size); // changed: bounded queue
let job_receiver = Arc::new(Mutex::new(job_receiver));
let workers = (0..size)
.map(|_| Self::spawn_worker(Arc::clone(&job_receiver)))
.collect();
Self {
workers,
job_sender: Some(job_sender),
}
}
fn spawn_worker(job_receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> JoinHandle<()> {
thread::spawn(move || {
loop {
let message = job_receiver.lock().unwrap().recv();
match message {
Ok(job) => job(),
Err(_) => break,
}
}
})
}
pub fn execute(&self, task: impl FnOnce() + Send + 'static) {
if let Some(job_sender) = &self.job_sender {
job_sender.send(Box::new(task)).unwrap(); // blocks when queue is full
}
}
// new: run a task and get its result back
pub fn submit<T: Send + 'static>(
&self,
task: impl FnOnce() -> T + Send + 'static,
) -> mpsc::Receiver<T> {
let (result_sender, result_receiver) = mpsc::channel();
self.execute(move || {
let _ = result_sender.send(task());
});
result_receiver
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
self.job_sender = None;
for worker in self.workers.drain(..) {
let _ = worker.join();
}
}
}
fn main() {
let pool = ThreadPool::new(4, 10); // 4 workers, at most 10 jobs waiting
let results: Vec<_> = (0..8)
.map(|i| pool.submit(move || i * i))
.collect();
for r in results {
println!("{}", r.recv().unwrap());
}
}
The comments marked changed and new show the two additions.
mpsc::sync_channel(queue_size) creates a channel that holds at most queue_size messages. SyncSender::send
blocks while the channel is full. This is the backpressure of section 17.1, built into the standard library’s channel. A caller that queues
jobs faster than the workers run them has to wait.
Figure 18.4 shows both additions: a queue that can fill up, and a private channel per job for its result.
submit returns a result. It creates a new channel for each job, and the job sends its return value into it. The
caller gets the Receiver back at once, and calls recv() when it needs the value. A value that will be ready
later is often called a future, and chapter 22 builds on that idea.
$ cargo run --bin thread_pool_with_back_pressure
0
1
4
9
16
25
36
49
The results print in order because main calls recv() on the receivers in order, even though the jobs may finish
in any order.
18.6.3 Many producers
In this pool, several threads submit jobs at the same time, each through its own clone of the Sender. The
worker loop also handles a poisoned lock and a closed channel separately.
SharedLockedChannel names the type every worker shares: one receiver, behind a Mutex, in an Arc. The pool holds a bounded sender in an Option, and the worker handles:
type Job = Box<dyn FnOnce() + Send + 'static>;
type SharedLockedChannel = Arc<Mutex<Receiver<Job>>>;
pub struct ThreadPool {
tx: Option<SyncSender<Job>>,
workers: Vec<JoinHandle<()>>,
}
Listing 18.14 The complete program. src/bin/mpmc_thread_pool_engine.rs
use std::{
sync::{
Arc,
Mutex,
mpsc::{self, Receiver, SendError, SyncSender},
},
thread::{self, JoinHandle},
};
type Job = Box<dyn FnOnce() + Send + 'static>;
type SharedLockedChannel = Arc<Mutex<Receiver<Job>>>;
pub struct ThreadPool {
tx: Option<SyncSender<Job>>,
workers: Vec<JoinHandle<()>>,
}
impl ThreadPool {
pub fn new(num_workers: usize, queue_size: usize) -> Self {
let (tx, rx) = mpsc::sync_channel::<Job>(queue_size);
let rx = SharedLockedChannel::new(Mutex::new(rx));
let workers = (0..num_workers)
.map(|_| Self::spawn_worker(Arc::clone(&rx)))
.collect();
Self {
tx: Some(tx),
workers,
}
}
fn spawn_worker(rx: SharedLockedChannel) -> JoinHandle<()> {
thread::spawn(move || {
loop {
let Ok(receiver) = rx.lock() else { break }; // mutex poisoned
let Ok(job) = receiver.recv() else { break }; // channel closed
drop(receiver); // release lock before running the job
job();
}
})
}
pub fn execute<F>(&self, f: F) -> Result<(), SendError<Job>>
where
F: FnOnce() + Send + 'static,
{
let job: Job = Box::new(f);
match &self.tx {
Some(tx) => tx.send(job), // blocks when queue is full
None => Err(SendError(job)),
}
}
pub fn join_all<I>(&self, handles: I)
where
I: IntoIterator<Item = JoinHandle<()>>,
{
for handle in handles {
let _ = handle.join();
}
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.tx.take()); // close channel so workers exit
for worker in self.workers.drain(..) {
let _ = worker.join();
}
}
}
fn main() {
let pool = Arc::new(ThreadPool::new(4, 16));
let producers: Vec<_> = (0..3)
.map(|p| {
let pool = Arc::clone(&pool);
thread::spawn(move || {
for i in 0..5 {
let _ = pool.execute(move || println!("producer {p}, job {i}"));
}
})
})
.collect();
pool.join_all(producers);
} // last Arc dropped here: Drop waits for all queued jobs
This pool’s worker loop is the clearest of the chapter:
let Ok(receiver) = rx.lock() else { break }; // mutex poisoned
let Ok(job) = receiver.recv() else { break }; // channel closed
drop(receiver); // release lock before running the job
job();
Each let ... else line handles one failure and says which. The lock is released explicitly before the job runs.
execute returns Result<(), SendError<Job>>. When the pool is shut down, it returns the job inside the error,
so the caller can still run it or report it.
main shares the pool between three producer threads with an Arc. Each producer queues five jobs. When the last
Arc is dropped at the end of main, Drop runs and waits for every queued job.
$ cargo run --bin mpmc_thread_pool_engine
producer 0, job 0
producer 0, job 3
producer 0, job 4
producer 1, job 1
producer 1, job 2
producer 0, job 1
producer 1, job 4
producer 2, job 0
producer 2, job 1
producer 2, job 2
producer 0, job 2
producer 2, job 4
producer 2, job 3
producer 1, job 3
producer 1, job 0
All 15 jobs ran. The order is mixed, even within one producer, because four workers run the jobs at the same time.
18.7 A parallel map that keeps order
The last pool is a single function. map_order applies a function to every input on several threads, and returns
the results in the same order as the inputs.
The difficulty is that jobs finish in any order. The fix is to tag each input with its position, and use the tag to put each result back in place (figure 18.5).
The function’s signature and its two channels come first:
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub fn map_order<T, R, F>(worker_count: usize, inputs: Vec<T>, mapper: F) -> Vec<R>
where
T: Send + 'static,
R: Send + 'static,
F: Fn(T) -> R + Send + Sync + 'static,
{
assert!(worker_count > 0, "worker_count must be > 0");
assert!(!inputs.is_empty(), "inputs must not be empty");
let expected = inputs.len();
let mapper = Arc::new(mapper);
let (job_tx, job_rx) = mpsc::channel::<(usize, T)>();
let job_rx = Arc::new(Mutex::new(job_rx));
let (result_tx, result_rx) = mpsc::channel::<(usize, R)>();
// ...
}
T is the input type, R the result type, and F the function applied to each input. Inputs and results
cross threads, so both must be Send. mapper is shared by all workers, so it goes in an Arc, and it must be
Sync as well as Send. It has type F: Fn(T) -> R, not FnOnce, because each worker calls it many times.
Jobs go out as (usize, T) pairs, and results come back as (usize, R) pairs. The usize is the input’s
position, the tag of figure 18.5.
Each worker gets its own handle to the mapper, the job receiver, and the result sender:
pub fn map_order<T, R, F>(worker_count: usize, inputs: Vec<T>, mapper: F) -> Vec<R>
where
T: Send + 'static,
R: Send + 'static,
F: Fn(T) -> R + Send + Sync + 'static,
{
// ...
let mut handles = Vec::with_capacity(worker_count);
for _worker_id in 0..worker_count {
let mapper = Arc::clone(&mapper);
let job_rx = Arc::clone(&job_rx);
let result_tx = result_tx.clone();
handles.push(thread::spawn(move || {
loop {
let job = {
let guard = job_rx.lock().expect("job_rx poisoned");
guard.recv()
};
match job {
Ok((idx, input)) => {
let output = mapper(input);
if result_tx.send((idx, output)).is_err() {
break;
}
}
Err(_) => break, // all senders dropped
}
}
}));
}
// ...
}
The worker takes the job inside a block: let job = { let guard = ...; guard.recv() };. The guard is dropped
at the end of the block, so the lock is released before mapper runs. This is the third way in this chapter to
write the fix from section 18.3. If the result channel is closed, the caller has gone, and the worker stops.
The caller sends every job, then gives up its own senders:
pub fn map_order<T, R, F>(worker_count: usize, inputs: Vec<T>, mapper: F) -> Vec<R>
where
T: Send + 'static,
R: Send + 'static,
F: Fn(T) -> R + Send + Sync + 'static,
{
// ...
// Send all jobs, then close the job channel so workers finish after draining it.
for (idx, input) in inputs.into_iter().enumerate() {
job_tx
.send((idx, input))
.expect("bounded by memory; channel only blocks until a worker receives");
}
drop(job_tx);
// Drop our result sender. Only the worker clones remain, so `iter()` ends
// after every worker exits.
drop(result_tx);
// ...
}
enumerate() attaches each input’s position. Dropping job_tx closes the job channel, so the workers stop when
the queue is empty. Dropping the caller’s result_tx leaves only the workers’ clones. result_rx.iter() then
ends when the last worker exits and drops its clone.
Last, the results are put back in input order:
pub fn map_order<T, R, F>(worker_count: usize, inputs: Vec<T>, mapper: F) -> Vec<R>
where
T: Send + 'static,
R: Send + 'static,
F: Fn(T) -> R + Send + Sync + 'static,
{
// ...
// Reassemble results in input order.
let mut ordered: Vec<Option<R>> = Vec::new();
for (idx, value) in result_rx.iter() {
if ordered.len() <= idx {
ordered.resize_with(idx + 1, || None);
}
ordered[idx] = Some(value);
}
for handle in handles {
let _ = handle.join();
}
assert_eq!(
ordered.len(),
expected,
"every job must produce exactly one result"
);
ordered
.into_iter()
.map(|value| value.expect("all results present"))
.collect()
}
Each result is stored at its index in ordered, a Vec<Option<R>>, which grows as needed with resize_with.
At the end, every slot must hold Some, and the options are unwrapped into the final Vec<R>.
Listing 18.15 The complete file, with tests. src/problems/worker_pool.rs
//! Bounded worker pool with ordered results.
//!
//! Programs often fan out network calls across a bounded number of workers.
//! This example maps `Vec<T>` to `Vec<R>` with N threads and preserves input
//! order in the output. It demonstrates `Arc<Mutex<_>>`, channels, scoped
//! ownership, and panic-free dispatch.
use std::{
sync::{Arc, Mutex, mpsc},
thread,
};
pub fn map_order<T, R, F>(worker_count: usize, inputs: Vec<T>, mapper: F) -> Vec<R>
where
T: Send + 'static,
R: Send + 'static,
F: Fn(T) -> R + Send + Sync + 'static,
{
assert!(worker_count > 0, "worker_count must be > 0");
assert!(!inputs.is_empty(), "inputs must not be empty");
let expected = inputs.len();
let mapper = Arc::new(mapper);
let (job_tx, job_rx) = mpsc::channel::<(usize, T)>();
let job_rx = Arc::new(Mutex::new(job_rx));
let (result_tx, result_rx) = mpsc::channel::<(usize, R)>();
let mut handles = Vec::with_capacity(worker_count);
for _worker_id in 0..worker_count {
let mapper = Arc::clone(&mapper);
let job_rx = Arc::clone(&job_rx);
let result_tx = result_tx.clone();
handles.push(thread::spawn(move || {
loop {
let job = {
let guard = job_rx.lock().expect("job_rx poisoned");
guard.recv()
};
match job {
Ok((idx, input)) => {
let output = mapper(input);
if result_tx.send((idx, output)).is_err() {
break;
}
}
Err(_) => break, // all senders dropped
}
}
}));
}
// Send all jobs, then close the job channel so workers finish after draining it.
for (idx, input) in inputs.into_iter().enumerate() {
job_tx
.send((idx, input))
.expect("bounded by memory; channel only blocks until a worker receives");
}
drop(job_tx);
// Drop our result sender. Only the worker clones remain, so `iter()` ends
// after every worker exits.
drop(result_tx);
// Reassemble results in input order.
let mut ordered: Vec<Option<R>> = Vec::new();
for (idx, value) in result_rx.iter() {
if ordered.len() <= idx {
ordered.resize_with(idx + 1, || None);
}
ordered[idx] = Some(value);
}
for handle in handles {
let _ = handle.join();
}
assert_eq!(
ordered.len(),
expected,
"every job must produce exactly one result"
);
ordered
.into_iter()
.map(|value| value.expect("all results present"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_all_inputs_and_preserves_order() {
let inputs: Vec<i32> = (0..100).collect();
let results = map_order(4, inputs, |x| x * x);
assert_eq!(results.len(), 100);
for (i, value) in results.iter().enumerate() {
assert_eq!(*value, (i as i32) * (i as i32));
}
}
#[test]
fn one_worker_works() {
let results = map_order(1, vec![1, 2, 3], |x| x + 10);
assert_eq!(results, vec![11, 12, 13]);
}
}
$ cargo test --lib problems::worker_pool
running 2 tests
test problems::worker_pool::tests::one_worker_works ... ok
test problems::worker_pool::tests::maps_all_inputs_and_preserves_order ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 135 filtered out
18.8 The versions compared
Table 18.1 What each pool handles
| Pool | Jobs run in parallel | Shutdown | Poisoned lock | Panicking job | Queue |
|---|---|---|---|---|---|
v1 thread_pool.rs | no: lock held during job | join(self) | panics | poisons the lock | unbounded |
v2 thread_pool_v2.rs | yes | join(self) | panics | kills the worker | unbounded |
v3 thread_pool_v3.rs | yes | Drop | worker exits | kills the worker | unbounded |
v4 thread_pool_v4.rs | yes | Drop, Report | worker exits | caught and counted | unbounded |
thread_pool_bruce_lee.rs | yes | Drop | panics | kills the worker | unbounded |
thread_pool_with_back_pressure.rs | yes | Drop | panics | kills the worker | bounded |
mpmc_thread_pool_engine.rs | yes | Drop | worker exits | kills the worker | bounded |
Summary
- A thread pool reuses a fixed set of worker threads. Jobs are boxed closures,
Box<dyn FnOnce() + Send + 'static>, sent through a channel whose receiver the workers share behindArc<Mutex<_>>. - In
while let Ok(job) = rx.lock().unwrap().recv(), the guard lives through the loop body. The workers ran one at a time: 814 ms instead of 205 ms for four 200 ms jobs. - Release the lock before running the job: take it in its own
letstatement, in a block, or withdrop(guard). - Dropping the sender closes the channel. Workers drain the queue, see
Errfromrecv, and exit. Doing this inDropmakes a pool finish its jobs when it goes out of scope. catch_unwindwithAssertUnwindSafekeeps a worker alive when its job panics.sync_channelbounds the queue and adds backpressure. A per-job channel carries a result back to the caller.- Tagging each job with its index returns parallel results in input order.
Chapter 19 turns to failures between programs: limiting request rates, retrying failed calls, and making repeated requests safe.
Exercises
- Add a test to
thread_pool_v2.rsthat measures four jobs sleeping 200 ms each, and asserts that they finish in under 400 ms. - Add
submitfrom listing 18.13 to version 4, returning the job’s result through a channel. - Make version 4’s queue bounded with
sync_channel, and add atry_executemethod that returns the job instead of blocking when the queue is full. - Write
map_orderso that it returnsResult<Vec<R>, E>for a mapper of typeFn(T) -> Result<R, E>, and stops sending jobs after the first error.
Rate limits, retries, and idempotency
This chapter covers
- A token bucket that limits how often something may happen, with a lock and with atomics
- A measured flaw in the atomic version, and where it comes from
- Retrying failed calls with exponential backoff, jitter, and the server’s
Retry-After - Deciding which errors to retry, with a trait
- Making a repeated request safe with an idempotency key
- Single flight: concurrent calls with one key share one execution, and other keys run in parallel
Programs that talk to other programs over a network see failures that local code never sees. A server is busy and refuses a request. A connection drops halfway. A reply takes so long that the client gives up, without knowing whether the server did the work.
This chapter builds three tools for those situations:
- A rate limiter protects a service from too many requests, by refusing requests above a set rate.
- A retry policy tries a failed call again, waiting longer after each failure.
- An idempotency key makes it safe to send the same request twice, by making the second one do nothing new.
The three work together. A client that retries must not overload the server, so it backs off. And a retried request must not repeat a side effect, such as charging a card twice.
19.1 A token bucket
Suppose a service allows each client 5 requests per second. A simple counter that resets every second allows 5 requests at 0.99 s and 5 more at 1.01 s: 10 requests in 20 ms. A token bucket smooths this out.
Picture a bucket that holds up to 5 tokens. Every request takes one token out. If the bucket is empty, the request is refused. Tokens flow back in at a steady rate, say 1 per second, until the bucket is full again (figure 19.1).
try_acquire adds the tokens earned since the last call, then takes one if it can. Nothing runs between calls.it_works: capacity 5, refill 1 token per second.Two numbers describe the bucket. The capacity is the largest burst allowed at once. The rate is the long-run number of requests per second.
The bucket does not need a timer that adds tokens. Each request computes how many tokens arrived since the last request, from the time that has passed. It adds them before it checks.
19.1.1 The bucket behind a lock
Listing 19.1 The bucket (lines 6 to 31). src/problems/rate_limiter.rs
use std::{sync::Mutex, time::Instant};
pub struct TokenBucket {
/// Max tokens.
capacity: f64,
/// Tokens per second.
rate: f64,
state: Mutex<BucketState>,
}
struct BucketState {
tokens: f64,
last_refill: Instant,
}
impl TokenBucket {
pub fn new(capacity: usize, rate_per_sec: f64) -> Self {
assert!(capacity > 0);
assert!(rate_per_sec > 0.0);
Self {
capacity: capacity as f64,
rate: rate_per_sec,
state: Mutex::new(BucketState {
tokens: capacity as f64, // Start full
last_refill: Instant::now(),
}),
}
}
// ...
}
The capacity and the rate never change, so they are plain fields. The token count and the time of the last refill
change on every call, so they live in BucketState behind a Mutex. The token count is an f64, because a
fraction of a token can arrive between two requests. The bucket starts full.
Instant is the standard library’s clock for measuring durations. It only moves forward, unlike the wall-clock
time, which can jump when the system clock is corrected.
Listing 19.2 try_acquire (lines 32 to 49).
impl TokenBucket {
// ...
pub fn try_acquire(&self) -> bool {
let mut s = self.state.lock().unwrap();
let now = Instant::now();
// Refill tokens based on elapsed time
let elapsed = now - s.last_refill;
let new_tokens = elapsed.as_secs_f64() * self.rate;
s.tokens = (s.tokens + new_tokens).min(self.capacity);
s.last_refill = now;
// Try to take one
if s.tokens >= 1.0 {
s.tokens -= 1.0;
true
} else {
false
}
}
}
try_acquire does all its work under the lock:
- It computes the time since the last refill,
now - s.last_refill, aDuration. - It converts that to seconds and multiplies by the rate: the number of new tokens.
- It adds them and caps the total at the capacity with
.min(self.capacity). - It records
nowas the last refill. - If at least one token is there, it takes it and returns
true. Otherwise it returnsfalse.
Work through the test by hand. The bucket starts with 5 tokens. Five calls happen within microseconds, so almost no
tokens refill, and each call takes one. The sixth finds about 0.00001 tokens and returns false. After a one-second
sleep, about 1.00001 tokens are there, so one call succeeds, and the next finds about 0.00001 again.
Listing 19.3 The complete file, with its test. src/problems/rate_limiter.rs
//! Thread-safe token bucket rate limiter.
use std::{sync::Mutex, time::Instant};
pub struct TokenBucket {
/// Max tokens.
capacity: f64,
/// Tokens per second.
rate: f64,
state: Mutex<BucketState>,
}
struct BucketState {
tokens: f64,
last_refill: Instant,
}
impl TokenBucket {
pub fn new(capacity: usize, rate_per_sec: f64) -> Self {
assert!(capacity > 0);
assert!(rate_per_sec > 0.0);
Self {
capacity: capacity as f64,
rate: rate_per_sec,
state: Mutex::new(BucketState {
tokens: capacity as f64, // Start full
last_refill: Instant::now(),
}),
}
}
pub fn try_acquire(&self) -> bool {
let mut s = self.state.lock().unwrap();
let now = Instant::now();
// Refill tokens based on elapsed time
let elapsed = now - s.last_refill;
let new_tokens = elapsed.as_secs_f64() * self.rate;
s.tokens = (s.tokens + new_tokens).min(self.capacity);
s.last_refill = now;
// Try to take one
if s.tokens >= 1.0 {
s.tokens -= 1.0;
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use std::{thread, time::Duration};
use super::*;
#[test]
fn it_works() {
let bucket = TokenBucket::new(5, 1.0); // 5 tokens, refill 1 per sec
// Use all 5
for _ in 0..5 {
assert!(bucket.try_acquire());
}
// 6th fails
assert!(!bucket.try_acquire());
// Wait 1 second
thread::sleep(Duration::from_secs(1));
// Should have 1 token back
assert!(bucket.try_acquire());
// And none left
assert!(!bucket.try_acquire());
}
}
$ cargo test --lib problems::rate_limiter
running 1 test
test problems::rate_limiter::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 136 filtered out; finished in 1.00s
The test takes one second, because it sleeps to let a token refill. A test that depends on real time can also fail on a heavily loaded machine. Exercise 1 removes the dependency by passing the time in.
19.1.2 The bucket with atomics
Every call to the locked bucket takes the mutex. Under heavy load, threads wait for each other there, as section 14.1 described for maps. The second version replaces the lock with atomic integers.
Listing 19.4 The limiter (lines 5 to 37). src/bin/rate_limiter_atomic_token_bucket.rs
use std::{
sync::atomic::{AtomicI64, AtomicU64, Ordering::Relaxed},
thread,
time::{Duration, Instant},
};
#[repr(align(64))]
pub struct RateLimiter {
tokens: AtomicI64,
last_refilled_at: AtomicU64, // secs since created_at
capacity: i64,
tokens_per_sec: i64,
created_at: Instant,
}
impl RateLimiter {
pub fn new(capacity: i64, tokens_per_sec: i64) -> Self {
Self {
tokens: AtomicI64::new(capacity),
last_refilled_at: AtomicU64::new(0),
capacity,
tokens_per_sec,
created_at: Instant::now(),
}
}
pub fn try_acquire(&self) -> bool {
let now = self.created_at.elapsed().as_secs();
let last = self
.last_refilled_at
.fetch_max(now, Relaxed);
if now > last {
self.tokens
.fetch_add((now - last) as i64 * self.tokens_per_sec, Relaxed);
self.tokens
.fetch_min(self.capacity, Relaxed);
}
if self.tokens.load(Relaxed) <= 0 {
return false;
}
self.tokens.fetch_sub(1, Relaxed) > 0
}
}
The design differs from the locked version in three ways:
- Tokens are whole numbers in an
AtomicI64, and time is whole seconds since the limiter was created. Tokens arrive in steps, once per second, instead of continuously. last_refilled_at.fetch_max(now, Relaxed)records the current second and returns the previous value, in one atomic step. If several threads start a new second at once, only the first sees an older value, so only that thread adds tokens.#[repr(align(64))]gives the limiter its own cache lines, so it does not share a line with a neighbor, as section 15.4 described.
Then comes the check. If the count is 0 or less, the call returns false. Otherwise it takes a token with
fetch_sub(1), and returns true if the count before the subtraction was positive.
Listing 19.5 The complete program. src/bin/rate_limiter_atomic_token_bucket.rs
use std::{
sync::atomic::{AtomicI64, AtomicU64, Ordering::Relaxed},
thread,
time::{Duration, Instant},
};
#[repr(align(64))]
pub struct RateLimiter {
tokens: AtomicI64,
last_refilled_at: AtomicU64, // secs since created_at
capacity: i64,
tokens_per_sec: i64,
created_at: Instant,
}
impl RateLimiter {
pub fn new(capacity: i64, tokens_per_sec: i64) -> Self {
Self {
tokens: AtomicI64::new(capacity),
last_refilled_at: AtomicU64::new(0),
capacity,
tokens_per_sec,
created_at: Instant::now(),
}
}
pub fn try_acquire(&self) -> bool {
let now = self.created_at.elapsed().as_secs();
let last = self
.last_refilled_at
.fetch_max(now, Relaxed);
if now > last {
self.tokens
.fetch_add((now - last) as i64 * self.tokens_per_sec, Relaxed);
self.tokens
.fetch_min(self.capacity, Relaxed);
}
if self.tokens.load(Relaxed) <= 0 {
return false;
}
self.tokens.fetch_sub(1, Relaxed) > 0
}
}
fn main() {
let limiter = RateLimiter::new(5, 5);
for i in 1..=6 {
println!("request {i}: {}", limiter.try_acquire());
}
thread::sleep(Duration::from_secs(1));
for i in 1..=6 {
println!("request {i}: {}", limiter.try_acquire());
}
}
$ cargo run --bin rate_limiter_atomic_token_bucket
request 1: true
request 2: true
request 3: true
request 4: true
request 5: true
request 6: false
request 1: true
request 2: true
request 3: true
request 4: true
request 5: true
request 6: false
With one thread, the output is exactly right: 5 requests pass, the sixth fails, and after a second 5 more pass.
19.1.3 What happens with many threads
Each atomic operation is safe on its own. The problem is that try_acquire makes several of them, and other threads
can act between them. Figure 19.2 shows one interleaving with one token left.
B is correctly refused, but its fetch_sub still ran. The count drops below zero, and the next refill starts from
there, so the limiter lets through fewer requests than its rate. To measure this, I ran 8 threads calling
try_acquire in a loop for 3.5 seconds, on a limiter with capacity 5 and rate 5. A correct limiter admits 20
requests: 5 at the start and 5 in each of the next three seconds. Three runs admitted 18, 19, and 17, and ended
with the count at 0, −1, and −1.
A second gap goes the other way. The refill is a fetch_add followed by a separate fetch_min. Between the two,
the count can be above the capacity, and other threads can take the extra tokens before the cap is applied. That
would let through more than the capacity in one burst. My runs did not catch this window, but nothing in the code
prevents it.
Both gaps close if the check, the take, and the refill happen as one atomic step. compare_exchange in a loop does that. It reads the count, computes the new count, and writes it only if
no other thread changed the count in between.
Exercise 2 asks you to write it.
19.2 Retrying failed calls
Many failures are temporary. A server restarts, a network link drops a packet, or a service is briefly overloaded. Trying again a moment later often works.
Retrying well takes three decisions:
- Which errors to retry. A “not found” error will fail again. A “server busy” error may not.
- How long to wait. Waiting a little longer after each failure gives a struggling server time to recover.
- How many times. At some point, the caller should give up and report the error.
19.2.1 Which errors to retry
The retry code works with ApiError, the error enum from section 7.7. Here it is again. Each variant is one
way a call to a service can fail, and carries the data that failure needs:
/// API error modeled as an enum with associated data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiError {
InvalidRequest(String),
Unauthorized,
RateLimited { retry_after: Duration },
NotFound { resource_id: String },
Server { status: u16, message: String },
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimited { .. }
| Self::Server {
status: 500..=599,
..
}
)
}
// ...
}
InvalidRequestandNotFounddescribe a request that is wrong. Sending it again gives the same answer.Unauthorizedneeds new credentials, not a second try.RateLimitedsays the server is busy, and carries how long it asked the client to wait.Serveris a failure on the server’s side, with the HTTP status code.
The enum’s own method, is_retryable, already answers the first question for each variant. matches! tests a
value against a pattern and returns true or false. It returns true for RateLimited, and for Server with a
status from 500 to 599.
The retry code should not be tied to ApiError, though. It should work with any error that can answer the
question. That is a job for a trait:
Listing 19.6 The Retryable trait (lines 22 to 44). src/problems/retry.rs
use std::time::Duration;
use crate::problems::state_machine::ApiError;
/// An error that knows whether retrying could help.
pub trait Retryable {
/// `true` if the operation is worth retrying.
fn is_retryable(&self) -> bool;
/// A server-provided minimum delay, such as HTTP `Retry-After`.
fn retry_after(&self) -> Option<Duration> {
None
}
}
impl Retryable for ApiError {
fn is_retryable(&self) -> bool {
ApiError::is_retryable(self)
}
fn retry_after(&self) -> Option<Duration> {
match self {
ApiError::RateLimited { retry_after } => Some(*retry_after),
_ => None,
}
}
}
Retryable is a trait that an error type implements to answer the first question. is_retryable has no default,
so each error type must decide. retry_after has a default body that returns None, so an error type only needs
to implement it if the server can send a hint.
The trait is implemented for ApiError. Its is_retryable passes the question to the enum’s own method.
retry_after returns the delay carried by a RateLimited error. That delay usually comes from the HTTP header Retry-After, which a server sends to say when
to try again.
The line ApiError::is_retryable(self) calls the enum’s own method, not the trait’s. Both have the same name. The
path ApiError::is_retryable finds the inherent method first, so the trait method does not call itself forever.
19.2.2 How long to wait
A fixed delay has a problem. If a server fails, all its clients fail at the same moment. With a fixed delay they all retry at the same moment too. The server is hit by a wave of retries at the moment it recovers. This is called a thundering herd.
Two techniques spread the retries out:
- Exponential backoff doubles the delay after each failure: 100 ms, 200 ms, 400 ms, and so on, up to a cap (figure 19.3). The longer the outage, the less often each client tries.
- Jitter adds randomness to the delay, so clients that failed together do not retry together (figure 19.4). Full jitter picks a random delay between 0 and the backoff.
exponential_backoff_doubles_until_capped.Listing 19.7 The policy (lines 46 to 102).
/// How randomness is applied to the backoff.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Jitter {
/// Delay is exactly the capped exponential backoff.
None,
/// Delay is `unit_random * capped_backoff` (AWS "full jitter").
Full,
}
/// Backoff configuration.
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
/// Total attempts, including the first one. Must be at least 1.
pub max_attempts: u32,
/// Delay before the first retry.
pub base_delay: Duration,
/// Upper bound on a single delay.
pub max_delay: Duration,
pub jitter: Jitter,
}
impl RetryPolicy {
pub fn new(
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
jitter: Jitter,
) -> Self {
assert!(max_attempts > 0, "max_attempts must be > 0");
Self {
max_attempts,
base_delay,
max_delay,
jitter,
}
}
/// Capped exponential backoff for a zero-based retry attempt:
/// `min(base * 2^attempt, max_delay)`.
pub fn exponential_delay(&self, attempt: u32) -> Duration {
let shift = attempt.min(31);
let factor = 1u32 << shift;
self.base_delay
.saturating_mul(factor)
.min(self.max_delay)
}
/// Backoff for `attempt` with `unit_random` in `[0, 1)` applied when jitter
/// is enabled.
pub fn delay_for(&self, attempt: u32, unit_random: f64) -> Duration {
let capped = self.exponential_delay(attempt);
match self.jitter {
Jitter::None => capped,
Jitter::Full => capped.mul_f64(unit_random.clamp(0.0, 1.0)),
}
}
}
RetryPolicy holds the four settings. max_attempts counts the first try as well, so 3 means one try and two
retries.
exponential_delay computes base * 2^attempt, capped at max_delay. Three details keep it from overflowing:
1u32 << shiftcomputes 2 to the powershiftby shifting the bit 1 left.attempt.min(31)keeps the shift within au32, because shifting by 32 or more is an error.Duration::saturating_mulreturns the largestDurationinstead of overflowing..min(self.max_delay)applies the cap.
delay_for applies jitter. It takes a random number in [0, 1) as an argument, and multiplies the delay by it
with Duration::mul_f64. clamp(0.0, 1.0) guards against a random source that returns something out of range.
19.2.3 The retry loop
Listing 19.8 retry (lines 104 to 137).
/// Retry `operation` until it succeeds, fails permanently, or the policy is
/// exhausted.
///
/// `unit_random` must return values in `[0, 1)`; pass `|| 0.0` for deterministic
/// tests or a real RNG in production.
pub fn retry<T, E, F, R>(policy: &RetryPolicy, mut operation: F, mut unit_random: R) -> Result<T, E>
where
F: FnMut(u32) -> Result<T, E>,
E: Retryable,
R: FnMut() -> f64,
{
let mut attempt = 0;
loop {
match operation(attempt) {
Ok(value) => return Ok(value),
Err(error) => {
let exhausted = attempt + 1 >= policy.max_attempts;
if exhausted || !error.is_retryable() {
return Err(error);
}
let backoff = policy.delay_for(attempt, unit_random());
// Honor the server's hint, but never retry sooner than our own
// backoff says is healthy.
let delay = error
.retry_after()
.map_or(backoff, |hint| hint.max(backoff));
std::thread::sleep(delay);
attempt += 1;
}
}
}
}
retry is generic over four types. T and E are the success and error types. F is the operation, a closure
that receives the attempt number. R produces random numbers.
The operation is FnMut, not FnOnce, because the loop calls it several times, and it may change captured state,
like the test’s attempt counter.
On each error, the loop checks two reasons to stop: the attempts are used up, or the error is not retryable. Either way it returns the last error. Otherwise it computes its own backoff, and then applies the server’s hint:
let delay = error.retry_after().map_or(backoff, |hint| hint.max(backoff));
map_or(default, f) returns default for None, and f(hint) for Some(hint). So without a hint, the delay is
the backoff. With a hint, it is the larger of the two. The client never retries sooner than the server asked, and
never sooner than its own backoff.
std::thread::sleep(delay) blocks the thread for the delay. In async code, chapter 22, the wait would be an async
timer instead, so the thread can do other work.
Injecting randomness. The random number comes from the unit_random closure the caller passes in. Production
code passes a real random number generator. Tests pass || 0.0, which makes every delay predictable. Passing in a
dependency this way, instead of creating it inside the function, is called dependency injection. It makes the
function testable without a real clock or a real random source.
Listing 19.9 The complete file, with tests. src/problems/retry.rs
//! Retry with exponential backoff, full jitter, and `Retry-After`.
//!
//! Every network client retries. Done badly it makes an outage worse: synchronized
//! retries from thousands of clients form a thundering herd, and retrying a
//! non-idempotent `POST` can create duplicate workloads. This module models the
//! policy explicitly.
//!
//! Two details:
//!
//! - **Jitter.** Full jitter picks a random delay in `[0, capped_backoff]` so
//! clients spread out instead of retrying in lockstep.
//! - **`Retry-After`.** When the server tells you when to come back, wait at
//! least that long; never retry sooner than the server asked.
//!
//! The random source is injected as a closure so the delay math is deterministic
//! under test.
use std::time::Duration;
use crate::problems::state_machine::ApiError;
/// An error that knows whether retrying could help.
pub trait Retryable {
/// `true` if the operation is worth retrying.
fn is_retryable(&self) -> bool;
/// A server-provided minimum delay, such as HTTP `Retry-After`.
fn retry_after(&self) -> Option<Duration> {
None
}
}
impl Retryable for ApiError {
fn is_retryable(&self) -> bool {
ApiError::is_retryable(self)
}
fn retry_after(&self) -> Option<Duration> {
match self {
ApiError::RateLimited { retry_after } => Some(*retry_after),
_ => None,
}
}
}
/// How randomness is applied to the backoff.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Jitter {
/// Delay is exactly the capped exponential backoff.
None,
/// Delay is `unit_random * capped_backoff` (AWS "full jitter").
Full,
}
/// Backoff configuration.
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
/// Total attempts, including the first one. Must be at least 1.
pub max_attempts: u32,
/// Delay before the first retry.
pub base_delay: Duration,
/// Upper bound on a single delay.
pub max_delay: Duration,
pub jitter: Jitter,
}
impl RetryPolicy {
pub fn new(
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
jitter: Jitter,
) -> Self {
assert!(max_attempts > 0, "max_attempts must be > 0");
Self {
max_attempts,
base_delay,
max_delay,
jitter,
}
}
/// Capped exponential backoff for a zero-based retry attempt:
/// `min(base * 2^attempt, max_delay)`.
pub fn exponential_delay(&self, attempt: u32) -> Duration {
let shift = attempt.min(31);
let factor = 1u32 << shift;
self.base_delay
.saturating_mul(factor)
.min(self.max_delay)
}
/// Backoff for `attempt` with `unit_random` in `[0, 1)` applied when jitter
/// is enabled.
pub fn delay_for(&self, attempt: u32, unit_random: f64) -> Duration {
let capped = self.exponential_delay(attempt);
match self.jitter {
Jitter::None => capped,
Jitter::Full => capped.mul_f64(unit_random.clamp(0.0, 1.0)),
}
}
}
/// Retry `operation` until it succeeds, fails permanently, or the policy is
/// exhausted.
///
/// `unit_random` must return values in `[0, 1)`; pass `|| 0.0` for deterministic
/// tests or a real RNG in production.
pub fn retry<T, E, F, R>(policy: &RetryPolicy, mut operation: F, mut unit_random: R) -> Result<T, E>
where
F: FnMut(u32) -> Result<T, E>,
E: Retryable,
R: FnMut() -> f64,
{
let mut attempt = 0;
loop {
match operation(attempt) {
Ok(value) => return Ok(value),
Err(error) => {
let exhausted = attempt + 1 >= policy.max_attempts;
if exhausted || !error.is_retryable() {
return Err(error);
}
let backoff = policy.delay_for(attempt, unit_random());
// Honor the server's hint, but never retry sooner than our own
// backoff says is healthy.
let delay = error
.retry_after()
.map_or(backoff, |hint| hint.max(backoff));
std::thread::sleep(delay);
attempt += 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn policy(max_attempts: u32, jitter: Jitter) -> RetryPolicy {
RetryPolicy::new(
max_attempts,
Duration::from_millis(100),
Duration::from_millis(1_000),
jitter,
)
}
#[test]
fn exponential_backoff_doubles_until_capped() {
let policy = policy(6, Jitter::None);
assert_eq!(policy.exponential_delay(0), Duration::from_millis(100));
assert_eq!(policy.exponential_delay(1), Duration::from_millis(200));
assert_eq!(policy.exponential_delay(2), Duration::from_millis(400));
assert_eq!(policy.exponential_delay(3), Duration::from_millis(800));
// Capped from here on.
assert_eq!(policy.exponential_delay(4), Duration::from_millis(1_000));
assert_eq!(policy.exponential_delay(50), Duration::from_millis(1_000));
}
#[test]
fn full_jitter_scales_the_delay() {
let policy = policy(6, Jitter::Full);
assert_eq!(policy.delay_for(2, 0.0), Duration::ZERO);
assert_eq!(policy.delay_for(2, 0.5), Duration::from_millis(200));
// Values above 1.0 are clamped rather than overflowing.
assert_eq!(policy.delay_for(2, 9.9), Duration::from_millis(400));
}
#[test]
fn retries_then_succeeds() {
let policy = RetryPolicy::new(5, Duration::ZERO, Duration::ZERO, Jitter::None);
let mut attempts = 0;
let result: Result<u32, ApiError> = retry(
&policy,
|_| {
attempts += 1;
if attempts < 3 {
Err(ApiError::Server {
status: 503,
message: "temporarily unavailable".into(),
})
} else {
Ok(42)
}
},
|| 0.0,
);
assert_eq!(result, Ok(42));
assert_eq!(attempts, 3);
}
#[test]
fn non_retryable_errors_return_immediately() {
let policy = RetryPolicy::new(5, Duration::ZERO, Duration::ZERO, Jitter::None);
let mut attempts = 0;
let result: Result<(), ApiError> = retry(
&policy,
|_| {
attempts += 1;
Err(ApiError::NotFound {
resource_id: "item-0".into(),
})
},
|| 0.0,
);
assert!(result.is_err());
assert_eq!(attempts, 1);
}
#[test]
fn exhausted_policy_returns_the_last_error() {
let policy = RetryPolicy::new(3, Duration::ZERO, Duration::ZERO, Jitter::None);
let mut attempts = 0;
let result: Result<(), ApiError> = retry(
&policy,
|_| {
attempts += 1;
Err(ApiError::Server {
status: 500,
message: "boom".into(),
})
},
|| 0.0,
);
assert!(result.is_err());
assert_eq!(attempts, 3);
}
#[test]
fn retry_after_is_honored_as_a_floor() {
let policy = policy(2, Jitter::None);
let error = ApiError::RateLimited {
retry_after: Duration::from_secs(5),
};
let backoff = policy.delay_for(0, 0.0);
let chosen = error
.retry_after()
.map_or(backoff, |hint| hint.max(backoff));
assert_eq!(chosen, Duration::from_secs(5));
}
}
The tests use a policy with zero delays, so they run instantly. retries_then_succeeds fails twice with a 503 and
then succeeds: three attempts in total. non_retryable_errors_return_immediately returns NotFound, and must stop
after one attempt.
$ cargo test --lib problems::retry
running 6 tests
test problems::retry::tests::full_jitter_scales_the_delay ... ok
test problems::retry::tests::exponential_backoff_doubles_until_capped ... ok
test problems::retry::tests::retry_after_is_honored_as_a_floor ... ok
test problems::retry::tests::non_retryable_errors_return_immediately ... ok
test problems::retry::tests::exhausted_policy_returns_the_last_error ... ok
test problems::retry::tests::retries_then_succeeds ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 131 filtered out
19.3 Idempotency keys
Retries have a danger. Suppose a client asks a server to charge a card, and the reply is lost. The client cannot tell whether the charge happened. If it retries, the card may be charged twice.
An operation is idempotent if doing it twice has the same effect as doing it once. Reading a record is idempotent. Charging a card is not, unless the server makes it so.
The usual way is an idempotency key. The client picks a unique key for each logical request, such as an order number, and sends it with every attempt. The server records the result under that key. When the same key arrives again, the server returns the recorded result and does not repeat the work (figure 19.5).
The store is a HashMap from key to result, behind a Mutex so that several threads can share it:
use std::{collections::HashMap, hash::Hash, sync::Mutex};
pub struct Idempotent<K, V> {
store: Mutex<HashMap<K, V>>,
}
Listing 19.10 The complete program. src/bin/idempotent_operation.rs
use std::{collections::HashMap, hash::Hash, sync::Mutex};
pub struct Idempotent<K, V> {
store: Mutex<HashMap<K, V>>,
}
impl<K, V> Idempotent<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
pub fn new() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}
pub fn execute<F>(&self, key: K, f: F) -> Result<V, Box<dyn std::error::Error>>
where
F: FnOnce() -> Result<V, Box<dyn std::error::Error>>,
{
let mut store = self
.store
.lock()
.map_err(|_| "lock poisoned")?;
if let Some(v) = store.get(&key) {
return Ok(v.clone());
}
let v = f()?;
store.insert(key, v.clone());
Ok(v)
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let idem = Idempotent::new();
let charge = || -> Result<String, Box<dyn std::error::Error>> {
println!("charging card...");
Ok("txn_42".into())
};
assert_eq!(idem.execute("order-1", charge)?, "txn_42");
assert_eq!(idem.execute("order-1", charge)?, "txn_42");
Ok(())
}
Idempotent<K, V> stores results in a HashMap behind a Mutex. execute takes a key and a closure f that does
the work:
- It locks the store. A poisoned lock becomes an error:
map_err(|_| "lock poisoned")?turns thePoisonErrorinto a&str, and?converts that into aBox<dyn Error>. - If the key is present, it returns a clone of the stored result.
- Otherwise it runs
f(). Ifffails,?returns the error, and nothing is stored, so a later call can try again. - It stores a clone of the result and returns it.
In main, charge is called with the same key twice. The closure captures nothing, so it is Copy, and passing it
to execute twice copies it. The line “charging card…” appears once:
$ cargo run --bin idempotent_operation
charging card...
The second file is the same program with a type alias, type RuntimeError = Box<dyn std::error::Error>;, which
shortens the signatures:
Listing 19.11 The complete program. src/bin/idempotent_operation_with_error_progagation.rs
use std::{collections::HashMap, hash::Hash, sync::Mutex};
type RuntimeError = Box<dyn std::error::Error>;
pub struct Idempotent<K, V> {
store: Mutex<HashMap<K, V>>,
}
impl<K, V> Idempotent<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
pub fn new() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}
pub fn execute<F>(&self, key: K, f: F) -> Result<V, RuntimeError>
where
F: FnOnce() -> Result<V, RuntimeError>,
{
let mut store = self
.store
.lock()
.map_err(|_| "lock poisoned")?;
if let Some(v) = store.get(&key) {
return Ok(v.clone());
}
let v = f()?;
store.insert(key, v.clone());
Ok(v)
}
}
fn main() -> Result<(), RuntimeError> {
let idem = Idempotent::new();
let charge = || -> Result<String, RuntimeError> {
println!("charging card...");
Ok("txn_42".into())
};
assert_eq!(idem.execute("order-1", charge)?, "txn_42");
assert_eq!(idem.execute("order-1", charge)?, "txn_42");
Ok(())
}
$ cargo run --bin idempotent_operation_with_error_progagation
charging card...
Holding the lock while f() runs makes the store correct. Two calls with the same key cannot both find it
missing and both run the work. It is also the store’s limit. Every call waits for the one lock, even calls with
different keys, so one slow operation delays all others. Section 19.4 removes that limit. A real service also keeps the results in a database, so they survive a
restart.
19.4 One execution per key: single flight
The store in section 19.3 has two problems under load.
- It serializes every key. It holds one lock while the work runs. A slow charge for
order-1makes a call fororder-2wait, although the two have nothing in common. - It cannot be split naively. Suppose the lock is released during the work. Two callers with the same key can then both find it missing, and both charge the card.
The same shape appears in caches. When a popular entry expires, hundreds of requests can miss at once, and each recomputes the value or queries the database. That burst is a cache stampede, a thundering herd aimed at one key.
The fix is to record, for each key, that work is in progress. The first caller for a key marks it, releases the lock, and runs the work. A caller that finds the mark waits for the result instead of starting its own. Callers with other keys never wait. This is called single flight: at most one execution per key is in the air at a time.
Each key moves through three states (figure 19.6). A success stores the value for every later caller. A failure, or a panic, removes the mark, so the next caller can try again.
19.4.1 The state
Each key maps to a Slot, and all the slots share one map behind one mutex. A Condvar wakes the waiters
when any slot changes:
use std::{
collections::HashMap,
hash::Hash,
panic::{self, AssertUnwindSafe},
sync::{
Barrier,
Condvar,
Mutex,
MutexGuard,
atomic::{AtomicUsize, Ordering::Relaxed},
},
thread,
time::{Duration, Instant},
};
type Error = Box<dyn std::error::Error + Send + Sync>;
/// The state of one key.
enum Slot<V> {
InProgress,
Done(V),
}
/// Results by key, with at most one execution in flight per key.
pub struct SingleFlight<K, V> {
slots: Mutex<HashMap<K, Slot<V>>>,
finished: Condvar,
}
The mutex is held only to read or change the map, never while the work runs. So one lock is enough for every
key. notify_all wakes every waiter, for any key. Each one checks its own key and goes back to sleep if
nothing it cares about changed. That costs a few spurious wakeups, and keeps the code to one Condvar.
19.4.2 The claim
The caller that runs the work holds a claim on its key. If the work fails or panics, someone has to remove
the InProgress mark, or the waiters sleep forever. Drop is the place that runs in both cases:
/// The claim of the caller that is running the work for `key`. If it is
/// dropped while it still holds the key, after an error or a panic, it clears
/// the `InProgress` mark and wakes the waiters, so one of them can run the work.
struct Claim<'a, K: Eq + Hash, V> {
flight: &'a SingleFlight<K, V>,
key: Option<K>,
}
impl<K: Eq + Hash, V> Drop for Claim<'_, K, V> {
fn drop(&mut self) {
if let Some(key) = self.key.take() {
self.flight.lock().remove(&key);
self.flight.finished.notify_all();
}
}
}
impl<K: Eq + Hash, V> SingleFlight<K, V> {
/// The map, even if a thread panicked while holding the lock: every
/// update leaves the map consistent, so its contents are still valid.
fn lock(&self) -> MutexGuard<'_, HashMap<K, Slot<V>>> {
self.slots
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
key is an Option. The success path takes the key out of the claim before the claim is dropped, so Drop
does nothing. On an error or a panic, the key is still inside, and Drop removes the mark and wakes the
waiters.
lock recovers a poisoned mutex with into_inner, as in section 16.6. The map is consistent after every
update, because each update is one insert or one remove, so the data is safe to keep using.
19.4.3 execute
execute loops while the key is in progress, then claims it, runs the work outside the lock, and stores the
result:
impl<K, V> SingleFlight<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
pub fn new() -> Self {
Self {
slots: Mutex::new(HashMap::new()),
finished: Condvar::new(),
}
}
/// Return the stored result for `key`, wait for the execution in flight,
/// or run `work` if neither exists.
pub fn execute<F>(&self, key: K, work: F) -> Result<V, Error>
where
F: FnOnce() -> Result<V, Error>,
{
let mut slots = self.lock();
loop {
match slots.get(&key) {
Some(Slot::Done(value)) => return Ok(value.clone()),
Some(Slot::InProgress) => {
slots = self
.finished
.wait(slots)
.unwrap_or_else(|poisoned| poisoned.into_inner());
}
None => break,
}
}
slots.insert(key.clone(), Slot::InProgress);
drop(slots);
let mut claim = Claim {
flight: self,
key: Some(key),
};
let value = work()?;
let key = claim
.key
.take()
.expect("claim holds its key");
self.lock()
.insert(key, Slot::Done(value.clone()));
self.finished.notify_all();
Ok(value)
}
}
The loop handles all three states:
Done(value): return a clone. This is the idempotency of section 19.3.InProgress: wait on theCondvar.waitreleases the lock while it sleeps, and takes it back on wakeup. The loop then checks the key again, because the wakeup may be for another key, or spurious.- No entry: leave the loop and claim the key.
After the claim, drop(slots) releases the lock, and work() runs with no lock held. If work returns an
error, ? returns it, and the claim’s Drop removes the mark. If work succeeds, the code takes the key out
of the claim, stores Done, and wakes the waiters.
19.4.4 Measured
main starts its callers together with a Barrier, so they really overlap, and times them:
/// Run `call` on `threads` threads that start together. Returns the results
/// and the time from the start to the last return.
fn together<T: Send>(threads: usize, call: impl Fn(usize) -> T + Sync) -> (Vec<T>, Duration) {
let start_line = Barrier::new(threads);
let start = Instant::now();
let results = thread::scope(|scope| {
let handles: Vec<_> = (0..threads)
.map(|i| {
let (start_line, call) = (&start_line, &call);
scope.spawn(move || {
start_line.wait();
call(i)
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().expect("caller thread"))
.collect()
});
(results, start.elapsed())
}
The work is a fake charge that sleeps for 100 ms and counts how many times it ran. The second scene also runs the four keys through a store like section 19.3’s: one lock, held while the work runs.
1. Eight concurrent calls with one key
executions 1, all got txn-order-1: true, 103 ms
2. Four concurrent calls with four keys
single flight: executions 4, 103 ms
one lock held during the work: executions 4, 417 ms
3. A failure is not stored
first: Err("card declined"), retry: Ok("txn-order-5"), executions 1
Eight callers with one key caused one charge, and all eight got its result in 103 ms. Four keys ran in parallel in 103 ms. The one-lock store ran them one after another, in 417 ms. A declined charge was not stored, so the retry ran the work, and that run succeeded.
The fourth scene makes the work panic while a second caller waits for the same key:
4. The work panics while another caller waits
thread '<unnamed>' panicked at src/bin/single_flight.rs:209:21:
payment provider crashed
caller 0: Err("panicked")
caller 1: Ok(Ok("txn-order-6"))
The panicked at lines are the default panic message, written to standard error. Caller 0’s panic dropped
its claim, and Drop removed the mark and woke caller 1. Caller 1 found no entry, claimed the key, and ran
the work itself. Without the claim’s Drop, the mark would stay InProgress, and caller 1 would wait
forever. Animation 19.2 shows both runs.
Drop is missing, a panic leaves the mark in place and the waiters sleep forever.A production version adds two things. Waiters need a deadline, wait_timeout, so a hung charge cannot hold
them forever. And the results need an expiry, or a store that only grows becomes a memory leak.
Listing 19.12 Single flight: one execution per key in flight. src/bin/single_flight.rs
//! Single flight: concurrent calls with the same key share one execution.
//!
//! The first caller marks the key in progress and runs the work without
//! holding the lock. Later callers with that key wait for its result, while
//! calls with other keys run in parallel. A result is kept, so a retry after
//! success gets the stored result. A failure is not kept, so a retry runs the
//! work again. A panic in the work clears the mark, so waiters are not stranded.
//!
//! Run with: cargo run --bin single_flight
use std::{
collections::HashMap,
hash::Hash,
panic::{self, AssertUnwindSafe},
sync::{
Barrier,
Condvar,
Mutex,
MutexGuard,
atomic::{AtomicUsize, Ordering::Relaxed},
},
thread,
time::{Duration, Instant},
};
type Error = Box<dyn std::error::Error + Send + Sync>;
/// The state of one key.
enum Slot<V> {
InProgress,
Done(V),
}
/// Results by key, with at most one execution in flight per key.
pub struct SingleFlight<K, V> {
slots: Mutex<HashMap<K, Slot<V>>>,
finished: Condvar,
}
/// The claim of the caller that is running the work for `key`. If it is
/// dropped while it still holds the key, after an error or a panic, it clears
/// the `InProgress` mark and wakes the waiters, so one of them can run the work.
struct Claim<'a, K: Eq + Hash, V> {
flight: &'a SingleFlight<K, V>,
key: Option<K>,
}
impl<K: Eq + Hash, V> Drop for Claim<'_, K, V> {
fn drop(&mut self) {
if let Some(key) = self.key.take() {
self.flight.lock().remove(&key);
self.flight.finished.notify_all();
}
}
}
impl<K: Eq + Hash, V> SingleFlight<K, V> {
/// The map, even if a thread panicked while holding the lock: every
/// update leaves the map consistent, so its contents are still valid.
fn lock(&self) -> MutexGuard<'_, HashMap<K, Slot<V>>> {
self.slots
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
impl<K, V> SingleFlight<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
pub fn new() -> Self {
Self {
slots: Mutex::new(HashMap::new()),
finished: Condvar::new(),
}
}
/// Return the stored result for `key`, wait for the execution in flight,
/// or run `work` if neither exists.
pub fn execute<F>(&self, key: K, work: F) -> Result<V, Error>
where
F: FnOnce() -> Result<V, Error>,
{
let mut slots = self.lock();
loop {
match slots.get(&key) {
Some(Slot::Done(value)) => return Ok(value.clone()),
Some(Slot::InProgress) => {
slots = self
.finished
.wait(slots)
.unwrap_or_else(|poisoned| poisoned.into_inner());
}
None => break,
}
}
slots.insert(key.clone(), Slot::InProgress);
drop(slots);
let mut claim = Claim {
flight: self,
key: Some(key),
};
let value = work()?;
let key = claim
.key
.take()
.expect("claim holds its key");
self.lock()
.insert(key, Slot::Done(value.clone()));
self.finished.notify_all();
Ok(value)
}
}
/// Run `call` on `threads` threads that start together. Returns the results
/// and the time from the start to the last return.
fn together<T: Send>(threads: usize, call: impl Fn(usize) -> T + Sync) -> (Vec<T>, Duration) {
let start_line = Barrier::new(threads);
let start = Instant::now();
let results = thread::scope(|scope| {
let handles: Vec<_> = (0..threads)
.map(|i| {
let (start_line, call) = (&start_line, &call);
scope.spawn(move || {
start_line.wait();
call(i)
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().expect("caller thread"))
.collect()
});
(results, start.elapsed())
}
fn main() -> Result<(), Error> {
let charges = AtomicUsize::new(0);
let charge = |order: &str| -> Result<String, Error> {
charges.fetch_add(1, Relaxed);
thread::sleep(Duration::from_millis(100));
Ok(format!("txn-{order}"))
};
println!("1. Eight concurrent calls with one key");
let flight = SingleFlight::new();
let (results, elapsed) = together(8, |_| flight.execute("order-1", || charge("order-1")));
let agreed = results
.iter()
.all(|result| matches!(result, Ok(txn) if txn == "txn-order-1"));
println!(
" executions {}, all got txn-order-1: {agreed}, {:.0} ms",
charges.swap(0, Relaxed),
elapsed.as_secs_f64() * 1000.0
);
println!("\n2. Four concurrent calls with four keys");
let orders = ["order-1", "order-2", "order-3", "order-4"];
let flight = SingleFlight::new();
let (_, elapsed) = together(4, |i| flight.execute(orders[i], || charge(orders[i])));
println!(
" single flight: executions {}, {:.0} ms",
charges.swap(0, Relaxed),
elapsed.as_secs_f64() * 1000.0
);
// The store of section 19.3: one lock, held while the work runs.
let one_lock: Mutex<HashMap<&str, String>> = Mutex::new(HashMap::new());
let (_, elapsed) = together(4, |i| -> Result<String, Error> {
let mut store = one_lock
.lock()
.map_err(|_| "lock poisoned")?;
if let Some(txn) = store.get(orders[i]) {
return Ok(txn.clone());
}
let txn = charge(orders[i])?;
store.insert(orders[i], txn.clone());
Ok(txn)
});
println!(
" one lock held during the work: executions {}, {:.0} ms",
charges.swap(0, Relaxed),
elapsed.as_secs_f64() * 1000.0
);
println!("\n3. A failure is not stored");
let flight = SingleFlight::new();
let declined = flight.execute("order-5", || Err("card declined".into()));
let retried = flight.execute("order-5", || charge("order-5"));
println!(
" first: {declined:?}, retry: {retried:?}, executions {}",
charges.swap(0, Relaxed)
);
println!("\n4. The work panics while another caller waits");
let flight = SingleFlight::new();
let (results, _) = together(2, |i| {
if i == 1 {
thread::sleep(Duration::from_millis(20));
}
panic::catch_unwind(AssertUnwindSafe(|| {
flight.execute("order-6", || {
if i == 0 {
thread::sleep(Duration::from_millis(50));
panic!("payment provider crashed");
}
charge("order-6")
})
}))
.map_err(|_| "panicked")
});
for (i, result) in results.iter().enumerate() {
println!(" caller {i}: {result:?}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn concurrent_calls_with_one_key_run_once() {
let runs = AtomicUsize::new(0);
let flight = SingleFlight::new();
let (results, _) = together(8, |_| {
flight.execute("k", || {
runs.fetch_add(1, Relaxed);
thread::sleep(Duration::from_millis(30));
Ok(7)
})
});
assert_eq!(runs.load(Relaxed), 1);
assert!(
results
.iter()
.all(|result| matches!(result, Ok(7)))
);
}
#[test]
fn a_failure_lets_the_next_call_run() {
let flight = SingleFlight::new();
assert!(
flight
.execute("k", || Err("no".into()))
.is_err()
);
assert_eq!(flight.execute("k", || Ok(1)).unwrap(), 1);
assert_eq!(flight.execute("k", || Ok(2)).unwrap(), 1);
}
}
Summary
- A token bucket allows bursts up to its capacity and a long-run rate. Each call adds the tokens earned since the last call before it checks.
- The locked bucket is correct and simple. The atomic bucket avoids the lock, but its separate check, take, and refill steps let the count go negative. With 8 threads it admitted 17 to 19 requests where 20 were due.
- Retry only errors that can succeed later. A
Retryabletrait lets each error type decide, and carry a server hint. - Exponential backoff with full jitter spreads retries out and prevents a thundering herd. The server’s
Retry-Afteris a floor on the delay. - Passing randomness in as a closure makes the retry logic testable.
- An idempotency key makes a retried request safe: the server stores the result under the key and returns it for every repeat.
- Single flight marks a key in progress and runs its work outside the lock. Eight callers with one key caused one
execution, and four keys took 103 ms, against 417 ms behind one lock. A claim with
Dropclears the mark after an error or a panic.
Chapter 20 moves down to the network itself: IP addresses, sockets, and a server that echoes back what it receives.
Exercises
- Change
TokenBucket::try_acquireto take the current time as an argument,try_acquire_at(&self, now: Instant). Rewrite the test withoutthread::sleep. - Rewrite the atomic limiter’s
try_acquirewith acompare_exchangeloop that refills, checks, and takes a token in one atomic step. Run the 8-thread test from section 19.1.3 on it. - Add a
max_elapsed: DurationtoRetryPolicy, and makeretrystop when the total time spent would pass it. - Add a deadline to
SingleFlight::execute: a caller that waits longer than a givenDurationfor a key in progress returns an error. UseCondvar::wait_timeout, and keep the remaining time across spurious wakeups.
Addresses, windows, and echo servers
This chapter covers
- Parsing IPv4 addresses with
??, matching private ranges with slice patterns, and byte order - The effective TCP window and the bandwidth-delay product
- A thread-per-connection echo server and the TCP lifecycle it walks through
- An epoll echo server over raw
libccalls: non-blocking sockets,EAGAIN, and write buffers - Where each model breaks, and what a production server adds
This chapter works up from the bottom of the network stack. Section 20.1 covers addresses, the numbers that
name a machine. Section 20.2 covers how much data TCP lets a sender have on the wire at once, and why that
limits speed on long links. Sections 20.3 and 20.4 build two echo servers. The first gives every client its
own thread. The second serves every client from one thread with epoll. It uses no crate beyond libc, so every
system call it makes is visible.
20.1 IPv4 addresses
An IPv4 address names one network interface on one machine. It is a 32-bit number. People write it as
four bytes in decimal, separated by dots, such as 10.0.0.1. Each of the four parts is called an octet,
so each must be between 0 and 255.
An address alone names a machine. A connection also needs to name a program on that machine. That is the job
of the port, a 16-bit number. An address and a port together, such as 127.0.0.1:8080, are a socket
address.
Some addresses are reserved for private networks: offices, homes, and the inside of a data center. Routers on the public internet do not forward them. Three ranges are private:
| Range | Written as | First octets |
|---|---|---|
| 10.0.0.0 to 10.255.255.255 | 10.0.0.0/8 | 10 |
| 172.16.0.0 to 172.31.255.255 | 172.16.0.0/12 | 172, then 16 to 31 |
| 192.168.0.0 to 192.168.255.255 | 192.168.0.0/16 | 192, 168 |
The /8 means that the first 8 bits are fixed and the rest can be anything.
When an address travels inside a packet, it is four bytes in a fixed order: the first octet first. That order is called network byte order, and it is big-endian: the first byte is the most significant. Most CPUs store numbers little-endian, with the least significant byte first. Code that puts an address into a packet has to convert it (figure 20.1).
u32, and on the wire.The program net_ipv4.rs does these three jobs: parse the text, check the private ranges, and convert to and
from network order.
parse splits the text on dots and parses each part as a u8:
/// Parse `a.b.c.d`, rejecting anything that is not exactly four octets.
fn parse(input: &str) -> Option<[u8; 4]> {
let mut parts = input
.split('.')
.map(|part| part.parse::<u8>().ok());
let address = [
parts.next()??,
parts.next()??,
parts.next()??,
parts.next()??,
];
parts
.next()
.is_none()
.then_some(address)
}
parse is four parts.next()?? expressions and a check that nothing is left over. The iterator yields
Option<u8> (the result of part.parse::<u8>().ok()), so next() returns Option<Option<u8>>. The
first ? returns None if there is no fourth part. The second returns None if a part is not a valid
u8, which is how "256.1.1.1" is rejected. parts.next().is_none().then_some(address) rejects a fifth
part.
is_private is the table above, written as one pattern:
/// `10.0.0.0/8`, `172.16.0.0/12`, and `192.168.0.0/16` are not routable on the
/// public internet.
fn is_private(address: [u8; 4]) -> bool {
matches!(address, [10, ..] | [172, 16..=31, ..] | [192, 168, ..])
}
[10, ..] matches any address starting with 10. [172, 16..=31, ..] uses a range pattern for the second
octet: exactly the /12 block from 172.16 to 172.31. Rust’s patterns are shorter here than the bit masks they
replace.
The conversions are one standard-library call each:
/// Addresses travel big-endian, so the first octet is the most significant byte.
fn to_network_order(address: [u8; 4]) -> u32 {
u32::from_be_bytes(address)
}
fn from_network_order(value: u32) -> [u8; 4] {
value.to_be_bytes()
}
u32::from_be_bytes reads the four octets as a big-endian number on any machine, whatever its native order.
to_be_bytes goes back. The program prints each input with its result:
input parsed private as u32
10.0.0.1 [10, 0, 0, 1] true 167772161
172.16.5.4 [172, 16, 5, 4] true 2886731012
192.168.1.10 [192, 168, 1, 10] true 3232235786
8.8.8.8 [8, 8, 8, 8] false 134744072
256.1.1.1 invalid
1.2.3 invalid
all checks passed
str::parse::<u8> accepts a leading + and leading zeros, so "+1.02.3.4" parses. The standard
library’s "1.2.3.4".parse::<std::net::Ipv4Addr>() rejects both, and Ipv4Addr::is_private implements
the same three ranges. Production code uses Ipv4Addr.
20.2 How much data can be in flight
TCP turns an unreliable network into a reliable byte stream. The sender numbers every byte it sends. The receiver answers with an acknowledgment, or ACK, that says how many bytes have arrived in order. A byte the sender has sent, but not yet seen acknowledged, is in flight. If its ACK never comes, the sender sends it again.
The time from sending a byte to receiving its ACK is the round-trip time, or RTT. Between two machines in one data center it is under a millisecond. Across a continent it is tens of milliseconds.
A sender may not send without limit. It keeps at most a window of bytes in flight, and stops when the window is full. Two limits set the window:
- The receive window is how much the receiver can buffer. The receiver states it in every ACK. This is flow control: it stops a fast sender from overrunning a slow reader.
- The congestion window is the sender’s own estimate of what the network can carry. It grows while ACKs return and shrinks when packets are lost. This is congestion control.
The sender uses the smaller of the two. Once that many bytes are in flight, it waits for an ACK.
The window decides how fast a single connection can go. The bytes a link holds while they travel are its bandwidth times the round-trip time, the bandwidth-delay product. If the window is smaller than that, the sender runs out of window before the first ACK returns. The link then sits idle for the rest of each round trip. A worked example: a 1 GB/s link with a 100 ms round trip holds 100,000,000 bytes in flight. A 64,000-byte window fills 0.064% of it, so the connection moves 64,000 bytes per 100 ms, which is 640 KB/s (figure 20.2).
The program net_window.rs computes both quantities. The effective window is the smaller of the two limits:
/// The sender may have this much unacknowledged data in flight.
fn effective_window(receive: u32, congestion: u32) -> u32 {
receive.min(congestion)
}
The bandwidth-delay product is a multiplication, done with care for integer precision:
/// Bytes in flight needed to keep a link busy: bandwidth times round-trip time.
fn bandwidth_delay_product(bytes_per_second: u64, rtt_millis: u64) -> u64 {
bytes_per_second * rtt_millis / 1_000
}
It multiplies before it divides, bytes_per_second * rtt_millis / 1_000, which keeps precision in integer
arithmetic. The intermediate product overflows u64 only when bytes per second times milliseconds passes
about 1.8 × 10¹⁹, far beyond any real link. The program prints:
effective window = min(receiver window, congestion window)
receiver 64000 B, congestion 16000 B -> 16000 B
receiver 16000 B, congestion 64000 B -> 16000 B
receiver 32000 B, congestion 32000 B -> 32000 B
bandwidth-delay product (bytes needed in flight)
1000000 B/s x 1 ms -> 1000 B
10000000 B/s x 50 ms -> 500000 B
1000000000 B/s x 100 ms -> 100000000 B
window 64000 B vs product 100000000 B: link is starved, the window too small
all checks passed
For bulk transfers, latency limits a connection as much as bandwidth does. TCP’s original window field is 16
bits, so it caps at 65,535 bytes. The window-scaling option multiplies it, so a window can reach the size of a
fast, long link. On Linux, net.ipv4.tcp_rmem and tcp_wmem bound how large the kernel lets the windows
grow.
20.3 A thread-per-connection echo server
An echo server writes back every byte it receives. It is a small server, but it still accepts connections, reads, writes, and notices when the client leaves.
A server reaches the network through a socket, a file descriptor that the kernel connects to a network endpoint. A listening server goes through a fixed sequence of system calls (figure 20.3):
socketcreates the socket and returns its file descriptor.bindgives it a socket address, so clients know where to connect.listenturns it into a listening socket. The kernel now completes TCP handshakes for it and keeps the finished connections in an accept queue.accepttakes one connection from that queue and returns a new file descriptor for it. The listening socket stays open for the next client.readandwriteon the new descriptor move bytes in both directions.closeends the connection. The kernel sends a FIN, the TCP message that says “no more bytes from me”.
accept runs once per client, and each client gets its own file descriptor.std::net wraps these calls. TcpListener::bind performs socket, bind, and listen. Iterating over
incoming() calls accept. The simplest way to serve several clients at once is to give each accepted
connection its own thread:
/// Accept connections forever, handling each on its own thread.
pub fn serve(listener: TcpListener) {
for incoming in listener.incoming() {
match incoming {
Ok(stream) => {
thread::spawn(move || {
if let Err(error) = handle_connection(stream) {
eprintln!("connection error: {error}");
}
});
}
Err(error) => eprintln!("accept error: {error}"),
}
}
}
Binding to port 0 asks the kernel for any free port. local_addr() reports which one it chose, which is how
both main and the test avoid colliding with other programs. listener.incoming() is an endless iterator
over accept, and each accepted stream moves into its own thread.
Each thread runs the whole protocol for one client:
/// Echo every byte back until the peer closes the connection.
fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> {
stream.set_nodelay(true)?;
if let Ok(peer) = stream.peer_addr() {
println!("connection from {peer}");
}
let mut buffer = [0u8; 4096];
loop {
let read = stream.read(&mut buffer)?;
if read == 0 {
// A zero-byte read is the peer's FIN: an orderly close.
return Ok(());
}
stream.write_all(&buffer[..read])?;
}
}
set_nodelay(true)disables Nagle’s algorithm. Nagle delays small writes to combine them into fewer packets. That is wrong for an echo server, whose reply is never followed by more data.readreturns how many bytes arrived, which can be anything from 1 to the buffer size, no matter how the client wrote them. TCP is a byte stream with no message boundaries.readreturning 0 means the peer sent FIN: an orderly close. The function returnsOk(())and the stream is closed when it is dropped.write_allloops over partial writes until every byte is sent.
accept returns, and each direction closes with its own FIN. Without shutdown, no FIN is sent, and both reads wait forever.The test is a model of how to test a server. It binds an ephemeral port, runs handle_connection for
exactly one connection in a thread, writes "hello echo", and then half-closes with
shutdown(Shutdown::Write). The half-close sends FIN, so the server’s read returns 0 and the server
finishes. The client’s read side stays open, so read_to_end can collect the echo.
Thread per connection is the right default when connections are few or handlers block on other I/O. Each thread costs a kernel stack and scheduling work. At thousands of mostly idle connections, the model spends its memory on stacks that wait. That is the case for the next program.
20.4 An epoll echo server
A thread blocked in read is waiting for one socket. To serve thousands of sockets with one thread, the
thread must never wait on any single one. Two kernel features make that possible.
A non-blocking socket never makes its caller wait. When there is nothing to read, read returns at once
with the error EAGAIN, which means “try again later”. When the kernel’s send buffer for the socket is full,
write returns EAGAIN the same way.
epoll tells the program which sockets have work. The program creates one epoll instance and adds every
socket to its interest list. For each socket it says what to watch for: EPOLLIN for “readable” and
EPOLLOUT for “writable”. When data arrives on a socket, the kernel puts that socket on the instance’s ready list.
epoll_wait sleeps until the ready list is not empty, then returns it (figure 20.4).
epoll_ctl edits the interest list; the kernel fills the ready list; epoll_wait hands it to the program.The server becomes a loop: wait for ready sockets, do whatever work is possible on each without blocking, and wait again. Figure 20.5 contrasts this with thread per connection.
epoll_wait.The program epoll_echo.rs is Linux only. The whole implementation lives in mod linux behind
#[cfg(target_os = "linux")], and a second main for every other platform prints a message instead. The
program compiles everywhere and runs where epoll exists.
20.4.1 Setting up the listener by hand
bind_listener is what TcpListener::bind does inside, spelled out. It calls C functions through the
libc crate. The crate declares them inside an unsafe extern "C" block, with C’s types. c_int is a C
int, and socklen_t is the size of an address in bytes. These are the five it uses, with what each parameter means:
unsafe extern "C" {
fn socket(domain: c_int, ty: c_int, protocol: c_int) -> c_int; // AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0
fn setsockopt(
fd: c_int,
level: c_int, // SOL_SOCKET
name: c_int, // SO_REUSEADDR
value: *const c_void, // points at the option's value
len: socklen_t, // the value's size in bytes
) -> c_int;
fn bind(fd: c_int, addr: *const sockaddr, len: socklen_t) -> c_int; // give the socket an address
fn listen(fd: c_int, backlog: c_int) -> c_int; // backlog: how many connections may wait for accept
fn getsockname(fd: c_int, addr: *mut sockaddr, len: *mut socklen_t) -> c_int; // read back the address
}
Each returns 0, or a new descriptor for socket, and -1 on failure with the reason in errno. sockaddr is
a generic address type. An IPv4 address is a sockaddr_in, passed as a pointer to sockaddr together with its
size. The epoll calls the loop uses later are declared the same way:
unsafe extern "C" {
fn epoll_create1(flags: c_int) -> c_int; // a new epoll instance's fd
fn epoll_ctl(epfd: c_int, op: c_int, fd: c_int, event: *mut epoll_event) -> c_int; // add, modify, remove
fn epoll_wait(epfd: c_int, events: *mut epoll_event, maxevents: c_int, timeout: c_int) -> c_int;
}
An epoll_event has two fields: events, the flags such as EPOLLIN, and u64, a number the program chooses.
This program stores the fd there, so each event says which socket it is about. epoll_wait fills an array of
them and returns how many it filled.
The function:
mod linux {
// ...
/// Bind a non-blocking listener on `127.0.0.1:port`; port 0 asks the kernel for
/// a free port, which is returned alongside the fd.
pub fn bind_listener(port: u16) -> io::Result<(i32, u16)> {
// SAFETY: all pointers are to stack values that outlive the call.
unsafe {
let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM | libc::SOCK_NONBLOCK, 0);
if fd < 0 {
return Err(io::Error::last_os_error());
}
let enable: libc::c_int = 1;
if libc::setsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_REUSEADDR,
&enable as *const _ as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
) < 0
{
let error = io::Error::last_os_error();
libc::close(fd);
return Err(error);
}
let mut address: libc::sockaddr_in = std::mem::zeroed();
address.sin_family = libc::AF_INET as libc::sa_family_t;
address.sin_port = port.to_be();
address.sin_addr.s_addr = libc::INADDR_LOOPBACK.to_be();
if libc::bind(
fd,
&address as *const _ as *const libc::sockaddr,
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
) < 0
{
let error = io::Error::last_os_error();
libc::close(fd);
return Err(error);
}
if libc::listen(fd, 128) < 0 {
let error = io::Error::last_os_error();
libc::close(fd);
return Err(error);
}
let mut bound: libc::sockaddr_in = std::mem::zeroed();
let mut length = std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t;
if libc::getsockname(fd, &mut bound as *mut _ as *mut libc::sockaddr, &mut length) < 0 {
let error = io::Error::last_os_error();
libc::close(fd);
return Err(error);
}
Ok((fd, u16::from_be(bound.sin_port)))
}
}
// ...
}
socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0) creates a non-blocking socket in one call.
SO_REUSEADDR lets a restarted server bind while old connections sit in TIME_WAIT. The sockaddr_in is
built with std::mem::zeroed(), and its fields are converted to network order: port.to_be() and
INADDR_LOOPBACK.to_be(). That is the byte order of section 20.1, used where the kernel reads it. Every
failure path closes the fd before returning the error, so nothing leaks, and the SAFETY comment explains
why the raw pointers are valid.
20.4.2 The loop
Each client needs a place for bytes that could not be written yet:
mod linux {
// ...
/// Per-connection state. `out`/`out_pos` form a write buffer that survives a
/// partial write.
#[derive(Default)]
struct Connection {
out: Vec<u8>,
out_pos: usize,
}
impl Connection {
fn has_pending_output(&self) -> bool {
self.out_pos < self.out.len()
}
// ...
}
Connection is two fields, out and out_pos, and has_pending_output compares them. That buffer keeps
one slow client harmless. Its reply waits in memory, and the loop moves on to other sockets instead of
blocking in write.
event_loop is the wait-and-dispatch loop:
mod linux {
// ...
/// Run the event loop until `shutdown` becomes true.
pub fn event_loop(epfd: i32, listen_fd: i32, shutdown: &AtomicBool) -> io::Result<()> {
epoll_add(epfd, listen_fd, libc::EPOLLIN as u32)?;
let mut connections: HashMap<i32, Connection> = HashMap::new();
let mut events = vec![libc::epoll_event { events: 0, u64: 0 }; MAX_EVENTS];
while !shutdown.load(Ordering::Relaxed) {
// SAFETY: `events` is a valid, writable buffer of MAX_EVENTS entries.
let ready = unsafe {
libc::epoll_wait(
epfd,
events.as_mut_ptr(),
events.len() as i32,
EPOLL_TIMEOUT_MS,
)
};
if ready < 0 {
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::EINTR) {
continue;
}
return Err(error);
}
for event in &events[..ready as usize] {
let fd = event.u64 as i32;
if fd == listen_fd {
accept_ready(epfd, listen_fd, &mut connections)?;
continue;
}
let readable = event.events & (libc::EPOLLIN as u32) != 0;
let writable = event.events & (libc::EPOLLOUT as u32) != 0;
if readable && !read_into(fd, &mut connections)? {
continue; // connection closed
}
if writable && connections.contains_key(&fd) {
flush(fd, &mut connections)?;
}
if connections.contains_key(&fd) {
update_interest(epfd, fd, &mut connections)?;
}
}
}
Ok(())
}
// ...
}
It registers the listening socket for EPOLLIN, then waits, at most 64 events at a time. The wait has a
100 ms timeout, so the loop re-checks the shutdown flag regularly. EINTR (a signal arrived during the
wait) is not an error; the loop continues. Each event carries the fd in its u64 field, put there at
registration.
When the listener is readable, new connections are waiting in the accept queue:
mod linux {
// ...
/// Accept every pending connection, then return; `accept4` with
/// `SOCK_NONBLOCK` means a client socket can never block the loop.
fn accept_ready(
epfd: i32,
listen_fd: i32,
connections: &mut HashMap<i32, Connection>,
) -> io::Result<()> {
loop {
// SAFETY: a null sockaddr is explicitly allowed for accept.
let client = unsafe {
libc::accept4(
listen_fd,
std::ptr::null_mut(),
std::ptr::null_mut(),
libc::SOCK_NONBLOCK,
)
};
if client < 0 {
if would_block() {
return Ok(());
}
return Err(io::Error::last_os_error());
}
epoll_add(epfd, client, libc::EPOLLIN as u32)?;
connections.insert(client, Connection::default());
}
}
// ...
}
accept_ready calls accept4 with SOCK_NONBLOCK in a loop until it returns EAGAIN, registering each new
client for EPOLLIN. Accepting until EAGAIN drains a burst of connections in one wake-up.
When a client is readable, its bytes go into its output buffer:
mod linux {
// ...
/// Drain readable bytes into the connection's output buffer.
///
/// Returns `false` when the peer closed the connection.
fn read_into(fd: i32, connections: &mut HashMap<i32, Connection>) -> io::Result<bool> {
let mut buffer = [0u8; 4096];
loop {
// SAFETY: `buffer` is a valid writable region of `buffer.len()` bytes.
let read =
unsafe { libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len()) };
if read == 0 {
close_connection(fd, connections);
return Ok(false);
}
if read < 0 {
if would_block() {
break;
}
close_connection(fd, connections);
return Err(io::Error::last_os_error());
}
if let Some(connection) = connections.get_mut(&fd) {
connection
.out
.extend_from_slice(&buffer[..read as usize]);
}
}
flush(fd, connections)?;
Ok(true)
}
// ...
}
read_into reads until EAGAIN, appending everything to the connection’s out buffer, then tries to
flush it. A read of 0 closes the connection.
flush writes as much as the socket accepts:
mod linux {
// ...
/// Write as much of the buffered reply as the socket accepts.
fn flush(fd: i32, connections: &mut HashMap<i32, Connection>) -> io::Result<()> {
let Some(connection) = connections.get_mut(&fd) else {
return Ok(());
};
while connection.has_pending_output() {
let remaining = connection.out.len() - connection.out_pos;
// SAFETY: the slice is valid for `remaining` bytes.
let written = unsafe {
libc::write(
fd,
connection.out[connection.out_pos..].as_ptr() as *const libc::c_void,
remaining,
)
};
if written < 0 {
if would_block() {
// Keep the rest buffered; EPOLLOUT will drive the next attempt.
return Ok(());
}
return Err(io::Error::last_os_error());
}
connection.out_pos += written as usize;
}
connection.out.clear();
connection.out_pos = 0;
Ok(())
}
// ...
}
It writes from out_pos onward. When write returns EAGAIN, the socket’s send buffer is full, and the rest
stays queued.
After either, update_interest sets what the loop waits for on this socket:
mod linux {
// ...
/// Ask for `EPOLLOUT` only while a reply is still buffered.
fn update_interest(
epfd: i32,
fd: i32,
connections: &mut HashMap<i32, Connection>,
) -> io::Result<()> {
let pending = connections
.get(&fd)
.is_some_and(Connection::has_pending_output);
let events = if pending {
(libc::EPOLLIN | libc::EPOLLOUT) as u32
} else {
libc::EPOLLIN as u32
};
epoll_modify(epfd, fd, events)
}
// ...
}
It asks for EPOLLOUT only while output is pending. A socket with room in its send buffer is almost always
writable, so leaving EPOLLOUT on permanently would wake the loop continuously for nothing.
out buffer, not a blocked thread. One blocking socket would stop every other socket.main ignores SIGPIPE. By default, writing to a socket whose peer has gone away raises SIGPIPE, which
kills the process. With the signal ignored, write returns EPIPE instead. Rust’s standard library does
this for you at startup on Unix, but a program that makes raw libc calls should not rely on it.
20.4.3 What a production server would change
The program is a complete, tested event loop, and reading it critically is good practice.
- One client’s error stops the server.
read_intoandflushreturnErron any failure other thanEAGAIN, andevent_looppropagates it with?. A client that resets its connection (ECONNRESET) makes the whole loop return. A per-connection error should close that connection and continue. - The output buffer is unbounded. A client that sends continuously and never reads grows
outwithout limit. Backpressure here means turning offEPOLLINfor that client while its buffer is over a threshold. epoll_ctlruns after every event.update_interestissues aEPOLL_CTL_MODsystem call even when the interest set has not changed. Tracking the current set per connection removes most of them.- Level-triggered mode. The loop registers without
EPOLLET, so epoll keeps reporting a socket while it stays ready. Reading untilEAGAINis required in edge-triggered mode and harmless here.
Frameworks such as mio (under Tokio) wrap exactly this loop, portably over epoll, kqueue, and IOCP.
Chapter 22 builds the other half of an async runtime, the part that turns readiness into resumed tasks.
20.5 The complete programs
Listing 20.1 Parse, classify, and convert IPv4 addresses. src/bin/net_ipv4.rs
//! IPv4 addresses: parse a dotted quad, check the private ranges, and show the
//! network byte order used on the wire.
//!
//! Run with: cargo run --bin net_ipv4
/// Parse `a.b.c.d`, rejecting anything that is not exactly four octets.
fn parse(input: &str) -> Option<[u8; 4]> {
let mut parts = input
.split('.')
.map(|part| part.parse::<u8>().ok());
let address = [
parts.next()??,
parts.next()??,
parts.next()??,
parts.next()??,
];
parts
.next()
.is_none()
.then_some(address)
}
/// `10.0.0.0/8`, `172.16.0.0/12`, and `192.168.0.0/16` are not routable on the
/// public internet.
fn is_private(address: [u8; 4]) -> bool {
matches!(address, [10, ..] | [172, 16..=31, ..] | [192, 168, ..])
}
/// Addresses travel big-endian, so the first octet is the most significant byte.
fn to_network_order(address: [u8; 4]) -> u32 {
u32::from_be_bytes(address)
}
fn from_network_order(value: u32) -> [u8; 4] {
value.to_be_bytes()
}
fn main() {
println!(
"{:<16} {:<18} {:>8} {:>12}",
"input", "parsed", "private", "as u32"
);
for text in [
"10.0.0.1",
"172.16.5.4",
"192.168.1.10",
"8.8.8.8",
"256.1.1.1",
"1.2.3",
] {
match parse(text) {
Some(address) => println!(
"{text:<16} {:<18} {:>8} {:>12}",
format!("{address:?}"),
is_private(address),
to_network_order(address)
),
None => println!("{text:<16} invalid"),
}
}
assert_eq!(parse("10.0.0.1"), Some([10, 0, 0, 1]));
assert_eq!(parse("1.2.3"), None);
assert_eq!(parse("1.2.3.4.5"), None);
assert_eq!(parse("256.0.0.1"), None);
assert!(is_private([10, 1, 2, 3]));
assert!(is_private([172, 31, 0, 1]));
assert!(!is_private([172, 32, 0, 1]));
assert!(!is_private([8, 8, 8, 8]));
assert_eq!(to_network_order([127, 0, 0, 1]), 0x7f00_0001);
assert_eq!(from_network_order(0x7f00_0001), [127, 0, 0, 1]);
println!("\nall checks passed");
}
Listing 20.2 The effective window and the bandwidth-delay product. src/bin/net_window.rs
//! TCP window math: the effective window is the smaller of the receiver and
//! congestion windows, and the bandwidth-delay product is how many bytes must be
//! in flight to fill a link.
//!
//! Run with: cargo run --bin net_window
/// The sender may have this much unacknowledged data in flight.
fn effective_window(receive: u32, congestion: u32) -> u32 {
receive.min(congestion)
}
/// Bytes in flight needed to keep a link busy: bandwidth times round-trip time.
fn bandwidth_delay_product(bytes_per_second: u64, rtt_millis: u64) -> u64 {
bytes_per_second * rtt_millis / 1_000
}
fn main() {
println!("effective window = min(receiver window, congestion window)");
for (receive, congestion) in [(64_000, 16_000), (16_000, 64_000), (32_000, 32_000)] {
println!(
" receiver {receive:>7} B, congestion {congestion:>7} B -> {:>7} B",
effective_window(receive, congestion)
);
}
println!("\nbandwidth-delay product (bytes needed in flight)");
for (rate, rtt) in [(1_000_000, 1), (10_000_000, 50), (1_000_000_000, 100)] {
println!(
" {:>12} B/s x {rtt:>3} ms -> {:>12} B",
rate,
bandwidth_delay_product(rate, rtt)
);
}
// A window smaller than the product leaves the link idle, no matter how much
// bandwidth is available. This is why latency, not only bandwidth, matters.
let product = bandwidth_delay_product(1_000_000_000, 100);
let window = u64::from(effective_window(64_000, 1_000_000));
println!(
"\nwindow {window} B vs product {product} B: link is {} the window too small",
if window < product {
"starved,"
} else {
"usable,"
}
);
assert_eq!(effective_window(64_000, 16_000), 16_000);
assert_eq!(effective_window(16_000, 64_000), 16_000);
assert_eq!(bandwidth_delay_product(10_000_000, 50), 500_000);
assert!(window < product);
println!("\nall checks passed");
}
Listing 20.3 A thread-per-connection echo server, with its test. src/bin/tcp_echo_server.rs
//! TCP echo server: the canonical socket exercise.
//!
//! Demonstrates the server side of the TCP lifecycle: `bind` -> `listen` ->
//! `accept` -> read/write loop -> peer `close` (a `read` of 0). It uses a
//! thread-per-connection model, which is the right default for bounded concurrency
//! and blocking handlers; the event-loop alternative is `src/bin/epoll_echo.rs`.
//!
//! `TCP_NODELAY` is set because an echo server must not sit on Nagle's algorithm
//! waiting to coalesce a reply that will never be followed by more data.
//!
//! ```bash
//! cargo run --bin tcp_echo_server # binds 127.0.0.1:0, prints the port
//! cargo run --bin tcp_echo_server 0.0.0.0:9000
//! ```
use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
thread,
};
/// Accept connections forever, handling each on its own thread.
pub fn serve(listener: TcpListener) {
for incoming in listener.incoming() {
match incoming {
Ok(stream) => {
thread::spawn(move || {
if let Err(error) = handle_connection(stream) {
eprintln!("connection error: {error}");
}
});
}
Err(error) => eprintln!("accept error: {error}"),
}
}
}
/// Echo every byte back until the peer closes the connection.
fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> {
stream.set_nodelay(true)?;
if let Ok(peer) = stream.peer_addr() {
println!("connection from {peer}");
}
let mut buffer = [0u8; 4096];
loop {
let read = stream.read(&mut buffer)?;
if read == 0 {
// A zero-byte read is the peer's FIN: an orderly close.
return Ok(());
}
stream.write_all(&buffer[..read])?;
}
}
fn main() {
let address = std::env::args()
.nth(1)
.unwrap_or_else(|| "127.0.0.1:0".to_string());
let listener = TcpListener::bind(&address).expect("failed to bind");
let local = listener
.local_addr()
.expect("failed to read local address");
println!("tcp echo server listening on {local}");
serve(listener);
}
#[cfg(test)]
mod tests {
use std::net::Shutdown;
use super::*;
#[test]
fn echoes_bytes_until_the_client_closes() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let address = listener
.local_addr()
.expect("local address");
// Accept exactly one connection, echo it, then return so the test can join.
let server = thread::spawn(move || {
let (stream, _) = listener
.accept()
.expect("accept one connection");
handle_connection(stream).expect("echo");
});
let mut client = TcpStream::connect(address).expect("connect");
client
.write_all(b"hello echo")
.expect("write");
client
.shutdown(Shutdown::Write)
.expect("half close");
let mut echoed = Vec::new();
client
.read_to_end(&mut echoed)
.expect("read echo");
server
.join()
.expect("server thread panicked");
assert_eq!(echoed, b"hello echo");
}
}
Listing 20.4 One thread, many non-blocking sockets, over libc. src/bin/epoll_echo.rs
//! epoll echo server: one thread, many non-blocking sockets.
//!
//! Reads run until `EAGAIN`, and a reply that does not fit the socket buffer is
//! queued and finished under `EPOLLOUT`, so one slow client cannot stall the loop.
//! Linux only; kqueue and IOCP are the portable equivalents.
#[cfg(target_os = "linux")]
mod linux {
use std::{
collections::HashMap,
io,
sync::atomic::{AtomicBool, Ordering},
};
const MAX_EVENTS: usize = 64;
const EPOLL_TIMEOUT_MS: i32 = 100;
/// Per-connection state. `out`/`out_pos` form a write buffer that survives a
/// partial write.
#[derive(Default)]
struct Connection {
out: Vec<u8>,
out_pos: usize,
}
impl Connection {
fn has_pending_output(&self) -> bool {
self.out_pos < self.out.len()
}
}
/// Bind a non-blocking listener on `127.0.0.1:port`; port 0 asks the kernel for
/// a free port, which is returned alongside the fd.
pub fn bind_listener(port: u16) -> io::Result<(i32, u16)> {
// SAFETY: all pointers are to stack values that outlive the call.
unsafe {
let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM | libc::SOCK_NONBLOCK, 0);
if fd < 0 {
return Err(io::Error::last_os_error());
}
let enable: libc::c_int = 1;
if libc::setsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_REUSEADDR,
&enable as *const _ as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
) < 0
{
let error = io::Error::last_os_error();
libc::close(fd);
return Err(error);
}
let mut address: libc::sockaddr_in = std::mem::zeroed();
address.sin_family = libc::AF_INET as libc::sa_family_t;
address.sin_port = port.to_be();
address.sin_addr.s_addr = libc::INADDR_LOOPBACK.to_be();
if libc::bind(
fd,
&address as *const _ as *const libc::sockaddr,
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
) < 0
{
let error = io::Error::last_os_error();
libc::close(fd);
return Err(error);
}
if libc::listen(fd, 128) < 0 {
let error = io::Error::last_os_error();
libc::close(fd);
return Err(error);
}
let mut bound: libc::sockaddr_in = std::mem::zeroed();
let mut length = std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t;
if libc::getsockname(fd, &mut bound as *mut _ as *mut libc::sockaddr, &mut length) < 0 {
let error = io::Error::last_os_error();
libc::close(fd);
return Err(error);
}
Ok((fd, u16::from_be(bound.sin_port)))
}
}
/// Run the event loop until `shutdown` becomes true.
pub fn event_loop(epfd: i32, listen_fd: i32, shutdown: &AtomicBool) -> io::Result<()> {
epoll_add(epfd, listen_fd, libc::EPOLLIN as u32)?;
let mut connections: HashMap<i32, Connection> = HashMap::new();
let mut events = vec![libc::epoll_event { events: 0, u64: 0 }; MAX_EVENTS];
while !shutdown.load(Ordering::Relaxed) {
// SAFETY: `events` is a valid, writable buffer of MAX_EVENTS entries.
let ready = unsafe {
libc::epoll_wait(
epfd,
events.as_mut_ptr(),
events.len() as i32,
EPOLL_TIMEOUT_MS,
)
};
if ready < 0 {
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::EINTR) {
continue;
}
return Err(error);
}
for event in &events[..ready as usize] {
let fd = event.u64 as i32;
if fd == listen_fd {
accept_ready(epfd, listen_fd, &mut connections)?;
continue;
}
let readable = event.events & (libc::EPOLLIN as u32) != 0;
let writable = event.events & (libc::EPOLLOUT as u32) != 0;
if readable && !read_into(fd, &mut connections)? {
continue; // connection closed
}
if writable && connections.contains_key(&fd) {
flush(fd, &mut connections)?;
}
if connections.contains_key(&fd) {
update_interest(epfd, fd, &mut connections)?;
}
}
}
Ok(())
}
/// Accept every pending connection, then return; `accept4` with
/// `SOCK_NONBLOCK` means a client socket can never block the loop.
fn accept_ready(
epfd: i32,
listen_fd: i32,
connections: &mut HashMap<i32, Connection>,
) -> io::Result<()> {
loop {
// SAFETY: a null sockaddr is explicitly allowed for accept.
let client = unsafe {
libc::accept4(
listen_fd,
std::ptr::null_mut(),
std::ptr::null_mut(),
libc::SOCK_NONBLOCK,
)
};
if client < 0 {
if would_block() {
return Ok(());
}
return Err(io::Error::last_os_error());
}
epoll_add(epfd, client, libc::EPOLLIN as u32)?;
connections.insert(client, Connection::default());
}
}
/// Drain readable bytes into the connection's output buffer.
///
/// Returns `false` when the peer closed the connection.
fn read_into(fd: i32, connections: &mut HashMap<i32, Connection>) -> io::Result<bool> {
let mut buffer = [0u8; 4096];
loop {
// SAFETY: `buffer` is a valid writable region of `buffer.len()` bytes.
let read =
unsafe { libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len()) };
if read == 0 {
close_connection(fd, connections);
return Ok(false);
}
if read < 0 {
if would_block() {
break;
}
close_connection(fd, connections);
return Err(io::Error::last_os_error());
}
if let Some(connection) = connections.get_mut(&fd) {
connection
.out
.extend_from_slice(&buffer[..read as usize]);
}
}
flush(fd, connections)?;
Ok(true)
}
/// Write as much of the buffered reply as the socket accepts.
fn flush(fd: i32, connections: &mut HashMap<i32, Connection>) -> io::Result<()> {
let Some(connection) = connections.get_mut(&fd) else {
return Ok(());
};
while connection.has_pending_output() {
let remaining = connection.out.len() - connection.out_pos;
// SAFETY: the slice is valid for `remaining` bytes.
let written = unsafe {
libc::write(
fd,
connection.out[connection.out_pos..].as_ptr() as *const libc::c_void,
remaining,
)
};
if written < 0 {
if would_block() {
// Keep the rest buffered; EPOLLOUT will drive the next attempt.
return Ok(());
}
return Err(io::Error::last_os_error());
}
connection.out_pos += written as usize;
}
connection.out.clear();
connection.out_pos = 0;
Ok(())
}
/// Ask for `EPOLLOUT` only while a reply is still buffered.
fn update_interest(
epfd: i32,
fd: i32,
connections: &mut HashMap<i32, Connection>,
) -> io::Result<()> {
let pending = connections
.get(&fd)
.is_some_and(Connection::has_pending_output);
let events = if pending {
(libc::EPOLLIN | libc::EPOLLOUT) as u32
} else {
libc::EPOLLIN as u32
};
epoll_modify(epfd, fd, events)
}
fn close_connection(fd: i32, connections: &mut HashMap<i32, Connection>) {
connections.remove(&fd);
// SAFETY: `fd` is owned by us and open at this point.
unsafe {
libc::close(fd);
}
}
fn epoll_add(epfd: i32, fd: i32, events: u32) -> io::Result<()> {
let mut event = libc::epoll_event {
events,
u64: fd as u64,
};
// SAFETY: `event` outlives the call.
if unsafe { libc::epoll_ctl(epfd, libc::EPOLL_CTL_ADD, fd, &mut event) } < 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
fn epoll_modify(epfd: i32, fd: i32, events: u32) -> io::Result<()> {
let mut event = libc::epoll_event {
events,
u64: fd as u64,
};
// SAFETY: `event` outlives the call.
if unsafe { libc::epoll_ctl(epfd, libc::EPOLL_CTL_MOD, fd, &mut event) } < 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
fn would_block() -> bool {
matches!(
io::Error::last_os_error().raw_os_error(),
Some(libc::EAGAIN)
)
}
}
#[cfg(target_os = "linux")]
fn main() {
use std::sync::atomic::AtomicBool;
let port: u16 = std::env::args()
.nth(1)
.and_then(|argument| argument.parse().ok())
.unwrap_or(9000);
let (listen_fd, bound_port) = linux::bind_listener(port).expect("failed to bind");
// SAFETY: epoll_create1 takes flags and returns an fd or -1.
let epfd = unsafe { libc::epoll_create1(0) };
assert!(epfd >= 0, "epoll_create1 failed");
// A write to a peer that vanished raises SIGPIPE, which by default kills the
// process. Servers ignore it and handle EPIPE from write instead.
// SAFETY: installing SIG_IGN is a single-threaded startup action.
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
}
println!("epoll echo server listening on 127.0.0.1:{bound_port} (Ctrl-C to stop)");
let shutdown = AtomicBool::new(false);
let result = linux::event_loop(epfd, listen_fd, &shutdown);
// SAFETY: both fds are owned by this process.
unsafe {
libc::close(epfd);
libc::close(listen_fd);
}
if let Err(error) = result {
eprintln!("event loop error: {error}");
std::process::exit(1);
}
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("epoll_echo requires Linux; use kqueue or IOCP on this platform.");
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use std::{
io::{Read, Write},
net::TcpStream,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
};
use super::linux;
#[test]
fn echoes_a_message_through_the_event_loop() {
let (listen_fd, port) = linux::bind_listener(0).expect("bind");
// SAFETY: epoll_create1 takes flags and returns an fd or -1.
let epfd = unsafe { libc::epoll_create1(0) };
assert!(epfd >= 0, "epoll_create1 failed");
let shutdown = Arc::new(AtomicBool::new(false));
let server_shutdown = Arc::clone(&shutdown);
let server = thread::spawn(move || {
let result = linux::event_loop(epfd, listen_fd, &server_shutdown);
// SAFETY: the test owns both fds.
unsafe {
libc::close(epfd);
libc::close(listen_fd);
}
result
});
let mut client = TcpStream::connect(("127.0.0.1", port)).expect("connect");
client
.write_all(b"epoll echo")
.expect("write");
let mut echoed = [0u8; 10];
client
.read_exact(&mut echoed)
.expect("read echo");
assert_eq!(&echoed, b"epoll echo");
shutdown.store(true, Ordering::Relaxed);
drop(client);
let result = server
.join()
.expect("server thread panicked");
assert!(result.is_ok(), "event loop returned {result:?}");
}
}
20.6 Questions that come up
“What does a read of 0 bytes mean on a TCP socket?”
The peer closed its sending side (FIN). No more data will arrive. It is not an error.
“Why set TCP_NODELAY?”
To stop Nagle’s algorithm from holding back small writes. Request-response protocols that write a small
reply and wait want it off.
“Thread per connection or an event loop?” Threads for a modest number of connections and simple blocking code. An event loop, or an async runtime on top of one, for many mostly idle connections, where per-thread stacks and context switches dominate.
“Level-triggered or edge-triggered epoll?”
Level-triggered reports a ready fd until it is no longer ready. Edge-triggered reports only transitions, so
the handler must drain the fd until EAGAIN. Otherwise it may never hear about the remaining data.
“A 10 Gb/s link between regions delivers 50 MB/s. Why?” Check the bandwidth-delay product against the window: at 80 ms RTT, 10 Gb/s needs about 100 MB in flight. A small socket buffer, or window scaling disabled somewhere on the path, caps throughput at window / RTT.
Parsing and serving HTTP/1.1
This chapter covers
- What a request and a response look like on the wire, and how a server finds where a request ends
- Parsing an HTTP/1.1 request from bytes: request line, headers, and body framing
- Why
Content-LengthplusTransfer-Encodingmust be rejected, and what request smuggling is - Decoding chunked bodies, enforcing size limits, and reporting “incomplete” as a normal result
- A small REST server: a pure routing function, keep-alive, and status codes
- Reading the server against the RFC, and the three things it gets wrong
HTTP looks like text with line breaks, but it is a byte stream whose message boundaries are decided by headers. The security bugs in real servers come from disagreeing about where one request ends and the next begins. The parser in this chapter is built around that boundary. The server on top of it keeps protocol handling separate from routing, so each can be tested alone.
21.1 Requests and responses on the wire
HTTP/1.1 runs over one TCP connection, the byte stream from chapter 20. The client writes a request into the connection. The server reads it, works out an answer, and writes a response back on the same connection.
A request is a few lines of text, then an optional body. Every line ends with two bytes, a carriage return
and a line feed, written \r\n in Rust strings and called CRLF. Figure 21.1 shows a request that creates a
GPU workload, byte for byte.
The first line is the request line. It has three parts separated by spaces:
- The method says what the client wants done.
GETreads,POSTcreates, andDELETEremoves. - The target names the thing to act on, here the path
/v1/gpu-workloads. - The version is
HTTP/1.1.
Each following line is a header: a name, a colon, and a value. Host names the server the client meant.
Content-Type says what kind of data the body holds. Content-Length says how many bytes the body has, here
14.
An empty line ends the head. The body follows it, and it is exactly Content-Length bytes long:
{"gpuCount":1} is 14 bytes.
A response has the same shape. Its first line is a status line: the version, a three-digit status code, and a short reason phrase. Codes from 200 to 299 mean success, 400 to 499 mean the client sent something wrong, and 500 to 599 mean the server failed.
HTTP/1.1 201 Created
Content-Length: 49
Content-Type: application/json
Connection: keep-alive
{"id":"wl-a100-2","gpuCount":1,"state":"Pending"}
21.1.1 Where one request ends
TCP delivers bytes, not messages. As chapter 20 showed, one read can return part of a request, exactly one
request, or one request and the start of the next. The bytes carry no marker that says “a request starts
here”. The server has to find the end of each request from the bytes themselves:
- The head ends at the first empty line, the four bytes
\r\n\r\n. - The head then says how long the body is.
Content-Lengthgives a byte count. Section 21.2.4 covers the other way, a body sent in sized pieces.
The next request depends on it too. HTTP/1.1 keeps a connection open after a response, so the client can send another request without a new TCP handshake. This is called keep-alive. The server can read the second request only if it knows exactly where the first one stopped.
21.1.2 The parts of the server
Figure 21.2 shows the pieces of the server in this chapter and the order in which a request passes through them.
- The server reads bytes from the connection and appends them to a buffer.
parse_requestlooks at the whole buffer. If the request is not complete yet, it saysIncomplete, and the server reads more. If it is complete, it returns aRequestvalue.routedecides the answer from theRequest: a status code and a body. It never sees bytes or sockets.build_responseturns the answer into the bytes of a response, which the server writes back.
The parser, the read loop, and the response bytes are the protocol. route is the application. Keeping
them apart means each can be tested without the other: the parser with byte strings, and route with
Request values.
21.2 The parser
The parser is one file, src/problems/http_request.rs. Listing 21.1 in section 21.4 is the whole file.
21.2.1 Types first
The file opens with its vocabulary. Method is an enum with a parse that returns None for an unknown
token, and two predicates from RFC 9110:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Method {
Get,
Head,
Post,
Put,
Patch,
Delete,
Options,
}
impl Method {
pub fn parse(token: &str) -> Option<Self> {
Some(match token {
"GET" => Self::Get,
"HEAD" => Self::Head,
"POST" => Self::Post,
"PUT" => Self::Put,
"PATCH" => Self::Patch,
"DELETE" => Self::Delete,
"OPTIONS" => Self::Options,
_ => return None,
})
}
pub fn is_safe(self) -> bool {
matches!(self, Self::Get | Self::Head | Self::Options)
}
pub fn is_idempotent(self) -> bool {
matches!(
self,
Self::Get | Self::Head | Self::Options | Self::Put | Self::Delete
)
}
}
is_safe is true for GET, HEAD, and OPTIONS, which do not change state. is_idempotent adds PUT and
DELETE to those. Chapter 19’s retry loop is the consumer that needs is_idempotent.
Version has two variants because the parser accepts two. Limits carries the header and body caps, with
defaults of 16 KiB and 8 MiB:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Version {
Http10,
Http11,
}
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_header_bytes: usize,
pub max_body_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_header_bytes: 16 * 1024,
max_body_bytes: 8 * 1024 * 1024,
}
}
}
Request holds what the parser found:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub method: Method,
pub target: String,
pub version: Version,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Request {
/// First header value matching `name`, case-insensitively.
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
/// HTTP/1.1 reuses the connection unless `Connection: close`; HTTP/1.0 only
/// reuses it with `Connection: keep-alive`.
pub fn should_keep_alive(&self) -> bool {
let connection = self.header("connection").unwrap_or("");
let has = |token: &str| {
connection
.split(',')
.any(|part| part.trim().eq_ignore_ascii_case(token))
};
match self.version {
Version::Http11 => !has("close"),
Version::Http10 => has("keep-alive"),
}
}
}
Request::header finds a header case-insensitively, because header names are case-insensitive. Then
should_keep_alive encodes the one rule that differs by version. HTTP/1.1 keeps the connection open
unless the client sends Connection: close. HTTP/1.0 closes it unless the client sends Connection: keep-alive. The has closure splits the header on commas because Connection carries a list.
ParseError distinguishes the cases a server must treat differently:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseError {
/// Not a complete request yet; read more and retry.
Incomplete,
Malformed(&'static str),
MissingHost,
/// `Content-Length` and `Transfer-Encoding` were both present.
AmbiguousBodyLength,
UnsupportedTransferEncoding,
PayloadTooLarge,
}
Incomplete is not a failure: it means “read more bytes and try again”, and the server’s loop in section
21.3 depends on it. PayloadTooLarge maps to status 413, MissingHost and Malformed to 400.
21.2.2 The head of the request
parse_request works on &[u8], not &str, because an HTTP body is arbitrary bytes and only the head is
text. It first looks for the blank line that ends the head, \r\n\r\n, with windows(4).position(...):
/// Parse one request from the start of `input`.
pub fn parse_request(input: &[u8], limits: Limits) -> Result<Request, ParseError> {
let Some(header_end) = input
.windows(4)
.position(|window| window == b"\r\n\r\n")
else {
return Err(if input.len() > limits.max_header_bytes {
ParseError::PayloadTooLarge
} else {
ParseError::Incomplete
});
};
if header_end + 4 > limits.max_header_bytes {
return Err(ParseError::PayloadTooLarge);
}
let head = &input[..header_end];
let body_start = header_end + 4;
// ...
}
If the blank line is missing, the request is Incomplete. The exception is a buffer already larger than
max_header_bytes: then the client is sending an endless header and gets PayloadTooLarge. That check
stops a slow client from holding memory with a header that never ends. Figure 21.3 shows the order of the
remaining checks.
parse_request and parse_body.The request line must be exactly three space-separated parts: a known method, a non-empty target, and
HTTP/1.1 or HTTP/1.0. A fourth part is an error.
pub fn parse_request(input: &[u8], limits: Limits) -> Result<Request, ParseError> {
// ...
let mut lines = head
.split(|byte| *byte == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line));
let request_line = std::str::from_utf8(
lines
.next()
.ok_or(ParseError::Malformed("empty"))?,
)
.map_err(|_| ParseError::Malformed("request line"))?;
let mut parts = request_line.split(' ');
let method = Method::parse(
parts
.next()
.ok_or(ParseError::Malformed("method"))?,
)
.ok_or(ParseError::Malformed("unknown method"))?;
let target = parts
.next()
.ok_or(ParseError::Malformed("target"))?;
let version = match parts.next() {
Some("HTTP/1.1") => Version::Http11,
Some("HTTP/1.0") => Version::Http10,
_ => return Err(ParseError::Malformed("version")),
};
if parts.next().is_some() || target.is_empty() {
return Err(ParseError::Malformed("request line"));
}
// ...
}
Header lines are split at the first colon. A name with whitespace is rejected, because RFC 9112 forbids whitespace between the name and the colon. A line that starts with a space or tab is obsolete line folding, a continuation syntax the RFC deprecates. Since different servers interpret it differently, the parser rejects it.
pub fn parse_request(input: &[u8], limits: Limits) -> Result<Request, ParseError> {
// ...
let mut headers = Vec::new();
for line in lines {
if line.is_empty() {
continue;
}
if matches!(line.first(), Some(b' ') | Some(b'\t')) {
return Err(ParseError::Malformed("obsolete line folding"));
}
let colon = line
.iter()
.position(|byte| *byte == b':')
.ok_or(ParseError::Malformed("header without colon"))?;
let name = std::str::from_utf8(&line[..colon])
.map_err(|_| ParseError::Malformed("header name"))?;
if name.is_empty()
|| name
.bytes()
.any(|byte| byte.is_ascii_whitespace())
{
return Err(ParseError::Malformed("invalid header name"));
}
let value = std::str::from_utf8(&line[colon + 1..])
.map_err(|_| ParseError::Malformed("header value"))?;
headers.push((name.to_string(), value.trim().to_string()));
}
// ...
}
HTTP/1.1 requires exactly one Host header. The count uses filter(...).count() != 1, which rejects both
zero and two.
pub fn parse_request(input: &[u8], limits: Limits) -> Result<Request, ParseError> {
// ...
if version == Version::Http11
&& headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("host"))
.count()
!= 1
{
return Err(ParseError::MissingHost);
}
// ...
}
21.2.3 Body framing and request smuggling
parse_body decides how long the body is. A request with both Content-Length and Transfer-Encoding is
rejected as AmbiguousBodyLength:
fn parse_body(
input: &[u8],
body_start: usize,
headers: &[(String, String)],
limits: Limits,
) -> Result<Vec<u8>, ParseError> {
let collect = |name: &str| -> Vec<&str> {
headers
.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
.collect()
};
let transfer_encoding = collect("transfer-encoding");
let content_length = collect("content-length");
// Two framing headers is the classic request-smuggling setup.
if !transfer_encoding.is_empty() && !content_length.is_empty() {
return Err(ParseError::AmbiguousBodyLength);
}
// ...
}
The reason is request smuggling (figure 21.4). A front-end proxy may frame a request by Content-Length,
while the back-end server frames the same bytes by Transfer-Encoding: chunked. The two then disagree
about where the request ends. The attacker places a second request in the part the back-end treats as
“after the body”. The back-end runs it as though it arrived on its own, past whatever checks the proxy
applied. Refusing ambiguous framing outright, and closing the connection, removes the disagreement.
Content-Length or chunked encoding then sets where the body ends. A message with both is refused with 400.Transfer-Encoding values from every such header are joined and split into codings. The parser supports
only chunked, and requires it to be last, because a body whose final coding is not chunked has no
defined end. Anything else is UnsupportedTransferEncoding.
fn parse_body(
input: &[u8],
body_start: usize,
headers: &[(String, String)],
limits: Limits,
) -> Result<Vec<u8>, ParseError> {
// ...
if !transfer_encoding.is_empty() {
let joined = transfer_encoding.join(",");
let codings: Vec<&str> = joined
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
.collect();
let all_chunked = codings
.iter()
.all(|token| token.eq_ignore_ascii_case("chunked"));
if !all_chunked
|| !codings
.last()
.is_some_and(|t| t.eq_ignore_ascii_case("chunked"))
{
return Err(ParseError::UnsupportedTransferEncoding);
}
return decode_chunked(&input[body_start..], limits.max_body_bytes);
}
// ...
}
Multiple Content-Length headers are allowed only if they all agree. The check parses each value and
compares with lengths.any(|length| length != Ok(first)), comparing Results directly. A length above
max_body_bytes is rejected before the server waits for the bytes. A client cannot claim a huge body and
make the server buffer it. If fewer bytes than the length have arrived, the result is Incomplete.
fn parse_body(
input: &[u8],
body_start: usize,
headers: &[(String, String)],
limits: Limits,
) -> Result<Vec<u8>, ParseError> {
// ...
if content_length.is_empty() {
return Ok(Vec::new());
}
let mut lengths = content_length.iter().map(|value| {
value
.trim()
.parse::<usize>()
.map_err(|_| ParseError::Malformed("invalid Content-Length"))
});
let first = lengths.next().expect("non-empty")?;
if lengths.any(|length| length != Ok(first)) {
return Err(ParseError::Malformed("conflicting Content-Length"));
}
if first > limits.max_body_bytes {
return Err(ParseError::PayloadTooLarge);
}
let end = body_start + first;
if input.len() < end {
return Err(ParseError::Incomplete);
}
Ok(input[body_start..end].to_vec())
}
21.2.4 Chunked bodies
decode_chunked walks a cursor through size-in-hex CRLF data CRLF records until a size of 0. Chunk
extensions after a ; are ignored, as the RFC permits.
fn decode_chunked(data: &[u8], max_body: usize) -> Result<Vec<u8>, ParseError> {
let mut cursor = 0;
let mut body = Vec::new();
loop {
let line_end = find_crlf(data, cursor).ok_or(ParseError::Incomplete)?;
let size_token = data[cursor..line_end]
.split(|byte| *byte == b';')
.next()
.unwrap_or(&[]);
let size = usize::from_str_radix(
std::str::from_utf8(size_token)
.map_err(|_| ParseError::Malformed("chunk size"))?
.trim(),
16,
)
.map_err(|_| ParseError::Malformed("chunk size"))?;
cursor = line_end + 2;
// ...
}
}
A size of 0 ends the body, after optional trailer lines up to an empty line:
fn decode_chunked(data: &[u8], max_body: usize) -> Result<Vec<u8>, ParseError> {
// ...
loop {
// ...
if size == 0 {
// Consume trailers up to the final empty line.
loop {
let trailer_end = find_crlf(data, cursor).ok_or(ParseError::Incomplete)?;
let is_empty = trailer_end == cursor;
cursor = trailer_end + 2;
if is_empty {
return Ok(body);
}
}
}
// ...
}
}
Each other chunk checks the running total against the body limit. It computes the chunk’s end with
checked_add, returns Incomplete if the data has not all arrived, and insists on the CRLF after the
data.
fn decode_chunked(data: &[u8], max_body: usize) -> Result<Vec<u8>, ParseError> {
// ...
loop {
// ...
if body.len() + size > max_body {
return Err(ParseError::PayloadTooLarge);
}
let chunk_end = cursor
.checked_add(size)
.ok_or(ParseError::Malformed("chunk overflow"))?;
if chunk_end + 2 > data.len() {
return Err(ParseError::Incomplete);
}
body.extend_from_slice(&data[cursor..chunk_end]);
if &data[chunk_end..chunk_end + 2] != b"\r\n" {
return Err(ParseError::Malformed("chunk not CRLF-terminated"));
}
cursor = chunk_end + 2;
}
}
find_crlf uses data.get(from..)? so that a cursor past the end is None rather than a panic.
fn find_crlf(data: &[u8], from: usize) -> Option<usize> {
data.get(from..)?
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| offset + from)
}
One arithmetic detail needs tightening. body.len() + size > max_body runs before the checked_add, and
a chunk size near usize::MAX overflows that addition. from_str_radix parses such a size from sixteen
fs. The form size > max_body - body.len() cannot overflow.
parse_request returns the request but not how many bytes it consumed. A caller therefore cannot find the
start of a second, pipelined request in the same buffer. The server below accepts that limitation, and its
module comment says so.
21.3 A small REST server
The server is src/bin/http_server.rs. Listing 21.2 in section 21.4 is the whole file.
21.3.1 Routing as a pure function
route(&Request) -> (u16, &'static str, Vec<u8>) decides the status, content type, and body without
touching a socket:
/// Map a request to `(status, content_type, body)`.
///
/// Pure, so the routing rules are unit-testable without a socket.
pub fn route(request: &Request) -> (u16, &'static str, Vec<u8>) {
let target = request.target.as_str();
if target == "/healthz" {
return match request.method {
Method::Get | Method::Head => (200, "text/plain", b"ok\n".to_vec()),
_ => method_not_allowed(),
};
}
// ...
}
A known path with an unsupported method returns 405, an unknown path 404.
pub fn route(request: &Request) -> (u16, &'static str, Vec<u8>) {
// ...
if target == "/v1/gpu-workloads" {
return match request.method {
Method::Get | Method::Head => (
200,
"application/json",
br#"[{"id":"wl-a100-1","gpuCount":1,"state":"Running"}]"#.to_vec(),
),
Method::Post => (
201,
"application/json",
br#"{"id":"wl-a100-2","gpuCount":1,"state":"Pending"}"#.to_vec(),
),
_ => method_not_allowed(),
};
}
// ...
}
For /v1/gpu-workloads/{id}, strip_prefix extracts the id, and an empty id or one containing / is a
404.
pub fn route(request: &Request) -> (u16, &'static str, Vec<u8>) {
// ...
if let Some(id) = target.strip_prefix("/v1/gpu-workloads/") {
if id.is_empty() || id.contains('/') {
return (404, "text/plain", b"not found\n".to_vec());
}
return match request.method {
Method::Get | Method::Head => (
200,
"application/json",
format!(r#"{{"id":"{id}","state":"Running"}}"#).into_bytes(),
),
Method::Delete => (204, "", Vec::new()),
_ => method_not_allowed(),
};
}
(404, "text/plain", b"not found\n".to_vec())
}
Its doc comment gives the reason it is a separate function: it is unit-testable. The tests at the bottom
of the file exercise every route by building a Request value directly.
build_response writes the status line, Content-Length, an optional Content-Type, and a Connection
header that tells the client what the server will do:
fn build_response(status: u16, content_type: &str, body: &[u8], keep_alive: bool) -> Vec<u8> {
let mut response = format!("HTTP/1.1 {} {}\r\n", status, status_reason(status));
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(if keep_alive {
"Connection: keep-alive\r\n"
} else {
"Connection: close\r\n"
});
response.push_str("\r\n");
let mut bytes = response.into_bytes();
bytes.extend_from_slice(body);
bytes
}
21.3.2 The connection loop
handle_connection accumulates bytes in buffer and asks the parser what it has (figure 21.5).
handle_connection.On Ok, it routes, writes the response, and either closes or clears the buffer for the next request:
fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> {
stream.set_nodelay(true)?;
let limits = Limits::default();
let mut buffer = Vec::new();
let mut chunk = [0u8; 4096];
loop {
match parse_request(&buffer, limits) {
Ok(request) => {
let keep_alive = request.should_keep_alive();
let (status, content_type, body) = route(&request);
let response = build_response(status, content_type, &body, keep_alive);
stream.write_all(&response)?;
if !keep_alive {
return Ok(());
}
buffer.clear();
}
// ...
}
}
}
On Incomplete, it reads more; a read of 0 means the client left mid-request. Any other error gets a 400
whose body is the error’s Display text, and the connection closes. After a framing error the server
cannot know where the next request starts.
fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> {
// ...
loop {
match parse_request(&buffer, limits) {
// ...
Err(ParseError::Incomplete) => {
let read = stream.read(&mut chunk)?;
if read == 0 {
// Peer closed mid-request.
return Ok(());
}
buffer.extend_from_slice(&chunk[..read]);
}
Err(error) => {
let response =
build_response(400, "text/plain", error.to_string().as_bytes(), false);
stream.write_all(&response)?;
return Ok(());
}
}
}
}
Incomplete once. The second reuses the connection. The third has both framing headers, gets 400, and the connection closes.A session with curl and nc against the server, started with
cargo run --bin http_server 127.0.0.1:18080:
$ curl -si http://127.0.0.1:18080/healthz
HTTP/1.1 200 OK
Content-Length: 3
Content-Type: text/plain
Connection: keep-alive
ok
$ curl -si -X POST -d '{"gpuCount":1}' http://127.0.0.1:18080/v1/gpu-workloads
HTTP/1.1 201 Created
Content-Length: 49
Content-Type: application/json
Connection: keep-alive
{"id":"wl-a100-2","gpuCount":1,"state":"Pending"}
$ printf 'POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\nhello' | nc 127.0.0.1 18080
HTTP/1.1 400 Bad Request
Content-Length: 59
Content-Type: text/plain
Connection: close
Content-Length and Transfer-Encoding are mutually exclusive
21.3.3 Reading the server against the RFC
The same session shows three places where the server departs from RFC 9110. None of them is hard to fix, and finding them is good practice.
$ curl -si -X DELETE http://127.0.0.1:18080/v1/gpu-workloads/wl-7
HTTP/1.1 204 No Content
Content-Length: 0
Connection: keep-alive
$ printf 'HEAD /healthz HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 18080
HTTP/1.1 200 OK
Content-Length: 3
Content-Type: text/plain
Connection: close
ok
- HEAD must not carry a body. A HEAD response repeats the GET headers, including the
Content-Lengthof a body it does not send. The server sendsok. A client that trusts the spec reads those three bytes as the start of the next response. The fix is one line inhandle_connection: skipbodywhenrequest.method == Method::Head. - 204 must not carry
Content-Length.build_responsealways writes it. Skipping it for 204 (and 1xx) fixes that. - Errors all become 400.
PayloadTooLargedeserves 413, andstatus_reasonalready has its text. MappingParseErrorto a status in onematchfixes it. A 405 should also carry anAllowheader listing the methods that path supports.
Two more points concern safety rather than the RFC. The id is interpolated into JSON with format!, so an
id containing " produces invalid JSON; a real server serializes with serde_json. And buffer.clear()
after each request discards any pipelined bytes that arrived with it, which the module comment
acknowledges.
21.4 The complete programs
Listing 21.1 An HTTP/1.1 request parser with framing checks and limits. src/problems/http_request.rs
//! HTTP/1.1 request parsing: request line, headers, and body framing.
//!
//! The framing checks are the point. `Content-Length` together with
//! `Transfer-Encoding` is rejected as ambiguous (request smuggling), as are
//! obsolete line folding and a missing `Host`. Header and body sizes are bounded
//! so a slow client cannot hold a connection open.
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Method {
Get,
Head,
Post,
Put,
Patch,
Delete,
Options,
}
impl Method {
pub fn parse(token: &str) -> Option<Self> {
Some(match token {
"GET" => Self::Get,
"HEAD" => Self::Head,
"POST" => Self::Post,
"PUT" => Self::Put,
"PATCH" => Self::Patch,
"DELETE" => Self::Delete,
"OPTIONS" => Self::Options,
_ => return None,
})
}
pub fn is_safe(self) -> bool {
matches!(self, Self::Get | Self::Head | Self::Options)
}
pub fn is_idempotent(self) -> bool {
matches!(
self,
Self::Get | Self::Head | Self::Options | Self::Put | Self::Delete
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Version {
Http10,
Http11,
}
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_header_bytes: usize,
pub max_body_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_header_bytes: 16 * 1024,
max_body_bytes: 8 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub method: Method,
pub target: String,
pub version: Version,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Request {
/// First header value matching `name`, case-insensitively.
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
/// HTTP/1.1 reuses the connection unless `Connection: close`; HTTP/1.0 only
/// reuses it with `Connection: keep-alive`.
pub fn should_keep_alive(&self) -> bool {
let connection = self.header("connection").unwrap_or("");
let has = |token: &str| {
connection
.split(',')
.any(|part| part.trim().eq_ignore_ascii_case(token))
};
match self.version {
Version::Http11 => !has("close"),
Version::Http10 => has("keep-alive"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseError {
/// Not a complete request yet; read more and retry.
Incomplete,
Malformed(&'static str),
MissingHost,
/// `Content-Length` and `Transfer-Encoding` were both present.
AmbiguousBodyLength,
UnsupportedTransferEncoding,
PayloadTooLarge,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Incomplete => write!(f, "incomplete request"),
Self::Malformed(reason) => write!(f, "malformed request: {reason}"),
Self::MissingHost => write!(f, "HTTP/1.1 requires exactly one Host header"),
Self::AmbiguousBodyLength => {
write!(
f,
"Content-Length and Transfer-Encoding are mutually exclusive"
)
}
Self::UnsupportedTransferEncoding => write!(f, "unsupported Transfer-Encoding"),
Self::PayloadTooLarge => write!(f, "request exceeds configured limits"),
}
}
}
impl std::error::Error for ParseError {}
/// Parse one request from the start of `input`.
pub fn parse_request(input: &[u8], limits: Limits) -> Result<Request, ParseError> {
let Some(header_end) = input
.windows(4)
.position(|window| window == b"\r\n\r\n")
else {
return Err(if input.len() > limits.max_header_bytes {
ParseError::PayloadTooLarge
} else {
ParseError::Incomplete
});
};
if header_end + 4 > limits.max_header_bytes {
return Err(ParseError::PayloadTooLarge);
}
let head = &input[..header_end];
let body_start = header_end + 4;
let mut lines = head
.split(|byte| *byte == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line));
let request_line = std::str::from_utf8(
lines
.next()
.ok_or(ParseError::Malformed("empty"))?,
)
.map_err(|_| ParseError::Malformed("request line"))?;
let mut parts = request_line.split(' ');
let method = Method::parse(
parts
.next()
.ok_or(ParseError::Malformed("method"))?,
)
.ok_or(ParseError::Malformed("unknown method"))?;
let target = parts
.next()
.ok_or(ParseError::Malformed("target"))?;
let version = match parts.next() {
Some("HTTP/1.1") => Version::Http11,
Some("HTTP/1.0") => Version::Http10,
_ => return Err(ParseError::Malformed("version")),
};
if parts.next().is_some() || target.is_empty() {
return Err(ParseError::Malformed("request line"));
}
let mut headers = Vec::new();
for line in lines {
if line.is_empty() {
continue;
}
if matches!(line.first(), Some(b' ') | Some(b'\t')) {
return Err(ParseError::Malformed("obsolete line folding"));
}
let colon = line
.iter()
.position(|byte| *byte == b':')
.ok_or(ParseError::Malformed("header without colon"))?;
let name = std::str::from_utf8(&line[..colon])
.map_err(|_| ParseError::Malformed("header name"))?;
if name.is_empty()
|| name
.bytes()
.any(|byte| byte.is_ascii_whitespace())
{
return Err(ParseError::Malformed("invalid header name"));
}
let value = std::str::from_utf8(&line[colon + 1..])
.map_err(|_| ParseError::Malformed("header value"))?;
headers.push((name.to_string(), value.trim().to_string()));
}
if version == Version::Http11
&& headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("host"))
.count()
!= 1
{
return Err(ParseError::MissingHost);
}
let body = parse_body(input, body_start, &headers, limits)?;
Ok(Request {
method,
target: target.to_string(),
version,
headers,
body,
})
}
fn parse_body(
input: &[u8],
body_start: usize,
headers: &[(String, String)],
limits: Limits,
) -> Result<Vec<u8>, ParseError> {
let collect = |name: &str| -> Vec<&str> {
headers
.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
.collect()
};
let transfer_encoding = collect("transfer-encoding");
let content_length = collect("content-length");
// Two framing headers is the classic request-smuggling setup.
if !transfer_encoding.is_empty() && !content_length.is_empty() {
return Err(ParseError::AmbiguousBodyLength);
}
if !transfer_encoding.is_empty() {
let joined = transfer_encoding.join(",");
let codings: Vec<&str> = joined
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
.collect();
let all_chunked = codings
.iter()
.all(|token| token.eq_ignore_ascii_case("chunked"));
if !all_chunked
|| !codings
.last()
.is_some_and(|t| t.eq_ignore_ascii_case("chunked"))
{
return Err(ParseError::UnsupportedTransferEncoding);
}
return decode_chunked(&input[body_start..], limits.max_body_bytes);
}
if content_length.is_empty() {
return Ok(Vec::new());
}
let mut lengths = content_length.iter().map(|value| {
value
.trim()
.parse::<usize>()
.map_err(|_| ParseError::Malformed("invalid Content-Length"))
});
let first = lengths.next().expect("non-empty")?;
if lengths.any(|length| length != Ok(first)) {
return Err(ParseError::Malformed("conflicting Content-Length"));
}
if first > limits.max_body_bytes {
return Err(ParseError::PayloadTooLarge);
}
let end = body_start + first;
if input.len() < end {
return Err(ParseError::Incomplete);
}
Ok(input[body_start..end].to_vec())
}
fn decode_chunked(data: &[u8], max_body: usize) -> Result<Vec<u8>, ParseError> {
let mut cursor = 0;
let mut body = Vec::new();
loop {
let line_end = find_crlf(data, cursor).ok_or(ParseError::Incomplete)?;
let size_token = data[cursor..line_end]
.split(|byte| *byte == b';')
.next()
.unwrap_or(&[]);
let size = usize::from_str_radix(
std::str::from_utf8(size_token)
.map_err(|_| ParseError::Malformed("chunk size"))?
.trim(),
16,
)
.map_err(|_| ParseError::Malformed("chunk size"))?;
cursor = line_end + 2;
if size == 0 {
// Consume trailers up to the final empty line.
loop {
let trailer_end = find_crlf(data, cursor).ok_or(ParseError::Incomplete)?;
let is_empty = trailer_end == cursor;
cursor = trailer_end + 2;
if is_empty {
return Ok(body);
}
}
}
if body.len() + size > max_body {
return Err(ParseError::PayloadTooLarge);
}
let chunk_end = cursor
.checked_add(size)
.ok_or(ParseError::Malformed("chunk overflow"))?;
if chunk_end + 2 > data.len() {
return Err(ParseError::Incomplete);
}
body.extend_from_slice(&data[cursor..chunk_end]);
if &data[chunk_end..chunk_end + 2] != b"\r\n" {
return Err(ParseError::Malformed("chunk not CRLF-terminated"));
}
cursor = chunk_end + 2;
}
}
fn find_crlf(data: &[u8], from: usize) -> Option<usize> {
data.get(from..)?
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| offset + from)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(raw: &[u8]) -> Result<Request, ParseError> {
parse_request(raw, Limits::default())
}
#[test]
fn parses_a_get_and_a_post_with_content_length() {
let get = parse(b"GET /v1/gpu-workloads HTTP/1.1\r\nHost: api\r\nAccept: */*\r\n\r\n")
.expect("valid");
assert_eq!(get.method, Method::Get);
assert_eq!(get.header("HOST"), Some("api"));
assert!(get.body.is_empty());
assert!(get.should_keep_alive());
let post = parse(b"POST / HTTP/1.1\r\nHost: api\r\nContent-Length: 5\r\n\r\nhello")
.expect("valid");
assert_eq!(post.body, b"hello");
assert!(!post.method.is_idempotent());
}
#[test]
fn decodes_chunked_bodies() {
let raw = b"POST /u HTTP/1.1\r\nHost: api\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
assert_eq!(parse(raw).expect("valid").body, b"hello world");
}
#[test]
fn rejects_ambiguous_framing_and_bad_framing() {
let smuggling = b"POST / HTTP/1.1\r\nHost: api\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\nhello";
assert_eq!(parse(smuggling), Err(ParseError::AmbiguousBodyLength));
let gzip = b"POST / HTTP/1.1\r\nHost: api\r\nTransfer-Encoding: gzip\r\n\r\n";
assert_eq!(parse(gzip), Err(ParseError::UnsupportedTransferEncoding));
}
#[test]
fn rejects_missing_host_and_line_folding() {
assert_eq!(
parse(b"GET / HTTP/1.1\r\nAccept: */*\r\n\r\n"),
Err(ParseError::MissingHost)
);
let folded = b"GET / HTTP/1.1\r\nHost: api\r\nX-A: 1\r\n continued\r\n\r\n";
assert!(matches!(parse(folded), Err(ParseError::Malformed(_))));
}
#[test]
fn reports_incomplete_and_enforces_limits() {
assert_eq!(
parse(b"GET / HTTP/1.1\r\nHost: api\r\n"),
Err(ParseError::Incomplete)
);
let limits = Limits {
max_header_bytes: 1024,
max_body_bytes: 10,
};
let raw = b"POST / HTTP/1.1\r\nHost: api\r\nContent-Length: 999\r\n\r\n";
assert_eq!(parse_request(raw, limits), Err(ParseError::PayloadTooLarge));
}
#[test]
fn http_1_0_needs_keep_alive_opt_in() {
let raw = b"GET / HTTP/1.0\r\n\r\n";
assert!(
!parse(raw)
.expect("valid")
.should_keep_alive()
);
let raw = b"GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n";
assert!(
parse(raw)
.expect("valid")
.should_keep_alive()
);
}
}
Listing 21.2 Routes, responses, and a keep-alive loop over std::net. src/bin/http_server.rs
//! Minimal HTTP/1.1 server with REST-style routes.
//!
//! It uses only `std::net` plus the request parser in
//! [`systems_lab::problems::http_request`], so the interesting parts
//! stay visible: the accept loop, the keep-alive loop, status codes, and the
//! routing decision.
//!
//! Routes:
//!
//! | Method | Path | Response |
//! |---|---|---|
//! | `GET` | `/healthz` | `200` plain text |
//! | `GET` | `/v1/gpu-workloads` | `200` JSON list |
//! | `POST` | `/v1/gpu-workloads` | `201` created |
//! | `GET` | `/v1/gpu-workloads/{id}` | `200` one workload |
//! | `DELETE` | `/v1/gpu-workloads/{id}` | `204` no content |
//!
//! A known path with the wrong method returns `405`, and an unknown path returns
//! `404`. Pipelined requests are not supported: the loop handles one request per
//! read buffer, which matches how most servers treat pipelining in practice.
//!
//! ```bash
//! cargo run --bin http_server # binds 127.0.0.1:8080
//! curl -i http://127.0.0.1:8080/healthz
//! curl -i -X POST -d '{"gpuCount":1}' http://127.0.0.1:8080/v1/gpu-workloads
//! ```
use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
thread,
};
use systems_lab::problems::http_request::{Limits, Method, ParseError, Request, parse_request};
/// Map a request to `(status, content_type, body)`.
///
/// Pure, so the routing rules are unit-testable without a socket.
pub fn route(request: &Request) -> (u16, &'static str, Vec<u8>) {
let target = request.target.as_str();
if target == "/healthz" {
return match request.method {
Method::Get | Method::Head => (200, "text/plain", b"ok\n".to_vec()),
_ => method_not_allowed(),
};
}
if target == "/v1/gpu-workloads" {
return match request.method {
Method::Get | Method::Head => (
200,
"application/json",
br#"[{"id":"wl-a100-1","gpuCount":1,"state":"Running"}]"#.to_vec(),
),
Method::Post => (
201,
"application/json",
br#"{"id":"wl-a100-2","gpuCount":1,"state":"Pending"}"#.to_vec(),
),
_ => method_not_allowed(),
};
}
if let Some(id) = target.strip_prefix("/v1/gpu-workloads/") {
if id.is_empty() || id.contains('/') {
return (404, "text/plain", b"not found\n".to_vec());
}
return match request.method {
Method::Get | Method::Head => (
200,
"application/json",
format!(r#"{{"id":"{id}","state":"Running"}}"#).into_bytes(),
),
Method::Delete => (204, "", Vec::new()),
_ => method_not_allowed(),
};
}
(404, "text/plain", b"not found\n".to_vec())
}
fn method_not_allowed() -> (u16, &'static str, Vec<u8>) {
(405, "text/plain", b"method not allowed\n".to_vec())
}
fn status_reason(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
204 => "No Content",
400 => "Bad Request",
404 => "Not Found",
405 => "Method Not Allowed",
413 => "Payload Too Large",
_ => "Internal Server Error",
}
}
fn build_response(status: u16, content_type: &str, body: &[u8], keep_alive: bool) -> Vec<u8> {
let mut response = format!("HTTP/1.1 {} {}\r\n", status, status_reason(status));
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(if keep_alive {
"Connection: keep-alive\r\n"
} else {
"Connection: close\r\n"
});
response.push_str("\r\n");
let mut bytes = response.into_bytes();
bytes.extend_from_slice(body);
bytes
}
/// Serve connections forever, one thread per connection.
pub fn serve(listener: TcpListener) {
for incoming in listener.incoming() {
match incoming {
Ok(stream) => {
thread::spawn(move || {
if let Err(error) = handle_connection(stream) {
eprintln!("connection error: {error}");
}
});
}
Err(error) => eprintln!("accept error: {error}"),
}
}
}
fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> {
stream.set_nodelay(true)?;
let limits = Limits::default();
let mut buffer = Vec::new();
let mut chunk = [0u8; 4096];
loop {
match parse_request(&buffer, limits) {
Ok(request) => {
let keep_alive = request.should_keep_alive();
let (status, content_type, body) = route(&request);
let response = build_response(status, content_type, &body, keep_alive);
stream.write_all(&response)?;
if !keep_alive {
return Ok(());
}
buffer.clear();
}
Err(ParseError::Incomplete) => {
let read = stream.read(&mut chunk)?;
if read == 0 {
// Peer closed mid-request.
return Ok(());
}
buffer.extend_from_slice(&chunk[..read]);
}
Err(error) => {
let response =
build_response(400, "text/plain", error.to_string().as_bytes(), false);
stream.write_all(&response)?;
return Ok(());
}
}
}
}
fn main() {
let address = std::env::args()
.nth(1)
.unwrap_or_else(|| "127.0.0.1:8080".to_string());
let listener = TcpListener::bind(&address).expect("failed to bind");
let local = listener
.local_addr()
.expect("failed to read local address");
println!("http server listening on http://{local}");
serve(listener);
}
#[cfg(test)]
mod tests {
use systems_lab::problems::http_request::Version;
use super::*;
fn request(method: Method, target: &str) -> Request {
Request {
method,
target: target.to_string(),
version: Version::Http11,
headers: vec![("host".to_string(), "localhost".to_string())],
body: Vec::new(),
}
}
#[test]
fn healthz_is_ok() {
let (status, content_type, body) = route(&request(Method::Get, "/healthz"));
assert_eq!(status, 200);
assert_eq!(content_type, "text/plain");
assert_eq!(body, b"ok\n");
}
#[test]
fn lists_and_creates_workloads() {
let (status, _, _) = route(&request(Method::Get, "/v1/gpu-workloads"));
assert_eq!(status, 200);
let (status, content_type, body) = route(&request(Method::Post, "/v1/gpu-workloads"));
assert_eq!(status, 201);
assert_eq!(content_type, "application/json");
assert!(
String::from_utf8(body)
.expect("json")
.contains("Pending")
);
}
#[test]
fn gets_and_deletes_one_workload() {
let (status, _, body) = route(&request(Method::Get, "/v1/gpu-workloads/wl-7"));
assert_eq!(status, 200);
assert!(
String::from_utf8(body)
.expect("json")
.contains("wl-7")
);
let (status, _, body) = route(&request(Method::Delete, "/v1/gpu-workloads/wl-7"));
assert_eq!(status, 204);
assert!(body.is_empty());
}
#[test]
fn wrong_method_on_known_path_is_405() {
let (status, _, _) = route(&request(Method::Put, "/healthz"));
assert_eq!(status, 405);
let (status, _, _) = route(&request(Method::Put, "/v1/gpu-workloads"));
assert_eq!(status, 405);
}
#[test]
fn unknown_path_is_404() {
let (status, _, _) = route(&request(Method::Get, "/nope"));
assert_eq!(status, 404);
}
}
21.5 Questions that come up
“How does a server know where an HTTP/1.1 request body ends?”
Transfer-Encoding: chunked means read chunks until a zero-size chunk. Else Content-Length gives the
count. With neither header there is no body. For responses, closing the connection can also end the body.
“What is request smuggling, and how do you prevent it?”
Two servers frame the same bytes differently, so each sees a different message.
Reject requests with both framing headers. Reject conflicting Content-Lengths. Close the
connection after any framing error.
“How do you stop a client from exhausting your server’s memory?” Limit header size and body size. Check the declared length before reading the body. Time out idle and slow connections.
“Why is routing a separate pure function?” So the rules are testable without sockets. The protocol code (framing, keep-alive) can then be changed without touching the application logic.
“What does HTTP/2 change?” Binary framing with explicit lengths, many concurrent streams on one connection, and header compression. Framing ambiguity of the HTTP/1.1 kind disappears inside HTTP/2, although it can return where a proxy downgrades to HTTP/1.1.
Async Rust from the executor up
This chapter covers
- What
Future::poll,Poll::Pending, and aWakerpromise each other - Why one thread can hold thousands of waiting connections
block_on: an executor in a dozen lines that parks the thread between polls- Two futures written by hand: one that yields, and one that waits on a timer
- A task queue where waking a task is the same as scheduling it
- What an
asyncblock compiles to, and whypolltakesPin<&mut Self> - Two HTTP clients with caching and retries, one blocking and one on Tokio
Chapter 20’s echo server gave every client a thread, and chapter 21 parsed HTTP on top of it. That design works for tens or hundreds of clients. It stops working when a program holds thousands of mostly idle connections.
This chapter builds the runtime that replaces one thread per connection with one thread and many waiting tasks. It starts at the bottom, with the single method a Future has, and works up to a client on Tokio.
22.1 Why a thread per connection does not scale
A spawned thread in Rust is given a stack of 2 MiB by default. The stack stays reserved for as long as the thread lives, including all the time the thread spends parked in read. Ten thousand idle connections held as threads reserve about 20 GB of address space before any work happens.
The alternative is to keep one thread and represent each connection as a future. A future is a value that may not have finished yet. While it waits, it is not running on a thread. It is a small object that records where it stopped and what it was doing.
A task is a future a runtime has taken responsibility for. The runtime will poll the future again when there is a reason to.
A suspended future holds only the values alive across its pause points. An idle connection can be a few hundred bytes instead of two megabytes. Ten thousand of them fit in a few megabytes rather than tens of gigabytes. Figure 22.1 contrasts the two arrangements.
The cost of this saving is that waiting has to be written as a state machine. async and .await are the notation that lets the state machine still look like ordinary code.
You can now work out what a thread-per-connection design costs, and name what a future stores instead.
22.2 The contract
The chapter starts with the one method a Future has. A type that implements Future answers a single question: has the work finished?
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
poll returns Poll::Ready(value) when the work is done. It returns Poll::Pending when the work is not finished yet.
Pending carries an obligation. Before returning it, the future must arrange for cx.waker() to be called once progress is possible. The waker is a handle the executor gives to the future. Calling it tells the executor that this task should be polled again.
The executor has the matching obligation. It polls the task again after a wake. Until a wake arrives it does not poll the task, and it does not spin. Figure 22.2 draws both sides.
Two rules follow from the contract. Polling a future that is not ready is always safe: it returns Pending again, so a spurious wake costs one wasted poll. Forgetting to arrange a wake is not safe: the task returns Pending, nothing calls its waker, and the task never runs again.
WARNING The second failure has no symptom. There is no panic, no error, and no CPU use. One task stops making progress while the rest of the program carries on. When an async program hangs for no visible reason, look first for a waker that was dropped instead of called.
One part of the signature is explained in section 22.6: poll takes Pin<&mut Self>, not &mut self. For now, read it as a promise that the future will not move in memory between polls.
You can now state what each side of the contract promises, and say which broken promise shows up as a hang.
22.3 The smallest executor
.await does not call the operating system. Something has to call poll and decide what a Pending result means. That something is an executor.
The smallest useful executor drives one future on the current thread. When the future is not ready, it parks the thread rather than polling again. Listing 22.1 is the whole of it.
Listing 22.1 block_on and its waker (lines 36 to 61). src/problems/async_mini.rs
use std::{
collections::VecDeque,
future::Future,
pin::Pin,
sync::{
Arc,
Mutex,
atomic::{AtomicBool, Ordering},
},
task::{Context, Poll, Wake, Waker},
thread,
time::Duration,
};
pub fn block_on<F: Future>(future: F) -> F::Output {
let mut future = Box::pin(future);
let waker = Waker::from(Arc::new(ThreadWaker(thread::current())));
let mut context = Context::from_waker(&waker);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => return value,
// `park` may return spuriously, so the loop re-polls either way.
Poll::Pending => thread::park(),
}
}
}
/// Waker that unparks the thread which is driving the future.
struct ThreadWaker(thread::Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
block_on does three things, in order:
Box::pin(future)moves the future to the heap and pins it there, so its address is the same for every poll.Waker::from(Arc::new(ThreadWaker(thread::current())))builds a waker.ThreadWakerimplementsWake, and itswakemethod unparks the thread driving the future.Waker::fromis the safe way to build a waker without writing a raw vtable.- The loop polls.
Readyreturns the value.Pendingcallsthread::park(), which sleeps untilunparkis called on this thread.
park can return without anyone calling unpark. The loop tolerates that by polling again: a second poll of a future that is not ready returns Pending a second time. Spurious wakes are harmless, which is the other half of the contract.
park and unpark also cover a race the loop depends on. If unpark runs before park, it leaves a token behind, and the next park returns at once.
block_on driving a future that waits on a timer, the Delay of section 22.4.2, with the line each thread is running. In the second run the waker is never stored, and the executor never wakes.You can now read block_on line by line and explain why an idle future costs no CPU.
22.4 Two futures written by hand
Almost every future has one of two shapes. The first re-arms itself and asks to be polled again. The second waits on an event outside the executor, and keeps the waker of whoever is waiting for it.
22.4.1 A future that yields to itself
YieldTimes returns Pending a set number of times before it completes. Nothing is blocking it. It is giving the executor a chance to run other tasks before it finishes its own work.
Listing 22.2 YieldTimes, a future that re-arms itself (lines 63 to 97). src/problems/async_mini.rs
/// A future that yields to the executor `times` times before completing.
///
/// Completes with the number of times it yielded, which makes it easy to assert
/// that a runtime actually suspended and resumed a task.
pub struct YieldTimes {
remaining: usize,
yielded: usize,
}
impl YieldTimes {
pub fn new(times: usize) -> Self {
Self {
remaining: times,
yielded: 0,
}
}
}
impl Future for YieldTimes {
type Output = usize;
fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<usize> {
// `YieldTimes` is `Unpin`, so a mutable reference is safe to take.
let this = self.get_mut();
if this.remaining == 0 {
return Poll::Ready(this.yielded);
}
this.remaining -= 1;
this.yielded += 1;
// Ask to be polled again; an executor that ignores this hangs.
context.waker().wake_by_ref();
Poll::Pending
}
}
YieldTimes holds two usize values, which makes it Unpin: safe to move even after a poll. self.get_mut() uses that to turn Pin<&mut Self> into &mut Self without unsafe.
One line asks for the next poll: context.waker().wake_by_ref(). It runs, then poll returns Pending. This is what tokio::task::yield_now does. YieldTimes completes with the number of times it yielded. A test can use that count to check that the executor suspended and resumed it.
22.4.2 A future that waits on a timer
Delay completes after a wall-clock duration. It is the second shape, and it is the one that looks like real I/O.
Its state is a ready flag and an optional stored Waker, shared with a background thread through Arc<Mutex<_>>.
Listing 22.3 Delay state and constructor (lines 99 to 137). src/problems/async_mini.rs
struct DelayState {
ready: bool,
waker: Option<Waker>,
}
/// A future that completes after a wall-clock duration.
///
/// A background thread sleeps and then wakes the task. Storing and waking the
/// [`Waker`] — rather than spinning in `poll` — is the whole point: `poll` never
/// blocks the executor thread.
pub struct Delay {
state: Arc<Mutex<DelayState>>,
}
impl Delay {
pub fn new(duration: Duration) -> Self {
let state = Arc::new(Mutex::new(DelayState {
ready: false,
waker: None,
}));
let thread_state = Arc::clone(&state);
thread::spawn(move || {
thread::sleep(duration);
let waker = {
let mut guard = thread_state
.lock()
.expect("delay mutex poisoned");
guard.ready = true;
guard.waker.take()
};
if let Some(waker) = waker {
waker.wake();
}
});
Self { state }
}
}
poll checks ready. While it is false, poll stores a clone of the current waker and returns Pending.
Listing 22.4 Delay as a future (lines 139 to 154). src/problems/async_mini.rs
impl Future for Delay {
type Output = ();
fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
let mut guard = self
.state
.lock()
.expect("delay mutex poisoned");
if guard.ready {
Poll::Ready(())
} else {
guard.waker = Some(context.waker().clone());
Poll::Pending
}
}
}
The background thread sleeps, sets ready, takes the waker out of the state, releases the lock, and calls wake(). Figure 22.3 shows the exchange.
Delay from start to finish. The executor thread sleeps in park, not in poll.Two details make it correct. poll stores the waker on every Pending, not only the first. A task can be polled with a different waker each time. A runtime may move the task to another thread, and only the latest waker reaches its current owner. The timer thread wakes after dropping the lock, so the woken executor does not immediately block on a mutex the waker still held.
A real runtime replaces one thread per timer with a single timer wheel. It replaces the thread per socket with the epoll loop from chapter 20. That loop’s reactor keeps wakers by file descriptor, and wakes them when epoll_wait reports the descriptor ready.
You can now write both shapes: a future that re-arms itself, and a future that keeps a waker for an outside event.
22.5 Waking is scheduling
block_on drives one future. A general executor drives many, and it shows that waking a task and scheduling it are the same operation.
A Task holds its future, a handle to the executor’s queue, and a completed flag. It also implements Wake. Waking a task pushes an Arc of the task back on the queue.
Listing 22.5 A task, and the Wake impl that reschedules it (lines 156 to 177). src/problems/async_mini.rs
struct Task {
future: Mutex<Pin<Box<dyn Future<Output = ()> + Send>>>,
queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
completed: AtomicBool,
}
impl Wake for Task {
fn wake(self: Arc<Self>) {
// Re-enqueue the task. Cloning the `Arc` is a refcount bump, not a deep copy.
self.queue
.lock()
.expect("executor queue poisoned")
.push_back(Arc::clone(&self));
}
fn wake_by_ref(self: &Arc<Self>) {
self.queue
.lock()
.expect("executor queue poisoned")
.push_back(Arc::clone(self));
}
}
spawn wraps a future in a task and enqueues it.
Listing 22.6 The executor's queue, and spawn (lines 184 to 208). src/problems/async_mini.rs
#[derive(Default)]
pub struct MiniExecutor {
queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
}
impl MiniExecutor {
pub fn new() -> Self {
Self::default()
}
/// Add a task to the executor.
pub fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + Send + 'static,
{
let task = Arc::new(Task {
future: Mutex::new(Box::pin(future)),
queue: Arc::clone(&self.queue),
completed: AtomicBool::new(false),
});
self.queue
.lock()
.expect("executor queue poisoned")
.push_back(task);
}
// ...
}
run pops tasks one at a time, builds a waker from the task itself, and polls the future. A Ready result marks the task completed, so a late wake is skipped by the completed check.
Listing 22.7 MiniExecutor::run (lines 210 to 245). src/problems/async_mini.rs
impl MiniExecutor {
// ...
/// Run until the queue is empty.
///
/// A task that returns `Pending` without waking stays suspended, which is why
/// this returns rather than blocks: a runtime with timers or I/O would also
/// park on an event source.
pub fn run(&self) {
loop {
let Some(task) = self
.queue
.lock()
.expect("executor queue poisoned")
.pop_front()
else {
return;
};
if task.completed.load(Ordering::Acquire) {
continue;
}
let waker = Waker::from(Arc::clone(&task));
let mut context = Context::from_waker(&waker);
let mut future = task
.future
.lock()
.expect("task mutex poisoned");
if future
.as_mut()
.poll(&mut context)
.is_ready()
{
task.completed
.store(true, Ordering::Release);
}
}
}
}
Pending without waking is never polled again.run returns when the queue is empty. A task that returned Pending without waking is waiting on something outside this executor, and this executor has nothing to park on. A real runtime would park on its timer or its I/O reactor instead of returning.
The + Send bound on spawned futures comes from the waker. A waker may be sent to another thread, as Delay does, so the task it can reach must be safe to send too. This is the same bound tokio::spawn imposes. It is also why holding a std::sync::MutexGuard or an Rc across an .await in a spawned task is a compile error.
Listings 22.8 and 22.9 are the complete pair: the runtime module, and the program that drives it, with no runtime dependency.
Listing 22.8 The complete runtime, with tests. src/problems/async_mini.rs
//! A minimal async runtime, built from `Future`, `Waker`, and `Pin`.
//!
//! `.await` is not magic. A `Future` is a state machine with one method, `poll`,
//! that returns `Ready(value)` or `Pending`; when it returns `Pending` it must
//! arrange for the `Waker` in its `Context` to be called later. An executor is a
//! loop that polls tasks and parks until a waker fires.
//!
//! This module implements that loop twice, from scratch and without a runtime
//! dependency:
//!
//! - [`block_on`] drives one future on the current thread, parking on `Pending`;
//! - [`MiniExecutor`] queues independent tasks and re-enqueues them when woken.
//!
//! It is deliberately small. It makes three things concrete: why `poll` takes `Pin<&mut Self>`, what `Pending` promises, and where
//! `Send` matters for a multi-threaded runtime.
use std::{
collections::VecDeque,
future::Future,
pin::Pin,
sync::{
Arc,
Mutex,
atomic::{AtomicBool, Ordering},
},
task::{Context, Poll, Wake, Waker},
thread,
time::Duration,
};
/// Block the current thread until `future` completes.
///
/// The future is pinned on the heap so its address is stable across `await`
/// points, and the thread parks whenever the future reports `Pending`. The waker
/// unparks it, so the loop polls again only when there is a reason to.
pub fn block_on<F: Future>(future: F) -> F::Output {
let mut future = Box::pin(future);
let waker = Waker::from(Arc::new(ThreadWaker(thread::current())));
let mut context = Context::from_waker(&waker);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => return value,
// `park` may return spuriously, so the loop re-polls either way.
Poll::Pending => thread::park(),
}
}
}
/// Waker that unparks the thread which is driving the future.
struct ThreadWaker(thread::Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
/// A future that yields to the executor `times` times before completing.
///
/// Completes with the number of times it yielded, which makes it easy to assert
/// that a runtime actually suspended and resumed a task.
pub struct YieldTimes {
remaining: usize,
yielded: usize,
}
impl YieldTimes {
pub fn new(times: usize) -> Self {
Self {
remaining: times,
yielded: 0,
}
}
}
impl Future for YieldTimes {
type Output = usize;
fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<usize> {
// `YieldTimes` is `Unpin`, so a mutable reference is safe to take.
let this = self.get_mut();
if this.remaining == 0 {
return Poll::Ready(this.yielded);
}
this.remaining -= 1;
this.yielded += 1;
// Ask to be polled again; an executor that ignores this hangs.
context.waker().wake_by_ref();
Poll::Pending
}
}
struct DelayState {
ready: bool,
waker: Option<Waker>,
}
/// A future that completes after a wall-clock duration.
///
/// A background thread sleeps and then wakes the task. Storing and waking the
/// [`Waker`] — rather than spinning in `poll` — is the whole point: `poll` never
/// blocks the executor thread.
pub struct Delay {
state: Arc<Mutex<DelayState>>,
}
impl Delay {
pub fn new(duration: Duration) -> Self {
let state = Arc::new(Mutex::new(DelayState {
ready: false,
waker: None,
}));
let thread_state = Arc::clone(&state);
thread::spawn(move || {
thread::sleep(duration);
let waker = {
let mut guard = thread_state
.lock()
.expect("delay mutex poisoned");
guard.ready = true;
guard.waker.take()
};
if let Some(waker) = waker {
waker.wake();
}
});
Self { state }
}
}
impl Future for Delay {
type Output = ();
fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
let mut guard = self
.state
.lock()
.expect("delay mutex poisoned");
if guard.ready {
Poll::Ready(())
} else {
guard.waker = Some(context.waker().clone());
Poll::Pending
}
}
}
struct Task {
future: Mutex<Pin<Box<dyn Future<Output = ()> + Send>>>,
queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
completed: AtomicBool,
}
impl Wake for Task {
fn wake(self: Arc<Self>) {
// Re-enqueue the task. Cloning the `Arc` is a refcount bump, not a deep copy.
self.queue
.lock()
.expect("executor queue poisoned")
.push_back(Arc::clone(&self));
}
fn wake_by_ref(self: &Arc<Self>) {
self.queue
.lock()
.expect("executor queue poisoned")
.push_back(Arc::clone(self));
}
}
/// A single-threaded, `Send`-only executor.
///
/// Real runtimes add work stealing, timers, and I/O readiness. This keeps only the
/// part that matters for explaining how `.await` makes progress: a queue of tasks
/// where waking a task puts it back on the queue.
#[derive(Default)]
pub struct MiniExecutor {
queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
}
impl MiniExecutor {
pub fn new() -> Self {
Self::default()
}
/// Add a task to the executor.
pub fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + Send + 'static,
{
let task = Arc::new(Task {
future: Mutex::new(Box::pin(future)),
queue: Arc::clone(&self.queue),
completed: AtomicBool::new(false),
});
self.queue
.lock()
.expect("executor queue poisoned")
.push_back(task);
}
/// Run until the queue is empty.
///
/// A task that returns `Pending` without waking stays suspended, which is why
/// this returns rather than blocks: a runtime with timers or I/O would also
/// park on an event source.
pub fn run(&self) {
loop {
let Some(task) = self
.queue
.lock()
.expect("executor queue poisoned")
.pop_front()
else {
return;
};
if task.completed.load(Ordering::Acquire) {
continue;
}
let waker = Waker::from(Arc::clone(&task));
let mut context = Context::from_waker(&waker);
let mut future = task
.future
.lock()
.expect("task mutex poisoned");
if future
.as_mut()
.poll(&mut context)
.is_ready()
{
task.completed
.store(true, Ordering::Release);
}
}
}
}
#[cfg(test)]
mod tests {
use std::{sync::atomic::AtomicUsize, time::Instant};
use super::*;
#[test]
fn block_on_drives_a_hand_written_future() {
assert_eq!(block_on(YieldTimes::new(3)), 3);
}
#[test]
fn block_on_polls_an_async_block() {
let total = block_on(async {
let a = YieldTimes::new(1).await;
let b = YieldTimes::new(2).await;
a + b
});
assert_eq!(total, 3);
}
#[test]
fn block_on_waits_for_a_delay() {
let start = Instant::now();
block_on(Delay::new(Duration::from_millis(30)));
assert!(start.elapsed() >= Duration::from_millis(20));
}
#[test]
fn executor_runs_spawned_tasks_to_completion() {
let executor = MiniExecutor::new();
let counter = Arc::new(AtomicUsize::new(0));
for _ in 0..4 {
let counter = Arc::clone(&counter);
executor.spawn(async move {
YieldTimes::new(2).await;
counter.fetch_add(1, Ordering::SeqCst);
});
}
executor.run();
assert_eq!(counter.load(Ordering::SeqCst), 4);
}
#[test]
fn executor_polls_an_immediately_ready_task() {
let executor = MiniExecutor::new();
let flag = Arc::new(AtomicBool::new(false));
let task_flag = Arc::clone(&flag);
executor.spawn(async move {
task_flag.store(true, Ordering::SeqCst);
});
executor.run();
assert!(flag.load(Ordering::SeqCst));
}
}
Listing 22.9 The program that drives the runtime. src/bin/async_demo.rs
//! Runs the hand-written async runtime in [`systems_lab::problems::async_mini`].
//!
//! There is no Tokio here on purpose: the point is to show that `Future`, `Waker`,
//! `Poll`, and `Pin` are enough to build an executor, and that `.await` is just
//! sugar over a state machine that is polled.
//!
//! ```bash
//! cargo run --bin async_demo
//! ```
use std::{
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use systems_lab::problems::async_mini::{Delay, MiniExecutor, YieldTimes, block_on};
fn main() {
println!("== block_on drives a hand-written future ==");
let yields = block_on(YieldTimes::new(3));
println!("YieldTimes suspended and resumed {yields} time(s)");
println!("\n== block_on drives an async block ==");
let total = block_on(async {
let first = YieldTimes::new(1).await;
let second = YieldTimes::new(2).await;
first + second
});
println!("async block finished with {total}");
println!("\n== block_on parks the thread until the waker fires ==");
let start = Instant::now();
block_on(Delay::new(Duration::from_millis(50)));
println!("Delay completed after {:?}", start.elapsed());
println!("\n== MiniExecutor runs independent tasks ==");
let executor = MiniExecutor::new();
let completed = Arc::new(AtomicUsize::new(0));
for id in 1..=3 {
let completed = Arc::clone(&completed);
executor.spawn(async move {
YieldTimes::new(id).await;
completed.fetch_add(1, Ordering::SeqCst);
println!("task {id} completed after {id} yield(s)");
});
}
executor.run();
println!("{} task(s) completed", completed.load(Ordering::SeqCst));
}
$ cargo run --bin async_demo
== block_on drives a hand-written future ==
YieldTimes suspended and resumed 3 time(s)
== block_on drives an async block ==
async block finished with 3
== block_on parks the thread until the waker fires ==
Delay completed after 52.083242ms
== MiniExecutor runs independent tasks ==
task 1 completed after 1 yield(s)
task 2 completed after 2 yield(s)
task 3 completed after 3 yield(s)
3 task(s) completed
The tasks finish in the order 1, 2, 3 because each yield sends its task to the back of the queue. Each task takes a turn, as in the round-robin scheduler of chapter 15, with a yield as the end of a time slice.
You can now follow a task from spawn to Ready, and say what wake does to the queue.
22.6 What async compiles to, and why Pin
An async block or function is notation. The compiler turns it into an anonymous type that implements Future. That type is usually an enum with one variant per suspension point, and each variant holds the values alive across that point.
The block used by the demo is:
async {
let first = YieldTimes::new(1).await;
let second = YieldTimes::new(2).await;
first + second
}
The compiler writes an enum with four states: Start, Awaiting first, Awaiting second, and Done. Figure 22.4 shows the states and the values each one keeps.
first survives the second .await, so it is stored in the second state.Polling the block runs code until the next .await whose inner future returns Pending. It saves the state, stores the inner future and the live locals, and returns Pending too. A later poll resumes at the saved state rather than starting the block again.
first lives across the second .await, so the future stores it. second does not, so the future never stores it.first survives the second .await, so it is stored in the Awaiting second state. The size of a future is fixed once its type is known. The compiler sizes each state to hold the largest set of locals that can live there.
The remaining question is why poll takes Pin<&mut Self>. A state can hold a reference to another field of the same state machine. One example is a local buffer that is borrowed and still used across an .await. If the machine moved after the borrow was created, the reference would point at the old location. Figure 22.5 shows the two cases.
Pin<&mut Self> is the promise that the value will not move after the first poll. That is why block_on calls Box::pin and why MiniExecutor stores Pin<Box<...>>. Both keep the future at one address for its whole life.
Pinning is not a property of every type. A type whose values are safe to move even after a poll is Unpin, and for those types pinning adds no restriction. YieldTimes is Unpin because it holds two integers, so its poll can call self.get_mut() and work with &mut Self directly.
Box::pin keeps the future at one address, so moving the handle the program holds moves only a pointer.You can now read an async block as an enum of states, and explain what Pin stops the compiler from doing.
22.7 Two HTTP clients with caching, retries, and idempotency
The last two programs put a runtime to work. Both send POST /post with a JSON body to httpbin.org, a public echo service, with the idempotency key create-demo-001. Both have the same three layers: a cache, a retry policy, and a transport. Figure 22.6 shows the stacks.
Chapter 19 built retries and idempotency keys on their own. Here they meet a network call.
22.7.1 A blocking client over TcpStream
The blocking client is built from four parts: a request type, a cache, a retry policy, and the client that joins them. A request is an enum, and each variant carries exactly the fields its HTTP method needs:
use std::{
collections::HashMap,
io::{Read, Write},
net::TcpStream,
sync::Mutex,
time::Duration,
};
type RuntimeError = Box<dyn std::error::Error>;
/// Request headers, as (name, value) pairs.
type RequestHeader = Vec<(String, String)>;
/// A request, with exactly the fields each method needs.
enum RequestType {
Get {
path: String,
headers: RequestHeader,
},
Post {
path: String,
headers: RequestHeader,
body: String,
},
}
RuntimeError is a boxed error, so ? can return any error type from main. RequestHeader is an alias for a
list of (name, value) pairs. A Get has no body and a Post has one, so a GET with a body cannot be built.
The cache keeps each successful response under its idempotency key:
/// Successful responses, stored by idempotency key.
struct IdemCache {
store: Mutex<HashMap<String, String>>,
}
impl IdemCache {
fn new() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}
fn get_or_insert<F>(&self, key: &str, f: F) -> Result<String, RuntimeError>
where
F: FnOnce() -> Result<String, RuntimeError>,
{
let mut store = self
.store
.lock()
.map_err(|_| "lock poisoned")?;
if let Some(v) = store.get(key) {
return Ok(v.clone());
}
let v = f()?;
store.insert(key.to_string(), v.clone());
Ok(v)
}
}
IdemCache is chapter 19’s Idempotent<K, V> store with String keys and values. get_or_insert returns the
stored response if there is one. Otherwise it runs f, stores the result, and returns it. A failure is not
stored, so a later call tries again. The lock is held while f runs, so two calls with one key cannot both send.
The retry policy runs an operation up to max_attempts times:
struct RetryPolicy {
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
}
impl RetryPolicy {
fn new() -> Self {
Self {
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
}
}
fn execute<F, T>(&self, mut f: F) -> Result<T, RuntimeError>
where
F: FnMut() -> Result<T, RuntimeError>,
{
let mut attempt = 0;
loop {
attempt += 1;
match f() {
Ok(v) => return Ok(v),
Err(e) if attempt >= self.max_attempts => return Err(e),
Err(_) => {
let delay = (self.base_delay * 2u32.pow(attempt - 1)).min(self.max_delay);
std::thread::sleep(delay);
}
}
}
}
}
After each failure except the last, execute sleeps for base × 2^(attempt - 1), capped at max_delay: 100 ms
after the first failure, 200 ms after the second. F is FnMut, not FnOnce, because the loop calls it more
than once.
The client holds the server’s address and one of each layer. execute joins the layers in one line:
/// An HTTP/1.1 client with a response cache and a retry policy.
struct HttpClient {
host: String,
port: u16,
cache: IdemCache,
retry: RetryPolicy,
}
impl HttpClient {
fn new(host: &str, port: u16) -> Self {
Self {
host: host.to_string(),
port,
cache: IdemCache::new(),
retry: RetryPolicy::new(),
}
}
fn execute(&self, key: &str, req: &RequestType) -> Result<String, RuntimeError> {
self.cache
.get_or_insert(key, || self.retry.execute(|| self.send(req)))
}
// ...
}
The cache calls the retry policy only on a miss, and the retry policy calls send once per attempt.
send writes the request by hand and reads the response:
impl HttpClient {
// ...
fn send(&self, req: &RequestType) -> Result<String, RuntimeError> {
let (method, path, headers, body) = match req {
RequestType::Get { path, headers } => ("GET", path, headers, None),
RequestType::Post {
path,
headers,
body,
} => ("POST", path, headers, Some(body)),
};
let mut raw = format!(
"{method} {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n",
self.host
);
for (k, v) in headers {
raw += &format!("{k}: {v}\r\n");
}
if let Some(b) = body {
raw += &format!("Content-Length: {}\r\n", b.len());
}
raw += "\r\n";
if let Some(b) = body {
raw += b;
}
let mut stream = TcpStream::connect((self.host.as_str(), self.port))?;
stream.write_all(raw.as_bytes())?;
let mut resp = String::new();
stream.read_to_string(&mut resp)?;
Ok(resp
.split_once("\r\n\r\n")
.map(|(_, b)| b)
.unwrap_or("")
.to_string())
}
}
The match turns either variant into the same four parts: the method name, the path, the headers, and an
optional body. The request text then follows section 21.1. It is the request line, Host,
Connection: close, and the caller’s headers. A body adds Content-Length, then the blank line, then the body.
Connection: close lets read_to_string read the whole response, because the server closes the connection at
the end. split_once("\r\n\r\n") drops the status line and the headers, and keeps the body.
main reads with a GET, then creates an item with a POST. Each request has its own idempotency key:
// --- The actual REST call ---
fn create_item(client: &HttpClient, item_name: &str) -> Result<String, RuntimeError> {
client.execute(
"create-demo-001",
&RequestType::Post {
path: "/post".into(),
headers: vec![
("Content-Type".into(), "application/json".into()),
("Accept".into(), "application/json".into()),
],
body: format!(r#"{{"name":"{item_name}"}}"#),
},
)
}
fn read_items(client: &HttpClient) -> Result<String, RuntimeError> {
client.execute(
"read-demo-001",
&RequestType::Get {
path: "/get".into(),
headers: vec![("Accept".into(), "application/json".into())],
},
)
}
fn main() -> Result<(), RuntimeError> {
let client = HttpClient::new("httpbin.org", 80);
println!("read:\n{}", read_items(&client)?);
println!("created:\n{}", create_item(&client, "demo")?);
Ok(())
}
Listing 22.10 Cache, retry, and a hand-written HTTP/1.1 request. src/bin/fun_network_call.rs
use std::{
collections::HashMap,
io::{Read, Write},
net::TcpStream,
sync::Mutex,
time::Duration,
};
type RuntimeError = Box<dyn std::error::Error>;
/// Request headers, as (name, value) pairs.
type RequestHeader = Vec<(String, String)>;
/// A request, with exactly the fields each method needs.
enum RequestType {
Get {
path: String,
headers: RequestHeader,
},
Post {
path: String,
headers: RequestHeader,
body: String,
},
}
/// Successful responses, stored by idempotency key.
struct IdemCache {
store: Mutex<HashMap<String, String>>,
}
impl IdemCache {
fn new() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}
fn get_or_insert<F>(&self, key: &str, f: F) -> Result<String, RuntimeError>
where
F: FnOnce() -> Result<String, RuntimeError>,
{
let mut store = self
.store
.lock()
.map_err(|_| "lock poisoned")?;
if let Some(v) = store.get(key) {
return Ok(v.clone());
}
let v = f()?;
store.insert(key.to_string(), v.clone());
Ok(v)
}
}
struct RetryPolicy {
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
}
impl RetryPolicy {
fn new() -> Self {
Self {
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
}
}
fn execute<F, T>(&self, mut f: F) -> Result<T, RuntimeError>
where
F: FnMut() -> Result<T, RuntimeError>,
{
let mut attempt = 0;
loop {
attempt += 1;
match f() {
Ok(v) => return Ok(v),
Err(e) if attempt >= self.max_attempts => return Err(e),
Err(_) => {
let delay = (self.base_delay * 2u32.pow(attempt - 1)).min(self.max_delay);
std::thread::sleep(delay);
}
}
}
}
}
/// An HTTP/1.1 client with a response cache and a retry policy.
struct HttpClient {
host: String,
port: u16,
cache: IdemCache,
retry: RetryPolicy,
}
impl HttpClient {
fn new(host: &str, port: u16) -> Self {
Self {
host: host.to_string(),
port,
cache: IdemCache::new(),
retry: RetryPolicy::new(),
}
}
fn execute(&self, key: &str, req: &RequestType) -> Result<String, RuntimeError> {
self.cache
.get_or_insert(key, || self.retry.execute(|| self.send(req)))
}
fn send(&self, req: &RequestType) -> Result<String, RuntimeError> {
let (method, path, headers, body) = match req {
RequestType::Get { path, headers } => ("GET", path, headers, None),
RequestType::Post {
path,
headers,
body,
} => ("POST", path, headers, Some(body)),
};
let mut raw = format!(
"{method} {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n",
self.host
);
for (k, v) in headers {
raw += &format!("{k}: {v}\r\n");
}
if let Some(b) = body {
raw += &format!("Content-Length: {}\r\n", b.len());
}
raw += "\r\n";
if let Some(b) = body {
raw += b;
}
let mut stream = TcpStream::connect((self.host.as_str(), self.port))?;
stream.write_all(raw.as_bytes())?;
let mut resp = String::new();
stream.read_to_string(&mut resp)?;
Ok(resp
.split_once("\r\n\r\n")
.map(|(_, b)| b)
.unwrap_or("")
.to_string())
}
}
// --- The actual REST call ---
fn create_item(client: &HttpClient, item_name: &str) -> Result<String, RuntimeError> {
client.execute(
"create-demo-001",
&RequestType::Post {
path: "/post".into(),
headers: vec![
("Content-Type".into(), "application/json".into()),
("Accept".into(), "application/json".into()),
],
body: format!(r#"{{"name":"{item_name}"}}"#),
},
)
}
fn read_items(client: &HttpClient) -> Result<String, RuntimeError> {
client.execute(
"read-demo-001",
&RequestType::Get {
path: "/get".into(),
headers: vec![("Accept".into(), "application/json".into())],
},
)
}
fn main() -> Result<(), RuntimeError> {
let client = HttpClient::new("httpbin.org", 80);
println!("read:\n{}", read_items(&client)?);
println!("created:\n{}", create_item(&client, "demo")?);
Ok(())
}
Two gaps separate this from a client you would ship, and both are visible in the code:
- The status code is never read.
sendreturns whatever follows the first blank line. A500error page is returned as success, cached under the idempotency key, and never retried. Only transport errors reach the retry loop. - Everything is retried. A policy should ask the error, as
Retryabledoes in chapter 19. Here a DNS failure for a misspelled host is retried three times.
22.7.2 An async client with reqwest
The async client keeps the same layers, on Tokio and the reqwest crate. It also reads the status code, and
decides from it whether to retry. Its settings come first:
use std::{collections::HashMap, sync::Mutex, time::Duration};
use reqwest::{Client, Method, StatusCode};
use serde_json::Value;
type Error = Box<dyn std::error::Error + Send + Sync>;
const RETRY: [StatusCode; 5] = [
StatusCode::TOO_MANY_REQUESTS,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::BAD_GATEWAY,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::GATEWAY_TIMEOUT,
];
struct Config {
base: String,
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
timeout: Duration,
}
struct Api {
http: Client,
cache: Mutex<HashMap<String, String>>,
cfg: Config,
}
RETRY lists the statuses to retry, where the server may succeed later: 429, Too Many Requests, and four
5xx codes. Config holds the base URL, the retry settings, and a timeout. Api holds a reqwest::Client, the
cache, and the configuration. type Error = Box<dyn std::error::Error + Send + Sync> lets errors cross task
boundaries, because a Send + Sync error can move between Tokio’s worker threads.
new builds the HTTP client, and two helpers read and write the cache:
impl Api {
fn new(cfg: Config) -> Result<Self, Error> {
Ok(Self {
http: Client::builder()
.timeout(cfg.timeout)
.build()?,
cache: Mutex::new(HashMap::new()),
cfg,
})
}
fn cached(&self, key: &str) -> Option<String> {
self.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(key)
.cloned()
}
fn store(&self, key: &str, text: &str) {
self.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key.into(), text.into());
}
// ...
}
Client::builder().timeout(cfg.timeout)bounds every request. A client without a timeout can wait forever on a server that accepted the connection and never answered.- The cache is a
std::sync::Mutex. That is allowed in async code because the guard is never held across an.await:cachedandstorelock, act, and return. The lock is taken withunwrap_or_else(|e| e.into_inner()), chapter 16’s ignore-the-poison choice. AHashMapinsert cannot leave the map half-updated, so recovering the guard is safe here.
call checks the cache, then sends and retries:
impl Api {
// ...
async fn call(
&self,
key: &str,
method: Method,
path: &str,
body: Option<&Value>,
) -> Result<String, Error> {
if let Some(hit) = self.cached(key) {
return Ok(hit);
}
let url = format!("{}{}", self.cfg.base, path);
let mut delay = self.cfg.base_delay;
let mut attempt = 0;
loop {
attempt += 1;
let req = self
.http
.request(method.clone(), &url)
.header("Idempotency-Key", key)
.header("Accept", "application/json");
let req = match body {
Some(b) => req.json(b),
None => req,
};
let resp = req.send().await?;
let status = resp.status();
let text = resp.text().await?;
match status {
s if s.is_success() => {
self.store(key, &text);
return Ok(text);
}
s if RETRY.contains(&s) && attempt < self.cfg.max_attempts => {
tokio::time::sleep(delay).await;
delay = (delay * 2).min(self.cfg.max_delay);
}
s => return Err(format!("HTTP {s} after {attempt} attempts: {text}").into()),
}
}
}
}
Each attempt builds a request with self.http.request(method, url). It adds two headers, and for a body calls
req.json(b), which serializes the JSON value and sets Content-Type. The request carries
Idempotency-Key: create-demo-001, so a server that honors the header can deduplicate a retried POST. The
client-side cache gives the same guarantee within one process.
req.send().await? sends the request and waits for the response headers. resp.text().await? waits for the
body. The match on the status then has three arms:
- A success is stored in the cache and returned.
- A status in
RETRY, with attempts left, sleeps and doubles the delay. - Anything else is returned as an error that names the status and the attempts.
tokio::time::sleep does not block the thread. It returns a future that registers with Tokio’s timer and
returns Pending, which is the Delay of section 22.4.2 done properly. Other tasks run on the same thread while
this one waits.
main builds the client and makes one call:
#[tokio::main]
async fn main() -> Result<(), Error> {
let api = Api::new(Config {
base: "https://httpbin.org".into(),
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
timeout: Duration::from_secs(15),
})?;
let body = serde_json::json!({ "name": "demo" });
let out = api
.call("create-demo-001", Method::POST, "/post", Some(&body))
.await?;
println!("created:\n{out}");
Ok(())
}
#[tokio::main] starts a Tokio runtime and runs main as its first task, so main can be async.
serde_json::json! builds a JSON value from JSON-like syntax.
Listing 22.11 The same layers on Tokio. src/bin/reqwest_and_tokio.rs
use std::{collections::HashMap, sync::Mutex, time::Duration};
use reqwest::{Client, Method, StatusCode};
use serde_json::Value;
type Error = Box<dyn std::error::Error + Send + Sync>;
const RETRY: [StatusCode; 5] = [
StatusCode::TOO_MANY_REQUESTS,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::BAD_GATEWAY,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::GATEWAY_TIMEOUT,
];
struct Config {
base: String,
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
timeout: Duration,
}
struct Api {
http: Client,
cache: Mutex<HashMap<String, String>>,
cfg: Config,
}
impl Api {
fn new(cfg: Config) -> Result<Self, Error> {
Ok(Self {
http: Client::builder()
.timeout(cfg.timeout)
.build()?,
cache: Mutex::new(HashMap::new()),
cfg,
})
}
fn cached(&self, key: &str) -> Option<String> {
self.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(key)
.cloned()
}
fn store(&self, key: &str, text: &str) {
self.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key.into(), text.into());
}
async fn call(
&self,
key: &str,
method: Method,
path: &str,
body: Option<&Value>,
) -> Result<String, Error> {
if let Some(hit) = self.cached(key) {
return Ok(hit);
}
let url = format!("{}{}", self.cfg.base, path);
let mut delay = self.cfg.base_delay;
let mut attempt = 0;
loop {
attempt += 1;
let req = self
.http
.request(method.clone(), &url)
.header("Idempotency-Key", key)
.header("Accept", "application/json");
let req = match body {
Some(b) => req.json(b),
None => req,
};
let resp = req.send().await?;
let status = resp.status();
let text = resp.text().await?;
match status {
s if s.is_success() => {
self.store(key, &text);
return Ok(text);
}
s if RETRY.contains(&s) && attempt < self.cfg.max_attempts => {
tokio::time::sleep(delay).await;
delay = (delay * 2).min(self.cfg.max_delay);
}
s => return Err(format!("HTTP {s} after {attempt} attempts: {text}").into()),
}
}
}
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let api = Api::new(Config {
base: "https://httpbin.org".into(),
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
timeout: Duration::from_secs(15),
})?;
let body = serde_json::json!({ "name": "demo" });
let out = api
.call("create-demo-001", Method::POST, "/post", Some(&body))
.await?;
println!("created:\n{out}");
Ok(())
}
22.7.3 The two clients side by side
The asynchronous client has the opposite gap from the blocking one. It reads the status code, but a transport error from send().await? is not retried at all. The blocking client retries transport errors and never reads the status code. Merging the two means retrying both, with a policy that asks the error which kind it is.
Neither client adds jitter, and neither honors Retry-After. Chapter 19 shows both in its delay policy.
22.7.4 Where the merged client belongs
The repository has an empty crate named rust-async-http-examples, created for this. Its first program can be the client from section 22.7.2 moved into lib.rs, with the retry and cache layers exposed for testing. Its tests can then run against a local server such as chapter 17’s, instead of a public one.
22.8 Questions that come up
“What happens when a future returns Pending?”
It has arranged for its waker to be called when it can make progress. The executor stops polling it and runs other tasks. When the waker fires, the executor polls it again.
“Why does poll take Pin<&mut Self>?”
An async state machine can hold a reference into itself across an .await. Pinning guarantees the value will not move after the first poll, so that reference stays valid.
“Why must spawned futures be Send in Tokio?”
The multi-threaded runtime may resume a task on a different worker thread after any .await. Everything the task holds across an .await must therefore be safe to move between threads.
“Can you call blocking code inside async?”
Only briefly. A blocking call holds the worker thread, and every task scheduled on it waits. Use tokio::task::spawn_blocking for blocking work, and async versions of I/O and sleep.
“Is a std::sync::Mutex allowed in async code?”
Yes, if the guard is dropped before the next .await. Holding it across an .await can block the worker or deadlock it. Use tokio::sync::Mutex when the lock must be held across an await.
Summary
- A future has one method,
poll.Readyfinishes the task.Pendingis a promise that the waker will be called when progress becomes possible. - The executor polls again after a wake and does not spin. Spurious wakes are harmless; a missing wake is a task that never runs again, with no error.
block_onis the whole executor for one future: pin, build a waker from the current thread, poll, and park onPending.- A future either re-arms itself (
YieldTimes,yield_now) or keeps a waker for an event outside the executor (Delay, sockets, timers). - Waking a task and scheduling it are the same operation.
TaskimplementsWake, andwakepushes the task back on the queue. - An
asyncblock compiles to an enum with one variant per suspension point. Each variant holds the values alive across that point. polltakesPin<&mut Self>because a state can borrow from another field of the same future. Pinning keeps the value at one address, so the borrow stays valid.- A spawned future must be
Send, because a waker may move it to another worker thread. - The two HTTP clients share three layers: cache, retry, and transport. The blocking client never reads the status code; the async one never retries a transport error.
Chapter 23 goes below the system calls these chapters made. It maps a file into memory and watches the page faults that load it. Then it sends a file to a socket without copying it through the program.
Exercises
- Add a poll counter to
block_onand print it. Check that the count matches the number of wakes forDelay, and that a spurious wake adds one poll without breaking anything. - Write a
ReadyNowfuture that returnsReadyon its first poll. Confirm thatMiniExecutor::runcompletes it in one poll. - Change
MiniExecutor::runto return the number of tasks it completed. Test it with tasks that yield different numbers of times. - Give
DelayaDropthat sets a flag the timer thread checks, so a cancelled delay does not keep a thread asleep. - Merge the two HTTP clients. Retry a retryable status and a transport error, add jitter to the delay, and honor
Retry-Afterwhen the server sends it. - Move the async client into
rust-async-http-examplesas a library, and test it against chapter 21’s local server.
Mapping files and copying less
This chapter covers
- The page cache: where a file’s bytes live in RAM, and the copy that
readmakes from it mmap: a file’s pages placed in your address space, loaded by page faults on first touch- Shared and private mappings,
msync, and copy-on-write - What a mapping cannot report as an error, and when
readis the better tool - Sending a file to a socket with
sendfile, without passing its bytes through your process - Why TLS puts the bytes back into user space
Part 7 works at the boundary between your program and the kernel. Chapter 2 read files with read, and
chapter 15 explained pages and page faults. This chapter joins the two. It maps a file into memory and
watches the page faults that load it. Then it sends a file over a socket without copying its bytes into
the program at all.
Two programs carry the chapter. mmap_file maps a 64-page file, counts the faults that two passes over it
take, and writes through a shared and a private mapping. zero_copy sends a 256 MiB file over a loopback
socket twice: once with a read/write loop, and once with sendfile.
23.1 Where a file’s bytes live
The kernel keeps recently used file data in RAM, in the page cache. The cache is organized by file and by page. Page 0 of a file holds bytes 0 to 4,095, and page 1 holds the next 4,096. Every way of
reading a file goes through it. A read of a page that is not cached makes the kernel load the page from
disk first. A later read of the same page, by any process, finds it in RAM.
The page cache belongs to the kernel, so a read cannot hand your program the cached page itself. It
copies the requested bytes into a buffer your program owns. After read(fd, &mut buffer) returns, the file’s
bytes sit in RAM twice: once in the page cache, and once in buffer.
A memory mapping removes the second copy. The system call mmap asks the kernel to make a range of a
file part of your address space. The kernel reserves a range of virtual addresses and returns a pointer to
its start. When the program reads from those addresses, the page table translates them to the page cache’s
own frames (figure 23.1). The program reads the file’s bytes where the kernel keeps them.
read copies from the page cache into your buffer. A mapping points at the page cache's frames.Figure 23.2 places the mapping in the process. It is one more region of the address space, beside the heap and the stack. Its page table entries point at frames the kernel already holds for the file.
mmap loads nothing when it returns. The page table entries for the range start empty. The first access
to each page is a page fault, as in section 15.5. The kernel finds the file’s page in the page cache, or
reads it from disk if it is not there. Then it fills in the page table entry and restarts the instruction
that faulted. A fault that finds the page already cached is a minor fault, and costs microseconds. A
fault that has to read the disk is a major fault, and costs at least a disk access.
Here is the plan for 64 pages:
| Step | What the kernel does | Faults |
|---|---|---|
mmap of 64 pages | reserves 64 pages of addresses, fills no entries | 0 |
| first read of each page | fills that page’s entry | one per page |
| second read of each page | nothing: the entries are already filled | 0 |
The rest of this section builds a type for a mapping and checks the table against a real run.
23.1.1 A type that owns a mapping
The program calls mmap through the libc crate. A mapping is a pointer and a length, and it must be
unmapped exactly once. That is the shape of an owning Rust type with a Drop:
use std::{
fs::{self, File},
io,
os::fd::AsRawFd,
path::Path,
ptr::NonNull,
slice,
};
/// A file mapped into this process's address space. Unmapped when dropped.
struct Mapping {
ptr: NonNull<u8>,
len: usize,
}
NonNull<u8> is a raw pointer that is never null. It records that Mapping always holds a real address.
Creating the mapping is one call to mmap. The libc crate declares each C function inside an
unsafe extern "C" block, with C’s types. c_int is a C int, size_t is an unsigned size, and off_t is a
file offset. *mut c_void is a pointer to bytes of no particular type. Here is the declaration, with what
each parameter means:
unsafe extern "C" {
fn mmap(
addr: *mut c_void, // where to map; null lets the kernel choose
len: size_t, // how many bytes to map
prot: c_int, // what is allowed: PROT_READ | PROT_WRITE
flags: c_int, // MAP_SHARED or MAP_PRIVATE (section 23.2)
fd: c_int, // the open file
offset: off_t, // where in the file; a multiple of the page size
) -> *mut c_void; // the mapping's address, or MAP_FAILED
}
Every call through such a declaration is unsafe, because the compiler cannot check what the C code does with
the pointers it is given. new makes the call and checks the result:
impl Mapping {
/// Map the first `len` bytes of `file` for reading and writing.
///
/// `shared` chooses `MAP_SHARED`, whose writes reach the file, over
/// `MAP_PRIVATE`, whose writes stay in this process.
fn new(file: &File, len: usize, shared: bool) -> io::Result<Self> {
let flags = if shared {
libc::MAP_SHARED
} else {
libc::MAP_PRIVATE
};
// SAFETY: a null address lets the kernel choose where to place the
// mapping, and the descriptor is open for reading and writing. The
// result is checked against MAP_FAILED before it is used.
let address = unsafe {
libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ | libc::PROT_WRITE,
flags,
file.as_raw_fd(),
0,
)
};
if address == libc::MAP_FAILED {
return Err(io::Error::last_os_error());
}
let ptr = NonNull::new(address.cast::<u8>()).ok_or_else(io::Error::last_os_error)?;
Ok(Self { ptr, len })
}
// ...
}
On failure mmap returns MAP_FAILED, not null, so the code checks for that value first. last_os_error
reads errno and turns it into an io::Error.
The program reaches the bytes through slices. bytes and bytes_mut build them from the pointer and the
length:
impl Mapping {
// ...
fn bytes(&self) -> &[u8] {
// SAFETY: the mapping is `len` bytes long and stays valid until drop.
unsafe { slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
}
fn bytes_mut(&mut self) -> &mut [u8] {
// SAFETY: as in `bytes`, and `&mut self` makes this the only borrow.
unsafe { slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) }
}
// ...
}
bytes_mut takes &mut self, so the borrow checker allows one mutable slice at a time, as with a Vec.
Drop gives the address range back to the kernel with munmap, which takes the same address and length:
unsafe extern "C" {
fn munmap(addr: *mut c_void, len: size_t) -> c_int; // 0, or -1 with errno set
}
impl Drop for Mapping {
fn drop(&mut self) {
// SAFETY: the range is exactly the mapping created in `new`, and no
// borrow of it can outlive `self`.
unsafe {
libc::munmap(self.ptr.as_ptr().cast(), self.len);
}
}
}
A slice returned by bytes borrows self, so it cannot outlive the Mapping. Without that borrow, a slice
could point into an unmapped range, and the next read through it would crash the process.
23.1.2 Counting the faults
The kernel counts each process’s page faults. getrusage copies the counts into a rusage struct that the
caller provides, and sysconf reads system constants such as the page size:
unsafe extern "C" {
fn getrusage(
who: c_int, // RUSAGE_SELF: this process
usage: *mut rusage, // filled in; ru_minflt counts minor faults
) -> c_int; // 0, or -1 with errno set
fn sysconf(name: c_int) -> c_long; // _SC_PAGESIZE gives the page size
}
The program reads the minor-fault count before and after a pass over the mapping:
/// Minor page faults this process has taken so far: faults the kernel served
/// from memory, without reading the disk.
fn minor_faults() -> u64 {
// SAFETY: `getrusage` fills the struct it is given and nothing else.
let usage = unsafe {
let mut usage = std::mem::zeroed::<libc::rusage>();
libc::getrusage(libc::RUSAGE_SELF, &mut usage);
usage
};
u64::try_from(usage.ru_minflt).expect("fault count is not negative")
}
/// Read one byte from every page, so each page is touched once.
fn touch_every_page(bytes: &[u8], page: usize) -> u64 {
bytes
.iter()
.step_by(page)
.map(|&byte| u64::from(byte))
.sum()
}
touch_every_page reads one byte per page. step_by(page) skips from the first byte of one page to the
first byte of the next. The function returns the sum of the bytes so the compiler cannot drop the reads as
unused.
main writes a file of 64 pages, maps it shared, and runs the pass twice:
fn main() -> io::Result<()> {
// ...
let mut shared = Mapping::new(&file, len, true)?;
let before = minor_faults();
touch_every_page(shared.bytes(), page);
let first = minor_faults() - before;
let before = minor_faults();
touch_every_page(shared.bytes(), page);
let second = minor_faults() - before;
println!("first pass over the mapping: {first} minor faults");
println!("second pass over the mapping: {second} minor faults\n");
// ...
}
Animation 23.1 follows both passes, the writes of section 23.2, and the truncation of section 23.2.1.
On macOS, the run matches the table:
$ cargo run --bin mmap_file
page size 4096 bytes, file 64 pages (262144 bytes)
first pass over the mapping: 64 minor faults
second pass over the mapping: 0 minor faults
The faults are minor because fs::write had put every page of the file in the page cache a moment
earlier. No disk read was needed.
On Linux, the same program reports first pass over the mapping: 5 minor faults. When a read faults on a
file mapping, Linux also fills the entries of the neighbouring pages that are already in the page cache.
This is fault-around, and by default it covers 64 KiB, which is 16 pages, per fault. 64 pages need at
least 4 such faults. The second pass is 0 faults on both systems.
23.2 Shared and private mappings
The flags argument decides where a write through the mapping goes (figure 23.3).
MAP_SHARED: the entry points at the page cache frame itself. A write changes the file’s cached page. Every process that maps or reads the file sees the change. The kernel writes the dirty page to disk later, during writeback.MAP_PRIVATE: the entry also points at the page cache frame, but it is marked read-only. The first write to a page faults. The kernel copies the page into a new frame that belongs to this process and points the entry at the copy. This is copy-on-write. The file never sees the write.
Writeback happens on the kernel’s schedule, typically within about 30 seconds. To put the data on disk at a known point, call msync:
unsafe extern "C" {
fn msync(
addr: *mut c_void, // start of the range, as returned by mmap
len: size_t, // length of the range
flags: c_int, // MS_SYNC: return after the write completes
) -> c_int; // 0, or -1 with errno set
}
With MS_SYNC, it returns after the dirty pages of the range are written:
impl Mapping {
// ...
/// Write the changed pages of a shared mapping back to the file now.
fn sync(&self) -> io::Result<()> {
// SAFETY: the range is exactly the mapping created in `new`.
let result = unsafe { libc::msync(self.ptr.as_ptr().cast(), self.len, libc::MS_SYNC) };
if result == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
}
main writes HELLO through the shared mapping and syncs it. Then it maps the same file privately and
writes world:
fn main() -> io::Result<()> {
// ...
shared.bytes_mut()[..5].copy_from_slice(b"HELLO");
shared.sync()?;
drop(shared);
println!(
"after writing HELLO through MAP_SHARED, the file starts with {:?}",
file_prefix(&path)?
);
let mut private = Mapping::new(&file, len, false)?;
private.bytes_mut()[..5].copy_from_slice(b"world");
println!(
"after writing world through MAP_PRIVATE, the mapping holds {:?}",
String::from_utf8_lossy(&private.bytes()[..5])
);
drop(private);
println!("and the file still starts with {:?}", file_prefix(&path)?);
// ...
}
file_prefix reads the file with fs::read, the ordinary path through the page cache. It reports what the
file holds, not what a mapping holds:
after writing HELLO through MAP_SHARED, the file starts with "HELLO"
after writing world through MAP_PRIVATE, the mapping holds "world"
and the file still starts with "HELLO"
fork uses the same mechanism for a whole process. The child gets a copy of the parent’s page tables with
every writable page marked read-only. Neither process copies a page until one of them writes to it.
23.2.1 What a mapping cannot tell you
A read that fails returns an error. An access through a mapping has no return value, so the kernel
reports a failure with a signal:
- If another process truncates the file, the pages past the new end no longer exist. Touching one of them
raises
SIGBUS, which kills the process unless it installs a handler. - If the disk returns an error while a major fault reads a page, the access also raises
SIGBUS.
There is also a rule that Rust’s types cannot check. A &[u8] promises that the bytes do not change while
the slice is alive. Through a MAP_SHARED mapping, another process can write the file at any moment, and
the slice’s bytes change under it. Mapping::bytes is sound only while no other process writes the file.
The memmap2 crate makes Mmap::map an unsafe fn for this reason: the caller promises that the file
stays unchanged.
23.2.2 When to map, and when to read
A mapping saves the copy into a buffer, but it has costs of its own. Every first touch of a page is a fault, and each fault enters the kernel. When a mapping is removed, every CPU that may hold its translations in a TLB must drop them. The kernel interrupts those CPUs to make them do it.
| Access pattern | Better tool | Reason |
|---|---|---|
| Read a file once, front to back | read into a reused buffer | readahead keeps the page cache ahead of you, and one buffer is cheap to copy into |
| Random reads over a large file, repeated | mmap | each page faults once, then reads cost what memory reads cost |
| Many processes reading the same large file | mmap (shared, read-only) | every process uses the page cache’s single copy |
| Files that other processes may truncate | read | a failed read returns an error instead of raising SIGBUS |
Databases such as LMDB map their data files for the second and third reasons. Log readers and copy tools
read sequentially, and use read.
23.3 Sending a file to a socket
A static file server sends files to sockets. The simple version reads a chunk of the file into a buffer and
writes the buffer to the socket, until the file ends. Figure 23.4 shows what each chunk costs. A read copies from the page cache to the buffer. A write copies from the buffer to the socket’s send buffer. The program never looks at the bytes. They pass through it only because read and write
are the calls it knows.
sendfile moves the bytes from a file to a socket inside the kernel. One call names both descriptors, an
offset, and a count. The kernel passes the page cache’s pages to the socket, and the bytes never enter the
process. On network cards that can gather data from several places in memory, the kernel passes references
to the pages instead of copying them. This is zero-copy: no copy of the payload by the CPU.
read/write loop, and the path of sendfile.23.3.1 Measuring both
sendfile with a socket as the destination is a Linux call, so zero_copy puts its code in a module
compiled only on Linux. It also needs a socket to send to. connected_pair opens a loopback connection
and starts a thread that reads everything from the far end and counts it:
#[cfg(target_os = "linux")]
mod linux {
use std::{
fs::{self, File},
io::{self, Read, Write},
net::{TcpListener, TcpStream},
os::fd::AsRawFd,
thread,
time::{Duration, Instant},
};
/// What one transfer cost.
pub struct Transfer {
pub bytes: u64,
pub syscalls: u64,
pub elapsed: Duration,
}
/// A connected socket pair over loopback, with a thread on the far end
/// that reads and discards everything until the sender closes.
fn connected_pair() -> io::Result<(TcpStream, thread::JoinHandle<u64>)> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
let sink = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buffer = vec![0u8; 1 << 20];
let mut total = 0u64;
loop {
match stream.read(&mut buffer) {
Ok(0) | Err(_) => return total,
Ok(read) => total += read as u64,
}
}
});
Ok((TcpStream::connect(address)?, sink))
}
// ...
}
Transfer records what one run cost. The receiving thread returns its byte count through join, so each
run checks that every byte arrived.
The loop reads up to buffer_size bytes and writes what it read. It counts every system call:
mod linux {
// ...
/// Send `file` with `read` into a buffer and `write` out of it.
pub fn copy_loop(path: &str, buffer_size: usize) -> io::Result<Transfer> {
let mut file = File::open(path)?;
let (mut socket, sink) = connected_pair()?;
let mut buffer = vec![0u8; buffer_size];
let mut syscalls = 0;
let start = Instant::now();
loop {
let read = file.read(&mut buffer)?;
syscalls += 1;
if read == 0 {
break;
}
socket.write_all(&buffer[..read])?;
syscalls += 1;
}
drop(socket);
let bytes = sink.join().expect("sink thread");
Ok(Transfer {
bytes,
syscalls,
elapsed: start.elapsed(),
})
}
// ...
}
write_all may need more than one write when the socket’s send buffer is full. The count adds one per
write_all, so it is a lower bound.
The sendfile version has no buffer. On Linux, sendfile copies from one descriptor to another inside the
kernel:
unsafe extern "C" {
fn sendfile(
out_fd: c_int, // where the bytes go: the socket
in_fd: c_int, // where they come from: the file
offset: *mut off_t, // where to start reading; advanced by the kernel
count: size_t, // at most this many bytes
) -> ssize_t; // bytes sent, or -1 with errno set
}
The program calls it until the offset reaches the file’s length:
mod linux {
// ...
/// Send `file` with `sendfile`, which moves the bytes inside the kernel.
pub fn send_file(path: &str) -> io::Result<Transfer> {
let file = File::open(path)?;
let length = file.metadata()?.len();
let (socket, sink) = connected_pair()?;
let mut offset: libc::off_t = 0;
let mut syscalls = 0;
let start = Instant::now();
while (offset as u64) < length {
let remaining = (length - offset as u64) as usize;
// SAFETY: both descriptors are open for the whole call, and the
// kernel advances `offset` by the number of bytes it sent.
let sent = unsafe {
libc::sendfile(socket.as_raw_fd(), file.as_raw_fd(), &mut offset, remaining)
};
syscalls += 1;
if sent < 0 {
return Err(io::Error::last_os_error());
}
}
drop(socket);
let bytes = sink.join().expect("sink thread");
Ok(Transfer {
bytes,
syscalls,
elapsed: start.elapsed(),
})
}
// ...
}
The kernel advances offset by the number of bytes it sent. A single call sends at most 2,147,479,552 bytes on Linux, and a signal can cut a call short. The loop handles both, although this run needs one call.
main writes a 256 MiB file and sends it once untimed, so both timed runs start with the file in the page
cache. Run in a Linux container, in release mode:
$ cargo run --release --bin zero_copy
method MiB syscalls ms MiB/s
read/write, 64 KiB 256 8193 316.4 809
sendfile 256 1 194.3 1318
256 MiB in 64 KiB chunks is 4,096 chunks. Each takes a read and a write, and one last read returns 0,
which gives 8,193 calls. sendfile made one. The time fell by about 40%. Both runs share the costs of loopback TCP and of the receiving thread, which still copies every byte into its own buffer. Over a real network card, the receiver is on another machine, and the sender’s saving
is a larger share of its work.
23.3.2 Where zero-copy stops
sendfile works only when the bytes leave the kernel exactly as they sit in the page cache. Any
transformation of the bytes needs a CPU to touch them:
- TLS. A TLS library such as
rustlsencrypts each record in user space. The plaintext has to come into the process first. A server that sends a file over HTTPS reads it into a buffer, encrypts the buffer, and writes the ciphertext. Linux can move the encryption into the kernel with kernel TLS: the library performs the handshake, then hands the session keys to the socket. With kernel TLS on,sendfileworks on a TLS connection again, and the kernel encrypts as it sends. - Compression. A server that compresses responses on the fly reads every byte. Storing a compressed
copy of each file ahead of time brings
sendfileback.
23.4 The complete programs
Listing 23.1 Shared and private mappings, and the faults that load them. src/bin/mmap_file.rs
//! Mapping a file into memory with `mmap`.
//!
//! A mapping makes a file's bytes addressable like a slice. The kernel loads a
//! page the first time it is touched, which shows up as a page fault. The flags
//! decide where writes go: through a `MAP_SHARED` mapping they reach the file,
//! and through a `MAP_PRIVATE` mapping they stay in a private copy of the page.
//!
//! Run with: cargo run --bin mmap_file
use std::{
fs::{self, File},
io,
os::fd::AsRawFd,
path::Path,
ptr::NonNull,
slice,
};
/// A file mapped into this process's address space. Unmapped when dropped.
struct Mapping {
ptr: NonNull<u8>,
len: usize,
}
impl Mapping {
/// Map the first `len` bytes of `file` for reading and writing.
///
/// `shared` chooses `MAP_SHARED`, whose writes reach the file, over
/// `MAP_PRIVATE`, whose writes stay in this process.
fn new(file: &File, len: usize, shared: bool) -> io::Result<Self> {
let flags = if shared {
libc::MAP_SHARED
} else {
libc::MAP_PRIVATE
};
// SAFETY: a null address lets the kernel choose where to place the
// mapping, and the descriptor is open for reading and writing. The
// result is checked against MAP_FAILED before it is used.
let address = unsafe {
libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ | libc::PROT_WRITE,
flags,
file.as_raw_fd(),
0,
)
};
if address == libc::MAP_FAILED {
return Err(io::Error::last_os_error());
}
let ptr = NonNull::new(address.cast::<u8>()).ok_or_else(io::Error::last_os_error)?;
Ok(Self { ptr, len })
}
fn bytes(&self) -> &[u8] {
// SAFETY: the mapping is `len` bytes long and stays valid until drop.
unsafe { slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
}
fn bytes_mut(&mut self) -> &mut [u8] {
// SAFETY: as in `bytes`, and `&mut self` makes this the only borrow.
unsafe { slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) }
}
/// Write the changed pages of a shared mapping back to the file now.
fn sync(&self) -> io::Result<()> {
// SAFETY: the range is exactly the mapping created in `new`.
let result = unsafe { libc::msync(self.ptr.as_ptr().cast(), self.len, libc::MS_SYNC) };
if result == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
}
impl Drop for Mapping {
fn drop(&mut self) {
// SAFETY: the range is exactly the mapping created in `new`, and no
// borrow of it can outlive `self`.
unsafe {
libc::munmap(self.ptr.as_ptr().cast(), self.len);
}
}
}
/// The size of one page of memory on this machine.
fn page_size() -> usize {
// SAFETY: `sysconf` reads a system constant and has no side effects.
let size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
usize::try_from(size).expect("page size is positive")
}
/// Minor page faults this process has taken so far: faults the kernel served
/// from memory, without reading the disk.
fn minor_faults() -> u64 {
// SAFETY: `getrusage` fills the struct it is given and nothing else.
let usage = unsafe {
let mut usage = std::mem::zeroed::<libc::rusage>();
libc::getrusage(libc::RUSAGE_SELF, &mut usage);
usage
};
u64::try_from(usage.ru_minflt).expect("fault count is not negative")
}
/// Read one byte from every page, so each page is touched once.
fn touch_every_page(bytes: &[u8], page: usize) -> u64 {
bytes
.iter()
.step_by(page)
.map(|&byte| u64::from(byte))
.sum()
}
/// The first five bytes of the file at `path`, as text.
fn file_prefix(path: &Path) -> io::Result<String> {
let bytes = fs::read(path)?;
Ok(String::from_utf8_lossy(&bytes[..5]).into_owned())
}
fn main() -> io::Result<()> {
const PAGES: usize = 64;
let page = page_size();
let len = PAGES * page;
let path = std::env::temp_dir().join("mmap_file_demo.bin");
fs::write(&path, vec![b'a'; len])?;
let file = File::options()
.read(true)
.write(true)
.open(&path)?;
println!("page size {page} bytes, file {PAGES} pages ({len} bytes)\n");
let mut shared = Mapping::new(&file, len, true)?;
let before = minor_faults();
touch_every_page(shared.bytes(), page);
let first = minor_faults() - before;
let before = minor_faults();
touch_every_page(shared.bytes(), page);
let second = minor_faults() - before;
println!("first pass over the mapping: {first} minor faults");
println!("second pass over the mapping: {second} minor faults\n");
shared.bytes_mut()[..5].copy_from_slice(b"HELLO");
shared.sync()?;
drop(shared);
println!(
"after writing HELLO through MAP_SHARED, the file starts with {:?}",
file_prefix(&path)?
);
let mut private = Mapping::new(&file, len, false)?;
private.bytes_mut()[..5].copy_from_slice(b"world");
println!(
"after writing world through MAP_PRIVATE, the mapping holds {:?}",
String::from_utf8_lossy(&private.bytes()[..5])
);
drop(private);
println!("and the file still starts with {:?}", file_prefix(&path)?);
fs::remove_file(&path)
}
#[cfg(test)]
mod tests {
use super::*;
/// A scratch file of `pages` pages of `b'a'`, unique to one test.
fn scratch(name: &str, pages: usize) -> (std::path::PathBuf, File, usize) {
let len = pages * page_size();
let path = std::env::temp_dir().join(format!("mmap_file_test_{name}.bin"));
fs::write(&path, vec![b'a'; len]).expect("write scratch file");
let file = File::options()
.read(true)
.write(true)
.open(&path)
.expect("open scratch file");
(path, file, len)
}
#[test]
fn shared_writes_reach_the_file() {
let (path, file, len) = scratch("shared", 2);
let mut mapping = Mapping::new(&file, len, true).expect("map");
mapping.bytes_mut()[..5].copy_from_slice(b"HELLO");
mapping.sync().expect("msync");
drop(mapping);
assert_eq!(file_prefix(&path).expect("read"), "HELLO");
fs::remove_file(path).expect("remove");
}
#[test]
fn private_writes_stay_in_the_process() {
let (path, file, len) = scratch("private", 2);
let mut mapping = Mapping::new(&file, len, false).expect("map");
mapping.bytes_mut()[..5].copy_from_slice(b"world");
assert_eq!(&mapping.bytes()[..5], b"world");
drop(mapping);
assert_eq!(file_prefix(&path).expect("read"), "aaaaa");
fs::remove_file(path).expect("remove");
}
}
$ cargo run --bin mmap_file
page size 4096 bytes, file 64 pages (262144 bytes)
first pass over the mapping: 64 minor faults
second pass over the mapping: 0 minor faults
after writing HELLO through MAP_SHARED, the file starts with "HELLO"
after writing world through MAP_PRIVATE, the mapping holds "world"
and the file still starts with "HELLO"
Listing 23.2 A file sent over a socket with a read/write loop and with sendfile. src/bin/zero_copy.rs
//! Sending a file over a socket two ways: a read/write loop, and `sendfile`.
//!
//! The loop moves every byte through a buffer in this process: the kernel
//! copies it into the buffer on `read`, and copies it out again on `write`.
//! `sendfile` asks the kernel to move the bytes from the file to the socket
//! itself, so they never enter user space.
//!
//! Linux only. Run with: cargo run --release --bin zero_copy
#[cfg(target_os = "linux")]
mod linux {
use std::{
fs::{self, File},
io::{self, Read, Write},
net::{TcpListener, TcpStream},
os::fd::AsRawFd,
thread,
time::{Duration, Instant},
};
/// What one transfer cost.
pub struct Transfer {
pub bytes: u64,
pub syscalls: u64,
pub elapsed: Duration,
}
/// A connected socket pair over loopback, with a thread on the far end
/// that reads and discards everything until the sender closes.
fn connected_pair() -> io::Result<(TcpStream, thread::JoinHandle<u64>)> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
let sink = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buffer = vec![0u8; 1 << 20];
let mut total = 0u64;
loop {
match stream.read(&mut buffer) {
Ok(0) | Err(_) => return total,
Ok(read) => total += read as u64,
}
}
});
Ok((TcpStream::connect(address)?, sink))
}
/// Send `file` with `read` into a buffer and `write` out of it.
pub fn copy_loop(path: &str, buffer_size: usize) -> io::Result<Transfer> {
let mut file = File::open(path)?;
let (mut socket, sink) = connected_pair()?;
let mut buffer = vec![0u8; buffer_size];
let mut syscalls = 0;
let start = Instant::now();
loop {
let read = file.read(&mut buffer)?;
syscalls += 1;
if read == 0 {
break;
}
socket.write_all(&buffer[..read])?;
syscalls += 1;
}
drop(socket);
let bytes = sink.join().expect("sink thread");
Ok(Transfer {
bytes,
syscalls,
elapsed: start.elapsed(),
})
}
/// Send `file` with `sendfile`, which moves the bytes inside the kernel.
pub fn send_file(path: &str) -> io::Result<Transfer> {
let file = File::open(path)?;
let length = file.metadata()?.len();
let (socket, sink) = connected_pair()?;
let mut offset: libc::off_t = 0;
let mut syscalls = 0;
let start = Instant::now();
while (offset as u64) < length {
let remaining = (length - offset as u64) as usize;
// SAFETY: both descriptors are open for the whole call, and the
// kernel advances `offset` by the number of bytes it sent.
let sent = unsafe {
libc::sendfile(socket.as_raw_fd(), file.as_raw_fd(), &mut offset, remaining)
};
syscalls += 1;
if sent < 0 {
return Err(io::Error::last_os_error());
}
}
drop(socket);
let bytes = sink.join().expect("sink thread");
Ok(Transfer {
bytes,
syscalls,
elapsed: start.elapsed(),
})
}
/// Write a scratch file of `size` bytes and return its path.
pub fn scratch_file(size: usize) -> io::Result<String> {
let path = std::env::temp_dir().join("zero_copy_demo.bin");
fs::write(&path, vec![b'z'; size])?;
Ok(path.to_string_lossy().into_owned())
}
}
#[cfg(target_os = "linux")]
fn main() -> std::io::Result<()> {
const SIZE: usize = 256 << 20;
let path = linux::scratch_file(SIZE)?;
// One untimed pass, so the file is in the page cache for both runs.
linux::copy_loop(&path, 64 << 10)?;
println!(
"{:<22} {:>10} {:>10} {:>10} {:>12}",
"method", "MiB", "syscalls", "ms", "MiB/s"
);
let runs = [
("read/write, 64 KiB", linux::copy_loop(&path, 64 << 10)?),
("sendfile", linux::send_file(&path)?),
];
for (name, run) in runs {
let mib = run.bytes as f64 / (1 << 20) as f64;
let ms = run.elapsed.as_secs_f64() * 1000.0;
println!(
"{name:<22} {mib:>10.0} {:>10} {ms:>10.1} {:>12.0}",
run.syscalls,
mib / run.elapsed.as_secs_f64()
);
}
std::fs::remove_file(&path)
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("zero_copy uses Linux sendfile; run it on Linux");
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::linux;
#[test]
fn both_methods_deliver_every_byte() {
let path = linux::scratch_file(3 << 20).expect("scratch file");
assert_eq!(
linux::copy_loop(&path, 64 << 10)
.expect("loop")
.bytes,
3 << 20
);
assert_eq!(
linux::send_file(&path)
.expect("sendfile")
.bytes,
3 << 20
);
}
}
23.5 Questions that come up
“Is a mapped file faster than read?”
For repeated random access to a large file, usually. Each page faults once, and later reads cost a memory read.
For one sequential pass, read with a reused buffer is as fast or faster, because it takes no faults.
“What happens when two processes map the same file with MAP_SHARED?”
Both page tables point at the same page cache frames. A write by one is visible to the other at once,
before any writeback. Shared memory between processes works this way.
“Does msync make a write durable?”
MS_SYNC waits until the dirty pages are written to the device. Whether the device has stored them
permanently depends on its write cache, as with fsync.
“Why does a file server stop using sendfile when it turns on TLS?”
The TLS library encrypts the bytes in user space. Kernel TLS moves the encryption into
the kernel and allows sendfile again.
“What does fault-around change?” It lowers the number of faults for a mapping read in order. It does not change what the program sees. It maps only neighbouring pages that are already cached, and reads nothing from disk.
Summary
- The page cache holds files’ pages in kernel memory.
readcopies from it into your buffer. mmapmaps a file’s pages into your address space. Nothing loads until the first touch of a page faults.- A minor fault fills the page table entry from the page cache. A major fault reads the disk first.
- Writes through
MAP_SHAREDchange the file’s cached page. Writes throughMAP_PRIVATEgo to a private copy made on the first write.msyncwrites the dirty pages of a shared mapping now. - A failed access through a mapping raises
SIGBUSinstead of returning an error. A&[u8]over a shared mapping is sound only while no other process writes the file. sendfilesends a file to a socket without passing its bytes through the process. This run made one call instead of 8,193.- TLS and compression need the CPU to read every byte, which brings the bytes back into user space unless the kernel does the work.
Chapter 24 looks at file descriptors themselves. It covers the table each process keeps, what fork and exec do to it, and how a shell joins two programs with a pipe.
Exercises
- Change
PAGESto 4,096 and runmmap_fileagain. Then drop the page cache (on Linux, as root:echo 1 > /proc/sys/vm/drop_caches) between writing the file and mapping it, and readru_majfltas well asru_minflt. Which faults turn major? - Map the file read-only with
PROT_READonly, and write through the slice from anunsafeblock. Which signal stops the process, and which kind of fault from section 15.5 is it? - In
copy_loop, try buffer sizes of 4 KiB, 64 KiB, and 1 MiB. Explain the change in system calls and in time. - Add a third method to
zero_copythat maps the file and callswrite_allon the whole mapping. Count its system calls and compare its time with the other two. - Write a test that truncates the file to one page while a two-page
Mappingof it is alive. Check that the first page still reads. Do not read the second page in the test. Explain why.
File descriptors, fork, and pipes
This chapter covers
- The descriptor table, the open file descriptions it points at, and the lowest-free-number rule
- What
forkcopies, whatexeckeeps, and why the child of a threaded program must be careful - Pipes: a kernel buffer with a read end and a write end, and when a reader sees end-of-file
ls | wc -lbuilt by hand withpipe,fork,dup2, andexecvp- A write end left open, which makes a reader wait forever
- Close-on-exec, and the descriptors a child inherits without it
Chapter 2 said that open returns a small number, the file descriptor, and that later calls name the
file by that number. Chapter 20 used the same numbers for sockets. This chapter looks at the table that
holds those numbers. It shows what happens to the table when a process starts another program. Then it shows how a shell uses the table to join two programs with a pipe.
The chapter’s program, fd_table, does the shell’s work with raw system calls from the libc crate. It
watches descriptor numbers being handed out and reused. It builds ls | wc -l, then breaks it by keeping
one descriptor open too long. Last, it checks which descriptors a child process inherits.
24.1 The descriptor table
Every process has a descriptor table. An entry maps a small number to an open file in the kernel. The kernel creates the first three entries for most programs: 0 is standard input, 1 is standard output, and 2 is standard error.
An entry does not point at the file on disk directly. It points at an open file description, a kernel
object that one open call creates. The description holds the state of that one opening: the current
offset, and flags such as read-only or O_APPEND. The description points at the file itself (figure 24.1).
Three rules follow from this layout:
- Two
opencalls on one file give two descriptions. Each has its own offset, so reading through descriptor 3 does not move descriptor 4. - Duplicating a descriptor shares the description.
dup,dup2, andfcntlwithF_DUPFDadd an entry that points at an existing description. A read through either number moves the offset of both. openreturns the lowest free number. When descriptor 3 is closed, the nextopenreturns 3 again.
A description lives as long as some entry points at it. close removes one entry. The description, and
for a pipe or socket the connection behind it, goes away when the last entry is closed.
The table has a limit, set per process by RLIMIT_NOFILE. A program that never closes its descriptors reaches the limit. Every later open, accept, or socket then fails with EMFILE, “Too many open files”. In Rust, a File or a TcpStream closes its descriptor when it is dropped, which prevents most of
these leaks.
24.1.1 Watching the numbers
On Linux and macOS, the directory /dev/fd lists the calling process’s open descriptors. open_fds reads
it:
use std::{
ffi::CString,
fs::{self, File},
io::{self, Read, Write},
os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
path::Path,
thread,
time::Duration,
};
/// The descriptors open in this process, read from `/dev/fd`.
///
/// Reading the directory opens one more descriptor, which is in the list.
fn open_fds() -> io::Result<Vec<RawFd>> {
let mut fds: Vec<RawFd> = fs::read_dir("/dev/fd")?
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse().ok())
.collect();
fds.sort_unstable();
Ok(fds)
}
Each directory entry is named after a descriptor number. The second filter_map keeps the names that parse
as numbers. Reading the directory needs a descriptor of its own, so that one is in the list too.
main opens the same file twice, closes the first descriptor, and opens it a third time:
fn main() -> io::Result<()> {
// ...
println!("open at start: {:?}", open_fds()?);
let first = File::open(&path)?;
let second = File::open(&path)?;
println!(
"opened two files: fd {} and fd {}",
first.as_raw_fd(),
second.as_raw_fd()
);
let freed = first.as_raw_fd();
drop(first);
let third = File::open(&path)?;
println!(
"closed fd {freed}, opened another: fd {}\n",
third.as_raw_fd()
);
// ...
}
The output shows both rules:
open at start: [0, 1, 2, 3]
opened two files: fd 3 and fd 4
closed fd 3, opened another: fd 3
At start, 0 to 2 are the standard streams and 3 is the /dev/fd directory being read. The directory is
closed when read_dir’s iterator is dropped, so the first File::open gets 3. Closing 3 frees the lowest
number, and the third open takes it again.
24.2 fork and exec
A Unix process starts another program in two steps.
forkcreates a child process that is a copy of the caller. The child gets a copy of the caller’s memory, shared copy-on-write as in section 23.2, and a copy of the descriptor table.forkreturns twice: in the parent it returns the child’s process id, and in the child it returns 0.execreplaces the program running in the process with a new one. The memory is replaced. The descriptor table is kept, except for entries marked close-on-exec, whichexeccloses.
The table copy is shallow (figure 24.2). The child’s descriptor 100 points at the same open file
description as the parent’s descriptor 100, so the two processes share one offset. After exec, the new
program finds its standard streams at 0, 1, and 2 because they were in the table before exec ran.
fork copies the table, and both copies point at the same descriptions. exec keeps the table and closes the close-on-exec entries.A shell uses the gap between the two calls. In the child, after fork and before exec, it rearranges the table. It moves a pipe end to descriptor 0 or 1 with dup2, then calls exec. The new program reads
descriptor 0 and writes descriptor 1 as always, without knowing they are a pipe.
24.2.1 What the child may do before exec
fork copies only the thread that called it. Suppose another thread held a lock at that moment, such as the memory allocator’s lock. The child’s copy of that lock stays locked forever. No thread exists in
the child to release it. A child that calls malloc can then deadlock.
So between fork and exec, the child of a multithreaded program may only make async-signal-safe calls. These are calls that take no locks, such as dup2, close, execvp, and _exit. Rust’s test harness runs tests on several threads, so the program follows this rule.
The program describes the command to run as C strings:
/// A program name and its arguments, as C strings.
struct Program(Vec<CString>);
impl Program {
fn new(args: &[&str]) -> Self {
Self(
args.iter()
.map(|arg| CString::new(*arg).expect("no NUL byte in an argument"))
.collect(),
)
}
/// The null-terminated pointer array `execvp` takes. It borrows `self`.
fn argv(&self) -> Vec<*const libc::c_char> {
self.0
.iter()
.map(|arg| arg.as_ptr())
.chain(std::iter::once(std::ptr::null()))
.collect()
}
}
execvp takes an array of pointers to C strings, ending with a null pointer. argv builds that array. The
pointers point into the CStrings, so the array is valid only while the Program is alive.
spawn uses three calls:
unsafe extern "C" {
fn fork() -> pid_t; // child's id in the parent, 0 in the child, -1 on error
fn dup2(src: c_int, dst: c_int) -> c_int; // make dst refer to src's open file
fn execvp(
file: *const c_char, // program name, searched in PATH
argv: *const *const c_char, // arguments, ending with a null pointer
) -> c_int; // returns only on failure
}
spawn forks, and in the child arranges descriptors 0 and 1 and runs the program:
/// Start `program` in a child process. `stdin` and `stdout`, when given,
/// become the child's descriptors 0 and 1. Returns the child's process id.
fn spawn(
program: &Program,
stdin: Option<RawFd>,
stdout: Option<RawFd>,
) -> io::Result<libc::pid_t> {
// Allocate before `fork`: the child may only make async-signal-safe calls.
let argv = program.argv();
// SAFETY: the child below calls only dup2, execvp, and _exit.
let pid = check(unsafe { libc::fork() })?;
if pid > 0 {
return Ok(pid);
}
// SAFETY: this is the child, a copy of the parent with one thread. dup2 copies
// a descriptor without its close-on-exec flag; execvp replaces the program.
unsafe {
if let Some(fd) = stdin {
libc::dup2(fd, 0);
}
if let Some(fd) = stdout {
libc::dup2(fd, 1);
}
libc::execvp(argv[0], argv.as_ptr());
libc::_exit(127)
}
}
argv is built before fork, because building it allocates. In the child, dup2(fd, 1) makes descriptor 1
point at the same description as fd, closing whatever 1 was before. execvp searches PATH for the
program and does not return if it succeeds. If it fails, the child exits with status 127, which shells
report as “command not found”. The child must not return from spawn: it would go on running the
parent’s code.
The parent waits for children with waitpid:
unsafe extern "C" {
fn waitpid(
pid: pid_t, // the child to wait for
status: *mut c_int, // filled in; WEXITSTATUS(status) is the exit code
options: c_int, // 0 to wait, WNOHANG to return at once
) -> pid_t; // the child's id, 0 if WNOHANG and still running, or -1
}
/// Wait for the child `pid` to exit, and return its exit status.
fn wait(pid: libc::pid_t) -> io::Result<i32> {
let mut status = 0;
// SAFETY: `waitpid` writes the status into the integer it is given.
check(unsafe { libc::waitpid(pid, &mut status, 0) })?;
Ok(libc::WEXITSTATUS(status))
}
/// Whether the child `pid` has exited, without waiting for it.
fn has_exited(pid: libc::pid_t) -> io::Result<bool> {
let mut status = 0;
// SAFETY: as in `wait`; WNOHANG returns 0 at once if the child is running.
let result = check(unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) })?;
Ok(result == pid)
}
Until the parent waits for it, an exited child stays in the process table as a zombie, holding its
exit status. WNOHANG asks whether the child has exited without waiting.
24.3 Pipes
A pipe is a buffer in the kernel with two descriptors: a read end and a write end. Bytes written to the write end come out of the read end in the same order. On Linux the buffer holds 64 KiB by default.
The ends block, and the rules for that are the rules of every pipeline:
readon an empty pipe waits until some process writes.readreturns 0, end-of-file, only when the pipe is empty and every descriptor for the write end, in every process, is closed.writeto a full pipe waits until the reader makes room.writeafter every read end is closed fails. The kernel sends the writerSIGPIPE, and if the writer ignores that signal,writereturns the errorEPIPE.
A shell runs ls | wc -l in five steps (figure 24.3):
pipecreates the two ends in the shell’s table.forka child,dup2the write end onto its descriptor 1, andexecls.forka second child,dup2the read end onto its descriptor 0, andexecwc -l.- Close both ends in the shell.
- Wait for both children.
ls | wc -l after setup. Only ls holds the write end, and only wc holds the read end.Step 4 makes the pipeline finish. After it, the only write end is ls’s descriptor 1. When ls exits,
that last write end closes, and wc reads end-of-file, prints its count, and exits.
24.3.1 Building the pipeline
The C functions take and return plain descriptor numbers:
unsafe extern "C" {
fn pipe(fds: *mut c_int) -> c_int; // fills [read end, write end]; 0 or -1
fn fcntl(fd: c_int, cmd: c_int, ...) -> c_int; // F_SETFD sets flags; F_DUPFD copies
}
pipe writes two numbers into an array of two. fcntl takes a command and, for most commands, one more
argument; the ... means the number of arguments varies, as with C’s printf. The program wraps the ends in
OwnedFds, which close themselves when dropped, and marks both close-on-exec:
/// Turn a C return value into an `io::Result`: -1 means `errno` holds the error.
fn check(result: libc::c_int) -> io::Result<libc::c_int> {
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(result)
}
}
/// Set close-on-exec on `fd`, so a program started with `exec` does not get it.
fn set_cloexec(fd: RawFd) -> io::Result<()> {
// SAFETY: F_SETFD changes only the descriptor's flags.
check(unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) })?;
Ok(())
}
/// A pipe as (read end, write end). Both ends are close-on-exec.
fn pipe() -> io::Result<(OwnedFd, OwnedFd)> {
let mut ends = [0; 2];
// SAFETY: `pipe` writes two new descriptors into the array.
check(unsafe { libc::pipe(ends.as_mut_ptr()) })?;
// SAFETY: both descriptors were just created, and nothing else owns them.
let (read_end, write_end) =
unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
set_cloexec(read_end.as_raw_fd())?;
set_cloexec(write_end.as_raw_fd())?;
Ok((read_end, write_end))
}
check turns the C convention, -1 with the error in errno, into an io::Result. Close-on-exec on the
pipe ends replaces the close calls a shell makes in each child. dup2 copies a descriptor without its
close-on-exec flag. So the copy at 0 or 1 survives exec, and the original pipe ends close.
ls_wc is the five steps:
/// `ls dir | wc -l`, built the way a shell builds it. `wc` writes to this
/// process's standard output.
fn ls_wc(dir: &Path) -> io::Result<()> {
let ls = Program::new(&["ls", dir.to_str().expect("UTF-8 path")]);
let wc = Program::new(&["wc", "-l"]);
let (read_end, write_end) = pipe()?;
let writer = spawn(&ls, None, Some(write_end.as_raw_fd()))?;
let reader = spawn(&wc, Some(read_end.as_raw_fd()), None)?;
drop(read_end);
drop(write_end);
wait(writer)?;
wait(reader)?;
Ok(())
}
The two drop calls are step 4. The program prints the line count of a directory with three files:
ls fd_table_demo | wc -l
3
On Linux, wc prints 3 without the leading spaces.
24.3.2 A write end left open
Animation 24.1 follows the pipeline, then runs it again without step 4.
reader_waits_for_the_write_end measures the failing case. It starts wc -l on a pipe, writes two lines,
and keeps its write end open for a while:
/// Start `wc -l` on a pipe, write two lines into it, and keep the write end
/// open for `hold`. Returns whether `wc` was still running when `hold` ended.
fn reader_waits_for_the_write_end(hold: Duration) -> io::Result<bool> {
let wc = Program::new(&["wc", "-l"]);
let (read_end, write_end) = pipe()?;
let reader = spawn(&wc, Some(read_end.as_raw_fd()), None)?;
drop(read_end);
let mut writer = File::from(write_end);
writer.write_all(b"one\ntwo\n")?;
thread::sleep(hold);
let still_running = !has_exited(reader)?;
drop(writer);
wait(reader)?;
Ok(still_running)
}
parent writes two lines to wc -l and holds the write end for 1 s:
2
wc still running after 1 s: true
wc had both lines after a few microseconds. It was still running after a second, because it cannot know
that no more lines will come while a write end is open. It printed 2 only after drop(writer). A real
program that forgets the drop waits forever, with nothing using the CPU and no error to report.
The same bug appears without a pipe. A process that forks a long-lived child, such as a daemon, while it holds a socket passes the socket to the child. Closing the socket in the parent then does not close the connection, because the child’s descriptor keeps the description alive.
24.4 Close-on-exec
A child inherits every descriptor that is not close-on-exec. The child can then do things the parent did not intend:
- A listening socket stays bound to its port after the parent exits, so a restarted server fails with
EADDRINUSE. - A write end of a pipe stays open, and the reader never sees end-of-file, as in section 24.3.2.
- A file opened with secrets stays readable by a program that should not have it.
Rust’s standard library sets close-on-exec on every descriptor it creates: files, sockets, and pipes. A
leak needs a descriptor made outside it, through libc or a C library. duplicate makes one each way:
/// A copy of `file`'s descriptor, numbered 100 or above, with or without
/// close-on-exec.
fn duplicate(file: &File, cloexec: bool) -> io::Result<OwnedFd> {
let command = if cloexec {
libc::F_DUPFD_CLOEXEC
} else {
libc::F_DUPFD
};
// SAFETY: F_DUPFD and F_DUPFD_CLOEXEC create a new descriptor and nothing else.
let fd = check(unsafe { libc::fcntl(file.as_raw_fd(), command, 100) })?;
// SAFETY: `fcntl` just created this descriptor, and nothing else owns it.
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
F_DUPFD creates a copy without close-on-exec, and F_DUPFD_CLOEXEC creates one with it. Both take the
lowest free number at or above 100. A high number keeps the copy away from the descriptors the child opens
for itself.
To find out what a child has open, child_has runs ls /dev/fd as a child and reads its output through a
pipe:
/// Run `program` with its standard output on a pipe, and return what it wrote.
fn output(program: &Program) -> io::Result<String> {
let (read_end, write_end) = pipe()?;
let child = spawn(program, None, Some(write_end.as_raw_fd()))?;
drop(write_end);
let mut text = String::new();
File::from(read_end).read_to_string(&mut text)?;
wait(child)?;
Ok(text)
}
output drops its write end as soon as the child has it. Without that drop, read_to_string would
wait for end-of-file forever, as in section 24.3.2.
/// Whether a child started with `exec` has descriptor `fd` open.
fn child_has(fd: &OwnedFd) -> io::Result<bool> {
let listing = output(&Program::new(&["ls", "/dev/fd"]))?;
let number = fd.as_raw_fd().to_string();
Ok(listing
.split_whitespace()
.any(|name| name == number))
}
fd 100 without close-on-exec, open in the child: true
fd 101 with close-on-exec, open in the child: false
pipe sets the flag in a second call, after the pipe exists. Between the two calls, another thread could
fork and exec, and its child would inherit both ends. Linux has pipe2, which takes O_CLOEXEC and
creates the ends with the flag already set. open and socket take the same flag. The standard library
uses these forms where the system has them.
24.5 The complete program
Listing 24.1 The descriptor table, ls | wc -l by hand, and close-on-exec. src/bin/fd_table.rs
//! The file descriptor table: how a process numbers its open files, what a
//! child inherits across `fork` and `exec`, and how a shell joins two programs
//! with a pipe.
//!
//! Run with: cargo run --bin fd_table
use std::{
ffi::CString,
fs::{self, File},
io::{self, Read, Write},
os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
path::Path,
thread,
time::Duration,
};
/// The descriptors open in this process, read from `/dev/fd`.
///
/// Reading the directory opens one more descriptor, which is in the list.
fn open_fds() -> io::Result<Vec<RawFd>> {
let mut fds: Vec<RawFd> = fs::read_dir("/dev/fd")?
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse().ok())
.collect();
fds.sort_unstable();
Ok(fds)
}
/// Turn a C return value into an `io::Result`: -1 means `errno` holds the error.
fn check(result: libc::c_int) -> io::Result<libc::c_int> {
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(result)
}
}
/// Set close-on-exec on `fd`, so a program started with `exec` does not get it.
fn set_cloexec(fd: RawFd) -> io::Result<()> {
// SAFETY: F_SETFD changes only the descriptor's flags.
check(unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) })?;
Ok(())
}
/// A pipe as (read end, write end). Both ends are close-on-exec.
fn pipe() -> io::Result<(OwnedFd, OwnedFd)> {
let mut ends = [0; 2];
// SAFETY: `pipe` writes two new descriptors into the array.
check(unsafe { libc::pipe(ends.as_mut_ptr()) })?;
// SAFETY: both descriptors were just created, and nothing else owns them.
let (read_end, write_end) =
unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
set_cloexec(read_end.as_raw_fd())?;
set_cloexec(write_end.as_raw_fd())?;
Ok((read_end, write_end))
}
/// A copy of `file`'s descriptor, numbered 100 or above, with or without
/// close-on-exec.
fn duplicate(file: &File, cloexec: bool) -> io::Result<OwnedFd> {
let command = if cloexec {
libc::F_DUPFD_CLOEXEC
} else {
libc::F_DUPFD
};
// SAFETY: F_DUPFD and F_DUPFD_CLOEXEC create a new descriptor and nothing else.
let fd = check(unsafe { libc::fcntl(file.as_raw_fd(), command, 100) })?;
// SAFETY: `fcntl` just created this descriptor, and nothing else owns it.
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
/// A program name and its arguments, as C strings.
struct Program(Vec<CString>);
impl Program {
fn new(args: &[&str]) -> Self {
Self(
args.iter()
.map(|arg| CString::new(*arg).expect("no NUL byte in an argument"))
.collect(),
)
}
/// The null-terminated pointer array `execvp` takes. It borrows `self`.
fn argv(&self) -> Vec<*const libc::c_char> {
self.0
.iter()
.map(|arg| arg.as_ptr())
.chain(std::iter::once(std::ptr::null()))
.collect()
}
}
/// Start `program` in a child process. `stdin` and `stdout`, when given,
/// become the child's descriptors 0 and 1. Returns the child's process id.
fn spawn(
program: &Program,
stdin: Option<RawFd>,
stdout: Option<RawFd>,
) -> io::Result<libc::pid_t> {
// Allocate before `fork`: the child may only make async-signal-safe calls.
let argv = program.argv();
// SAFETY: the child below calls only dup2, execvp, and _exit.
let pid = check(unsafe { libc::fork() })?;
if pid > 0 {
return Ok(pid);
}
// SAFETY: this is the child, a copy of the parent with one thread. dup2 copies
// a descriptor without its close-on-exec flag; execvp replaces the program.
unsafe {
if let Some(fd) = stdin {
libc::dup2(fd, 0);
}
if let Some(fd) = stdout {
libc::dup2(fd, 1);
}
libc::execvp(argv[0], argv.as_ptr());
libc::_exit(127)
}
}
/// Wait for the child `pid` to exit, and return its exit status.
fn wait(pid: libc::pid_t) -> io::Result<i32> {
let mut status = 0;
// SAFETY: `waitpid` writes the status into the integer it is given.
check(unsafe { libc::waitpid(pid, &mut status, 0) })?;
Ok(libc::WEXITSTATUS(status))
}
/// Whether the child `pid` has exited, without waiting for it.
fn has_exited(pid: libc::pid_t) -> io::Result<bool> {
let mut status = 0;
// SAFETY: as in `wait`; WNOHANG returns 0 at once if the child is running.
let result = check(unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) })?;
Ok(result == pid)
}
/// Run `program` with its standard output on a pipe, and return what it wrote.
fn output(program: &Program) -> io::Result<String> {
let (read_end, write_end) = pipe()?;
let child = spawn(program, None, Some(write_end.as_raw_fd()))?;
drop(write_end);
let mut text = String::new();
File::from(read_end).read_to_string(&mut text)?;
wait(child)?;
Ok(text)
}
/// `ls dir | wc -l`, built the way a shell builds it. `wc` writes to this
/// process's standard output.
fn ls_wc(dir: &Path) -> io::Result<()> {
let ls = Program::new(&["ls", dir.to_str().expect("UTF-8 path")]);
let wc = Program::new(&["wc", "-l"]);
let (read_end, write_end) = pipe()?;
let writer = spawn(&ls, None, Some(write_end.as_raw_fd()))?;
let reader = spawn(&wc, Some(read_end.as_raw_fd()), None)?;
drop(read_end);
drop(write_end);
wait(writer)?;
wait(reader)?;
Ok(())
}
/// Start `wc -l` on a pipe, write two lines into it, and keep the write end
/// open for `hold`. Returns whether `wc` was still running when `hold` ended.
fn reader_waits_for_the_write_end(hold: Duration) -> io::Result<bool> {
let wc = Program::new(&["wc", "-l"]);
let (read_end, write_end) = pipe()?;
let reader = spawn(&wc, Some(read_end.as_raw_fd()), None)?;
drop(read_end);
let mut writer = File::from(write_end);
writer.write_all(b"one\ntwo\n")?;
thread::sleep(hold);
let still_running = !has_exited(reader)?;
drop(writer);
wait(reader)?;
Ok(still_running)
}
/// Whether a child started with `exec` has descriptor `fd` open.
fn child_has(fd: &OwnedFd) -> io::Result<bool> {
let listing = output(&Program::new(&["ls", "/dev/fd"]))?;
let number = fd.as_raw_fd().to_string();
Ok(listing
.split_whitespace()
.any(|name| name == number))
}
fn main() -> io::Result<()> {
let dir = std::env::temp_dir().join("fd_table_demo");
fs::create_dir_all(&dir)?;
for name in ["a.txt", "b.txt", "c.txt"] {
fs::write(dir.join(name), name)?;
}
let path = dir.join("a.txt");
println!("open at start: {:?}", open_fds()?);
let first = File::open(&path)?;
let second = File::open(&path)?;
println!(
"opened two files: fd {} and fd {}",
first.as_raw_fd(),
second.as_raw_fd()
);
let freed = first.as_raw_fd();
drop(first);
let third = File::open(&path)?;
println!(
"closed fd {freed}, opened another: fd {}\n",
third.as_raw_fd()
);
println!("ls fd_table_demo | wc -l");
ls_wc(&dir)?;
println!("\nparent writes two lines to wc -l and holds the write end for 1 s:");
let still_running = reader_waits_for_the_write_end(Duration::from_secs(1))?;
println!("wc still running after 1 s: {still_running}\n");
let leaked = duplicate(&third, false)?;
let kept = duplicate(&third, true)?;
println!(
"fd {} without close-on-exec, open in the child: {}",
leaked.as_raw_fd(),
child_has(&leaked)?
);
println!(
"fd {} with close-on-exec, open in the child: {}",
kept.as_raw_fd(),
child_has(&kept)?
);
fs::remove_dir_all(&dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_child_writes_through_a_pipe() {
let text = output(&Program::new(&["echo", "through the pipe"])).expect("run echo");
assert_eq!(text, "through the pipe\n");
}
#[test]
fn close_on_exec_decides_what_a_child_inherits() {
let file = File::open("/dev/null").expect("open /dev/null");
let leaked = duplicate(&file, false).expect("dup");
let kept = duplicate(&file, true).expect("dup");
assert!(child_has(&leaked).expect("ls"));
assert!(!child_has(&kept).expect("ls"));
}
#[test]
fn a_reader_waits_while_any_write_end_is_open() {
let still_running = reader_waits_for_the_write_end(Duration::from_millis(200)).expect("wc");
assert!(still_running);
}
}
$ cargo run --bin fd_table
open at start: [0, 1, 2, 3]
opened two files: fd 3 and fd 4
closed fd 3, opened another: fd 3
ls fd_table_demo | wc -l
3
parent writes two lines to wc -l and holds the write end for 1 s:
2
wc still running after 1 s: true
fd 100 without close-on-exec, open in the child: true
fd 101 with close-on-exec, open in the child: false
24.6 Questions that come up
“What does 2>&1 do?”
It is dup2(1, 2) in the child before exec. Descriptor 2 then points at the same description as
descriptor 1, so errors go wherever output goes, with one shared offset.
“Why does yes | head -1 stop?”
head exits after one line and closes the read end. The next write by yes gets SIGPIPE, which ends
it. A Rust program ignores SIGPIPE by default, so its write returns a BrokenPipe error instead.
“Why is fork slow for a large process, and what replaces it?”
fork copies the page tables, although the pages are shared copy-on-write. For a process
with many gigabytes mapped, that copy takes milliseconds. posix_spawn and vfork start the new program
without copying the tables, and std::process::Command uses them where it can.
“A server restarted and failed with EADDRINUSE. The old process is gone. What holds the port?”
Look for a child that inherited the listening socket. lsof -i :PORT shows which process has it open.
Another cause is a connection in TIME_WAIT, which SO_REUSEADDR allows a new listener to ignore.
“What does Too many open files mean?”
The process reached RLIMIT_NOFILE. Check /proc/PID/fd on Linux to see what is open: often it is
sockets that are never dropped, or files opened in a loop.
Summary
- A descriptor is an index into the process’s table. The entry points at an open file description, which holds the offset and flags, and the description points at the file.
openreturns the lowest free number.dup2points a chosen number at an existing description.forkcopies the table, and the copies share descriptions.execkeeps the table and closes the close-on-exec entries.- Between
forkandexec, a child of a multithreaded program may only make async-signal-safe calls. - A pipe’s reader sees end-of-file only when every write end in every process is closed. A forgotten write end makes the reader wait forever.
- Rust’s standard library opens everything close-on-exec. Descriptors made through
libcneed the flag set by hand, in the same call when the system allows it.
Chapter 25 returns to chapter 20’s epoll loop in edge-triggered mode. There, each readiness change is reported once, and a handler that reads too little stalls.
Exercises
- Run
fd_tablewith descriptor 0 closed:cargo run --bin fd_table <&-. Which number does the firstFile::openreturn, and why? - Read a few bytes from a file, then
forkwithspawnrunningcatwith the file’s descriptor as standard input. Doescatprint the whole file or the rest of it? Explain with figure 24.2. - Remove
drop(write_end)fromoutputand run the tests. Which test hangs, and which call is it waiting in? - Extend
ls_wcinto a function that takes a list of commands and connects them all, likels | sort | wc -l. How many pipes does it create, and how many descriptors does the parent close? - Replace
pipewithpipe2andO_CLOEXECunder#[cfg(target_os = "linux")], and keep the two-call version for other systems.
Edge-triggered epoll
This chapter covers
- Readiness as a level and as an edge, and how epoll’s ready list implements both
- Three handlers measured: read once under each mode, and read until
EAGAIN - The stall: data left in a socket that no event will report
- Why edge-triggered mode suits writes, and how a loop keeps one busy socket from starving the rest
EPOLLONESHOTfor a loop shared by several threadsEPOLLEXCLUSIVEandSO_REUSEPORTfor many threads waiting on one listener
Section 20.4 built an echo server on epoll in its default mode, level-triggered. Epoll has a second mode, edge-triggered, selected with the flag EPOLLET. Section 20.6 noted that it needs a handler that reads until EAGAIN. This chapter shows why, by measuring what each mode reports. Then it covers two flags for loops that several threads share: EPOLLONESHOT and EPOLLEXCLUSIVE.
The program, epoll_edge, uses Linux epoll, so its output comes from a Linux container. It writes 10,000
bytes into a socket and counts the events and bytes each handler sees.
25.1 Levels and edges
A socket is readable while its receive buffer holds at least one byte. That is a state, or a level: it stays true until a read empties the buffer. The moment new bytes arrive is a change, or an edge.
Level-triggered epoll reports the level: every epoll_wait returns the socket while it is readable.
Edge-triggered epoll reports the edge: one event when bytes arrive, and no more until more bytes arrive.
The difference comes from the ready list, introduced in section 20.4 (figure 25.1). When bytes arrive, a
callback that epoll registered on the socket adds the socket to the ready list. epoll_wait takes entries
off the list and returns them. For a level-triggered socket, the kernel puts the entry back on the list,
and the next epoll_wait checks the socket again. It reports the socket only if it is still readable. An
edge-triggered entry is not put back. Only the callback, on the next arrival, can add it again.
Here is what each mode reports when 10,000 bytes arrive at once and the handler reads 4,096 bytes per event:
| Step | Bytes in the buffer | Level-triggered | Edge-triggered |
|---|---|---|---|
| 10,000 bytes arrive | 10,000 | event | event |
| handler reads 4,096 | 5,904 | event: still readable | nothing |
| handler reads 4,096 | 1,808 | event: still readable | — |
| handler reads 1,808 | 0 | nothing | — |
In edge-triggered mode, 5,904 bytes stay in the buffer after the first event. No event will report them
until the peer sends more. If the peer is waiting for a reply to the bytes it already sent, it never sends
more, and both sides wait forever. This is the stall. Edge-triggered mode needs a handler that reads until
read returns EAGAIN, which proves the buffer is empty.
25.2 Three handlers, measured
Epoll is three system calls. epoll_event is the record that carries a descriptor’s flags in and its
events out:
struct epoll_event {
events: u32, // EPOLLIN, EPOLLOUT, EPOLLET, ... combined with |
u64: u64, // data the caller chooses; the program stores the fd
}
unsafe extern "C" {
fn epoll_create1(flags: c_int) -> c_int; // a new instance's fd; EPOLL_CLOEXEC
fn epoll_ctl(
epfd: c_int, // the instance
op: c_int, // EPOLL_CTL_ADD, EPOLL_CTL_MOD, or EPOLL_CTL_DEL
fd: c_int, // the descriptor to watch
event: *mut epoll_event, // its flags
) -> c_int; // 0, or -1 with errno set
fn epoll_wait(
epfd: c_int,
events: *mut epoll_event, // an array the kernel fills
maxevents: c_int, // its length
timeout: c_int, // milliseconds; -1 waits forever
) -> c_int; // how many entries were filled, or -1
}
The program wraps the epoll instance in a type that closes it when dropped:
#[cfg(target_os = "linux")]
mod linux {
use std::{
io::{self, Read, Write},
net::{TcpListener, TcpStream},
os::{
fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
unix::net::UnixStream,
},
sync::{Arc, Barrier},
thread,
time::Duration,
};
/// How long `epoll_wait` waits before the program decides no event is coming.
const QUIET_MS: i32 = 100;
/// An epoll instance. Closed when dropped.
pub struct Epoll(OwnedFd);
impl Epoll {
pub fn new() -> io::Result<Self> {
// SAFETY: epoll_create1 takes no pointers, and the result is checked.
let fd = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: the descriptor was just created, and nothing else owns it.
Ok(Self(unsafe { OwnedFd::from_raw_fd(fd) }))
}
fn control(&self, op: libc::c_int, fd: RawFd, flags: u32) -> io::Result<()> {
let mut event = libc::epoll_event {
events: flags,
u64: fd as u64,
};
// SAFETY: `event` is a valid epoll_event that outlives the call.
let result = unsafe { libc::epoll_ctl(self.0.as_raw_fd(), op, fd, &mut event) };
if result < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// Add `fd` to the interest list.
pub fn add(&self, fd: RawFd, flags: u32) -> io::Result<()> {
self.control(libc::EPOLL_CTL_ADD, fd, flags)
}
/// Change the flags of `fd`. This also re-arms an `EPOLLONESHOT` descriptor.
pub fn modify(&self, fd: RawFd, flags: u32) -> io::Result<()> {
self.control(libc::EPOLL_CTL_MOD, fd, flags)
}
/// Wait up to `timeout_ms` and return the number of events.
pub fn wait(&self, timeout_ms: i32) -> io::Result<usize> {
let mut events = [libc::epoll_event { events: 0, u64: 0 }; 8];
// SAFETY: `events` is a writable buffer of 8 entries.
let ready =
unsafe { libc::epoll_wait(self.0.as_raw_fd(), events.as_mut_ptr(), 8, timeout_ms) };
if ready < 0 {
return Err(io::Error::last_os_error());
}
Ok(ready as usize)
}
}
// ...
}
epoll_create1(EPOLL_CLOEXEC) creates the instance with close-on-exec set, as section 24.4 recommends.
control fills an epoll_event with the flags and with the descriptor as the event’s data, then calls
epoll_ctl. wait returns only the number of events, because each run watches a single socket.
The two handlers differ in one loop:
mod linux {
// ...
/// Read once, at most 4,096 bytes, and return how many arrived.
pub fn read_once(socket: &mut UnixStream) -> io::Result<usize> {
let mut buffer = [0; 4096];
socket.read(&mut buffer)
}
/// Read until the socket has nothing left (`EAGAIN`), and return the total.
pub fn read_until_eagain(socket: &mut UnixStream) -> io::Result<usize> {
let mut buffer = [0; 4096];
let mut total = 0;
loop {
match socket.read(&mut buffer) {
Ok(0) => return Ok(total),
Ok(read) => total += read,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(total),
Err(error) => return Err(error),
}
}
}
// ...
}
read_once makes one read of up to 4,096 bytes. read_until_eagain reads until the socket is empty. On a
non-blocking socket, EAGAIN reaches Rust as io::ErrorKind::WouldBlock, which is the loop’s normal exit.
run connects a pair of Unix sockets and writes the bytes into one end. Then it handles events on the other end until 100 ms pass without one:
mod linux {
// ...
/// Write `sent` bytes into one end of a socket pair. Then wait on the other
/// end with `flags`, and call `handler` once per event, until no event comes.
/// Returns the bytes `handler` read at each event.
pub fn run(
flags: u32,
sent: usize,
handler: fn(&mut UnixStream) -> io::Result<usize>,
) -> io::Result<Vec<usize>> {
let (mut writer, mut reader) = UnixStream::pair()?;
reader.set_nonblocking(true)?;
let epoll = Epoll::new()?;
epoll.add(reader.as_raw_fd(), flags)?;
writer.write_all(&vec![b'x'; sent])?;
let mut per_event = Vec::new();
while epoll.wait(QUIET_MS)? > 0 {
per_event.push(handler(&mut reader)?);
}
Ok(per_event)
}
// ...
}
UnixStream::pair returns two sockets connected to each other, which keeps the test off the network. The
reading end is non-blocking, as epoll requires. handler is a function pointer, so one run measures
both handlers.
Animation 25.1 follows the three runs.
The output matches the table in section 25.1:
10000 bytes written into a socket; one handler call per epoll event
level-triggered, read once events 3, bytes per event [4096, 4096, 1808], left unread 0
edge-triggered, read once events 1, bytes per event [4096], left unread 5904
edge-triggered, to EAGAIN events 1, bytes per event [10000], left unread 0
Level-triggered mode with one read per event delivered every byte, in three events. Edge-triggered mode with the same handler left 5,904 bytes unread. The drained handler took one event for all 10,000 bytes.
25.2.1 Why use edge-triggered mode
Edge-triggered mode reports each arrival once, which saves work in two places.
- Fewer re-checks. A level-triggered loop gets the same socket back from every
epoll_waituntil it is drained. The kernel re-checks every such socket on every call. - Writes. A socket is writable whenever its send buffer has room, which is nearly always. Section 20.4
registered
EPOLLOUTonly while a reply was pending, because a level-triggeredEPOLLOUTwould report the socket on every call. In edge-triggered mode, a loop registersEPOLLIN | EPOLLOUT | EPOLLETonce. The writable edge comes only after awritehas returnedEAGAINand the buffer has drained. That is when the loop has a reply to finish.
The mio crate, which Tokio uses for its I/O on Linux, registers every socket edge-triggered for these
reasons.
Draining to EAGAIN has a cost of its own. A client that sends faster than the loop reads keeps the
handler in its read loop, and every other socket waits. A production loop caps the bytes it reads per event.
It keeps the sockets it stopped early on a list of its own, and returns to them after serving the other
events. Tokio’s tasks do the same with a budget: after a fixed amount of work, a task yields even if its
socket still has data.
25.3 Several threads on one epoll: EPOLLONESHOT
A server can run several threads that all call epoll_wait on one epoll instance. In edge-triggered mode,
a socket can then be handled by two threads at once:
- Bytes arrive, and thread A wakes with the socket and starts reading.
- More bytes arrive while A is still reading. The arrival is a new edge, so the socket is ready again.
- Thread B wakes with the same socket and reads too. A and B each get part of the stream, and the order between their parts is lost.
EPOLLONESHOT disables a descriptor in the interest list after it reports one event. It stays
registered, but reports nothing until the thread that handled it calls epoll_ctl with EPOLL_CTL_MOD to
re-arm it. Only one thread can hold the socket between the event and the re-arm.
mod linux {
// ...
/// Events seen with `EPOLLONESHOT`: after the first write, after a second
/// write, and after re-arming the descriptor.
pub fn oneshot() -> io::Result<[usize; 3]> {
let (mut writer, mut reader) = UnixStream::pair()?;
reader.set_nonblocking(true)?;
let epoll = Epoll::new()?;
let flags = (libc::EPOLLIN | libc::EPOLLET | libc::EPOLLONESHOT) as u32;
epoll.add(reader.as_raw_fd(), flags)?;
writer.write_all(b"first")?;
let first = epoll.wait(QUIET_MS)?;
read_until_eagain(&mut reader)?;
writer.write_all(b"second")?;
let before_rearm = epoll.wait(QUIET_MS)?;
epoll.modify(reader.as_raw_fd(), flags)?;
let after_rearm = epoll.wait(QUIET_MS)?;
Ok([first, before_rearm, after_rearm])
}
// ...
}
EPOLLONESHOT: first write 1 event, second write 0 events, after re-arming 1 event
The second write arrived while the descriptor was disabled, and epoll_wait timed out. The re-arm found the
socket readable, so the next epoll_wait reported it at once. No data is lost while a descriptor is
disabled. It waits in the socket’s buffer for the re-arm.
25.4 Many waiters on one listener: EPOLLEXCLUSIVE
A common server design gives each thread its own epoll instance, and adds the same listening socket to all
of them. When one connection arrives, the listener’s callback runs for every instance, and every thread
wakes. One of them wins accept. The others get EAGAIN and go back to sleep. With 64 threads, one
connection costs 64 wakeups and 63 wasted system calls. This is the thundering herd.
EPOLLEXCLUSIVE, added in Linux 4.5, marks an instance’s interest in the listener as exclusive. When a
connection arrives, the kernel wakes one of the exclusive waiters, or a few, instead of all of them
(figure 25.3). The flag is allowed only with EPOLL_CTL_ADD.
EPOLLEXCLUSIVE.woken_by_one_connection starts the threads, waits until each has registered the listener, connects one
client, and counts the threads whose epoll_wait returned an event:
mod linux {
// ...
/// Start `waiters` threads, each blocked in `epoll_wait` on its own epoll
/// instance that watches one shared listener. Connect one client, and
/// return how many threads woke.
pub fn woken_by_one_connection(waiters: usize, exclusive: bool) -> io::Result<usize> {
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
let fd = listener.as_raw_fd();
let flags = if exclusive {
(libc::EPOLLIN | libc::EPOLLEXCLUSIVE) as u32
} else {
libc::EPOLLIN as u32
};
let registered = Arc::new(Barrier::new(waiters + 1));
let threads: Vec<_> = (0..waiters)
.map(|_| {
let registered = Arc::clone(®istered);
thread::spawn(move || -> io::Result<bool> {
let epoll = Epoll::new()?;
epoll.add(fd, flags)?;
registered.wait();
Ok(epoll.wait(500)? > 0)
})
})
.collect();
registered.wait();
thread::sleep(Duration::from_millis(50));
let _client = TcpStream::connect(listener.local_addr()?)?;
let mut woken = 0;
for thread in threads {
if thread.join().expect("waiter thread")? {
woken += 1;
}
}
Ok(woken)
}
}
The Barrier holds the main thread until every waiter has called epoll_ctl. The 50 ms sleep gives each
waiter time to block in epoll_wait before the client connects.
4 threads wait on one listener, and one client connects:
without EPOLLEXCLUSIVE: 4 threads woke
with EPOLLEXCLUSIVE: 1 thread woke
SO_REUSEPORT solves the same problem another way. Each thread creates its own listening socket on the same
port, with the option set before bind. The kernel then assigns each new connection to one of the sockets,
chosen by a hash of the client’s address and port. No two threads share a listener, so none is woken for
another’s connection.
25.5 The complete program
Listing 25.1 Level- and edge-triggered handlers, EPOLLONESHOT, and EPOLLEXCLUSIVE. src/bin/epoll_edge.rs
//! Edge-triggered epoll: what it reports, and the handler it needs.
//!
//! Level-triggered epoll reports a socket for as long as it has data.
//! Edge-triggered epoll reports it once per arrival, so a handler that leaves
//! data behind never hears about it again. The program also shows
//! `EPOLLONESHOT`, which disables a descriptor after one event, and
//! `EPOLLEXCLUSIVE`, which wakes one waiter instead of all of them.
//!
//! Linux only. Run with: cargo run --bin epoll_edge
#[cfg(target_os = "linux")]
mod linux {
use std::{
io::{self, Read, Write},
net::{TcpListener, TcpStream},
os::{
fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
unix::net::UnixStream,
},
sync::{Arc, Barrier},
thread,
time::Duration,
};
/// How long `epoll_wait` waits before the program decides no event is coming.
const QUIET_MS: i32 = 100;
/// An epoll instance. Closed when dropped.
pub struct Epoll(OwnedFd);
impl Epoll {
pub fn new() -> io::Result<Self> {
// SAFETY: epoll_create1 takes no pointers, and the result is checked.
let fd = unsafe { libc::epoll_create1(libc::EPOLL_CLOEXEC) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: the descriptor was just created, and nothing else owns it.
Ok(Self(unsafe { OwnedFd::from_raw_fd(fd) }))
}
fn control(&self, op: libc::c_int, fd: RawFd, flags: u32) -> io::Result<()> {
let mut event = libc::epoll_event {
events: flags,
u64: fd as u64,
};
// SAFETY: `event` is a valid epoll_event that outlives the call.
let result = unsafe { libc::epoll_ctl(self.0.as_raw_fd(), op, fd, &mut event) };
if result < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// Add `fd` to the interest list.
pub fn add(&self, fd: RawFd, flags: u32) -> io::Result<()> {
self.control(libc::EPOLL_CTL_ADD, fd, flags)
}
/// Change the flags of `fd`. This also re-arms an `EPOLLONESHOT` descriptor.
pub fn modify(&self, fd: RawFd, flags: u32) -> io::Result<()> {
self.control(libc::EPOLL_CTL_MOD, fd, flags)
}
/// Wait up to `timeout_ms` and return the number of events.
pub fn wait(&self, timeout_ms: i32) -> io::Result<usize> {
let mut events = [libc::epoll_event { events: 0, u64: 0 }; 8];
// SAFETY: `events` is a writable buffer of 8 entries.
let ready =
unsafe { libc::epoll_wait(self.0.as_raw_fd(), events.as_mut_ptr(), 8, timeout_ms) };
if ready < 0 {
return Err(io::Error::last_os_error());
}
Ok(ready as usize)
}
}
/// Read once, at most 4,096 bytes, and return how many arrived.
pub fn read_once(socket: &mut UnixStream) -> io::Result<usize> {
let mut buffer = [0; 4096];
socket.read(&mut buffer)
}
/// Read until the socket has nothing left (`EAGAIN`), and return the total.
pub fn read_until_eagain(socket: &mut UnixStream) -> io::Result<usize> {
let mut buffer = [0; 4096];
let mut total = 0;
loop {
match socket.read(&mut buffer) {
Ok(0) => return Ok(total),
Ok(read) => total += read,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(total),
Err(error) => return Err(error),
}
}
}
/// Write `sent` bytes into one end of a socket pair. Then wait on the other
/// end with `flags`, and call `handler` once per event, until no event comes.
/// Returns the bytes `handler` read at each event.
pub fn run(
flags: u32,
sent: usize,
handler: fn(&mut UnixStream) -> io::Result<usize>,
) -> io::Result<Vec<usize>> {
let (mut writer, mut reader) = UnixStream::pair()?;
reader.set_nonblocking(true)?;
let epoll = Epoll::new()?;
epoll.add(reader.as_raw_fd(), flags)?;
writer.write_all(&vec![b'x'; sent])?;
let mut per_event = Vec::new();
while epoll.wait(QUIET_MS)? > 0 {
per_event.push(handler(&mut reader)?);
}
Ok(per_event)
}
/// Events seen with `EPOLLONESHOT`: after the first write, after a second
/// write, and after re-arming the descriptor.
pub fn oneshot() -> io::Result<[usize; 3]> {
let (mut writer, mut reader) = UnixStream::pair()?;
reader.set_nonblocking(true)?;
let epoll = Epoll::new()?;
let flags = (libc::EPOLLIN | libc::EPOLLET | libc::EPOLLONESHOT) as u32;
epoll.add(reader.as_raw_fd(), flags)?;
writer.write_all(b"first")?;
let first = epoll.wait(QUIET_MS)?;
read_until_eagain(&mut reader)?;
writer.write_all(b"second")?;
let before_rearm = epoll.wait(QUIET_MS)?;
epoll.modify(reader.as_raw_fd(), flags)?;
let after_rearm = epoll.wait(QUIET_MS)?;
Ok([first, before_rearm, after_rearm])
}
/// Start `waiters` threads, each blocked in `epoll_wait` on its own epoll
/// instance that watches one shared listener. Connect one client, and
/// return how many threads woke.
pub fn woken_by_one_connection(waiters: usize, exclusive: bool) -> io::Result<usize> {
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
let fd = listener.as_raw_fd();
let flags = if exclusive {
(libc::EPOLLIN | libc::EPOLLEXCLUSIVE) as u32
} else {
libc::EPOLLIN as u32
};
let registered = Arc::new(Barrier::new(waiters + 1));
let threads: Vec<_> = (0..waiters)
.map(|_| {
let registered = Arc::clone(®istered);
thread::spawn(move || -> io::Result<bool> {
let epoll = Epoll::new()?;
epoll.add(fd, flags)?;
registered.wait();
Ok(epoll.wait(500)? > 0)
})
})
.collect();
registered.wait();
thread::sleep(Duration::from_millis(50));
let _client = TcpStream::connect(listener.local_addr()?)?;
let mut woken = 0;
for thread in threads {
if thread.join().expect("waiter thread")? {
woken += 1;
}
}
Ok(woken)
}
}
#[cfg(target_os = "linux")]
fn main() -> std::io::Result<()> {
use linux::{read_once, read_until_eagain, run};
const SENT: usize = 10_000;
let level = libc::EPOLLIN as u32;
let edge = (libc::EPOLLIN | libc::EPOLLET) as u32;
println!("{SENT} bytes written into a socket; one handler call per epoll event\n");
let runs = [
("level-triggered, read once ", run(level, SENT, read_once)?),
("edge-triggered, read once ", run(edge, SENT, read_once)?),
(
"edge-triggered, to EAGAIN ",
run(edge, SENT, read_until_eagain)?,
),
];
for (name, per_event) in runs {
let left = SENT - per_event.iter().sum::<usize>();
println!(
"{name} events {}, bytes per event {per_event:?}, left unread {left}",
per_event.len()
);
}
let [first, before, after] = linux::oneshot()?;
println!(
"\nEPOLLONESHOT: first write {first} event, second write {before} events, \
after re-arming {after} event"
);
println!("\n4 threads wait on one listener, and one client connects:");
println!(
" without EPOLLEXCLUSIVE: {} threads woke",
linux::woken_by_one_connection(4, false)?
);
println!(
" with EPOLLEXCLUSIVE: {} thread woke",
linux::woken_by_one_connection(4, true)?
);
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("epoll_edge uses Linux epoll; run it on Linux");
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::linux::*;
const LEVEL: u32 = libc::EPOLLIN as u32;
const EDGE: u32 = (libc::EPOLLIN | libc::EPOLLET) as u32;
#[test]
fn level_triggered_reports_until_the_data_is_gone() {
assert_eq!(
run(LEVEL, 10_000, read_once).expect("run"),
[4096, 4096, 1808]
);
}
#[test]
fn edge_triggered_with_one_read_leaves_data_behind() {
assert_eq!(run(EDGE, 10_000, read_once).expect("run"), [4096]);
}
#[test]
fn edge_triggered_drained_to_eagain_reads_everything() {
assert_eq!(run(EDGE, 10_000, read_until_eagain).expect("run"), [10_000]);
}
#[test]
fn oneshot_waits_for_a_rearm() {
assert_eq!(oneshot().expect("oneshot"), [1, 0, 1]);
}
}
$ cargo run --bin epoll_edge
10000 bytes written into a socket; one handler call per epoll event
level-triggered, read once events 3, bytes per event [4096, 4096, 1808], left unread 0
edge-triggered, read once events 1, bytes per event [4096], left unread 5904
edge-triggered, to EAGAIN events 1, bytes per event [10000], left unread 0
EPOLLONESHOT: first write 1 event, second write 0 events, after re-arming 1 event
4 threads wait on one listener, and one client connects:
without EPOLLEXCLUSIVE: 4 threads woke
with EPOLLEXCLUSIVE: 1 thread woke
25.6 Questions that come up
“A server under edge-triggered epoll sometimes stops answering one client. CPU use is zero. What do you
check first?”
Whether every read loop runs until EAGAIN. A loop that stops after a full buffer, or after a fixed number
of bytes, leaves data that no event will report.
“Does edge-triggered mode apply to accept?”
Yes. An edge-triggered listener reports once when connections arrive, so the handler calls accept until
EAGAIN, as accept_ready in section 20.4 does.
“What happens to an edge-triggered socket when the peer closes it?”
The close is an arrival. Epoll reports EPOLLIN, and EPOLLRDHUP if it was requested. The drain loop then
reads the remaining bytes and sees read return 0.
“Is kqueue edge-triggered?”
kqueue offers both. EV_CLEAR gives edge-triggered behavior, and EV_DISPATCH disables an event after it
fires, like EPOLLONESHOT.
“Why not have every thread call accept on one blocking listener?”
That works. The kernel queues blocked accept callers and wakes one per connection. The herd appears when
threads wait in epoll_wait instead, because each epoll instance is a separate waiter on the listener.
Summary
- Readable is a level: true while the buffer has bytes. An arrival is an edge.
- Level-triggered epoll puts a reported socket back on the ready list and reports it again while it is readable. Edge-triggered epoll reports it again only after new bytes arrive.
- An edge-triggered handler must read until
EAGAIN. One 4,096-byte read per event left 5,904 of 10,000 bytes unread, with no further event. - Edge-triggered mode saves re-checks and lets
EPOLLOUTstay registered. A drain loop needs a cap so one busy socket cannot starve the others. EPOLLONESHOTdisables a descriptor after one event untilEPOLL_CTL_MODre-arms it, so one thread handles a socket at a time.EPOLLEXCLUSIVEwakes one of the waiters on a shared listener: 1 of 4 threads in this run, against 4 of 4 without it.SO_REUSEPORTgives each thread its own listener instead.
Chapter 26 goes under the locks used in chapter 16. It builds a mutex on the futex system call, and runs litmus tests that show what each memory ordering allows.
Exercises
- Change
runso that after the edge-triggered stall it writes one more byte, and count the events. How many bytes doesread_oncereturn now, and why? - Add
EPOLLOUT | EPOLLETto a socket inrunand count the events while nothing is written. Then fill the socket’s send buffer untilwritereturnsWouldBlock, read from the other end, and count again. - Cap
read_until_eagainat 8,192 bytes per call and return whether it stopped early. Write a loop that keeps a list of sockets that stopped early, and serves them after the events fromepoll_wait. - Run
woken_by_one_connectionwith 16 threads and 10 connections. How many wakeups does each mode cost? - Rewrite
woken_by_one_connectionwithSO_REUSEPORT, using thesocket2crate to set the option beforebind. Count how the 10 connections spread across the threads.
Futexes and memory ordering
This chapter covers
- The futex: a wait queue in the kernel, keyed by the address of a word in your memory
- A mutex with three states that enters the kernel only to sleep and to wake
- Why the kernel checks the word before it puts a thread to sleep
- Counting the system calls a mutex makes, and comparing it with
std::sync::Mutex - Store buffers, and the reordering they allow even on x86
- Two litmus tests run a million times: store buffering, and message passing
Chapter 16 built a spin lock from one atomic flag. A thread that found the lock taken kept the CPU busy until the lock came free. That is a good trade for a lock held for a few nanoseconds. It is a poor one for a lock held while a thread writes a file. Such a lock has to put the waiting thread to sleep and wake it later. Only the kernel can do that.
This chapter builds such a lock on the Linux futex system call, the same design std::sync::Mutex uses
on Linux. Then it looks under the orderings chapter 16 used. It runs two classic litmus tests a million times each, and counts how often the hardware reorders memory operations.
26.1 Sleeping on a word
A futex, a fast user-space mutex, is a wait queue in the kernel, keyed by an address. The word at that address lives in your program’s memory, and the kernel never interprets it. A futex has two operations:
futex_wait(addr, expected): if the 32-bit word ataddrstill holdsexpected, put this thread to sleep on the queue foraddr. If it holds anything else, return at once.futex_wake(addr, n): wake up tonthreads sleeping on the queue foraddr.
The lock state stays in an atomic in user space (figure 26.1). Taking a free lock is one compare-exchange
on that word, with no system call. Only a thread that has to wait calls futex_wait, and only an unlock that may have a sleeper to wake calls futex_wake.
26.1.1 Why the kernel checks the word
futex_wait checks the word and queues the thread as one step, under a kernel lock. Without the check,
this sequence would lose a wake-up:
- Thread B reads the lock word and sees it held. B decides to sleep.
- Thread A unlocks and calls
futex_wake. No thread is on the queue yet, so the wake does nothing. - B enters the queue and sleeps. Nobody will wake it.
With the check, step 3 finds that the word no longer holds the value B saw, and futex_wait returns at
once. B then tries the lock again. This is the lost wake-up problem, and it is the reason Condvar in
chapter 16 takes the mutex as an argument.
26.2 A mutex with three states
The lock word has three values (figure 26.2):
- 0, unlocked.
- 1, locked, and no thread is waiting.
- 2, locked, and some thread may be asleep in
futex_wait.
The third state exists so that unlock knows whether to call futex_wake. Unlocking from 1 costs one
atomic instruction. Unlocking from 2 also costs a system call.
26.2.1 The system calls
libc has no wrapper for futex, so the program calls it through syscall, which takes a system call’s
number and its arguments:
unsafe extern "C" {
fn syscall(number: c_long, ...) -> c_long;
}
// The futex call, as the kernel defines its arguments:
// syscall(SYS_futex, uaddr: *const u32, op: c_int, val: u32, timeout: *const timespec)
// FUTEX_WAIT: sleep if *uaddr == val, until woken or until the timeout (null: none)
// FUTEX_WAKE: wake up to val threads sleeping on uaddr; timeout is unused
The two wrappers:
#[cfg(target_os = "linux")]
mod linux {
use std::{
cell::UnsafeCell,
ops::{Deref, DerefMut},
sync::atomic::{
AtomicU32,
AtomicU64,
Ordering::{Acquire, Relaxed, Release},
},
};
const UNLOCKED: u32 = 0;
const LOCKED: u32 = 1;
const CONTENDED: u32 = 2;
/// Sleep while `word` holds `expected`. The kernel checks the value and
/// queues the thread as one step, so a wake that happens first is not lost.
fn futex_wait(word: &AtomicU32, expected: u32) {
// SAFETY: `word` is a valid, aligned u32 for the whole call, and a null
// timeout means wait without a time limit.
unsafe {
libc::syscall(
libc::SYS_futex,
word.as_ptr(),
libc::FUTEX_WAIT | libc::FUTEX_PRIVATE_FLAG,
expected,
std::ptr::null::<libc::timespec>(),
);
}
}
/// Wake one thread sleeping on `word`, if there is one.
fn futex_wake_one(word: &AtomicU32) {
// SAFETY: `word` is a valid, aligned u32 for the whole call.
unsafe {
libc::syscall(
libc::SYS_futex,
word.as_ptr(),
libc::FUTEX_WAKE | libc::FUTEX_PRIVATE_FLAG,
1,
);
}
}
// ...
}
FUTEX_PRIVATE_FLAG tells the kernel that only this process uses the word. The kernel can then key the queue by the virtual address alone. A word shared between processes needs the physical page, which costs more to find. AtomicU32::as_ptr gives the address of the atomic’s value. The return value is ignored. futex_wait returns early for several reasons, a signal among them, and the caller checks the word again anyway.
26.2.2 lock and unlock
The type holds the word, two counters for this chapter’s measurements, and the protected value:
mod linux {
// ...
/// A mutual-exclusion lock that sleeps in the kernel while it waits.
pub struct FutexMutex<T> {
state: AtomicU32,
waits: AtomicU64,
wakes: AtomicU64,
value: UnsafeCell<T>,
}
// SAFETY: the lock gives one thread at a time access to `value`, so sharing
// the mutex is safe whenever `T` may move between threads.
unsafe impl<T: Send> Sync for FutexMutex<T> {}
impl<T> FutexMutex<T> {
pub const fn new(value: T) -> Self {
Self {
state: AtomicU32::new(UNLOCKED),
waits: AtomicU64::new(0),
wakes: AtomicU64::new(0),
value: UnsafeCell::new(value),
}
}
// ...
}
As with the spin lock in chapter 16, the unsafe impl Sync promises that the lock hands out value to one
thread at a time. lock returns a Guard, which section 26.2.2 shows below: it gives access to the value,
and unlocks when it is dropped. lock tries the uncontended path first:
mod linux {
// ...
impl<T> FutexMutex<T> {
// ...
pub fn lock(&self) -> Guard<'_, T> {
if self
.state
.compare_exchange(UNLOCKED, LOCKED, Acquire, Relaxed)
.is_err()
{
self.lock_contended();
}
Guard { mutex: self }
}
/// The slow path: mark the lock contended, and sleep until it is free.
fn lock_contended(&self) {
while self.state.swap(CONTENDED, Acquire) != UNLOCKED {
self.waits.fetch_add(1, Relaxed);
futex_wait(&self.state, CONTENDED);
}
}
fn unlock(&self) {
if self.state.swap(UNLOCKED, Release) == CONTENDED {
self.wakes.fetch_add(1, Relaxed);
futex_wake_one(&self.state);
}
}
// ...
}
// ...
}
compare_exchange(UNLOCKED, LOCKED, Acquire, Relaxed) takes a free lock in one instruction. If it fails,
lock_contended swaps in 2 and looks at the old value. An old value of 0 means the lock was free, and this
thread now holds it. Any other value means the lock is held. The thread sleeps while the word is still 2,
then tries again.
A thread that takes the lock in lock_contended leaves the word at 2, even if no other thread waits. It
cannot know whether others are asleep, so it assumes they are. The cost is a futex_wake call that may find
nobody to wake.
unlock swaps in 0 with Release, which publishes the writes made under the lock, as in section 16.4.3.
The old value says whether to wake a sleeper.
The guard is the same as the spin lock’s:
mod linux {
// ...
/// Access to the value while the lock is held. Unlocks when dropped.
pub struct Guard<'a, T> {
mutex: &'a FutexMutex<T>,
}
impl<T> Deref for Guard<'_, T> {
type Target = T;
fn deref(&self) -> &T {
// SAFETY: the guard exists only while this thread holds the lock.
unsafe { &*self.mutex.value.get() }
}
}
impl<T> DerefMut for Guard<'_, T> {
fn deref_mut(&mut self) -> &mut T {
// SAFETY: as in `deref`, and `&mut self` makes this the only borrow.
unsafe { &mut *self.mutex.value.get() }
}
}
impl<T> Drop for Guard<'_, T> {
fn drop(&mut self) {
self.mutex.unlock();
}
}
}
Animation 26.1 follows two threads through the three states, and then shows the lost wake-up.
futex_wait keeps a wake-up from being lost.26.2.3 Measured
main adds 1 to a counter a million times under each lock: with one thread, then split across two and four
threads. It runs in a Linux container with two CPUs:
$ cargo run --release --bin futex_mutex
threads total waits wakes futex ms std ms
1 1000000 0 0 30.8 25.1
2 1000000 23712 59752 64.4 56.2
4 1000000 28006 71826 71.0 56.1
With one thread, a million increments made no system calls, at about 30 ns each. With
two threads, about 2.4% of the locks slept. There were 2.5 times as many wakes as waits. Most wakes came
from a thread that had taken the lock in lock_contended, left the word at 2, and woke an empty queue.
std::sync::Mutex uses the same three states, and was faster with contention. Before it sleeps, it spins
for up to 100 rounds, reading the word. A lock held for a few nanoseconds is often free again within those
rounds, so the waiter avoids two system calls. The timings vary between runs by 10% or more on this
container. In one of three runs, std with four threads took 214 ms. The counts of waits and wakes are the
steadier measurement.
26.3 What the hardware reorders
Section 16.2.3 said that Relaxed promises nothing about other memory, and that SeqCst gives one order
all threads agree on. This section shows a reordering that real hardware performs, and the ordering that
forbids it.
26.3.1 Store buffers
A store to memory is slow for a core: the cache line may have to be fetched from another core first. So each core puts its stores in a store buffer and goes on running. The buffer drains to the cache in the background. The core’s own loads check its buffer, so a thread always sees its own stores. Other cores see a store only after it drains.
A load that comes after a store can therefore complete before the store is visible to anyone else. Figure 26.3 shows two cores each doing that at once. Both loads read the old value, 0, from memory, while both stores still sit in the buffers.
Animation 26.2 runs one round of this, then the same round with SeqCst.
Relaxed, both loads run while both stores are still buffered. A SeqCst store drains the buffer first, so the later load of the other thread sees it.x86 processors keep a strong ordering, called total store order. They never reorder two stores, or two
loads, and this store-then-load case is the one reordering they allow. ARM processors, including Apple’s
M-series and AWS Graviton, also reorder stores with stores and loads with loads. The compiler may reorder
Relaxed operations too, on any processor.
26.3.2 The litmus tests
A litmus test is two short threads and a question about the results. Each test here runs a million rounds. Every round uses fresh atomics, so no round needs a reset, and both threads meet before each round so their operations overlap:
use std::{
sync::atomic::{
AtomicU32,
AtomicUsize,
Ordering::{self, Acquire, Relaxed, Release, SeqCst},
},
thread,
};
const ROUNDS: usize = 1_000_000;
/// A fresh, zeroed atomic for every round, so no round needs a reset.
fn zeroed(rounds: usize) -> Vec<AtomicU32> {
(0..rounds)
.map(|_| AtomicU32::new(0))
.collect()
}
/// Wait until both threads have reached `round`. Keeps the two threads on the
/// same round, so their operations overlap in time.
fn meet(arrived: &AtomicUsize, round: usize) {
arrived.fetch_add(1, SeqCst);
while arrived.load(SeqCst) < 2 * (round + 1) {
std::hint::spin_loop();
}
}
meet is a spin barrier on one counter. Each thread adds 1 and waits until both have arrived for this round.
Store buffering is figure 26.3 as code. The question is whether both loads can read 0:
/// Store buffering. Thread 1 stores x and loads y; thread 2 stores y and loads
/// x. Returns the rounds in which both loads read 0.
fn store_buffering(store: Ordering, load: Ordering) -> usize {
let (x, y) = (zeroed(ROUNDS), zeroed(ROUNDS));
let (r1, r2) = (zeroed(ROUNDS), zeroed(ROUNDS));
let arrived = AtomicUsize::new(0);
thread::scope(|scope| {
scope.spawn(|| {
for i in 0..ROUNDS {
meet(&arrived, i);
x[i].store(1, store);
r1[i].store(y[i].load(load), Relaxed);
}
});
scope.spawn(|| {
for i in 0..ROUNDS {
meet(&arrived, i);
y[i].store(1, store);
r2[i].store(x[i].load(load), Relaxed);
}
});
});
(0..ROUNDS)
.filter(|&i| r1[i].load(Relaxed) == 0 && r2[i].load(Relaxed) == 0)
.count()
}
Message passing writes data, then sets a flag. The question is whether the reader can see the flag set and the data still 0:
/// Message passing. Thread 1 writes data, then sets a flag; thread 2 reads the
/// flag, then the data. Returns the rounds in which thread 2 saw the flag set
/// but the data still 0.
fn message_passing(store: Ordering, load: Ordering) -> usize {
let (data, flag) = (zeroed(ROUNDS), zeroed(ROUNDS));
let (seen_flag, seen_data) = (zeroed(ROUNDS), zeroed(ROUNDS));
let arrived = AtomicUsize::new(0);
thread::scope(|scope| {
scope.spawn(|| {
for i in 0..ROUNDS {
meet(&arrived, i);
data[i].store(42, Relaxed);
flag[i].store(1, store);
}
});
scope.spawn(|| {
for i in 0..ROUNDS {
meet(&arrived, i);
seen_flag[i].store(flag[i].load(load), Relaxed);
seen_data[i].store(data[i].load(Relaxed), Relaxed);
}
});
});
(0..ROUNDS)
.filter(|&i| seen_flag[i].load(Relaxed) == 1 && seen_data[i].load(Relaxed) == 0)
.count()
}
Message passing is the pattern behind every lock and every queue: the flag says the data is ready. Its
Release store and Acquire load are the pair from section 16.4.3.
The run on an Intel x86 laptop:
$ cargo run --release --bin litmus
store buffering, 1000000 rounds: both loads read 0
Relaxed 14472
Release / Acquire 15019
SeqCst 0
message passing, 1000000 rounds: flag read as 1, data read as 0
Relaxed 0
Release / Acquire 0
SeqCst 0
The Linux container on the same machine found 6,342 and 5,045 rounds for store buffering, and the same zeros elsewhere. The results say three things:
- Store buffering happens. About 1.5% of rounds read 0 twice with
Relaxed.ReleaseandAcquiredid not prevent it. They order a thread’s earlier operations before a release store, and later operations after an acquire load. Neither stops a store from being passed by a later load. SeqCstforbids it. On x86, aSeqCststore compiles to anxchginstruction, which waits for the store buffer to drain. EverySeqCstoperation then fits one global order, and in any such order one of the stores comes first, so one load sees 1.- Message passing did not fail, even with
Relaxed. On x86 the hardware does not reorder the two stores or the two loads. TheRelaxedversion is still wrong. On ARM the same test fails in some rounds, and the compiler may reorder theRelaxedoperations on any processor. A test that passes on x86 does not show that the orderings are right.
26.4 The complete programs
Listing 26.1 A mutex on the futex system call. src/bin/futex_mutex.rs
//! A mutex built on the futex system call.
//!
//! The lock is one 32-bit word with three states: 0 unlocked, 1 locked, and
//! 2 locked with threads that may be asleep waiting for it. Taking a free lock
//! and releasing a lock nobody waits for are single atomic instructions. Only a
//! thread that has to wait, and an unlock that has to wake it, enter the kernel.
//!
//! Linux only. Run with: cargo run --release --bin futex_mutex
#[cfg(target_os = "linux")]
mod linux {
use std::{
cell::UnsafeCell,
ops::{Deref, DerefMut},
sync::atomic::{
AtomicU32,
AtomicU64,
Ordering::{Acquire, Relaxed, Release},
},
};
const UNLOCKED: u32 = 0;
const LOCKED: u32 = 1;
const CONTENDED: u32 = 2;
/// Sleep while `word` holds `expected`. The kernel checks the value and
/// queues the thread as one step, so a wake that happens first is not lost.
fn futex_wait(word: &AtomicU32, expected: u32) {
// SAFETY: `word` is a valid, aligned u32 for the whole call, and a null
// timeout means wait without a time limit.
unsafe {
libc::syscall(
libc::SYS_futex,
word.as_ptr(),
libc::FUTEX_WAIT | libc::FUTEX_PRIVATE_FLAG,
expected,
std::ptr::null::<libc::timespec>(),
);
}
}
/// Wake one thread sleeping on `word`, if there is one.
fn futex_wake_one(word: &AtomicU32) {
// SAFETY: `word` is a valid, aligned u32 for the whole call.
unsafe {
libc::syscall(
libc::SYS_futex,
word.as_ptr(),
libc::FUTEX_WAKE | libc::FUTEX_PRIVATE_FLAG,
1,
);
}
}
/// A mutual-exclusion lock that sleeps in the kernel while it waits.
pub struct FutexMutex<T> {
state: AtomicU32,
waits: AtomicU64,
wakes: AtomicU64,
value: UnsafeCell<T>,
}
// SAFETY: the lock gives one thread at a time access to `value`, so sharing
// the mutex is safe whenever `T` may move between threads.
unsafe impl<T: Send> Sync for FutexMutex<T> {}
impl<T> FutexMutex<T> {
pub const fn new(value: T) -> Self {
Self {
state: AtomicU32::new(UNLOCKED),
waits: AtomicU64::new(0),
wakes: AtomicU64::new(0),
value: UnsafeCell::new(value),
}
}
pub fn lock(&self) -> Guard<'_, T> {
if self
.state
.compare_exchange(UNLOCKED, LOCKED, Acquire, Relaxed)
.is_err()
{
self.lock_contended();
}
Guard { mutex: self }
}
/// The slow path: mark the lock contended, and sleep until it is free.
fn lock_contended(&self) {
while self.state.swap(CONTENDED, Acquire) != UNLOCKED {
self.waits.fetch_add(1, Relaxed);
futex_wait(&self.state, CONTENDED);
}
}
fn unlock(&self) {
if self.state.swap(UNLOCKED, Release) == CONTENDED {
self.wakes.fetch_add(1, Relaxed);
futex_wake_one(&self.state);
}
}
/// The number of `futex` wait and wake calls this mutex has made.
pub fn syscalls(&self) -> (u64, u64) {
(self.waits.load(Relaxed), self.wakes.load(Relaxed))
}
pub fn into_inner(self) -> T {
self.value.into_inner()
}
}
/// Access to the value while the lock is held. Unlocks when dropped.
pub struct Guard<'a, T> {
mutex: &'a FutexMutex<T>,
}
impl<T> Deref for Guard<'_, T> {
type Target = T;
fn deref(&self) -> &T {
// SAFETY: the guard exists only while this thread holds the lock.
unsafe { &*self.mutex.value.get() }
}
}
impl<T> DerefMut for Guard<'_, T> {
fn deref_mut(&mut self) -> &mut T {
// SAFETY: as in `deref`, and `&mut self` makes this the only borrow.
unsafe { &mut *self.mutex.value.get() }
}
}
impl<T> Drop for Guard<'_, T> {
fn drop(&mut self) {
self.mutex.unlock();
}
}
}
#[cfg(target_os = "linux")]
fn main() {
use std::{
sync::Mutex,
thread,
time::{Duration, Instant},
};
use linux::FutexMutex;
const INCREMENTS: u64 = 1_000_000;
/// Run `add` INCREMENTS times in total, split across `threads`.
fn timed(threads: u64, add: &(dyn Fn() + Sync)) -> Duration {
let start = Instant::now();
thread::scope(|scope| {
for _ in 0..threads {
scope.spawn(|| (0..INCREMENTS / threads).for_each(|_| add()));
}
});
start.elapsed()
}
// One untimed pass of each, so neither pays for a cold start in the table.
let warm = FutexMutex::new(0u64);
timed(1, &|| *warm.lock() += 1);
let warm = Mutex::new(0u64);
timed(1, &|| *warm.lock().unwrap() += 1);
println!(
"{:>7} {:>9} {:>8} {:>8} {:>12} {:>12}",
"threads", "total", "waits", "wakes", "futex ms", "std ms"
);
for threads in [1, 2, 4] {
let futex = FutexMutex::new(0u64);
let futex_time = timed(threads, &|| *futex.lock() += 1);
let (waits, wakes) = futex.syscalls();
let std = Mutex::new(0u64);
let std_time = timed(threads, &|| *std.lock().unwrap() += 1);
assert_eq!(std.into_inner().unwrap(), INCREMENTS);
println!(
"{threads:>7} {:>9} {waits:>8} {wakes:>8} {:>12.1} {:>12.1}",
futex.into_inner(),
futex_time.as_secs_f64() * 1000.0,
std_time.as_secs_f64() * 1000.0
);
}
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("futex_mutex uses the Linux futex system call; run it on Linux");
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use std::thread;
use super::linux::FutexMutex;
#[test]
fn an_uncontended_lock_makes_no_system_calls() {
let mutex = FutexMutex::new(0);
for _ in 0..1000 {
*mutex.lock() += 1;
}
assert_eq!(mutex.syscalls(), (0, 0));
assert_eq!(mutex.into_inner(), 1000);
}
#[test]
fn contended_increments_are_not_lost() {
let mutex = FutexMutex::new(0);
thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| {
for _ in 0..10_000 {
*mutex.lock() += 1;
}
});
}
});
assert_eq!(mutex.into_inner(), 80_000);
}
}
Listing 26.2 Store buffering and message passing, a million rounds each. src/bin/litmus.rs
//! Memory-ordering litmus tests, run many times on real hardware.
//!
//! A litmus test is two short threads and a question: can this combination
//! of results happen? Each test runs a million rounds and counts the rounds
//! that ended with the result in question.
//!
//! Run with: cargo run --release --bin litmus
use std::{
sync::atomic::{
AtomicU32,
AtomicUsize,
Ordering::{self, Acquire, Relaxed, Release, SeqCst},
},
thread,
};
const ROUNDS: usize = 1_000_000;
/// A fresh, zeroed atomic for every round, so no round needs a reset.
fn zeroed(rounds: usize) -> Vec<AtomicU32> {
(0..rounds)
.map(|_| AtomicU32::new(0))
.collect()
}
/// Wait until both threads have reached `round`. Keeps the two threads on the
/// same round, so their operations overlap in time.
fn meet(arrived: &AtomicUsize, round: usize) {
arrived.fetch_add(1, SeqCst);
while arrived.load(SeqCst) < 2 * (round + 1) {
std::hint::spin_loop();
}
}
/// Store buffering. Thread 1 stores x and loads y; thread 2 stores y and loads
/// x. Returns the rounds in which both loads read 0.
fn store_buffering(store: Ordering, load: Ordering) -> usize {
let (x, y) = (zeroed(ROUNDS), zeroed(ROUNDS));
let (r1, r2) = (zeroed(ROUNDS), zeroed(ROUNDS));
let arrived = AtomicUsize::new(0);
thread::scope(|scope| {
scope.spawn(|| {
for i in 0..ROUNDS {
meet(&arrived, i);
x[i].store(1, store);
r1[i].store(y[i].load(load), Relaxed);
}
});
scope.spawn(|| {
for i in 0..ROUNDS {
meet(&arrived, i);
y[i].store(1, store);
r2[i].store(x[i].load(load), Relaxed);
}
});
});
(0..ROUNDS)
.filter(|&i| r1[i].load(Relaxed) == 0 && r2[i].load(Relaxed) == 0)
.count()
}
/// Message passing. Thread 1 writes data, then sets a flag; thread 2 reads the
/// flag, then the data. Returns the rounds in which thread 2 saw the flag set
/// but the data still 0.
fn message_passing(store: Ordering, load: Ordering) -> usize {
let (data, flag) = (zeroed(ROUNDS), zeroed(ROUNDS));
let (seen_flag, seen_data) = (zeroed(ROUNDS), zeroed(ROUNDS));
let arrived = AtomicUsize::new(0);
thread::scope(|scope| {
scope.spawn(|| {
for i in 0..ROUNDS {
meet(&arrived, i);
data[i].store(42, Relaxed);
flag[i].store(1, store);
}
});
scope.spawn(|| {
for i in 0..ROUNDS {
meet(&arrived, i);
seen_flag[i].store(flag[i].load(load), Relaxed);
seen_data[i].store(data[i].load(Relaxed), Relaxed);
}
});
});
(0..ROUNDS)
.filter(|&i| seen_flag[i].load(Relaxed) == 1 && seen_data[i].load(Relaxed) == 0)
.count()
}
fn main() {
let orderings = [
("Relaxed", Relaxed, Relaxed),
("Release / Acquire", Release, Acquire),
("SeqCst", SeqCst, SeqCst),
];
println!("store buffering, {ROUNDS} rounds: both loads read 0");
for (name, store, load) in orderings {
println!(" {name:<18} {:>8}", store_buffering(store, load));
}
println!("\nmessage passing, {ROUNDS} rounds: flag read as 1, data read as 0");
for (name, store, load) in orderings {
println!(" {name:<18} {:>8}", message_passing(store, load));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seq_cst_forbids_both_loads_reading_zero() {
assert_eq!(store_buffering(SeqCst, SeqCst), 0);
}
#[test]
fn release_acquire_forbids_a_flag_without_its_data() {
assert_eq!(message_passing(Release, Acquire), 0);
}
}
26.5 Questions that come up
“Why not always spin, or always sleep?”
Spinning wastes a CPU for as long as the wait lasts. Sleeping costs two system calls and a context switch,
a few microseconds. A short spin followed by a sleep handles both short and long holds, which is what
std::sync::Mutex does.
“What is priority inversion, and does a futex help?”
A low-priority thread holds a lock that a high-priority thread waits for. Meanwhile a medium-priority thread keeps the low one off the CPU. Linux has priority-inheritance futexes, FUTEX_LOCK_PI, which raise the
holder’s priority while a higher-priority thread waits.
“Can a futex be shared between processes?”
Yes. Put the word in memory both processes map, such as a MAP_SHARED mapping from section 23.2, and leave
out FUTEX_PRIVATE_FLAG. The kernel then keys the queue by the physical page.
“When is Relaxed correct?”
When no other memory depends on the value: a statistics counter, or a unique id from fetch_add. Once a
value says that other data is ready, it needs Release and Acquire.
“When is SeqCst needed?”
When correctness depends on a single order of operations on different variables, as in store buffering.
Dekker’s mutual-exclusion algorithm is the classic case. Most code built from locks, channels, and
Release/Acquire pairs does not need it.
Summary
- A futex is a kernel wait queue keyed by the address of a word in user memory.
futex_waitsleeps only if the word still holds the expected value, which prevents a lost wake-up. - A three-state mutex takes and releases an uncontended lock with one atomic instruction each. It enters the kernel only to sleep or to wake a sleeper.
- One thread made a million increments under the lock with no system calls. Under contention, the conservative state 2 made more wakes than waits.
std::sync::Mutexspins briefly before sleeping, which saved system calls in this test.- A store buffer lets a later load complete before an earlier store is visible. x86 allows this one reordering. ARM allows more.
- Store buffering read 0 twice in about 1.5% of a million rounds with
RelaxedorRelease/Acquire, and never withSeqCst. Message passing never failed on x86, which does not makeRelaxedcorrect there.
Chapter 27 starts Part 9, production networking. It follows a TCP connection through its close. It covers the states after FIN, the length of TIME_WAIT, and the CLOSE_WAIT sockets a leaking server piles up.
Exercises
- Add a spin to
lock_contended: up to 100 relaxed loads of the word, stopping early when it reads 0, before the firstswap. Compare the waits, wakes, and times with the table in section 26.2.3. - Hold the lock for 50 microseconds inside each increment, with a busy loop, and run four threads. Compare
the futex mutex with chapter 16’s spin lock in total time and in CPU time (
/usr/bin/time -v). - Remove the
expectedcheck by passing a value the word never holds, such as 7, tofutex_wait. Explain why the program now burns CPU instead of sleeping. - Change the message-passing test to
Relaxedfor both and build it foraarch64(or run it on an ARM machine). Count the failing rounds. - Add the “load buffering” test: thread 1 loads x then stores y = 1, and thread 2 loads y then stores
x = 1. Can both loads read 1? Run it with
Relaxedon x86 and explain the result.
Closing TCP connections
This chapter covers
- How each direction of a TCP connection closes with its own
FIN - The states of the side that closes first and of the side that closes second, read from the kernel
TIME_WAIT: what it protects, how long it lasts, and the port limit it puts on busy clientsSO_REUSEADDR, and why a restarted server can bind its portCLOSE_WAIT: the sockets a server keeps when it never closes, and how to find them- The reset that replaces
FINwhen a socket closes with unread data
Chapter 20 opened TCP connections and closed them with shutdown and drop. Each close sent a FIN, and
the other side’s read returned 0. That is all a program sees. The kernel, though, keeps each side of the
connection in a sequence of states after the program has moved on. Some of those states last a minute. One
lasts for as long as the program forgets to close the socket.
This chapter follows a connection through its close, as the kernel records it. The program, tcp_close,
reads each socket’s state from Linux’s /proc/net/tcp at every step, so its output comes from a Linux
container. It also shows the two production problems the close states cause, and the reset that a close
can send instead of a FIN.
27.1 Two directions, two FINs
A TCP connection carries bytes in both directions, and each direction closes on its own. A side that has
no more bytes to send sends a FIN, and the other side acknowledges it. After that, bytes can still
flow the other way. The connection is gone only when both directions have closed, which takes four
segments: FIN, ACK, FIN, ACK.
The side that sends the first FIN makes the active close. The other side makes the passive
close. They pass through different states (figure 27.1):
| Side | State | Waiting for |
|---|---|---|
| active | FIN_WAIT1 | the ACK of its FIN |
| active | FIN_WAIT2 | the peer’s FIN |
| active | TIME_WAIT | a fixed time, 60 seconds on Linux, before the connection is forgotten |
| passive | CLOSE_WAIT | its own program to close the socket |
| passive | LAST_ACK | the ACK of its FIN |
Two of these states wait on something other than the network. CLOSE_WAIT waits for the program, and
lasts until the program closes the socket. TIME_WAIT waits for a clock. Sections 27.2 and 27.3 cover each.
27.1.1 Reading the states
Linux lists every IPv4 TCP socket in the file /proc/net/tcp, one per line. The second and third columns
are the local and remote addresses, written as 0100007F:1F90: the IP address, then the port, in
hexadecimal. The fourth column is the state, as a number from 01 for ESTABLISHED to 0B for CLOSING.
The program parses the lines into the ports and a state name:
#[cfg(target_os = "linux")]
mod linux {
use std::{
collections::BTreeMap,
fs,
io::{self, Read, Write},
mem,
net::{SocketAddr, SocketAddrV4, TcpListener, TcpStream},
os::fd::{FromRawFd, OwnedFd},
thread,
time::Duration,
};
/// Long enough for loopback segments to be sent, received, and acknowledged.
pub const SETTLE: Duration = Duration::from_millis(50);
/// One line of /proc/net/tcp: local port, remote port, and state.
struct Entry {
local: u16,
remote: u16,
state: &'static str,
}
/// The name of a TCP state, from the number Linux prints for it.
fn state_name(code: u8) -> &'static str {
match code {
0x01 => "ESTABLISHED",
0x02 => "SYN_SENT",
0x03 => "SYN_RECV",
0x04 => "FIN_WAIT1",
0x05 => "FIN_WAIT2",
0x06 => "TIME_WAIT",
0x07 => "CLOSE",
0x08 => "CLOSE_WAIT",
0x09 => "LAST_ACK",
0x0A => "LISTEN",
0x0B => "CLOSING",
_ => "UNKNOWN",
}
}
/// The port in an address written as `0100007F:1F90`.
fn port_of(field: &str) -> Option<u16> {
let (_, port) = field.split_once(':')?;
u16::from_str_radix(port, 16).ok()
}
/// Every IPv4 TCP socket the kernel knows about.
fn entries() -> io::Result<Vec<Entry>> {
let table = fs::read_to_string("/proc/net/tcp")?;
Ok(table
.lines()
.skip(1)
.filter_map(|line| {
let fields: Vec<&str> = line.split_whitespace().collect();
Some(Entry {
local: port_of(fields.get(1)?)?,
remote: port_of(fields.get(2)?)?,
state: state_name(u8::from_str_radix(fields.get(3)?, 16).ok()?),
})
})
.collect())
}
// ...
}
entries skips the header line, splits each line on whitespace, and keeps the three columns it needs. The
? operators inside filter_map skip any line that does not parse. All connections in this program run
over 127.0.0.1, so the two ports alone identify a socket.
state looks up one socket, and census counts the states of every socket on one server port:
mod linux {
// ...
/// The state of the socket whose local and remote ports are `local` and
/// `remote`, or "gone" if the kernel has forgotten it.
pub fn state(local: SocketAddr, remote: SocketAddr) -> io::Result<&'static str> {
Ok(entries()?
.into_iter()
.find(|entry| entry.local == local.port() && entry.remote == remote.port())
.map_or("gone", |entry| entry.state))
}
/// How many sockets on the server's `port` are in each state, counting
/// both the server's side and the clients' side of each connection.
pub fn census(port: u16) -> io::Result<BTreeMap<&'static str, usize>> {
let mut counts = BTreeMap::new();
for entry in entries()? {
if entry.state != "LISTEN" && (entry.local == port || entry.remote == port) {
*counts.entry(entry.state).or_insert(0) += 1;
}
}
Ok(counts)
}
/// A connected pair over loopback: (client, server's accepted socket).
pub fn connected(listener: &TcpListener) -> io::Result<(TcpStream, TcpStream)> {
let client = TcpStream::connect(listener.local_addr()?)?;
let (server, _) = listener.accept()?;
Ok((client, server))
}
// ...
}
A socket the kernel has forgotten has no line in the file, and state reports it as gone. connected
returns both ends of a new connection, so one process can watch both sides.
27.1.2 A close, step by step
The first scene closes a connection the way chapter 20’s echo test did. The client half-closes with
shutdown(Write), the server reads to the end and replies, then both drop their sockets. After each step
the program waits 50 ms, so the loopback segments have arrived, and prints both states:
1. An orderly close, client first, with a half-close
step client server
connected ESTABLISHED ESTABLISHED
client shutdown(Write): FIN sent FIN_WAIT2 CLOSE_WAIT
server read to end, wrote a reply FIN_WAIT2 CLOSE_WAIT
server dropped its socket: FIN sent TIME_WAIT gone
client read the reply, dropped TIME_WAIT gone
The client never shows FIN_WAIT1. On loopback the ACK of its FIN arrived within microseconds, so by
the time the program looked, the client was in FIN_WAIT2. On a network with a 50 ms round trip,
FIN_WAIT1 would show.
The third row is the half-close at work. The client’s direction is closed, the server’s is open, and the
server’s reply still reached the client. After the server dropped its socket, the server’s side passed
through LAST_ACK and was gone. The client side went to TIME_WAIT, and stayed there after the client
dropped its socket. TIME_WAIT belongs to the kernel, not to the program.
Animation 27.1 follows these steps, then shows a server that never closes.
FIN closes one direction. A server that never closes leaves every connection in CLOSE_WAIT, holding a descriptor.27.2 TIME_WAIT
The side that closes first waits in TIME_WAIT before the kernel forgets the connection. The wait covers
two failures:
- A lost final
ACK. If the lastACKis lost, the peer, still inLAST_ACK, sends itsFINagain. A side inTIME_WAITstill knows the connection and answers with anotherACK. A side that had forgotten it would answer with a reset. The peer would then report an error for a connection that closed cleanly. - Old segments. A segment from the old connection can arrive late, delayed in some router. Suppose a new connection with the same four values, both addresses and both ports, exists by then. The old segment could be taken as part of the new stream.
TIME_WAITkeeps those four values reserved until such segments have expired.
The TCP standard sets the wait to twice the maximum segment lifetime. Linux fixes it at 60 seconds.
27.2.1 The cost: ports on a busy client
A socket in TIME_WAIT holds a few hundred bytes of kernel memory, which is cheap. The scarce resource is
the four values it reserves. A client uses a new local port for each connection to a server. The kernel picks it from the ephemeral port range: 32768 to 60999 on Linux, which is 28,232 ports. If
the client closes first, each port stays in TIME_WAIT for 60 seconds. The client can then open about 470 new connections per second to one server address and port. Beyond that, connect fails with EADDRNOTAVAIL.
Three fixes are common:
- Reuse connections. An HTTP client that keeps connections alive, or a connection pool, as in chapter 29, makes few connections and few closes.
- Let the server close first. Then
TIME_WAITlands on the server. All its connections share its one port, so the entries use up nothing. net.ipv4.tcp_tw_reuse. This Linux setting lets an outgoing connection reuse a port inTIME_WAITwhen TCP timestamps prove its segments are newer.
27.2.2 Binding a port with connections in TIME_WAIT
A server that closes first collects TIME_WAIT entries on its own port. If it restarts within a minute,
its new listening socket asks for a port that those entries still use. By default, bind refuses.
SO_REUSEADDR allows the bind as long as no socket on the port is listening. Rust’s TcpListener::bind
sets it on Unix, so the program uses raw calls to bind both ways:
unsafe extern "C" {
fn socket(domain: c_int, ty: c_int, protocol: c_int) -> c_int; // AF_INET, SOCK_STREAM, 0
fn setsockopt(
fd: c_int,
level: c_int, // SOL_SOCKET
name: c_int, // SO_REUSEADDR
value: *const c_void, // points at the option's value, a c_int here
len: socklen_t, // its size in bytes
) -> c_int;
fn bind(fd: c_int, addr: *const sockaddr, len: socklen_t) -> c_int;
}
sockaddr is a generic address type. An IPv4 address is a sockaddr_in, passed as a pointer to sockaddr
with its own size:
mod linux {
// ...
/// Bind a listening socket to `port` on loopback with raw calls, setting
/// `SO_REUSEADDR` only if `reuse` is true. `TcpListener::bind` always sets it.
pub fn bind_raw(port: u16, reuse: bool) -> io::Result<OwnedFd> {
// SAFETY: socket takes no pointers, and the result is checked.
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: the descriptor was just created, and nothing else owns it.
let socket = unsafe { OwnedFd::from_raw_fd(fd) };
let enable: libc::c_int = 1;
let address = SocketAddrV4::new([127, 0, 0, 1].into(), port);
// SAFETY: sockaddr_in is plain data, and all zeros is a valid value.
let mut raw: libc::sockaddr_in = unsafe { mem::zeroed() };
raw.sin_family = libc::AF_INET as libc::sa_family_t;
raw.sin_port = address.port().to_be();
raw.sin_addr.s_addr = u32::from_ne_bytes(address.ip().octets());
// SAFETY: the option value and the address are valid for the lengths
// passed, and outlive both calls.
let result = unsafe {
if reuse {
libc::setsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_REUSEADDR,
(&enable as *const libc::c_int).cast(),
mem::size_of::<libc::c_int>() as libc::socklen_t,
);
}
libc::bind(
fd,
(&raw as *const libc::sockaddr_in).cast(),
mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
Ok(socket)
}
// ...
}
The third scene closes one connection server-first, drops the listener, and binds the port again:
3. TIME_WAIT on the server's port, and binding it again
server closed first, then the client: {"TIME_WAIT": 1}
bind port again, SO_REUSEADDR false: Address already in use (os error 98)
bind port again, SO_REUSEADDR true : bound
One socket in TIME_WAIT was enough to stop a plain bind.
27.3 CLOSE_WAIT
The side that receives the first FIN moves to CLOSE_WAIT, and stays there until its own program
closes the socket. The kernel cannot leave CLOSE_WAIT on its own. Only the program knows whether it still
wants to send.
A server that never closes a connection after the client has gone leaves it in CLOSE_WAIT for as long as
the server runs. Each such connection holds a descriptor. The usual causes are a handler that ignores a read of 0, and a connection kept in a map that nothing removes. A reference cycle that keeps a socket’s owner alive does the same.
leaky_server keeps every accepted socket in a Vec, while each client connects and closes at once:
mod linux {
// ...
/// A server that accepts `clients` connections and never closes them,
/// while each client connects and closes at once. Returns the listener's
/// port and the accepted sockets, still open.
pub fn leaky_server(clients: usize) -> io::Result<(u16, Vec<TcpStream>)> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let mut kept = Vec::new();
for _ in 0..clients {
let (client, server) = connected(&listener)?;
drop(client);
kept.push(server);
}
thread::sleep(SETTLE);
Ok((port, kept))
}
// ...
}
2. A server that never closes what clients closed
while the server keeps 5 sockets: {"CLOSE_WAIT": 5, "FIN_WAIT2": 5}
after the server drops them: {"TIME_WAIT": 5}
Five connections waited at the halfway point of figure 27.1. The clients were in FIN_WAIT2, waiting for a FIN, and the server in CLOSE_WAIT, waiting for itself. When the server dropped the Vec, it sent five
FINs, and the clients’ sides moved to TIME_WAIT. The clients had already closed their descriptors.
Linux keeps such an orphaned FIN_WAIT2 socket for 60 seconds (tcp_fin_timeout), then drops it.
In production, ss -tan state close-wait lists the CLOSE_WAIT sockets of every process. A count that only grows is a leak in the program that owns them. It ends in EMFILE, “Too many open files”, as in section 24.1.
27.4 Reset instead of FIN
A socket that is closed while its receive buffer still holds unread bytes does not send FIN. The kernel
sends a reset (RST) instead, which tells the peer that data was thrown away. The peer’s next read
fails with ECONNRESET, and any bytes the peer had not yet read from its own buffer are discarded.
mod linux {
// ...
/// The client writes bytes the server never reads, then the server closes.
/// Returns what the client's next read reports.
pub fn close_with_unread_data() -> io::Result<io::Result<usize>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let (mut client, server) = connected(&listener)?;
client.write_all(b"a request the server never reads")?;
thread::sleep(SETTLE);
drop(server);
thread::sleep(SETTLE);
Ok(client.read(&mut [0; 64]))
}
}
4. Closing with unread data
client read failed: Connection reset by peer (os error 104)
This bites HTTP servers. Suppose a server sends an error response and closes before reading the whole request body. The reset can arrive while the response is still on its way. The client then reports a reset
instead of the response. Servers avoid it with a lingering close. After the response they call shutdown(Write), read and discard the rest of the request for a short time, then close.
A program can also ask for a reset on purpose, by setting SO_LINGER with a timeout of 0 before closing.
The connection skips TIME_WAIT, and the peer gets ECONNRESET. Load balancers use it to drop connections
quickly. It also discards any unsent data, so it is a poor default.
27.5 The complete program
Listing 27.1 The states of a closing connection, read from /proc/net/tcp. src/bin/tcp_close.rs
//! Closing a TCP connection: the states each side passes through, `TIME_WAIT`
//! on the side that closes first, the `CLOSE_WAIT` sockets a server keeps when
//! it never closes, and the reset that replaces `FIN` when data goes unread.
//!
//! Linux only: the states are read from /proc/net/tcp.
//! Run with: cargo run --bin tcp_close
#[cfg(target_os = "linux")]
mod linux {
use std::{
collections::BTreeMap,
fs,
io::{self, Read, Write},
mem,
net::{SocketAddr, SocketAddrV4, TcpListener, TcpStream},
os::fd::{FromRawFd, OwnedFd},
thread,
time::Duration,
};
/// Long enough for loopback segments to be sent, received, and acknowledged.
pub const SETTLE: Duration = Duration::from_millis(50);
/// One line of /proc/net/tcp: local port, remote port, and state.
struct Entry {
local: u16,
remote: u16,
state: &'static str,
}
/// The name of a TCP state, from the number Linux prints for it.
fn state_name(code: u8) -> &'static str {
match code {
0x01 => "ESTABLISHED",
0x02 => "SYN_SENT",
0x03 => "SYN_RECV",
0x04 => "FIN_WAIT1",
0x05 => "FIN_WAIT2",
0x06 => "TIME_WAIT",
0x07 => "CLOSE",
0x08 => "CLOSE_WAIT",
0x09 => "LAST_ACK",
0x0A => "LISTEN",
0x0B => "CLOSING",
_ => "UNKNOWN",
}
}
/// The port in an address written as `0100007F:1F90`.
fn port_of(field: &str) -> Option<u16> {
let (_, port) = field.split_once(':')?;
u16::from_str_radix(port, 16).ok()
}
/// Every IPv4 TCP socket the kernel knows about.
fn entries() -> io::Result<Vec<Entry>> {
let table = fs::read_to_string("/proc/net/tcp")?;
Ok(table
.lines()
.skip(1)
.filter_map(|line| {
let fields: Vec<&str> = line.split_whitespace().collect();
Some(Entry {
local: port_of(fields.get(1)?)?,
remote: port_of(fields.get(2)?)?,
state: state_name(u8::from_str_radix(fields.get(3)?, 16).ok()?),
})
})
.collect())
}
/// The state of the socket whose local and remote ports are `local` and
/// `remote`, or "gone" if the kernel has forgotten it.
pub fn state(local: SocketAddr, remote: SocketAddr) -> io::Result<&'static str> {
Ok(entries()?
.into_iter()
.find(|entry| entry.local == local.port() && entry.remote == remote.port())
.map_or("gone", |entry| entry.state))
}
/// How many sockets on the server's `port` are in each state, counting
/// both the server's side and the clients' side of each connection.
pub fn census(port: u16) -> io::Result<BTreeMap<&'static str, usize>> {
let mut counts = BTreeMap::new();
for entry in entries()? {
if entry.state != "LISTEN" && (entry.local == port || entry.remote == port) {
*counts.entry(entry.state).or_insert(0) += 1;
}
}
Ok(counts)
}
/// A connected pair over loopback: (client, server's accepted socket).
pub fn connected(listener: &TcpListener) -> io::Result<(TcpStream, TcpStream)> {
let client = TcpStream::connect(listener.local_addr()?)?;
let (server, _) = listener.accept()?;
Ok((client, server))
}
/// Bind a listening socket to `port` on loopback with raw calls, setting
/// `SO_REUSEADDR` only if `reuse` is true. `TcpListener::bind` always sets it.
pub fn bind_raw(port: u16, reuse: bool) -> io::Result<OwnedFd> {
// SAFETY: socket takes no pointers, and the result is checked.
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: the descriptor was just created, and nothing else owns it.
let socket = unsafe { OwnedFd::from_raw_fd(fd) };
let enable: libc::c_int = 1;
let address = SocketAddrV4::new([127, 0, 0, 1].into(), port);
// SAFETY: sockaddr_in is plain data, and all zeros is a valid value.
let mut raw: libc::sockaddr_in = unsafe { mem::zeroed() };
raw.sin_family = libc::AF_INET as libc::sa_family_t;
raw.sin_port = address.port().to_be();
raw.sin_addr.s_addr = u32::from_ne_bytes(address.ip().octets());
// SAFETY: the option value and the address are valid for the lengths
// passed, and outlive both calls.
let result = unsafe {
if reuse {
libc::setsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_REUSEADDR,
(&enable as *const libc::c_int).cast(),
mem::size_of::<libc::c_int>() as libc::socklen_t,
);
}
libc::bind(
fd,
(&raw as *const libc::sockaddr_in).cast(),
mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
Ok(socket)
}
/// A server that accepts `clients` connections and never closes them,
/// while each client connects and closes at once. Returns the listener's
/// port and the accepted sockets, still open.
pub fn leaky_server(clients: usize) -> io::Result<(u16, Vec<TcpStream>)> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let mut kept = Vec::new();
for _ in 0..clients {
let (client, server) = connected(&listener)?;
drop(client);
kept.push(server);
}
thread::sleep(SETTLE);
Ok((port, kept))
}
/// The client writes bytes the server never reads, then the server closes.
/// Returns what the client's next read reports.
pub fn close_with_unread_data() -> io::Result<io::Result<usize>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let (mut client, server) = connected(&listener)?;
client.write_all(b"a request the server never reads")?;
thread::sleep(SETTLE);
drop(server);
thread::sleep(SETTLE);
Ok(client.read(&mut [0; 64]))
}
}
#[cfg(target_os = "linux")]
fn main() -> std::io::Result<()> {
use std::{
io::{Read, Write},
net::{Shutdown, TcpListener},
thread,
};
use linux::{SETTLE, census, connected, state};
println!("1. An orderly close, client first, with a half-close\n");
let listener = TcpListener::bind("127.0.0.1:0")?;
let (mut client, mut server) = connected(&listener)?;
let (c, s) = (client.local_addr()?, server.local_addr()?);
let show = |step: &str| -> std::io::Result<()> {
thread::sleep(SETTLE);
println!(" {step:<38} {:<12} {}", state(c, s)?, state(s, c)?);
Ok(())
};
println!(" {:<38} {:<12} server", "step", "client");
show("connected")?;
client.shutdown(Shutdown::Write)?;
show("client shutdown(Write): FIN sent")?;
let mut request = Vec::new();
server.read_to_end(&mut request)?;
server.write_all(b"reply")?;
show("server read to end, wrote a reply")?;
drop(server);
show("server dropped its socket: FIN sent")?;
let mut reply = String::new();
client.read_to_string(&mut reply)?;
drop(client);
show("client read the reply, dropped")?;
println!("\n2. A server that never closes what clients closed\n");
let (port, kept) = linux::leaky_server(5)?;
println!(" while the server keeps 5 sockets: {:?}", census(port)?);
drop(kept);
thread::sleep(SETTLE);
println!(" after the server drops them: {:?}", census(port)?);
println!("\n3. TIME_WAIT on the server's port, and binding it again\n");
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let (client, server) = connected(&listener)?;
drop(server);
thread::sleep(SETTLE);
drop(client);
drop(listener);
thread::sleep(SETTLE);
println!(
" server closed first, then the client: {:?}",
census(port)?
);
for reuse in [false, true] {
let outcome = match linux::bind_raw(port, reuse) {
Ok(_) => "bound".to_string(),
Err(error) => error.to_string(),
};
println!(" bind port again, SO_REUSEADDR {reuse:<5}: {outcome}");
}
println!("\n4. Closing with unread data\n");
match linux::close_with_unread_data()? {
Ok(read) => println!(" client read returned {read}"),
Err(error) => println!(" client read failed: {error}"),
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("tcp_close reads TCP states from Linux's /proc/net/tcp; run it on Linux");
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use std::{io::ErrorKind, net::TcpListener, thread};
use super::linux::*;
#[test]
fn the_side_that_closes_first_holds_time_wait() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let (client, server) = connected(&listener).expect("connect");
let (c, s) = (client.local_addr().unwrap(), server.local_addr().unwrap());
drop(server);
thread::sleep(SETTLE);
drop(client);
thread::sleep(SETTLE);
assert_eq!(state(s, c).expect("state"), "TIME_WAIT");
assert_eq!(state(c, s).expect("state"), "gone");
}
#[test]
fn a_server_that_never_closes_holds_close_wait() {
let (port, _kept) = leaky_server(3).expect("server");
assert_eq!(
census(port)
.expect("census")
.get("CLOSE_WAIT"),
Some(&3)
);
}
#[test]
fn unread_data_turns_close_into_reset() {
let error = close_with_unread_data()
.expect("setup")
.expect_err("read should fail");
assert_eq!(error.kind(), ErrorKind::ConnectionReset);
}
}
$ cargo run --bin tcp_close
1. An orderly close, client first, with a half-close
step client server
connected ESTABLISHED ESTABLISHED
client shutdown(Write): FIN sent FIN_WAIT2 CLOSE_WAIT
server read to end, wrote a reply FIN_WAIT2 CLOSE_WAIT
server dropped its socket: FIN sent TIME_WAIT gone
client read the reply, dropped TIME_WAIT gone
2. A server that never closes what clients closed
while the server keeps 5 sockets: {"CLOSE_WAIT": 5, "FIN_WAIT2": 5}
after the server drops them: {"TIME_WAIT": 5}
3. TIME_WAIT on the server's port, and binding it again
server closed first, then the client: {"TIME_WAIT": 1}
bind port again, SO_REUSEADDR false: Address already in use (os error 98)
bind port again, SO_REUSEADDR true : bound
4. Closing with unread data
client read failed: Connection reset by peer (os error 104)
27.6 Questions that come up
“A server has 30,000 sockets in TIME_WAIT. Is that a problem?”
Usually not on the server. They share the server’s port and cost little memory. It is a problem on a
client, where each one holds an ephemeral port to the same destination.
“A server has 3,000 sockets in CLOSE_WAIT and the number keeps rising. What happened?”
The clients closed, and the server never closed its side. Look for a code path that returns without
dropping the connection, or a collection that keeps connections after they end.
“Why not shorten TIME_WAIT?”
Linux has no setting for it: the 60 seconds is a constant in the kernel. Reusing connections removes the need, and tcp_tw_reuse covers the outgoing case safely.
“What is the difference between shutdown(Write) and close?”
shutdown(Write) sends FIN and keeps the socket open for reading. close releases the descriptor. If no
other descriptor refers to the socket, the kernel sends FIN, or a reset if unread data remains.
“Both sides send FIN at the same moment. What happens?”
Both go from FIN_WAIT1 to CLOSING, a state for this simultaneous close, then both to TIME_WAIT.
Summary
- Each direction of a connection closes with its own
FINandACK, four segments in all. - The side that closes first passes through
FIN_WAIT1,FIN_WAIT2, andTIME_WAIT. The other passes throughCLOSE_WAITandLAST_ACK. TIME_WAITlasts 60 seconds on Linux. It answers a retransmittedFINand keeps late segments out of a new connection with the same addresses and ports.- On a client,
TIME_WAITholds ephemeral ports: about 470 new connections per second to one server. Reusing connections is the fix. SO_REUSEADDRlets a server bind a port that has connections inTIME_WAIT.TcpListener::bindsets it.CLOSE_WAITlasts until the program closes the socket. A growing count is a leak of descriptors.- Closing with unread data sends a reset, and the peer’s
readfails withECONNRESET.
Chapter 28 adds encryption to a connection. It runs TLS with rustls, checks the server’s certificate,
and then has the server check a certificate from the client.
Exercises
- Add
thread::sleep(Duration::from_secs(61))at the end of the first scene and print the client’s state again. What does it show? - Change
leaky_serverso the server closes first and the clients never do. Which side holdsFIN_WAIT2, which holdsCLOSE_WAIT, and which ends up inTIME_WAIT? - Open 1,000 connections to one listener in a loop, closing each from the client side, and print the
censusafterwards. Then make the server close first and compare. - In
close_with_unread_data, read the client’s bytes on the server before dropping it. What does the client’sreadreturn now? - Set
SO_LINGERto{ l_onoff: 1, l_linger: 0 }on the server’s socket withlibc::setsockoptbefore dropping it in the first scene. Which states disappear, and what does the client’sreadreport?
TLS and mutual TLS
This chapter covers
- What TLS adds to a TCP connection: secrecy, integrity, and proof of who the server is
- Certificates, certificate authorities, and the three checks a client makes
- The TLS 1.3 handshake, and the records it puts on the wire, read byte by byte
- A client and server on
rustls, with certificates made at startup byrcgen - The errors a client reports for a wrong name and for an unknown authority
- Mutual TLS, where the server checks a certificate from the client
The connections in chapters 20 to 27 sent their bytes as they were. Every router between the two machines could read them, and could change them without either side noticing. TLS, Transport Layer Security, runs on top of TCP and fixes three things. The bytes are encrypted, so others cannot read them. Each record carries a tag, so a changed byte is detected. And the server proves who it is with a certificate, so the client knows it is not talking to an impostor.
This chapter runs TLS with the rustls crate. The program, tls_mtls, makes its own certificate authority
and certificates when it starts, runs five connections over loopback, and prints what each side reports.
28.1 Certificates and trust
TLS identifies a server with public-key cryptography. A key pair is two numbers: a private key that the owner keeps secret, and a public key that anyone may have. A signature made with the private key can be checked with the public key. Only the holder of the private key can make a signature that checks out.
A certificate is a signed statement: “this public key belongs to localhost, from this date to that
date”. The signer is a certificate authority (CA). A client keeps a trust store, a list of CA
certificates it accepts. When a server presents its certificate, the client makes three checks (figure 28.1):
- The signature. The certificate is signed by a CA in the trust store, possibly through intermediate certificates that the server also sends.
- The dates. The current time is inside the certificate’s validity period.
- The name. One of the names in the certificate matches the name the client connected to.
A certificate is public, so anyone can present one. During the handshake, the server also signs data from that handshake with its private key. That signature proves the server holds the key that matches the certificate.
On the public internet, trust stores hold a few hundred commercial CAs, and browsers ship them. Inside a company, services often trust a private CA of their own, as this program does.
28.1.1 Making the certificates
rcgen creates key pairs and certificates in memory. Pki makes a CA, then has it sign one certificate
for the server and one for the client:
use std::{
io::{self, Read, Write},
net::{TcpListener, TcpStream},
sync::{Arc, Mutex},
thread,
};
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair};
use rustls::{
ClientConfig,
ClientConnection,
RootCertStore,
ServerConfig,
ServerConnection,
StreamOwned,
crypto::{CryptoProvider, ring},
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName},
server::WebPkiClientVerifier,
};
type Error = Box<dyn std::error::Error + Send + Sync>;
/// A certificate and the private key that goes with it.
struct Identity {
cert: CertificateDer<'static>,
key: PrivatePkcs8KeyDer<'static>,
}
impl Identity {
fn key(&self) -> PrivateKeyDer<'static> {
PrivateKeyDer::Pkcs8(self.key.clone_key())
}
}
/// A certificate authority, and certificates it signed for a server and a client.
struct Pki {
ca: CertificateDer<'static>,
server: Identity,
client: Identity,
}
impl Pki {
fn new() -> Result<Self, Error> {
let ca_key = KeyPair::generate()?;
let mut ca_params = CertificateParams::new(Vec::<String>::new())?;
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params
.distinguished_name
.push(DnType::CommonName, "lab CA");
let ca = ca_params.self_signed(&ca_key)?;
let issue = |name: &str| -> Result<Identity, Error> {
let key = KeyPair::generate()?;
let mut params = CertificateParams::new(vec![name.to_string()])?;
params
.distinguished_name
.push(DnType::CommonName, name);
let cert = params.signed_by(&key, &ca, &ca_key)?;
Ok(Identity {
cert: cert.der().clone(),
key: PrivatePkcs8KeyDer::from(key.serialize_der()),
})
};
let server = issue("localhost")?;
let client = issue("billing")?;
Ok(Self {
ca: ca.der().clone(),
server,
client,
})
}
/// A trust store holding only this CA.
fn roots(&self) -> Result<RootCertStore, Error> {
let mut roots = RootCertStore::empty();
roots.add(self.ca.clone())?;
Ok(roots)
}
}
IsCa::Ca marks the first certificate as one that may sign others. self_signed signs it with its own key. A CA at the top of a chain vouches for itself. A client trusts it only if it is in the trust store.
CertificateParams::new takes the names the certificate covers. signed_by signs the new certificate’s
public key with the CA’s key. The private keys never leave the process. roots builds a trust store that
holds only this CA.
28.2 The TLS 1.3 handshake
Before any application bytes move, the two sides run a handshake. In TLS 1.3 it takes one round trip:
| Step | From | Messages | Encrypted |
|---|---|---|---|
| 1 | client | ClientHello: cipher suites, a key share, and the server’s name (SNI) | no |
| 2 | server | ServerHello: the chosen suite and the server’s key share | no |
| 3 | server | Certificate, CertificateVerify (the signature), Finished | yes |
| 4 | client | Finished | yes |
After step 2, each side combines its own secret with the other’s key share, and both get the same keys. Anyone watching the wire cannot compute them. This is an elliptic-curve Diffie-Hellman exchange. Everything after
ServerHello is encrypted with those keys, including the server’s certificate. The client checks the
certificate and the signature in step 3 before it sends step 4. The client’s first application bytes can
travel with its Finished.
TLS sends everything in records. Each record starts with a 5-byte header: a content type (22 for handshake, 23 for application data), a version, and a 2-byte length.
28.2.1 Watching the wire
To see the records, the client’s socket is wrapped in Tap, which keeps a copy of every byte written.
records walks the copy, one header at a time:
/// A TCP stream that keeps a copy of every byte it sends.
struct Tap {
stream: TcpStream,
sent: Arc<Mutex<Vec<u8>>>,
}
impl Read for Tap {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
self.stream.read(buffer)
}
}
impl Write for Tap {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
let written = self.stream.write(buffer)?;
self.sent
.lock()
.expect("tap lock")
.extend_from_slice(&buffer[..written]);
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
self.stream.flush()
}
}
/// The TLS records in a byte stream, as (content type, length) pairs.
fn records(mut bytes: &[u8]) -> Vec<(&'static str, usize)> {
let mut found = Vec::new();
while let [kind, _, _, high, low, rest @ ..] = bytes {
let length = usize::from(*high) << 8 | usize::from(*low);
let name = match kind {
20 => "change_cipher_spec",
21 => "alert",
22 => "handshake",
23 => "application_data",
_ => "unknown",
};
found.push((name, length));
bytes = rest.get(length..).unwrap_or_default();
}
found
}
The slice pattern [kind, _, _, high, low, rest @ ..] matches only while at least five bytes remain, and
names the parts of the header. rest.get(length..) skips the record’s body, and returns None if the body
is cut short, which ends the loop.
28.2.2 A client and a server
rustls keeps TLS separate from I/O. A ClientConnection or ServerConnection holds the TLS state, and
StreamOwned joins it to any type that implements Read and Write. The handshake runs inside the first
read or write.
The configurations are built in steps. Both name the crypto provider, ring, explicitly. The server gives
its certificate and key, and the client gives its trust store:
fn main() -> Result<(), Error> {
let pki = Pki::new()?;
let server_only = || -> Result<ServerConfig, Error> {
Ok(ServerConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(vec![pki.server.cert.clone()], pki.server.key())?)
};
let client_trusting = |roots: RootCertStore| -> Result<ClientConfig, Error> {
Ok(ClientConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth())
};
// ...
}
with_safe_default_protocol_versions allows TLS 1.3 and 1.2. with_no_client_auth means the server asks
for no client certificate, and the client offers none.
The server accepts one connection and answers one message in capitals:
/// Accept one connection, and answer one message in capitals. Returns what
/// the server learned about the client, or the error that ended the handshake.
fn serve_once(listener: TcpListener, config: Arc<ServerConfig>) -> thread::JoinHandle<String> {
thread::spawn(move || {
let outcome = || -> Result<String, Error> {
let (stream, _) = listener.accept()?;
let mut tls = StreamOwned::new(ServerConnection::new(config)?, stream);
let mut message = [0; 64];
let read = tls.read(&mut message)?;
tls.write_all(&message[..read].to_ascii_uppercase())?;
tls.conn.send_close_notify();
tls.flush()?;
Ok(match tls.conn.peer_certificates() {
Some(chain) => format!("client presented {} verified certificate", chain.len()),
None => "client presented no certificate".to_string(),
})
};
outcome().unwrap_or_else(|error| format!("handshake failed: {error}"))
})
}
send_close_notify queues a closing alert, which tells the client that the stream ended on purpose. TLS
needs it because an attacker can cut a TCP connection at any point. Without the alert, the client could not
tell a complete reply from a truncated one, and rustls reports that case as an error.
The client connects through the tap, sends hello over tls, and reads the reply:
/// What one client connection saw.
struct Session {
version: String,
suite: String,
reply: String,
sent: Vec<u8>,
}
/// Connect to `listener` with `config`, expecting the server to be `name`,
/// send one message, and read the reply.
fn connect(listener: &TcpListener, config: ClientConfig, name: &str) -> Result<Session, Error> {
let sent = Arc::new(Mutex::new(Vec::new()));
let tap = Tap {
stream: TcpStream::connect(listener.local_addr()?)?,
sent: Arc::clone(&sent),
};
let name = ServerName::try_from(name.to_string())?;
let mut tls = StreamOwned::new(ClientConnection::new(Arc::new(config), name)?, tap);
tls.write_all(b"hello over tls")?;
let mut reply = String::new();
tls.read_to_string(&mut reply)?;
Ok(Session {
version: format!(
"{:?}",
tls.conn
.protocol_version()
.ok_or("no version")?
),
suite: format!(
"{:?}",
tls.conn
.negotiated_cipher_suite()
.ok_or("no suite")?
.suite()
),
reply,
sent: sent.lock().expect("tap lock").clone(),
})
}
ServerName is the name the client expects. rustls sends it as SNI in the ClientHello, and checks the
server’s certificate against it.
Animation 28.1 follows this connection, then the mutual TLS connections of section 28.4.
ServerHello is encrypted. In mutual TLS the server asks for a certificate, and refuses a client without one.main runs the scenes. The first is the plain case:
1. The client checks the server's certificate
client: TLSv1_3 TLS13_AES_256_GCM_SHA384, reply "HELLO OVER TLS"
client sent records: [("handshake", 231), ("change_cipher_spec", 1), ("application_data", 69), ("application_data", 31)]
"hello" readable on the wire: false
server: client presented no certificate
The client sent four records:
handshake, 231 bytes: theClientHello, the only record the client sent in the clear.change_cipher_spec, 1 byte: a record TLS 1.3 does not use. It is sent so that old middleboxes, which expect TLS 1.2, let the connection through.application_data, 69 bytes: the client’sFinished. In TLS 1.3, encrypted handshake messages are labeled as application data, so an observer cannot tell them apart.application_data, 31 bytes: the 14 bytes ofhello over tls, plus 1 byte for the real content type and a 16-byte tag that detects any change.
The plaintext appears nowhere in what the client sent.
28.3 When the check fails
The second and third scenes break one of the client’s checks each:
2. The client expects a different name
client: invalid peer certificate: certificate not valid for name "billing.example"; certificate is only valid for DnsName("localhost")
server: handshake failed: unexpected end of file
3. The client does not trust the CA
client: invalid peer certificate: UnknownIssuer
server: handshake failed: unexpected end of file
In both cases the client stopped in step 3 of the handshake and closed the connection, so the server saw the stream end. The certificate was valid in both. The client rejected it because it named a different server, or because nothing in the trust store had signed it.
These checks are the whole of TLS’s protection against an impostor. Encryption to an unchecked server is
encryption to whoever answered. rustls has no flag to turn the checks off. A program that wants to skip
them must write its own ServerCertVerifier, under a module named danger.
28.4 Mutual TLS
In plain TLS, only the server proves who it is. The client is anonymous at the TLS level, and proves itself,
if at all, with a password or token inside the encrypted stream. In mutual TLS (mTLS), the server asks
for a certificate in the handshake. The client sends one, with its own CertificateVerify signature. Each
side then knows which key the other holds.
Services inside one company use mutual TLS to authenticate each other. The certificate’s name becomes the
caller’s identity, and the server can then authorize by it: requests from billing may read invoices,
for example.
The server adds a client certificate verifier that trusts the same CA:
fn main() -> Result<(), Error> {
// ...
let verifier =
WebPkiClientVerifier::builder_with_provider(Arc::new(pki.roots()?), provider()).build()?;
let mutual = || -> Result<ServerConfig, Error> {
Ok(ServerConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_client_cert_verifier(verifier.clone())
.with_single_cert(vec![pki.server.cert.clone()], pki.server.key())?)
};
println!("\n4. Mutual TLS, client without a certificate");
run(mutual()?, client_trusting(pki.roots()?)?, "localhost")?;
println!("\n5. Mutual TLS, client with the billing certificate");
let client = ClientConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_root_certificates(pki.roots()?)
.with_client_auth_cert(vec![pki.client.cert.clone()], pki.client.key())?;
run(mutual()?, client, "localhost")
}
4. Mutual TLS, client without a certificate
client: received fatal alert: CertificateRequired
server: handshake failed: peer sent no certificates
5. Mutual TLS, client with the billing certificate
client: TLSv1_3 TLS13_AES_256_GCM_SHA384, reply "HELLO OVER TLS"
client sent records: [("handshake", 231), ("change_cipher_spec", 1), ("application_data", 481), ("application_data", 31)]
"hello" readable on the wire: false
server: client presented 1 verified certificate
The client without a certificate got a fatal alert, CertificateRequired, and the server reported why.
With the certificate, the client’s encrypted handshake record grew from 69 to 481 bytes. The extra 412
bytes carry the client’s certificate and its signature.
Certificates expire, and a service that uses mutual TLS has to replace them before they do. Production systems issue certificates that last hours or days, and rotate them automatically. A leaked key is then useful only until its certificate expires.
28.4.1 What TLS costs
- A round trip. A new connection waits for TCP’s handshake and then for TLS’s, two round trips before the first request. Across regions that is 100 ms or more. Session resumption lets a client that has connected before skip the certificate exchange. A connection pool (chapter 29) avoids the handshake for most requests.
- CPU. The key exchange and the signatures cost tens to hundreds of microseconds per handshake. The symmetric encryption of the data afterwards runs at gigabytes per second on modern CPUs.
- Copies.
rustlsencrypts in user space, so a TLS server cannot usesendfileto send a file without copying it, as section 23.3.2 explained.
28.5 The complete program
Listing 28.1 TLS and mutual TLS with rustls, on certificates made by rcgen. src/bin/tls_mtls.rs
//! TLS with rustls: a client that checks the server's certificate, the errors
//! it reports when the check fails, and mutual TLS, where the server checks a
//! certificate from the client as well.
//!
//! The certificates are made at startup with rcgen: one certificate authority,
//! a server certificate for "localhost", and a client certificate for a
//! service called "billing". Nothing secret is stored on disk.
//!
//! Run with: cargo run --bin tls_mtls
use std::{
io::{self, Read, Write},
net::{TcpListener, TcpStream},
sync::{Arc, Mutex},
thread,
};
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair};
use rustls::{
ClientConfig,
ClientConnection,
RootCertStore,
ServerConfig,
ServerConnection,
StreamOwned,
crypto::{CryptoProvider, ring},
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName},
server::WebPkiClientVerifier,
};
type Error = Box<dyn std::error::Error + Send + Sync>;
/// A certificate and the private key that goes with it.
struct Identity {
cert: CertificateDer<'static>,
key: PrivatePkcs8KeyDer<'static>,
}
impl Identity {
fn key(&self) -> PrivateKeyDer<'static> {
PrivateKeyDer::Pkcs8(self.key.clone_key())
}
}
/// A certificate authority, and certificates it signed for a server and a client.
struct Pki {
ca: CertificateDer<'static>,
server: Identity,
client: Identity,
}
impl Pki {
fn new() -> Result<Self, Error> {
let ca_key = KeyPair::generate()?;
let mut ca_params = CertificateParams::new(Vec::<String>::new())?;
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params
.distinguished_name
.push(DnType::CommonName, "lab CA");
let ca = ca_params.self_signed(&ca_key)?;
let issue = |name: &str| -> Result<Identity, Error> {
let key = KeyPair::generate()?;
let mut params = CertificateParams::new(vec![name.to_string()])?;
params
.distinguished_name
.push(DnType::CommonName, name);
let cert = params.signed_by(&key, &ca, &ca_key)?;
Ok(Identity {
cert: cert.der().clone(),
key: PrivatePkcs8KeyDer::from(key.serialize_der()),
})
};
let server = issue("localhost")?;
let client = issue("billing")?;
Ok(Self {
ca: ca.der().clone(),
server,
client,
})
}
/// A trust store holding only this CA.
fn roots(&self) -> Result<RootCertStore, Error> {
let mut roots = RootCertStore::empty();
roots.add(self.ca.clone())?;
Ok(roots)
}
}
/// The ring crypto provider, named explicitly so the program does not depend
/// on which provider other crates enable.
fn provider() -> Arc<CryptoProvider> {
Arc::new(ring::default_provider())
}
/// A TCP stream that keeps a copy of every byte it sends.
struct Tap {
stream: TcpStream,
sent: Arc<Mutex<Vec<u8>>>,
}
impl Read for Tap {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
self.stream.read(buffer)
}
}
impl Write for Tap {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
let written = self.stream.write(buffer)?;
self.sent
.lock()
.expect("tap lock")
.extend_from_slice(&buffer[..written]);
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
self.stream.flush()
}
}
/// The TLS records in a byte stream, as (content type, length) pairs.
fn records(mut bytes: &[u8]) -> Vec<(&'static str, usize)> {
let mut found = Vec::new();
while let [kind, _, _, high, low, rest @ ..] = bytes {
let length = usize::from(*high) << 8 | usize::from(*low);
let name = match kind {
20 => "change_cipher_spec",
21 => "alert",
22 => "handshake",
23 => "application_data",
_ => "unknown",
};
found.push((name, length));
bytes = rest.get(length..).unwrap_or_default();
}
found
}
/// Accept one connection, and answer one message in capitals. Returns what
/// the server learned about the client, or the error that ended the handshake.
fn serve_once(listener: TcpListener, config: Arc<ServerConfig>) -> thread::JoinHandle<String> {
thread::spawn(move || {
let outcome = || -> Result<String, Error> {
let (stream, _) = listener.accept()?;
let mut tls = StreamOwned::new(ServerConnection::new(config)?, stream);
let mut message = [0; 64];
let read = tls.read(&mut message)?;
tls.write_all(&message[..read].to_ascii_uppercase())?;
tls.conn.send_close_notify();
tls.flush()?;
Ok(match tls.conn.peer_certificates() {
Some(chain) => format!("client presented {} verified certificate", chain.len()),
None => "client presented no certificate".to_string(),
})
};
outcome().unwrap_or_else(|error| format!("handshake failed: {error}"))
})
}
/// What one client connection saw.
struct Session {
version: String,
suite: String,
reply: String,
sent: Vec<u8>,
}
/// Connect to `listener` with `config`, expecting the server to be `name`,
/// send one message, and read the reply.
fn connect(listener: &TcpListener, config: ClientConfig, name: &str) -> Result<Session, Error> {
let sent = Arc::new(Mutex::new(Vec::new()));
let tap = Tap {
stream: TcpStream::connect(listener.local_addr()?)?,
sent: Arc::clone(&sent),
};
let name = ServerName::try_from(name.to_string())?;
let mut tls = StreamOwned::new(ClientConnection::new(Arc::new(config), name)?, tap);
tls.write_all(b"hello over tls")?;
let mut reply = String::new();
tls.read_to_string(&mut reply)?;
Ok(Session {
version: format!(
"{:?}",
tls.conn
.protocol_version()
.ok_or("no version")?
),
suite: format!(
"{:?}",
tls.conn
.negotiated_cipher_suite()
.ok_or("no suite")?
.suite()
),
reply,
sent: sent.lock().expect("tap lock").clone(),
})
}
/// Run one client against one server, and print what each side reported.
fn run(server: ServerConfig, client: ClientConfig, name: &str) -> Result<(), Error> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let server = serve_once(listener.try_clone()?, Arc::new(server));
match connect(&listener, client, name) {
Ok(session) => {
println!(
" client: {} {}, reply {:?}",
session.version, session.suite, session.reply
);
println!(" client sent records: {:?}", records(&session.sent));
let visible = session
.sent
.windows(5)
.any(|window| window == b"hello");
println!(" \"hello\" readable on the wire: {visible}");
}
Err(error) => println!(" client: {error}"),
}
println!(" server: {}", server.join().expect("server thread"));
Ok(())
}
fn main() -> Result<(), Error> {
let pki = Pki::new()?;
let server_only = || -> Result<ServerConfig, Error> {
Ok(ServerConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(vec![pki.server.cert.clone()], pki.server.key())?)
};
let client_trusting = |roots: RootCertStore| -> Result<ClientConfig, Error> {
Ok(ClientConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth())
};
println!("1. The client checks the server's certificate");
run(server_only()?, client_trusting(pki.roots()?)?, "localhost")?;
println!("\n2. The client expects a different name");
run(
server_only()?,
client_trusting(pki.roots()?)?,
"billing.example",
)?;
println!("\n3. The client does not trust the CA");
run(
server_only()?,
client_trusting(RootCertStore::empty())?,
"localhost",
)?;
let verifier =
WebPkiClientVerifier::builder_with_provider(Arc::new(pki.roots()?), provider()).build()?;
let mutual = || -> Result<ServerConfig, Error> {
Ok(ServerConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_client_cert_verifier(verifier.clone())
.with_single_cert(vec![pki.server.cert.clone()], pki.server.key())?)
};
println!("\n4. Mutual TLS, client without a certificate");
run(mutual()?, client_trusting(pki.roots()?)?, "localhost")?;
println!("\n5. Mutual TLS, client with the billing certificate");
let client = ClientConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_root_certificates(pki.roots()?)
.with_client_auth_cert(vec![pki.client.cert.clone()], pki.client.key())?;
run(mutual()?, client, "localhost")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_are_split_by_their_length_field() {
let bytes = [22, 3, 1, 0, 2, 0xAA, 0xBB, 23, 3, 3, 0, 1, 0xCC];
assert_eq!(records(&bytes), [("handshake", 2), ("application_data", 1)]);
}
#[test]
fn a_trusted_server_answers() {
let pki = Pki::new().expect("pki");
let server = ServerConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![pki.server.cert.clone()], pki.server.key())
.unwrap();
let client = ClientConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(pki.roots().unwrap())
.with_no_client_auth();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let handle = serve_once(listener.try_clone().unwrap(), Arc::new(server));
let session = connect(&listener, client, "localhost").expect("session");
assert_eq!(session.reply, "HELLO OVER TLS");
handle.join().unwrap();
}
}
$ cargo run --bin tls_mtls
1. The client checks the server's certificate
client: TLSv1_3 TLS13_AES_256_GCM_SHA384, reply "HELLO OVER TLS"
client sent records: [("handshake", 231), ("change_cipher_spec", 1), ("application_data", 69), ("application_data", 31)]
"hello" readable on the wire: false
server: client presented no certificate
2. The client expects a different name
client: invalid peer certificate: certificate not valid for name "billing.example"; certificate is only valid for DnsName("localhost")
server: handshake failed: unexpected end of file
3. The client does not trust the CA
client: invalid peer certificate: UnknownIssuer
server: handshake failed: unexpected end of file
4. Mutual TLS, client without a certificate
client: received fatal alert: CertificateRequired
server: handshake failed: peer sent no certificates
5. Mutual TLS, client with the billing certificate
client: TLSv1_3 TLS13_AES_256_GCM_SHA384, reply "HELLO OVER TLS"
client sent records: [("handshake", 231), ("change_cipher_spec", 1), ("application_data", 481), ("application_data", 31)]
"hello" readable on the wire: false
server: client presented 1 verified certificate
28.6 Questions that come up
“What does SNI do, and is it encrypted?”
SNI, Server Name Indication, carries the host name in the ClientHello. One IP address can then serve certificates for many names. It is sent in the clear. Encrypted Client Hello (ECH) hides it, where both
sides support it.
“Where should TLS end in a system?” At a load balancer, the bytes are plaintext between the balancer and the servers behind it. That is acceptable on a trusted network. Mutual TLS between services keeps them encrypted and authenticated all the way.
“A client fails with UnknownIssuer against a server that works in a browser. Why?”
Usually the server sends only its own certificate, without the intermediate that links it to a root. A
browser may have the intermediate cached, and a fresh client does not. The server should send the full chain.
“What does 0-RTT mean?” A client resuming an earlier session can send application data in its first flight, before the handshake completes. An attacker can replay that data, so it is only safe for requests that are idempotent, in the sense of chapter 19.
“How does a server revoke a client’s certificate before it expires?”
With a revocation list that the verifier checks, which WebPkiClientVerifier accepts. Short-lived
certificates often replace revocation: the certificate is not renewed, and it expires within hours.
Summary
- TLS adds encryption, tamper detection, and server authentication on top of TCP.
- A certificate binds a name to a public key, signed by a CA. A client checks the signature chain to its trust store, the dates, and the name.
- The
CertificateVerifysignature proves that the server holds the certificate’s private key. - TLS 1.3 sets up keys in one round trip and encrypts everything after
ServerHello. On the wire, only theClientHellowas readable, and the 14-byte message became a 31-byte record. - A wrong name and an unknown CA both fail in the handshake, before any application data moves.
- Mutual TLS has the client present a certificate too. Without one, the server ended the handshake with
CertificateRequired. - TLS costs a round trip and some CPU per new connection, which connection reuse avoids.
Chapter 29 builds that reuse: a client connection pool. It bounds the number of connections, takes each one back when its user is done, and drops connections idle for too long.
Exercises
- Print the length of the server’s certificate and of the client’s. Compare with the 412 extra bytes in the mutual TLS record, and account for the rest.
- Make the server certificate valid for both
localhostandapi.internalwithCertificateParams::new, and connect with each name. - Set the server certificate’s
not_afterto yesterday withrcgen’sdate_time_ymd, and record the client’s error. - Issue the server certificate from an intermediate CA signed by the root, and have the server send only its own certificate. Then send the chain. What does the client report in each case?
- Restrict the server to TLS 1.2 with
with_protocol_versions(&[&rustls::version::TLS12]). Compare the records the client sends with the TLS 1.3 run.
A client connection pool
This chapter covers
- What a new connection costs, and why a client keeps connections open between requests
- A pool with a hard limit on open connections, and a deadline for callers that have to wait
- A guard type that returns its connection when dropped, and a way to throw a broken one away
- Reusing the newest idle connection first, and closing connections idle for too long
- Connections the server closed while they sat idle, and how to detect them before reuse
- 2,000 requests with and without a pool, measured
Chapter 27 showed that each closed connection leaves a socket in TIME_WAIT, and that a busy client runs
out of ports. Chapter 28 added a TLS handshake to every new connection. Both costs come from making connections. A client avoids most of them by keeping a few connections open, and sending request after request over them. A connection pool holds those connections and lends them to the threads that
need them.
This chapter builds a pool on the standard library: a Mutex, a Condvar, and a guard type with Drop.
The program, conn_pool, runs it against a small line server on loopback, and measures each of the pool’s
rules.
29.1 What a new connection costs
Each new TCP connection costs:
- A round trip for the TCP handshake before the first byte, and a second one for TLS.
- A slow start. A new connection begins with a small congestion window and grows it over several round trips (section 20.2). A connection that has been in use already has a large one.
- Work on the server: an
accept, a descriptor, and often a thread or a task. - A socket in
TIME_WAITafter the close, on whichever side closed first.
On a network with a 50 ms round trip, the handshakes alone add 100 ms. On an open connection, the same request might take 5 ms.
Keeping connections open has costs too. Each open connection holds a descriptor and buffers on both machines, and a server can hold only so many. A pool therefore keeps a bounded number of connections and lends them out one caller at a time. It follows four rules:
- A bound. At most
maxconnections are open, counting those lent out and those idle. - Automatic return. A borrowed connection comes back when the borrower drops it, unless the borrower reports it broken.
- Newest first. The most recently returned idle connection is reused first.
- No stale connections. A connection idle longer than
idle_timeout, or one the server has closed, is closed instead of lent.
Figure 29.1 shows the path of a call to get through the four rules.
get. A caller gets an idle connection, a new one, or waits for a return until its deadline.29.2 The pool’s state
Config holds the limits, and Stats counts what the pool does, so the program can print it:
use std::{
io::{self, BufRead, BufReader, ErrorKind, Write},
net::{SocketAddr, TcpListener, TcpStream},
ops::{Deref, DerefMut},
sync::{
Arc,
Condvar,
Mutex,
atomic::{AtomicUsize, Ordering::Relaxed},
},
thread,
time::{Duration, Instant},
};
/// The limits a pool enforces.
#[derive(Clone, Copy)]
struct Config {
/// The most connections open at once, lent out or idle.
max: usize,
/// How long `get` waits for a connection before it gives up.
wait_timeout: Duration,
/// How long a connection may sit idle before the pool closes it.
idle_timeout: Duration,
/// Whether to check that an idle connection is still open before lending it.
check_alive: bool,
}
/// What the pool has done, for the demonstration.
#[derive(Default)]
struct Stats {
connects: AtomicUsize,
reuses: AtomicUsize,
evicted: AtomicUsize,
stale: AtomicUsize,
}
The pool keeps its idle connections in a Vec used as a stack, each with the time it was returned. open
counts every connection the pool owns, idle or lent out:
struct Idle {
stream: TcpStream,
since: Instant,
}
/// The connections the pool owns: the idle ones, and a count of all open ones.
struct State {
idle: Vec<Idle>,
open: usize,
}
struct Pool {
address: SocketAddr,
config: Config,
state: Mutex<State>,
returned: Condvar,
stats: Stats,
}
/// A connection lent out by the pool. Dropping it gives it back.
struct Pooled<'a> {
pool: &'a Pool,
stream: Option<TcpStream>,
broken: bool,
}
The invariant is that open equals the number of idle connections plus the number lent out. Every change to
open happens under the mutex, so get can trust it when it decides whether to connect.
Pooled is the borrowed connection. It holds a reference to its pool, so the borrow checker keeps every
Pooled from outliving the pool. The stream sits in an Option, so Drop can move it out.
29.3 Borrowing a connection
get loops under the lock until it has a connection or runs out of time:
impl Pool {
fn new(address: SocketAddr, config: Config) -> Self {
Self {
address,
config,
state: Mutex::new(State {
idle: Vec::new(),
open: 0,
}),
returned: Condvar::new(),
stats: Stats::default(),
}
}
/// Lend a connection: an idle one if there is one, a new one if the pool
/// is below `max`, or the next one returned, waiting up to `wait_timeout`.
fn get(&self) -> io::Result<Pooled<'_>> {
let deadline = Instant::now() + self.config.wait_timeout;
let mut state = self.state.lock().expect("pool lock");
loop {
if let Some(stream) = self.take_idle(&mut state) {
self.stats.reuses.fetch_add(1, Relaxed);
return Ok(self.lend(stream));
}
if state.open < self.config.max {
state.open += 1;
drop(state);
return self.connect();
}
let now = Instant::now();
if now >= deadline {
return Err(io::Error::new(ErrorKind::TimedOut, "pool exhausted"));
}
state = self
.returned
.wait_timeout(state, deadline - now)
.expect("pool lock")
.0;
}
}
// ...
}
Each pass tries three things in order:
- An idle connection.
take_idlereturns the newest usable one, orNone. - A new connection. If fewer than
maxare open,getcounts the new one inopenunder the lock. Then it drops the lock and connects. A connect can take a whole round trip, or seconds if the server is slow to answer. Holding the lock that long would stop every other caller, including those returning connections. Counting the slot first keeps two callers from both deciding there is room for one more. - Wait. Otherwise the caller sleeps on the
Condvaruntil a connection is returned or the deadline passes.wait_timeoutreleases the lock while it sleeps, as in section 16.5. It can also return early, so the loop checks the state again on every wake.
A caller that cannot get a connection by its deadline gets ErrorKind::TimedOut. Waiting forever would
turn an overloaded server into callers that never return. The deadline turns overload into an error the
caller can handle, as with the bounded queues of chapter 17.
If the connect fails, connect gives the slot back:
impl Pool {
// ...
/// Open a new connection. The slot was reserved under the lock, and the
/// connect runs outside it, so other callers are not held up.
fn connect(&self) -> io::Result<Pooled<'_>> {
match TcpStream::connect(self.address) {
Ok(stream) => {
self.stats
.connects
.fetch_add(1, Relaxed);
Ok(self.lend(stream))
}
Err(error) => {
self.release_slot();
Err(error)
}
}
}
fn lend(&self, stream: TcpStream) -> Pooled<'_> {
Pooled {
pool: self,
stream: Some(stream),
broken: false,
}
}
// ...
}
29.4 Giving it back
Drop returns the connection, so a caller cannot forget to. Early returns and ? return it too:
impl Pool {
// ...
/// Take back a connection. A broken one is closed, and its slot is freed.
fn give_back(&self, stream: TcpStream, broken: bool) {
if broken {
return self.release_slot();
}
let mut state = self.state.lock().expect("pool lock");
state.idle.push(Idle {
stream,
since: Instant::now(),
});
drop(state);
self.returned.notify_one();
}
fn release_slot(&self) {
self.state
.lock()
.expect("pool lock")
.open -= 1;
self.returned.notify_one();
}
/// (open connections, idle connections)
fn sizes(&self) -> (usize, usize) {
let state = self.state.lock().expect("pool lock");
(state.open, state.idle.len())
}
}
impl Pooled<'_> {
/// Close this connection instead of returning it, after an error.
fn discard(mut self) {
self.broken = true;
}
}
impl Deref for Pooled<'_> {
type Target = TcpStream;
fn deref(&self) -> &TcpStream {
self.stream
.as_ref()
.expect("stream until drop")
}
}
impl DerefMut for Pooled<'_> {
fn deref_mut(&mut self) -> &mut TcpStream {
self.stream
.as_mut()
.expect("stream until drop")
}
}
impl Drop for Pooled<'_> {
fn drop(&mut self) {
if let Some(stream) = self.stream.take() {
self.pool.give_back(stream, self.broken);
}
}
}
give_back pushes the connection onto the idle stack and wakes one waiting caller. discard marks the
guard broken, so Drop closes the connection and frees its slot instead. A caller discards a connection
after any error on it. A failed request may have left half a response in the socket. The next caller would read it as the start of its own.
ask puts the two together. It is how the program sends every pooled request:
/// Send one line, and read one line back.
fn request(stream: &mut TcpStream, line: &str) -> io::Result<String> {
writeln!(stream, "{line}")?;
let mut reply = String::new();
if BufReader::new(&*stream).read_line(&mut reply)? == 0 {
return Err(io::Error::new(
ErrorKind::UnexpectedEof,
"server closed the connection",
));
}
Ok(reply.trim_end().to_string())
}
/// One request on a pooled connection. A connection that failed is discarded,
/// so the pool does not lend it again.
fn ask(pool: &Pool, line: &str) -> io::Result<String> {
let mut connection = pool.get()?;
let reply = request(&mut connection, line);
if reply.is_err() {
connection.discard();
}
reply
}
29.5 Stale connections
The pool takes the newest idle connection first. Under light load, a few connections then do all the work. The rest sink to the bottom of the stack and age past idle_timeout. Taking the oldest first
would keep every connection in light use, and none would ever be closed.
The server also closes connections it considers idle. Most HTTP servers do it after somewhere between 5
seconds and a few minutes. The server’s FIN arrives while the connection sits in the pool, and nothing
reads it. The next caller to borrow that connection writes its request into a socket the server has closed.
Its read then returns 0, or the write draws a reset.
Figure 29.2 shows the timing with this chapter’s numbers. The server closes idle connections after 50 ms, and the pool reuses the connection after 100 ms.
FIN.take_idle handles both kinds of stale connection:
/// Whether the server has closed `stream`, checked without blocking: a
/// readable socket with nothing to read has seen the server's `FIN`.
fn is_alive(stream: &TcpStream) -> bool {
if stream.set_nonblocking(true).is_err() {
return false;
}
let alive = match stream.peek(&mut [0]) {
Err(error) => error.kind() == ErrorKind::WouldBlock,
Ok(_) => false,
};
stream.set_nonblocking(false).is_ok() && alive
}
impl Pool {
// ...
/// The most recently returned idle connection that is fresh and open.
/// Connections idle too long, or closed by the server, are dropped.
fn take_idle(&self, state: &mut State) -> Option<TcpStream> {
while let Some(idle) = state.idle.pop() {
if idle.since.elapsed() > self.config.idle_timeout {
state.open -= 1;
self.stats.evicted.fetch_add(1, Relaxed);
} else if self.config.check_alive && !is_alive(&idle.stream) {
state.open -= 1;
self.stats.stale.fetch_add(1, Relaxed);
} else {
return Some(idle.stream);
}
}
None
}
// ...
}
is_alive peeks at the socket without blocking. On an idle connection nothing should be waiting to be
read, so WouldBlock means the connection is open. A peek that returns 0 bytes has found the server’s
FIN. A peek that returns data has found bytes nobody asked for. Either way, the connection is closed.
The check narrows the window, and does not close it. The server can close the connection between the peek and the request. Two more measures cover the rest:
- Set the pool’s
idle_timeoutshorter than the server’s idle timeout, so the pool closes connections before the server does. - Retry an idempotent request once on a new connection when a reused one fails, with the retry policy of chapter 19.
Animation 29.1 follows the pool through borrowing, waiting, eviction, and a stale connection.
29.6 Measured
main runs four scenes against the line server. The first sends 2,000 requests from eight threads, first
with a new connection for each request, then through a pool of four:
1. 2000 requests from 8 threads
a new connection each: 2000 connections, 169.8 ms
a pool of 4: 4 connections, 80.4 ms, 1996 reuses
On loopback a connect costs microseconds, and the pool still halved the time. Each request without the pool also left a socket in TIME_WAIT: 2,000 in 0.17 seconds. At that rate, the ephemeral ports of section 27.2.1 run out in under 3 seconds. On a real network with TLS, each of those connections would also cost two
round trips.
The other scenes check one rule each:
2. The bound: 2 connections, both lent out
third get after 103 ms: Err(Custom { kind: TimedOut, error: "pool exhausted" })
after one is returned: Ok(()), sizes (open, idle) = (2, 1)
3. Idle eviction: idle_timeout 100 ms
connects 2, evicted 1
4. A server that closes idle connections after 50 ms
check_alive false: Err(Custom { kind: UnexpectedEof, error: "server closed the connection" }), stale discarded 0
check_alive true : Ok("ok second"), stale discarded 1
With both connections lent out, the third get waited its full 100 ms deadline and failed. Once a
connection came back, the next get reused it. A connection idle for 150 ms against a 100 ms limit was
closed, and the second request connected again. Against a server that closes idle connections after 50 ms,
the pool without the check lent a closed connection, and the request failed. With the check, the pool found
the server’s FIN, discarded the connection, and connected again.
29.7 The complete program
Listing 29.1 A bounded connection pool with automatic return, idle eviction, and a liveness check. src/bin/conn_pool.rs
//! A client connection pool: at most `max` connections to one server, each
//! lent out behind a guard that gives it back when dropped. Idle connections
//! are reused, closed after `idle_timeout`, and checked before reuse in case
//! the server closed them first.
//!
//! Run with: cargo run --release --bin conn_pool
use std::{
io::{self, BufRead, BufReader, ErrorKind, Write},
net::{SocketAddr, TcpListener, TcpStream},
ops::{Deref, DerefMut},
sync::{
Arc,
Condvar,
Mutex,
atomic::{AtomicUsize, Ordering::Relaxed},
},
thread,
time::{Duration, Instant},
};
/// The limits a pool enforces.
#[derive(Clone, Copy)]
struct Config {
/// The most connections open at once, lent out or idle.
max: usize,
/// How long `get` waits for a connection before it gives up.
wait_timeout: Duration,
/// How long a connection may sit idle before the pool closes it.
idle_timeout: Duration,
/// Whether to check that an idle connection is still open before lending it.
check_alive: bool,
}
/// What the pool has done, for the demonstration.
#[derive(Default)]
struct Stats {
connects: AtomicUsize,
reuses: AtomicUsize,
evicted: AtomicUsize,
stale: AtomicUsize,
}
struct Idle {
stream: TcpStream,
since: Instant,
}
/// The connections the pool owns: the idle ones, and a count of all open ones.
struct State {
idle: Vec<Idle>,
open: usize,
}
struct Pool {
address: SocketAddr,
config: Config,
state: Mutex<State>,
returned: Condvar,
stats: Stats,
}
/// A connection lent out by the pool. Dropping it gives it back.
struct Pooled<'a> {
pool: &'a Pool,
stream: Option<TcpStream>,
broken: bool,
}
/// Whether the server has closed `stream`, checked without blocking: a
/// readable socket with nothing to read has seen the server's `FIN`.
fn is_alive(stream: &TcpStream) -> bool {
if stream.set_nonblocking(true).is_err() {
return false;
}
let alive = match stream.peek(&mut [0]) {
Err(error) => error.kind() == ErrorKind::WouldBlock,
Ok(_) => false,
};
stream.set_nonblocking(false).is_ok() && alive
}
impl Pool {
fn new(address: SocketAddr, config: Config) -> Self {
Self {
address,
config,
state: Mutex::new(State {
idle: Vec::new(),
open: 0,
}),
returned: Condvar::new(),
stats: Stats::default(),
}
}
/// Lend a connection: an idle one if there is one, a new one if the pool
/// is below `max`, or the next one returned, waiting up to `wait_timeout`.
fn get(&self) -> io::Result<Pooled<'_>> {
let deadline = Instant::now() + self.config.wait_timeout;
let mut state = self.state.lock().expect("pool lock");
loop {
if let Some(stream) = self.take_idle(&mut state) {
self.stats.reuses.fetch_add(1, Relaxed);
return Ok(self.lend(stream));
}
if state.open < self.config.max {
state.open += 1;
drop(state);
return self.connect();
}
let now = Instant::now();
if now >= deadline {
return Err(io::Error::new(ErrorKind::TimedOut, "pool exhausted"));
}
state = self
.returned
.wait_timeout(state, deadline - now)
.expect("pool lock")
.0;
}
}
/// The most recently returned idle connection that is fresh and open.
/// Connections idle too long, or closed by the server, are dropped.
fn take_idle(&self, state: &mut State) -> Option<TcpStream> {
while let Some(idle) = state.idle.pop() {
if idle.since.elapsed() > self.config.idle_timeout {
state.open -= 1;
self.stats.evicted.fetch_add(1, Relaxed);
} else if self.config.check_alive && !is_alive(&idle.stream) {
state.open -= 1;
self.stats.stale.fetch_add(1, Relaxed);
} else {
return Some(idle.stream);
}
}
None
}
/// Open a new connection. The slot was reserved under the lock, and the
/// connect runs outside it, so other callers are not held up.
fn connect(&self) -> io::Result<Pooled<'_>> {
match TcpStream::connect(self.address) {
Ok(stream) => {
self.stats
.connects
.fetch_add(1, Relaxed);
Ok(self.lend(stream))
}
Err(error) => {
self.release_slot();
Err(error)
}
}
}
fn lend(&self, stream: TcpStream) -> Pooled<'_> {
Pooled {
pool: self,
stream: Some(stream),
broken: false,
}
}
/// Take back a connection. A broken one is closed, and its slot is freed.
fn give_back(&self, stream: TcpStream, broken: bool) {
if broken {
return self.release_slot();
}
let mut state = self.state.lock().expect("pool lock");
state.idle.push(Idle {
stream,
since: Instant::now(),
});
drop(state);
self.returned.notify_one();
}
fn release_slot(&self) {
self.state
.lock()
.expect("pool lock")
.open -= 1;
self.returned.notify_one();
}
/// (open connections, idle connections)
fn sizes(&self) -> (usize, usize) {
let state = self.state.lock().expect("pool lock");
(state.open, state.idle.len())
}
}
impl Pooled<'_> {
/// Close this connection instead of returning it, after an error.
fn discard(mut self) {
self.broken = true;
}
}
impl Deref for Pooled<'_> {
type Target = TcpStream;
fn deref(&self) -> &TcpStream {
self.stream
.as_ref()
.expect("stream until drop")
}
}
impl DerefMut for Pooled<'_> {
fn deref_mut(&mut self) -> &mut TcpStream {
self.stream
.as_mut()
.expect("stream until drop")
}
}
impl Drop for Pooled<'_> {
fn drop(&mut self) {
if let Some(stream) = self.stream.take() {
self.pool.give_back(stream, self.broken);
}
}
}
/// Send one line, and read one line back.
fn request(stream: &mut TcpStream, line: &str) -> io::Result<String> {
writeln!(stream, "{line}")?;
let mut reply = String::new();
if BufReader::new(&*stream).read_line(&mut reply)? == 0 {
return Err(io::Error::new(
ErrorKind::UnexpectedEof,
"server closed the connection",
));
}
Ok(reply.trim_end().to_string())
}
/// One request on a pooled connection. A connection that failed is discarded,
/// so the pool does not lend it again.
fn ask(pool: &Pool, line: &str) -> io::Result<String> {
let mut connection = pool.get()?;
let reply = request(&mut connection, line);
if reply.is_err() {
connection.discard();
}
reply
}
/// A line server on loopback that closes a connection idle for `idle_close`.
/// Returns its address and a count of the connections it has accepted.
fn start_server(idle_close: Duration) -> io::Result<(SocketAddr, Arc<AtomicUsize>)> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
let accepted = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&accepted);
thread::spawn(move || {
for stream in listener.incoming().flatten() {
counter.fetch_add(1, Relaxed);
thread::spawn(move || serve(stream, idle_close));
}
});
Ok((address, accepted))
}
/// Answer each line with "ok", until the client closes or stays quiet too long.
fn serve(stream: TcpStream, idle_close: Duration) -> io::Result<()> {
stream.set_read_timeout(Some(idle_close))?;
let mut writer = stream.try_clone()?;
for line in BufReader::new(stream).lines() {
let line = line?;
writeln!(writer, "ok {line}")?;
}
Ok(())
}
fn main() -> io::Result<()> {
const REQUESTS: usize = 2_000;
const THREADS: usize = 8;
let config = Config {
max: 4,
wait_timeout: Duration::from_secs(1),
idle_timeout: Duration::from_secs(30),
check_alive: true,
};
println!("1. {REQUESTS} requests from {THREADS} threads");
let (address, accepted) = start_server(Duration::from_secs(30))?;
let start = Instant::now();
thread::scope(|scope| {
for _ in 0..THREADS {
scope.spawn(|| -> io::Result<()> {
for _ in 0..REQUESTS / THREADS {
request(&mut TcpStream::connect(address)?, "ping")?;
}
Ok(())
});
}
});
println!(
" a new connection each: {:>5} connections, {:>6.1} ms",
accepted.load(Relaxed),
start.elapsed().as_secs_f64() * 1000.0
);
let (address, accepted) = start_server(Duration::from_secs(30))?;
let pool = Pool::new(address, config);
let start = Instant::now();
thread::scope(|scope| {
for _ in 0..THREADS {
scope.spawn(|| -> io::Result<()> {
for _ in 0..REQUESTS / THREADS {
ask(&pool, "ping")?;
}
Ok(())
});
}
});
println!(
" a pool of {}: {:>5} connections, {:>6.1} ms, {} reuses",
config.max,
accepted.load(Relaxed),
start.elapsed().as_secs_f64() * 1000.0,
pool.stats.reuses.load(Relaxed)
);
println!("\n2. The bound: 2 connections, both lent out");
let pool = Pool::new(
address,
Config {
max: 2,
wait_timeout: Duration::from_millis(100),
..config
},
);
let (first, second) = (pool.get()?, pool.get()?);
let start = Instant::now();
let third = pool.get().map(|_| ());
println!(
" third get after {:.0} ms: {third:?}",
start.elapsed().as_secs_f64() * 1000.0
);
drop(first);
let fourth = pool.get().map(|_| ());
println!(
" after one is returned: {fourth:?}, sizes (open, idle) = {:?}",
pool.sizes()
);
drop(second);
println!("\n3. Idle eviction: idle_timeout 100 ms");
let pool = Pool::new(
address,
Config {
idle_timeout: Duration::from_millis(100),
..config
},
);
ask(&pool, "first")?;
thread::sleep(Duration::from_millis(150));
ask(&pool, "second")?;
println!(
" connects {}, evicted {}",
pool.stats.connects.load(Relaxed),
pool.stats.evicted.load(Relaxed)
);
println!("\n4. A server that closes idle connections after 50 ms");
let (address, _) = start_server(Duration::from_millis(50))?;
for check_alive in [false, true] {
let pool = Pool::new(
address,
Config {
check_alive,
..config
},
);
ask(&pool, "first")?;
thread::sleep(Duration::from_millis(100));
let outcome = ask(&pool, "second");
println!(
" check_alive {check_alive:<5}: {outcome:?}, stale discarded {}",
pool.stats.stale.load(Relaxed)
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn config(max: usize) -> Config {
Config {
max,
wait_timeout: Duration::from_millis(50),
idle_timeout: Duration::from_secs(30),
check_alive: true,
}
}
#[test]
fn a_returned_connection_is_reused() {
let (address, accepted) = start_server(Duration::from_secs(5)).unwrap();
let pool = Pool::new(address, config(2));
for _ in 0..10 {
assert_eq!(request(&mut pool.get().unwrap(), "x").unwrap(), "ok x");
}
assert_eq!(accepted.load(Relaxed), 1);
assert_eq!(pool.stats.reuses.load(Relaxed), 9);
}
#[test]
fn the_pool_never_exceeds_max() {
let (address, _) = start_server(Duration::from_secs(5)).unwrap();
let pool = Pool::new(address, config(1));
let held = pool.get().unwrap();
let error = pool.get().map(|_| ()).unwrap_err();
assert_eq!(error.kind(), ErrorKind::TimedOut);
drop(held);
assert!(pool.get().is_ok());
}
#[test]
fn a_discarded_connection_frees_its_slot() {
let (address, _) = start_server(Duration::from_secs(5)).unwrap();
let pool = Pool::new(address, config(1));
pool.get().unwrap().discard();
assert_eq!(pool.sizes(), (0, 0));
drop(pool.get().unwrap());
assert_eq!(pool.stats.connects.load(Relaxed), 2);
}
}
$ cargo run --release --bin conn_pool
1. 2000 requests from 8 threads
a new connection each: 2000 connections, 169.8 ms
a pool of 4: 4 connections, 80.4 ms, 1996 reuses
2. The bound: 2 connections, both lent out
third get after 103 ms: Err(Custom { kind: TimedOut, error: "pool exhausted" })
after one is returned: Ok(()), sizes (open, idle) = (2, 1)
3. Idle eviction: idle_timeout 100 ms
connects 2, evicted 1
4. A server that closes idle connections after 50 ms
check_alive false: Err(Custom { kind: UnexpectedEof, error: "server closed the connection" }), stale discarded 0
check_alive true : Ok("ok second"), stale discarded 1
29.8 Questions that come up
“How large should the pool be?” Large enough for the requests in flight at peak. That is the request rate times the time each request holds a connection. At 2,000 requests per second and 5 ms each, that is 10. Each client process multiplies it on the server, so the server’s connection limit divided by the number of clients is the upper bound.
“Why not one pool shared by every server address?” A connection is to one address. Real clients keep one pool per host and port, often with a limit per host and one overall.
“What does HTTP/2 change?” HTTP/2 sends many requests at once over one connection, each in its own stream. A client then needs one connection per server, or a few, and the pool limits streams rather than connections.
“What happens when the server restarts?” Every idle connection in the pool is closed by the server at once. The liveness check catches most of them. Requests already in flight fail, and the idempotent ones can be retried.
“Is a Mutex too slow for a pool?”
The lock is held for a few pushes and pops. It is never held across a connect or a request. At hundreds of thousands
of requests per second, a pool can split into shards with one lock each, as chapter 14 split a map.
Summary
- A new connection costs a round trip, a TLS handshake, a slow start, and a socket in
TIME_WAITwhen it closes. A pool pays those costs once per connection, not once per request. - The pool counts every open connection under one mutex, and never opens more than
max. getreuses an idle connection, opens a new one outside the lock, or waits on aCondvaruntil a deadline. Overload becomesTimedOut, not a caller that hangs.- The guard returns its connection in
Drop.discardcloses a connection after an error, so no caller reads another’s half-finished response. - Reusing the newest connection first lets the oldest ones expire under light load.
- A non-blocking
peekfinds a connection the server has closed. Idle timeouts shorter than the server’s, and one retry for idempotent requests, cover the rest. - 2,000 requests took 4 connections and 80 ms through the pool, against 2,000 connections and 170 ms without it.
Chapter 30 collects compact versions of the structures from the earlier parts. Each is the shortest program that still shows the structure working.
Exercises
- Add a
max_lifetimetoConfig, and close a connection older than it even if it is in use often. Why do production pools have this limit, when the server’s address can change behind a DNS name? - Give
geta fairness guarantee: callers that started waiting first get connections first. What doesCondvar::notify_onepromise about order, and what structure would you add? - Make
askretry once on a new connection when a reused connection fails, but only if the request is marked idempotent. - Run scene 1 with
maxset to 1, 2, 8, and 16, and plot the time. Where does the curve flatten, and why? - Wrap the pool’s connections in TLS from chapter 28, and measure scene 1 again with and without the pool.
Drills against the clock
This chapter covers
- Ten short implementations, each sized to type in a live round
- Where each drill’s full version lives in this book
- Two drills that differ from their reference versions, one of them in a way that changes the answer
- A plan for a 45-minute round, and a way to practice with this repository
Every chapter so far has shown the long form: the reference implementation with its explanations, its
tests, and its history. In a live round you will not have that. You will have a blank editor, a
problem stated in two sentences, and about forty minutes. drills.rs is the short form: ten answers
written at the length you can type under that pressure. Each group carries one test, so you know it runs.
This chapter is a guide to using them. It maps each drill to its chapter, reads the places where the short form cuts a corner, and ends with a way to rehearse.
30.1 The drills
Listing 30.1 Ten answers sized for a live round. src/problems/drills.rs
//! Compact implementations of the common primitives.
//!
//! Each item is a short standalone implementation. The modules next to this one
//! are the full versions with long explanations and complete test suites.
use std::{
cell::UnsafeCell,
collections::{VecDeque, hash_map::DefaultHasher},
hash::Hasher,
ops::{Deref, DerefMut},
sync::{
Arc,
Condvar,
Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread::{self, JoinHandle},
time::Duration,
};
// --- Thread-safe counter (Arc + Mutex) ---
pub fn count_parallel(threads: usize, per_thread: usize) -> usize {
let counter = Arc::new(Mutex::new(0usize));
let handles: Vec<_> = (0..threads)
.map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
for _ in 0..per_thread {
*counter.lock().expect("poisoned") += 1;
}
})
})
.collect();
for handle in handles {
handle.join().expect("worker panicked");
}
*counter.lock().expect("poisoned")
}
// --- Scoped threads (borrow instead of Arc) ---
pub fn parallel_sum(values: &[i32]) -> i64 {
let chunk_size = values.len().div_ceil(4).max(1);
thread::scope(|scope| {
values
.chunks(chunk_size)
.map(|chunk| {
scope.spawn(move || {
chunk
.iter()
.map(|value| i64::from(*value))
.sum::<i64>()
})
})
.map(|handle| handle.join().expect("worker panicked"))
.sum()
})
}
// --- Spin lock from an atomic ---
pub struct SpinLock<T> {
locked: AtomicBool,
value: UnsafeCell<T>,
}
unsafe impl<T: Send> Sync for SpinLock<T> {}
impl<T> SpinLock<T> {
pub const fn new(value: T) -> Self {
Self {
locked: AtomicBool::new(false),
value: UnsafeCell::new(value),
}
}
pub fn lock(&self) -> SpinGuard<'_, T> {
while self
.locked
.swap(true, Ordering::Acquire)
{
std::hint::spin_loop();
}
SpinGuard { lock: self }
}
}
pub struct SpinGuard<'a, T> {
lock: &'a SpinLock<T>,
}
impl<T> Drop for SpinGuard<'_, T> {
fn drop(&mut self) {
self.lock
.locked
.store(false, Ordering::Release);
}
}
impl<T> Deref for SpinGuard<'_, T> {
type Target = T;
fn deref(&self) -> &T {
unsafe { &*self.lock.value.get() }
}
}
impl<T> DerefMut for SpinGuard<'_, T> {
fn deref_mut(&mut self) -> &mut T {
unsafe { &mut *self.lock.value.get() }
}
}
// --- Semaphore (acquire blocks) ---
pub struct Semaphore {
permits: Mutex<usize>,
released: Condvar,
}
impl Semaphore {
pub fn new(permits: usize) -> Self {
Self {
permits: Mutex::new(permits),
released: Condvar::new(),
}
}
pub fn acquire(&self) {
let mut permits = self.permits.lock().expect("poisoned");
while *permits == 0 {
permits = self
.released
.wait(permits)
.expect("poisoned");
}
*permits -= 1;
}
pub fn try_acquire(&self) -> bool {
let mut permits = self.permits.lock().expect("poisoned");
if *permits == 0 {
false
} else {
*permits -= 1;
true
}
}
pub fn release(&self) {
*self.permits.lock().expect("poisoned") += 1;
self.released.notify_one();
}
}
// --- Bounded blocking queue (backpressure + close) ---
pub struct BlockingQueue<T> {
inner: Mutex<Inner<T>>,
not_empty: Condvar,
not_full: Condvar,
}
struct Inner<T> {
items: VecDeque<T>,
capacity: usize,
closed: bool,
}
impl<T> BlockingQueue<T> {
pub fn new(capacity: usize) -> Self {
Self {
inner: Mutex::new(Inner {
items: VecDeque::new(),
capacity,
closed: false,
}),
not_empty: Condvar::new(),
not_full: Condvar::new(),
}
}
pub fn push(&self, item: T) -> Result<(), T> {
let mut inner = self.inner.lock().expect("poisoned");
loop {
if inner.closed {
return Err(item);
}
if inner.items.len() < inner.capacity {
inner.items.push_back(item);
self.not_empty.notify_one();
return Ok(());
}
inner = self
.not_full
.wait(inner)
.expect("poisoned");
}
}
pub fn pop(&self) -> Option<T> {
let mut inner = self.inner.lock().expect("poisoned");
loop {
if let Some(item) = inner.items.pop_front() {
self.not_full.notify_one();
return Some(item);
}
if inner.closed {
return None;
}
inner = self
.not_empty
.wait(inner)
.expect("poisoned");
}
}
pub fn close(&self) {
let mut inner = self.inner.lock().expect("poisoned");
inner.closed = true;
drop(inner);
self.not_empty.notify_all();
self.not_full.notify_all();
}
}
// --- Thread pool (fixed workers + channel) ---
pub struct ThreadPool {
sender: Option<mpsc::Sender<Box<dyn FnOnce() + Send + 'static>>>,
workers: Vec<JoinHandle<()>>,
}
impl ThreadPool {
pub fn new(size: usize) -> Self {
let (sender, receiver) = mpsc::channel::<Box<dyn FnOnce() + Send + 'static>>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..size)
.map(|_| {
let receiver = Arc::clone(&receiver);
thread::spawn(move || {
loop {
let Ok(job) = receiver
.lock()
.expect("poisoned")
.recv()
else {
return;
};
job();
}
})
})
.collect();
Self {
sender: Some(sender),
workers,
}
}
pub fn execute(&self, job: impl FnOnce() + Send + 'static) {
self.sender
.as_ref()
.expect("pool dropped")
.send(Box::new(job))
.expect("worker gone");
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take());
for worker in self.workers.drain(..) {
let _ = worker.join();
}
}
}
// --- Retry with exponential backoff ---
pub fn retry<T, E>(
max_attempts: u32,
mut operation: impl FnMut() -> Result<T, E>,
retryable: impl Fn(&E) -> bool,
) -> Result<T, E> {
let mut delay = Duration::from_millis(25);
let mut attempt = 0;
loop {
match operation() {
Ok(value) => return Ok(value),
Err(error) => {
attempt += 1;
if attempt >= max_attempts || !retryable(&error) {
return Err(error);
}
thread::sleep(delay);
delay = (delay * 2).min(Duration::from_secs(1));
}
}
}
}
// --- Consistent hashing ring ---
#[derive(Default)]
pub struct HashRing {
points: Vec<(u64, String)>,
}
impl HashRing {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, node: &str, replicas: usize) {
for replica in 0..replicas {
self.points
.push((hash(node, replica), node.to_string()));
}
self.points
.sort_unstable_by_key(|(point, _)| *point);
}
pub fn get(&self, key: &str) -> Option<&str> {
if self.points.is_empty() {
return None;
}
let hash = hash(key, 0);
let index = self
.points
.partition_point(|(point, _)| *point <= hash)
% self.points.len();
Some(self.points[index].1.as_str())
}
}
fn hash(input: &str, replica: usize) -> u64 {
let mut hasher = DefaultHasher::new();
hasher.write(input.as_bytes());
hasher.write_usize(replica);
hasher.finish()
}
// --- HTTP request head ---
pub struct RequestHead {
pub method: String,
pub target: String,
pub headers: Vec<(String, String)>,
}
pub fn parse_request_head(input: &str) -> Option<RequestHead> {
let mut lines = input.split("\r\n");
let mut request_line = lines.next()?.split(' ');
let method = request_line.next()?.to_string();
let target = request_line.next()?.to_string();
if request_line.next()? != "HTTP/1.1" {
return None;
}
let mut headers = Vec::new();
for line in lines {
if line.is_empty() {
break;
}
let (name, value) = line.split_once(':')?;
headers.push((name.trim().to_ascii_lowercase(), value.trim().to_string()));
}
Some(RequestHead {
method,
target,
headers,
})
}
// --- Newtype and enum ADT ---
pub struct Port(u16);
impl Port {
pub fn new(value: u16) -> Result<Self, &'static str> {
(1..=u16::MAX)
.contains(&value)
.then_some(Self(value))
.ok_or("port must be non-zero")
}
pub fn get(self) -> u16 {
self.0
}
}
pub enum Route {
Health,
List,
ById(String),
}
impl Route {
pub fn parse(path: &str) -> Option<Self> {
match path {
"/healthz" => Some(Self::Health),
"/v1/gpu-workloads" => Some(Self::List),
_ => path
.strip_prefix("/v1/gpu-workloads/")
.map(|id| Self::ById(id.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use super::*;
#[test]
fn counter_scoped_sum_and_spin_lock() {
assert_eq!(count_parallel(4, 100), 400);
assert_eq!(parallel_sum(&[1, 2, 3, 4]), 10);
let lock = Arc::new(SpinLock::new(0usize));
let handles: Vec<_> = (0..4)
.map(|_| {
let lock = Arc::clone(&lock);
thread::spawn(move || {
for _ in 0..100 {
*lock.lock() += 1;
}
})
})
.collect();
for handle in handles {
handle.join().expect("worker panicked");
}
assert_eq!(*lock.lock(), 400);
}
#[test]
fn semaphore_queue_and_pool() {
let semaphore = Semaphore::new(1);
assert!(semaphore.try_acquire());
assert!(!semaphore.try_acquire());
semaphore.release();
assert!(semaphore.try_acquire());
let queue = BlockingQueue::new(1);
assert_eq!(queue.push(1), Ok(()));
queue.close();
assert_eq!(queue.pop(), Some(1));
assert_eq!(queue.pop(), None);
assert_eq!(queue.push(2), Err(2));
let pool = ThreadPool::new(3);
let counter = Arc::new(AtomicUsize::new(0));
for _ in 0..100 {
let counter = Arc::clone(&counter);
pool.execute(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
}
drop(pool);
assert_eq!(counter.load(Ordering::SeqCst), 100);
}
#[test]
fn retry_ring_http_and_adt() {
let mut attempts = 0;
let ok = retry(
3,
|| {
attempts += 1;
if attempts < 3 {
Err("transient")
} else {
Ok(7)
}
},
|_| true,
);
assert_eq!(ok, Ok(7));
let mut attempts = 0;
let bad: Result<(), &str> = retry(
3,
|| {
attempts += 1;
Err("permanent")
},
|_| false,
);
assert!(bad.is_err());
assert_eq!(attempts, 1);
let mut ring = HashRing::new();
ring.add("a", 64);
ring.add("b", 64);
let before = ring.get("job-1").map(str::to_string);
ring.add("c", 64);
let after = ring.get("job-1").map(str::to_string);
assert!(after == before || after.as_deref() == Some("c"));
let head = parse_request_head("GET /v1/gpu-workloads HTTP/1.1\r\nHost: api\r\n\r\n")
.expect("valid");
assert_eq!(
(head.method.as_str(), head.target.as_str()),
("GET", "/v1/gpu-workloads")
);
assert_eq!(head.headers[0], ("host".to_string(), "api".to_string()));
assert!(parse_request_head("GET / HTTP/2\r\n\r\n").is_none());
assert!(Port::new(8080).is_ok());
assert!(Port::new(0).is_err());
assert!(matches!(Route::parse("/healthz"), Some(Route::Health)));
assert!(matches!(
Route::parse("/v1/gpu-workloads/wl-1"),
Some(Route::ById(id)) if id == "wl-1"
));
}
}
The module comment states the file’s role in one line: the neighbouring modules are the reference versions, and this file is what you type. Table 30.1 pairs each drill with the chapter that explains its full form.
| Drill | Lines | Full version |
|---|---|---|
count_parallel: Arc<Mutex<usize>> counter | 18 | chapter 8 (total_with_arc_mutex, listing 8.7), chapter 16 |
parallel_sum: scoped threads | 12 | chapter 16, threads::parallel_sum, listing 16.5 |
SpinLock and SpinGuard | 45 | chapter 16, listing 16.12 |
Semaphore | 36 | chapter 16, listing 16.15 |
BlockingQueue with close | 62 | chapter 17, listing 17.22 |
ThreadPool with Drop | 45 | chapter 18, listing 18.7 |
retry with backoff | 21 | chapter 19, listing 19.9 |
HashRing | 33 | chapter 14, listing 14.9 |
parse_request_head | 29 | chapter 21, listing 21.1 |
Port newtype and Route enum | 32 | chapter 7 (the GpuCount newtype, listing 7.2); chapter 21, route |
30.2 What the short forms leave out
A drill is allowed to be simpler than its reference. Knowing what it left out is part of the answer, because the follow-up question is usually aimed at exactly that.
The spin lock uses swap. while self.locked.swap(true, Acquire) { spin_loop() } is a
test-and-set lock: every waiting thread writes the lock’s cache line on every iteration. The reference
version in chapter 16 uses compare_exchange_weak and spins on a load, so waiters share the line
read-only until it is released. Both are correct; the reference scales better under contention. The
drill also omits the SAFETY comments, which in a real review you would add back.
The semaphore has no guard. acquire and release are separate calls, so a caller that panics
between them leaks a permit. The reference in section 16.5 returns a SemaphoreGuard whose Drop gives the
permit back.
The thread pool receives in a let-else. let Ok(job) = receiver.lock().expect(..).recv() else { return; };
is a let statement, so the MutexGuard temporary is dropped at its end, before job() runs. The
pool is fully parallel, which a timing confirms: four 200 ms jobs on four workers take 205 ms. The same
receive written as while let would serialize the pool (chapter 18, figure 18.2), so this is the form
to memorize.
The hash ring wraps with %. partition_point(..) % self.points.len() maps an index one past the
end back to 0 in one expression. The reference uses an if instead. The drill also hashes in a plainer
way: it calls hasher.write(input.as_bytes()) and write_usize(replica) directly. It stores a String
per virtual node where the reference shares an Arc<str>.
The request parser checks the version and little else. parse_request_head accepts only
HTTP/1.1, splits headers at the first colon, and lowercases the names. It has no size limits, no
Host check, and no body framing. That is the right scope for a ten-minute question about a request
head. For a question about serving requests, chapter 21’s checks are the answer.
Port::new is a one-expression validator. (1..=u16::MAX).contains(&value).then_some(Self(value)).ok_or(..)
reads left to right as the rule. Route::parse matches the two fixed paths and falls back to
strip_prefix for the id route, returning None for everything else.
30.3 The drill that is slower than it looks
parallel_sum changed behaviour in the short form. Compare it with the reference in chapter 16:
// drills.rs
values
.chunks(chunk_size)
.map(|chunk| scope.spawn(move || ...))
.map(|handle| handle.join().expect("worker panicked"))
.sum()
// threads.rs
let handles: Vec<_> = values.chunks(chunk_size).map(|chunk| scope.spawn(...)).collect();
handles.into_iter().map(|handle| handle.join().expect("worker panicked")).sum()
Iterators are lazy. In the drill, sum pulls one item through both maps before it asks for the next.
The first chunk’s thread is therefore spawned and then joined before the second chunk’s thread is
spawned. The four workers run one after another. The reference collects every JoinHandle into a Vec
first, which spawns all four, and only then joins them.
The result is the same number either way, so the drill’s test passes. Timing four workers that each sleep 200 ms shows the difference:
lazy spawn/join chain, four 200 ms workers: 809.402756ms (sum 4)
collect first, then join: 203.482125ms (sum 4)
The fix is one collect() between the two maps. Chapter 18 drew the same lesson from the
thread pool. A concurrency test that checks only the answer cannot tell parallel from serial. Put a
barrier in it, as thread_pool_v2’s jobs_run_at_the_same_time does, or time it.
30.4 Running a round
A 45-minute coding round has a shape. Planning it keeps the last ten minutes, where most of the signal is, from being spent debugging (figure 30.1).
- Clarify. Ask what the inputs are, what should happen on bad input, and what the limits are (size, concurrency, latency). Each answer becomes a type or a check.
- Types first. Write the enums, newtypes, and signatures before the bodies. A
Result<T, ParseError>return type decided in minute six saves a rewrite in minute twenty-five. - The happy path, then each edge. Get one case working end to end, then add the edges you listed.
- Tests. Test the edges you named, not only the example given.
- Failure modes. Say what breaks at scale and what you would change: the reference versions in this book are that list, written out.
30.5 Practicing with this repository
The repository is set up for the drill loop that the problem index in appendix C describes. Write a minimal correct version in 30 to 45 minutes, then explain its failure modes and how you would test them.
- Pick a drill. Read its chapter once.
- Close the book and the file. Write it from an empty buffer, in a scratch binary under
src/bin/. - Run
cargo teston it, then run the reference tests:cargo test thread_pool,cargo test bounded_queue,cargo test --bin http_server. - Compare with the reference. Write down what you left out, and whether it was a choice.
- Say the failure modes out loud, as if to a reviewer.
The chapters’ “Questions that come up” sections are the follow-ups to rehearse. The history this book has followed, eleven merges, nine pools, six buffers, is what that loop looks like when you keep the drafts.
30.6 Questions that come up
“Your parallel sum gives the right answer. Is it parallel?” Check that all threads are spawned before any is joined. A lazy iterator that spawns and joins in one chain is serial.
“What would you add to this spin lock for production?”
Spin on a relaxed load before retrying the exchange. Back off or yield under contention. Add SAFETY
comments, and prefer std::sync::Mutex unless measurements say otherwise.
“Your semaphore’s caller panicked. What happened to the permit?”
It leaked. Return a guard whose Drop releases it.
“How did you decide what to leave out?” By the question’s scope. Name what a full version adds, and where it would change the answer.
Appendix A. The Rust cheat sheet
This appendix reproduces rust-interview-lab/src/bin/RUST_CHEATSHEET.md, the page of compile errors and
idioms that cost the most time while the Rust lab was being written. Its own instructions are the right way
to use it: read a block, close the file, and type it from memory.
Its sections map onto the chapters of this book:
- reference levels and iterator closures: chapters 1 and 3
OptionandResultcombinators: chapter 1- collections and heaps: chapter 3
- locks and guards: chapters 12 to 14
- strings and match ergonomics: chapters 1 to 3
Rust cheat sheet — the gotchas that cost you minutes
Every snippet below was compiled with rustc 1.96, edition 2024. Comments in the code
are the explanation.
Read a block, close the file, type it from memory. If you need to look, you don’t have it yet.
0. The errors I hit while writing this sheet
These are real, not hypothetical. All three are the kind of thing that eats 5 minutes.
// 1. filter does not change the iterator's Item, even when you destructure it.
let d: Vec<i32> = v.iter().filter(|&&x| x > 2).collect();
// error[E0277]: a value of type `Vec<i32>` cannot be built from an iterator
// over elements of type `&i32`
let d: Vec<&i32> = v.iter().filter(|&&x| x > 2).collect(); // ok
let d: Vec<i32> = v.iter().filter(|&&x| x > 2).copied().collect(); // ok
// 2. An unannotated float literal can stay ambiguous until a method needs the type.
let mut ys = vec![2.5, 1.0, 3.5];
ys.sort_by(|a, b| b.total_cmp(a));
// error[E0599]: no method named `total_cmp` found for reference `&{float}`
let mut ys: Vec<f64> = vec![2.5, 1.0, 3.5]; // ok
// 3. Option/Result combinators take `self`, so they move. A second call fails.
let r: Result<i32, String> = Ok(1);
let m = r.map(|v| v + 1);
let e = r.map_err(|err| err.len());
// error[E0382]: use of moved value: `r`
let m = r.as_ref().map(|v| v + 1); // borrow instead
let e = r.clone().map_err(|err| err.len()); // or clone
1. Which reference level do I have?
The single most common source of noise. Memorise this table.
| You have | You want | Write |
|---|---|---|
&Option<T> | Option<&T> | .as_ref() |
&mut Option<T> | Option<&mut T> | .as_mut() |
Option<String> | Option<&str> | .as_deref() |
Option<Box<T>> | Option<&T> | .as_deref() |
Option<Vec<T>> | Option<&[T]> | .as_deref() |
Option<&T>, T: Copy | Option<T> | .copied() |
Option<&T>, T: Clone | Option<T> | .cloned() |
Option<T> | Option<T> copy | .clone() |
let a: Option<String> = Some("x".into());
let r: Option<&String> = a.as_ref(); // &Option<T> -> Option<&T>
let s: Option<&str> = a.as_deref(); // Option<String> -> Option<&str>
let c: Option<String> = a.clone(); // deep copy
let n: Option<i32> = Some(4);
let nr: Option<&i32> = n.as_ref();
let copied: Option<i32> = nr.copied(); // Option<&i32> -> Option<i32>
let cloned: Option<String> = r.cloned();
// Most common real signature: you receive a reference, you return a borrow.
fn takes_ref_option(o: &Option<String>) -> Option<&str> {
o.as_deref()
}
Why it matters: &Option<T> is a reference to the whole Option. You usually want the
Option of a reference, which is Option<&T>. as_ref is the bridge, as_deref does the
String -> str step at the same time.
2. Option combinators
let n: Option<i32> = Some(4);
// Transform the value. Closure gets T by value.
let doubled = n.map(|v| v * 2); // Some(8)
// Transform and possibly fail. Closure returns Option.
let positive = n.and_then(|v| (v > 0).then_some(v)); // Some(4)
let lazy = n.and_then(|v| if v > 100 { Some(v) } else { None });
// Replace None. map_or is eager, map_or_else is lazy. unwrap_or is for a plain value.
let defaulted = n.map_or(0, |v| v * 2); // 8
let lazy_default = n.map_or_else(|| 0, |v| v * 2); // 8
let plain = n.unwrap_or(0); // 4
let plain_lazy = n.unwrap_or_else(|| 0); // 4
let default_trait = n.unwrap_or_default(); // needs i32: Default
// filter takes &T, so deref the closure argument.
let filtered = n.filter(|v| *v > 10); // None
// Combining two Options.
let chained = n.and(Some(9)); // Some(9): n is Some, so take the arg
let alternative = n.or(Some(9)); // Some(4): n is Some, keep it
let paired = n.zip(Some("a")); // Some((4, "a"))
// Predicate by value.
let checked = n.is_some_and(|v| v > 3); // true
// Mutating a slot in place.
let mut slot: Option<i32> = None;
let inserted: &mut i32 = slot.get_or_insert(7); // inserts, returns &mut T
*inserted += 1;
let taken: Option<i32> = slot.take(); // moves out, leaves None
let old: Option<i32> = slot.replace(3); // swaps in 3, returns the old value
Which one to say out loud: map transforms, and_then chains a second fallible step,
unwrap_or is the default, ok_or converts to a Result so ? can take over.
3. Result and ?
fn option_to_result() -> Result<i32, String> {
let o: Option<i32> = Some(1);
let v = o.ok_or_else(|| "missing".to_string())?; // Option -> Result -> `?`
Ok(v)
}
fn result_combinators() -> Result<i32, String> {
let r: Result<i32, String> = Ok(1);
let m = r.as_ref().map(|v| v + 1); // borrow, do not move
let e = r.clone().map_err(|err| err.len());
let chain: Result<i32, String> = Ok(2).and_then(|v| Ok(v * 2));
let o: Option<i32> = r.ok(); // discards the error
Ok(1)
}
// `?` in main: no unwrap needed, and the error type is erased.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let n: i32 = "42".parse::<i32>()?;
Ok(())
}
4. Iterator closures: |x| vs |&x| vs *x
The rule: some adapters take Self::Item, some take &Self::Item. The ones taking a
reference hand you one extra &.
For v.iter() where Item = &i32:
| Adapter | Closure argument |
|---|---|
map, filter_map, for_each, flat_map | &i32 |
any, all, position, fold’s item | &i32 |
filter, find, take_while, skip_while, partition | &&i32 |
max_by_key, min_by_key | &&i32 |
max_by, min_by | &&i32, &&i32 |
slice sort_by | &T, &T |
slice sort_by_key, binary_search_by | &T |
The escape hatch: call .copied() or .cloned() first and every downstream closure
works by value. That removes the whole problem.
let v = vec![3, 1, 4, 1, 5];
// Item is &i32. `x * 2` works because i32: Copy and auto-deref handles the rest.
let a: Vec<i32> = v.iter().map(|x| x * 2).collect();
// Three ways to filter, all valid. Pick one and stay consistent.
let b: Vec<i32> = v.iter().copied().filter(|x| *x > 2).collect(); // Item = i32
let cc: Vec<i32> = v.iter().filter(|x| **x > 2).copied().collect(); // Item = &i32
let d: Vec<&i32> = v.iter().filter(|&&x| x > 2).collect(); // destructure
let e: Vec<i32> = v.clone().into_iter().filter(|x| *x > 2).collect(); // owned
let found: Option<&i32> = v.iter().find(|x| **x == 4); // && i32
let idx: Option<usize> = v.iter().position(|x| *x == 4); // &i32
let any: bool = v.iter().any(|x| *x == 4); // &i32
let all: bool = v.iter().all(|x| *x > 0); // &i32
let mx: Option<&i32> = v.iter().max_by_key(|x| **x); // && i32
let taken: Vec<&i32> = v.iter().take_while(|x| **x > 1).collect();
let total: i32 = v.iter().fold(0, |acc, x| acc + x); // acc: i32, x: &i32
let summed: i32 = v.iter().sum();
let count = v.iter().count();
let mapped: Vec<String> = v.iter().map(|x| x.to_string()).collect();
let (small, big): (Vec<i32>, Vec<i32>) = v.iter().copied().partition(|x| *x > 2);
let zipped: Vec<(i32, i32)> = v.iter().copied().zip(v.iter().copied()).collect();
// Option inside a collection: flatten drops the Nones.
let flat: Vec<i32> = vec![Some(1), None, Some(2)].into_iter().flatten().collect();
let transposed: Option<i32> = Some(Some(1)).flatten();
filter does not change the Item type, even with |&&x|. That is error 1 above.
5. HashMap, HashSet, BTreeMap
use std::collections::{BTreeMap, HashMap, HashSet};
// The counting idiom. Memorise this exact line.
let words = ["a", "b", "a"];
let mut counts: HashMap<&str, u32> = HashMap::new();
for w in words {
*counts.entry(w).or_insert(0) += 1;
}
// entry(): insert default, then modify in one lookup.
counts.entry("c").or_insert(0);
counts.entry("a").and_modify(|c| *c += 10).or_insert(1);
// Grouping: or_default() needs V: Default. Vec<u32> is.
let mut groups: HashMap<u32, Vec<u32>> = HashMap::new();
for n in [1u32, 2, 3, 4] {
groups.entry(n % 2).or_default().push(n);
}
// get returns Option<&V>, never V. That is why you keep needing .copied().
let map: HashMap<i32, i32> = vec![(1, 2)].into_iter().collect();
let lookup: Option<&i32> = map.get(&1);
let value: Option<i32> = map.get(&1).copied();
let exists: bool = map.contains_key(&1);
let set: HashSet<i32> = vec![1, 1, 2].into_iter().collect();
// Ordered iteration needs BTreeMap. HashMap order is arbitrary.
let mut bt: BTreeMap<i32, &str> = BTreeMap::new();
bt.insert(1, "a");
let first = bt.first_key_value();
let in_range = bt.range(0..2).count();
Key requirements: HashMap/HashSet need Hash + Eq. BTreeMap/BTreeSet need Ord.
f64 has neither, so it cannot be a key in any of them.
6. Sorting, searching, dequeues
let mut v = vec![(1, "b"), (2, "a"), (1, "a")];
v.sort_by_key(|p| p.0); // stable, key: &T -> K
v.sort_by_key(|p| std::cmp::Reverse(p.0)); // descending without a custom comparator
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(b.1))); // tie-break: Ordering::then
v.sort_unstable(); // fastest, order not preserved
v.sort_unstable_by(|a, b| b.0.cmp(&a.0)); // descending
let mut xs = vec![3, 1, 4, 1, 5];
xs.sort_unstable();
xs.dedup(); // only removes CONSECUTIVE duplicates
let idx: Result<usize, usize> = xs.binary_search(&4); // Ok(found) | Err(insert_at)
let idx2: Result<usize, usize> = v.binary_search_by_key(&2, |p| p.0); // slice must be sorted
let has = xs.contains(&4); // linear scan, no sort needed
// Floats: sort with total_cmp, never partial_cmp().unwrap(). NaN makes the latter panic.
let mut ys: Vec<f64> = vec![2.5, 1.0, 3.5];
ys.sort_by(|a, b| b.total_cmp(a)); // descending, NaN-safe, total order
// VecDeque is the queue and stack with O(1) at both ends.
let mut q = std::collections::VecDeque::new();
q.push_back(1);
q.push_front(0);
let front = q.pop_front();
let back = q.pop_back();
sort_by_key cannot return a borrowed value — use sort_by for that. dedup without a
preceding sort does almost nothing.
7. BinaryHeap, Ord, and f64
BinaryHeap<T> requires T: Ord, which is why f64 does not work: it only implements
PartialOrd, because NaN has no ordering.
use std::cmp::{Ordering, Reverse};
use std::collections::BinaryHeap;
// Ord types are fine. This is a MAX-heap.
let mut max_heap: BinaryHeap<i32> = BinaryHeap::new();
max_heap.push(3);
max_heap.push(1);
let top: Option<i32> = max_heap.pop(); // Some(3) - largest first
let peek: Option<&i32> = max_heap.peek(); // borrow, does not remove
let from_vec: BinaryHeap<i32> = BinaryHeap::from(vec![1, 2, 3]);
// Min-heap: wrap in Reverse. pop() then gives Reverse<T>, so unwrap the field.
let mut min_heap: BinaryHeap<Reverse<i32>> = BinaryHeap::new();
min_heap.push(Reverse(5));
min_heap.push(Reverse(2));
let smallest = min_heap.pop().map(|r| r.0); // Some(2)
// Tuples are Ord when all elements are. This is a tie-broken key.
let mut pairs: BinaryHeap<(i32, usize)> = BinaryHeap::new();
pairs.push((1, 0));
pairs.push((2, 1));
// A heap of f64 needs a wrapper. All five items below are required:
// derive PartialEq, then impl Eq, PartialOrd, Ord.
#[derive(PartialEq)]
struct F(f64);
impl Eq for F {}
impl PartialOrd for F {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other)) // delegate: total_cmp is already a total order
}
}
impl Ord for F {
fn cmp(&self, other: &Self) -> Ordering {
self.0.total_cmp(&other.0) // never panics, handles NaN and -0.0
}
}
let mut float_heap: BinaryHeap<F> = BinaryHeap::new();
float_heap.push(F(1.5));
float_heap.push(F(0.5));
let smallest_float = float_heap.pop().map(|f| f.0);
If the interviewer only wants a sorted list, skip the wrapper: sort_by(|a, b| b.total_cmp(a)).
Reach for the heap when you need streaming top-k, and say why.
8. Locks, guards, and the deadlock I wrote
Mutex::lock() returns LockResult<MutexGuard<T>>, so it needs unwrap(), and the guard
is what derefs to T.
use std::sync::{Arc, Mutex, RwLock};
use std::sync::atomic::{AtomicUsize, Ordering};
let shared = Arc::new(Mutex::new(vec![1]));
let mut handles = Vec::new();
for i in 0..4 {
let shared = Arc::clone(&shared); // clone the handle, move it in
handles.push(std::thread::spawn(move || {
let mut guard = shared.lock().unwrap(); // MutexGuard<Vec<i32>>
guard.push(i); // method call auto-derefs
*guard = vec![i]; // explicit deref to replace the value
})); // guard drops here -> unlock
}
for h in handles { h.join().unwrap(); }
// Poisoning: lock() is a Result. Propagate, or take the data back.
let ignoring = shared.lock().unwrap_or_else(|e| e.into_inner());
// THE TRAP. These two look identical and are not.
let _ = shared.lock(); // guard dropped at the end of THIS statement: not locked
let _g = shared.lock(); // guard held until end of scope: intended
// THE OTHER TRAP. `lock()` returns a guard, so binding it holds the lock for the
// rest of the body. This deadlocks on the final line, same thread, no warning:
// let again = shared.lock().unwrap();
// let g2 = shared.lock().unwrap(); // hangs forever
// Bind the DATA instead, or scope the guard:
let len = { let g = shared.lock().unwrap(); g.len() };
// RwLock: many readers OR one writer.
let rw = Arc::new(RwLock::new(0));
{
let r1 = rw.read().unwrap();
let r2 = rw.read().unwrap(); // two readers coexist
}
{
let mut w = rw.write().unwrap();
*w += 1;
}
// Atomics: every operation takes an Ordering. Forgetting it is a compile error.
let counter = AtomicUsize::new(0);
counter.fetch_add(1, Ordering::Relaxed);
let loaded = counter.load(Ordering::SeqCst);
let swapped = counter.swap(5, Ordering::AcqRel);
let cas: Result<usize, usize> =
counter.compare_exchange(5, 6, Ordering::AcqRel, Ordering::Relaxed);
Which to reach for:
| Need | Type | Notes |
|---|---|---|
| Counter across threads | AtomicUsize | needs an Ordering every call |
| Counter, single thread | Cell<T> | T: Copy only, get/set |
| Shared mutable state, single thread | RefCell<T> | borrow/borrow_mut, panics on conflict |
| Shared mutable state, threads | Mutex<T> | blocking, poisons on panic |
| Read-mostly across threads | RwLock<T> | many readers or one writer |
| Single-thread shared ownership | Rc<T> | not Send |
| Cross-thread shared ownership | Arc<T> | clone the handle, not the data |
Lock guard held across .await is not Send — that needs tokio::sync::Mutex.
9. Strings
let s: String = "hi there".to_string();
let s2: String = String::from("hi");
let s3: String = "hi".to_owned();
let r: &str = &s; // deref coercion: String -> &str
let r2: &str = s.as_str(); // the explicit form
let own: String = r.to_string(); // &str -> String
// parse returns Result, so it needs `?` or an unwrap in a function.
let n: i32 = "42".parse::<i32>()?;
let with_default = "42".parse::<i32>().unwrap_or(0);
let as_option = "42".parse::<i32>().ok();
let parts: Vec<&str> = s.split_whitespace().collect();
let joined = parts.join(",");
let upper = s.to_uppercase();
let first: Option<char> = s.chars().next(); // bytes vs chars are different
let nth: Option<char> = s.chars().nth(1);
let reversed: String = s.chars().rev().collect();
let formatted = format!("{s} {n}");
let mut m = String::new();
m.push('a');
m.push_str("bc");
s[0] gives a byte and panics on a non-ASCII boundary. Use s.chars().nth(i).
Function parameters should be &str, struct fields should be String.
10. Match ergonomics
Matching a reference makes the bindings references automatically. You rarely need & in
the pattern.
match &opt {
Some(x) => { let _: &String = x; } // x is &String, no `&` written
None => {}
}
match &mut mopt {
Some(x) => x.push('!'), // x is &mut String
None => {}
}
match opt {
Some(x) => { let _: String = x; } // x is String: this one MOVES
None => {}
}
if let Some(x) = &mopt {} // x: &String
while let Some(x) = stack.pop() {} // x: i32, owned
let Some(x) = mopt else { return }; // let-else: bind or leave
if let Some(Ok(v)) = Some(Ok::<i32, String>(1)) {} // nested patterns in one line
let s = String::from("quit");
match &s[..] { // &str patterns need a slice, not &String
"quit" => {}
_ => {}
}
match n {
x @ 1..=5 => { let _ = x; } // bind and test at once
_ => {}
}
When the compiler says expected &String, found String, add & to the scrutinee
(match &opt) rather than to the pattern.
11. Escaping the borrow checker
let mut v = vec![1, 2, 3, 4];
// Two mutable borrows into one slice, without cloning.
let (left, right) = v.split_at_mut(2);
left[0] = 9;
right[0] = 8;
// Read and write in one expression does not compile. Copy the index out first.
let i = 1usize;
let value = v[i];
v[i] = value + 1;
for x in &mut v { *x += 1; } // &mut i32, deref to assign
for x in v.iter().copied() { } // by value, no borrow held
// Move a value out of a slot that is borrowed elsewhere.
let mut owned = vec![1, 2, 3];
let taken = std::mem::take(&mut owned); // leaves Default (empty)
let replaced = std::mem::replace(&mut owned, vec![7]); // leaves vec![7]
let mut opt = Some(vec![1]);
let moved = opt.take(); // Option<T> -> Option<T>, leaves None
let snapshot = v.len(); // copy the value out
let copy = v.clone(); // the pragmatic unblock when you are short on time
Order of attack when the borrow checker fights you: copy the value out, then
split_at_mut, then mem::take, then clone. Save clone for when the clock matters
and say out loud that you know it is a clone.
12. Final checklist of syntax slips
Vec::pop,HashMap::get,stack.pop(),BinaryHeap::popall returnOption. Handle it..iter()gives&T; add.copied()/.cloned()before the closures.filter,find,take_while,max_by_keygive&&T.- The binding needs
mut, not the value:let mut v = .... .collect()needs a target type:let v: Vec<_> = ...orcollect::<Vec<_>>().- Integer literals default to
i32; annotate0usize,vec![0u32; n]when indexing. - Indexing needs
usize:v[x as usize]. - Unannotated float literals can stay ambiguous until a method pins the type.
Option/Resultcombinators takeselfand move. Use.as_ref()to keep the value.- Locks return guards. Bind the data, not the guard.
let _ = lock()unlocks instantly. #[derive(Debug, Clone, PartialEq)]on any struct you plan to print or assert on.assert_eq!needsDebugandPartialEqon both sides..to_string()needsDisplay;format!("{x:?}")needsDebug.- Closure needs
moveonly to cross a thread boundary or to return it. Arc::clone(&x)inside the loop, thenmovethe clone into the spawn.
Appendix B. The benchmark report
Chapters 4 and 9 quote measurements from the Rust lab’s benchmark program. This appendix reproduces the full
report written from those runs: rust-interview-lab/benchmarking_examples/benchmarking-report.md. It
presents them as a short paper, with method, results, discussion, and threats to validity. The recorded
program output it relies on is in rust-interview-lab/BENCHMARKS.md.
A correction to sections 2.2 and 5.2 of the report. They attribute the enum list’s slower push to a discriminant stored alongside the boxed continuation. On a 64-bit target,
std::mem::size_of::<ListNode<u64>>()is 16, the same asNode<u64>: theBoxfield’s non-null niche encodesEmpty, so there is no separate discriminant. Chapter 4, section 4.3, gives the two structural differences that remain (the inline head moved on every push, and the extra allocation for the finalEmpty). The measured timings are unaffected.
The report is built as its own PDF by this script and stylesheet:
Listing B.1 The report's build script. benchmarking_examples/build_report_pdf.py
#!/usr/bin/env python3
"""Render `benchmarking-report.md` as a PDF.
python3 benchmarking_examples/build_report_pdf.py
Markdown is rendered by Python-Markdown with Pygments for the code blocks, the
result is wrapped in one HTML document, and WeasyPrint paginates it using
`report.css`. Requires `markdown`, `pygments`, and `weasyprint`.
"""
from __future__ import annotations
import pathlib
import sys
import markdown
from pygments.formatters import HtmlFormatter
from weasyprint import CSS, HTML
HERE = pathlib.Path(__file__).resolve().parent
SOURCE = HERE / "benchmarking-report.md"
STYLESHEET = HERE / "report.css"
OUTPUT = HERE / "benchmarking-report.pdf"
TITLE = "A Comparative Study of LRU Cache Storage Strategies and Singly Linked List Deallocation"
EXTENSIONS = ["extra", "codehilite", "sane_lists"]
CONFIG = {"codehilite": {"guess_lang": False, "css_class": "highlight"}}
# The title block is written as ordinary Markdown paragraphs. These identifiers
# let the stylesheet centre them and distinguish them from body text.
TITLE_BLOCK = (
('<p><strong>Arpan Pathak</strong></p>', '<p id="title-author"><strong>Arpan Pathak</strong></p>'),
('<p>11 September 2026</p>', '<p id="title-date">11 September 2026</p>'),
(
'<p><strong>Keywords:</strong>',
'<p id="title-keywords"><strong>Keywords:</strong>',
),
)
def build_document() -> str:
"""The whole report as one HTML document."""
body = markdown.markdown(
SOURCE.read_text(encoding="utf-8"), extensions=EXTENSIONS, extension_configs=CONFIG
)
for old, new in TITLE_BLOCK:
body = body.replace(old, new, 1)
style = HtmlFormatter(style="friendly").get_style_defs(".highlight")
return "\n".join(
[
"<!doctype html>",
'<html lang="en">',
"<head>",
'<meta charset="utf-8">',
f"<title>{TITLE}</title>",
'<meta name="author" content="Arpan Pathak">',
f"<style>{style}</style>",
"</head>",
"<body>",
body,
"</body>",
"</html>",
]
)
def main() -> int:
if not SOURCE.exists():
print(f"missing {SOURCE}", file=sys.stderr)
return 1
document = build_document()
stylesheet = CSS(filename=str(STYLESHEET))
rendered = HTML(string=document, base_url=str(HERE))
page_count = len(rendered.render(stylesheets=[stylesheet]).pages)
rendered.write_pdf(target=str(OUTPUT), stylesheets=[stylesheet])
print(f"wrote {OUTPUT.name} ({page_count} pages)")
return 0
if __name__ == "__main__":
sys.exit(main())
Listing B.2 The report's stylesheet. benchmarking_examples/report.css
/* Print stylesheet for the benchmarking report.
Rendered by WeasyPrint, which implements the paged-media parts of CSS. */
@page {
size: A4;
margin: 22mm 20mm 20mm 20mm;
@bottom-center {
content: counter(page);
font-family: "DejaVu Sans", Helvetica, sans-serif;
font-size: 8.5pt;
color: #666;
}
}
html {
font-size: 10.5pt;
}
body {
font-family: "DejaVu Serif", Georgia, serif;
line-height: 1.5;
color: #111;
margin: 0;
text-align: left;
}
h1,
h2,
h3 {
font-family: "DejaVu Sans", Helvetica, sans-serif;
color: #000;
line-height: 1.25;
}
/* Title block: centered, in the manner of a journal article. */
h1 {
font-size: 16pt;
text-align: center;
margin: 0 0 0.7em 0;
}
#title-author {
text-align: center;
font-size: 12pt;
margin: 0.5em 0 0 0;
}
#title-date {
text-align: center;
font-size: 10pt;
color: #444;
margin: 0.15em 0 0 0;
}
#title-keywords {
font-size: 9.5pt;
color: #333;
margin: 0.9em 0 0.4em 0;
}
h2 {
font-size: 12.5pt;
margin: 1.7em 0 0.5em 0;
padding-bottom: 0.12em;
border-bottom: 0.6pt solid #bbb;
break-after: avoid;
}
h3 {
font-size: 11pt;
margin: 1.1em 0 0.4em 0;
break-after: avoid;
}
p {
margin: 0 0 0.7em 0;
text-align: justify;
}
table {
width: 100%;
border-collapse: collapse;
margin: 0.7em 0 1.1em 0;
font-size: 9.5pt;
break-inside: avoid;
}
th {
text-align: left;
font-family: "DejaVu Sans", Helvetica, sans-serif;
font-size: 8.5pt;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #333;
border-bottom: 1pt solid #333;
padding: 0.35em 0.5em 0.3em 0.5em;
}
td {
border-bottom: 0.4pt solid #ddd;
padding: 0.35em 0.5em;
vertical-align: top;
}
pre {
background: #f6f6f6;
border: 0.4pt solid #ddd;
padding: 0.6em 0.7em;
font-size: 8.6pt;
line-height: 1.35;
white-space: pre-wrap;
break-inside: avoid;
}
code {
font-family: "DejaVu Sans Mono", monospace;
font-size: 0.92em;
}
p code,
li code {
background: #f2f2f2;
padding: 0 0.15em;
/* An identifier split across two lines is a defect, and the longest token in
this report is short enough to fit on a line of the text block. */
white-space: nowrap;
}
td code,
th code {
background: #f2f2f2;
padding: 0 0.15em;
}
h1 code {
white-space: nowrap;
}
hr {
border: none;
border-top: 0.6pt solid #bbb;
margin: 1.6em 0;
}
ol,
ul {
margin: 0 0 0.8em 1.3em;
padding: 0;
}
li {
margin-bottom: 0.3em;
text-align: justify;
}
h2 + p,
h2 + table,
h2 + pre,
h3 + p {
margin-top: 0;
}
A Comparative Study of LRU Cache Storage
Strategies and Singly Linked List Deallocation
Arpan Pathak
11 September 2026
Abstract
Two experiments are reported. The first compares two storage strategies for a fixed-capacity least-recently-used cache: an index arena, in which nodes are held in a contiguous vector and linked by integer offsets, and a reference-counted doubly linked list, in which each node is a separate heap allocation linked by pointers. On a workload of ten million requests with a measured hit rate of 89.8 percent, the arena completed in 1.00 s against 1.86 s for the reference-counted list, a ratio of 1.87. The magnitude of the difference is bounded by the hash table lookup that both implementations perform on every request; across access patterns the ratio ranged from 1.1 to 1.9.
The second experiment compares four singly linked list variants that combine two node representations with two deallocation strategies. The variants that rely on compiler-generated deallocation cannot free more than approximately 265,000 nodes on an 8 MiB stack, because freeing a node frees its successor and the recursion consumes one stack frame per element; beyond that size the process terminates with a stack overflow. The iterative variants free five million nodes without incident. The iterative strategy is not substantially faster, saving about 0.3 ms on a 3.2 ms deallocation at 200,000 nodes; its principal contribution is the removal of the size limit.
The measurement procedure, including the treatment of allocation warm-up, and the threats to the validity of both results are discussed.
Keywords: cache replacement, index arena, reference counting, linked list, stack overflow, benchmarking methodology
1. Introduction
A cache that must bound memory occupancy needs a replacement policy, and the least-recently-used policy requires a recency order over the stored entries. The order is conventionally maintained by a doubly linked list, and the two decisions that determine the cost of that list are how nodes are allocated and how links between them are represented.
The first decision concerns ownership. A node may be owned individually, as one heap allocation reached through a pointer, or collectively, as one element of a contiguous array. Individually owned nodes support stable addresses and permit removal without disturbing the rest of the structure, at the cost of one allocation per node and a reference count or equivalent mechanism to express ownership. Collectively owned nodes require the allocator to be invoked once, and express the identity of a node by its index, at the cost of a level of indirection.
The second decision concerns deallocation. A recursively defined structure releases its storage through a recursive destructor unless the recursion is replaced by an iterative walk. This choice is frequently treated as a matter of performance, and the present study finds that the performance difference is small. Its consequences are otherwise: the recursive form has a maximum size beyond which the program terminates.
This document reports measurements of both decisions. Section 2 describes the structures. Section 3 gives the experimental method. Section 4 presents the results, Section 5 discusses them and states the threats to their validity, and Section 6 concludes.
2. Background
2.1 Storage for a fixed-capacity LRU cache
Both cache implementations under study maintain a hash table that maps a key to a node, and a doubly linked list that orders the nodes by recency. Insertion of a new entry when the cache is full removes the least recently used node. A lookup that succeeds moves the located node to the most recently used end.
In the index arena implementation, the nodes are elements of a Vec, and the
links are values of type Option<usize>. The hash table maps a key to the index
of its node. Removal of the least recently used entry reuses that entry’s slot
rather than deallocating it, so the arena is allocated once, grows to its capacity,
and is never resized during operation. The implementation is
benchmarking_examples/cache/arena.rs.
In the second implementation, each node is allocated separately and reached
through a reference-counted cell. Forward links hold strong references and
backward links hold weak references, which prevents ownership cycles. Removal of the least recently used entry
removes the tail node from the hash table and detaches it, after which the new
entry is allocated as a fresh node. The implementation is
benchmarking_examples/cache/rc_list.rs.
Both implementations satisfy the same trait, cache::Cache, and are therefore
exercised by one workload.
2.2 Node representation and deallocation in a singly linked list
Four variants of a singly linked list are compared. They share push and pop operations and differ in two independent properties.
| Variant | Node representation | Deallocation |
|---|---|---|
box | Option<Box<Node<T>>> | compiler-generated, recursive |
enum | enum ListNode<T> | compiler-generated, recursive |
box+drop | Option<Box<Node<T>>> | explicit, iterative |
enum+drop | enum ListNode<T> | explicit, iterative |
The two representations have different sizes. Option<Box<Node<T>>> occupies one
machine word on a 64-bit target, because a boxed value is never null and the
all-zero address is therefore available to encode the empty case. The enum
representation carries a discriminant in addition to the boxed continuation, and
is consequently larger. The variant definitions are in
benchmarking_examples/lists/.
The deallocation strategies differ in their recursion. The compiler-generated destructor for a node releases the node, whose destructor releases its successor, and so on, consuming one stack frame per element. The iterative strategy replaces the head of the list with the empty state and then walks the chain, detaching each node before it is freed, so no destructor is called with the remainder of the list still attached.
3. Method
3.1 Environment
| Property | Value |
|---|---|
| Processor | AArch64, 8 cores, development board |
| Compiler | rustc 1.96.0-nightly |
| Build profile | --release |
| Concurrency | one thread per measurement |
| Default stack size | 8 MiB |
Absolute times are properties of this machine and this toolchain. The ratios and the maximum list size are the quantities intended to generalise, the latter only within the constraint of the stack size in force.
3.2 Workloads
Cache workload. A fixed-capacity cache of 65,536 entries receives ten million requests over a key space of 131,072 keys. Four requests in five are drawn from a hot set comprising one percent of the key space, and the remainder are drawn uniformly over the whole key space. The request sequence is generated from a fixed seed, so both implementations receive an identical sequence. A request that misses performs an insertion, which evicts the least recently used entry once the cache has reached capacity. The measured hit rate was 89.8 percent, comprising 8,979,314 hits and 1,020,686 insertions.
Linked list workload. Each variant is measured at 200,000 elements, a size at which all four complete. The two iterative variants are additionally measured at 5,000,000 elements. The recursive variants are measured at incrementally increasing sizes until termination occurs, to establish the size at which deallocation fails.
3.3 Measurement procedure
An initial observation established that a single timing pass is not reproducible on this platform. Timings of one push loop of fixed size varied between 2.3 ms and 11.4 ms depending on whether the measurement was the first large allocation performed by the process. The variance is attributable to the allocator and the operating system rather than to the structure under test: a first pass over a fresh heap requires pages to be mapped, and that cost is charged to the structure being measured.
Accordingly, each measurement is preceded by a warm-up pass of the same magnitude as the measured pass, and then repeated three times, with the shortest of the three reported. The shortest pass is preferred to the mean because the quantity of interest is the cost of the work, while a lengthened pass reflects interference from outside the process. This procedure follows the practice recommended for benchmarking where the number of repetitions is constrained [3, 4]. The residual variation between runs of the cache experiment, computed over the reported configuration, is approximately three percent.
Measurements are normalised per request for the cache experiment and per element for the list experiment, so that the two experiments can be reported in comparable units.
3.4 Verification and controls
Each measurement is accompanied by a check that the intended work was performed.
In the list experiment, every value inserted is removed and compared with the expected value, so that a structure which loses or reorders elements cannot report a time. The verification loop is not included in any timed interval.
In the cache experiment, the two implementations process the same request sequence and their hit counts are compared before any timing is reported. Agreement establishes that both implementations were given the same problem.
The variants box and box+drop share an identical push implementation and differ
only in deallocation. Their push times therefore serve as a control: a discrepancy
beyond the run-to-run variation indicates a fault in the measurement rather than a
difference between the structures.
4. Results
4.1 Cache storage strategies
LRU cache: 65,536 entries, 10,000,000 requests over 131,072 keys
hit rate: 89.8% (8,979,314 hits, 1,020,686 inserts)
storage total per request
------------------------------------------------
arena (Vec + indices) 1.00s 100 ns
Rc<RefCell> list 1.86s 186 ns
The arena required 1.00 s and the reference-counted list 1.86 s, giving a ratio of 1.87 in favour of the arena. Measured per request, the costs are 100 ns and 186 ns respectively. Repeated runs of the same configuration produced ratios between 1.81 and 1.87.
4.2 Deallocation in singly linked lists
All four variants completed at 260,000 elements. At 270,000 elements the two recursive variants terminated with a stack overflow and the two iterative variants completed.
| Variant | 260,000 elements | 270,000 elements |
|---|---|---|
box | completed | stack overflow |
enum | completed | stack overflow |
box+drop | completed | completed |
enum+drop | completed | completed |
The termination message recorded for the recursive variants is:
thread 'main' has overflowed its stack
fatal runtime error: stack overflow, aborting
On an 8 MiB stack, the limit therefore lies between 260,000 and 270,000 elements, corresponding to approximately 32 bytes of stack per element.
Timings at 200,000 elements, where all four variants complete:
variant elements push drop
----------------------------------------------------
box 200,000 2.40ms 3.23ms
enum 200,000 3.42ms 3.20ms
box+drop 200,000 2.32ms 2.91ms
enum+drop 200,000 3.62ms 2.52ms
Timings at 5,000,000 elements, where only the iterative variants complete:
box+drop 5,000,000 58.11ms 72.32ms
enum+drop 5,000,000 90.98ms 67.51ms
Normalised per element at 200,000 elements, insertion costs 12.0 ns for the boxed node and 17.1 ns for the enum node, and deallocation costs 16.2 ns, 16.0 ns, 14.6 ns and 12.6 ns for the four variants in the order listed.
5. Discussion
5.1 Interpretation of the cache result
The arena was faster by a factor of 1.87 on the reported workload, and the
mechanism is visible in the operations each implementation performs when a lookup
succeeds. The arena writes two indices into vector elements and reads two more; no
memory is allocated or released and no reference count is modified. The
reference-counted list clones a handle, upgrades a weak handle, borrows a
RefCell, writes through the borrow, and drops the clone. Each step is small, but
all of them occur on every successful lookup, and the node they operate on must be
reached through a pointer to a separate allocation.
Eviction differs in the same direction. The arena reuses the least recently used slot. The list frees the tail node and allocates a node for the incoming key, an allocation and a deallocation that the arena does not perform.
The ratio is nevertheless smaller than the difference in list manipulation alone would suggest, because every request performs a hash table lookup and that cost is identical in both implementations. The measured ratio therefore depends on the proportion of each request that is spent on list maintenance. Across access patterns, the ratio ranged from 1.1 to 1.9; a stream with no reuse tends towards the lower end, since such a stream is dominated by lookups that miss and by evictions.
5.2 Interpretation of the list result
The difference in insertion cost between the two node representations, 12.0 ns against 17.1 ns per element, is consistent with the difference in node size. Both representations perform one allocation per element; the enum node additionally carries a discriminant, so each insertion moves more memory.
The difference in deallocation cost between the recursive and the iterative strategies is small, 0.32 ms and 0.68 ms at 200,000 elements, against totals of 3.23 ms and 3.20 ms. The iterative strategy is therefore not a performance measure. Its contribution is that it removes the maximum size: the recursive variants terminate above approximately 265,000 elements on an 8 MiB stack, and the iterative variants complete at 5,000,000, the largest size tested.
5.3 Threats to validity
Internal validity. The measurements are taken on a single machine, with one implementation measured after another in the same process. Allocation warm-up was identified as a source of variation and is controlled by the procedure described in Section 3.3, and the two list variants that share an insertion implementation provide a control on that stage. The deallocation measurements remain the least stable part of the results, since they follow a sequence of allocations and deallocations of the same memory.
External validity. Absolute times are specific to this processor, compiler and allocator. The maximum list size is a property of the stack size in addition to the code; a program running on a thread with a smaller stack would fail at a smaller size, and one with a larger stack at a larger size. The value of 5,000,000 reported for the iterative variants is the largest size tested and not an observed limit.
Construct validity. The cache fn wrappedexperiment measures request latency for both a lookup and an insertion, but reports only the aggregate. It does not separate the cost of the hash table lookup from the cost of list maintenance, and it does not report peak memory, although the two node representations differ in size. The list experiment measures insertion, removal and deallocation, and does not measure iteration, search, or insertion at an arbitrary position.
Generality. Both experiments are single-threaded and therefore characterise neither contention nor synchronisation cost. A shared arena would require sharding or locking, and the cost of that mechanism is not represented here.
6. Conclusion
For a fixed-capacity LRU cache, an index arena was faster than a reference-counted linked list by a factor of 1.87 on the reported workload, with the difference attributable to the per-node bookkeeping and the allocation performed on eviction. The ratio is bounded by the hash table lookup common to both implementations, and ranged from 1.1 to 1.9 across access patterns.
For a singly linked list, an iterative deallocation strategy is worth adopting for robustness rather than for speed. It saved about ten percent of the deallocation time at the size measured, and it raised the maximum list size from approximately 265,000 elements to beyond 5,000,000, the largest size tested.
Further work would separate lookup cost from list maintenance cost by timing the two phases independently, report peak memory for each variant, and extend the cache experiment to a multi-threaded configuration in which the synchronisation cost of the arena becomes measurable.
References
- The Rust Reference. Type layout. On size, alignment, and the null-pointer optimisation.
- The Rust Standard Library.
std::mem::size_of,std::mem::align_of, andstd::thread::Builder::stack_size. - T. Kalibera and R. Jones. Rigorous Benchmarking in Reasonable Time. Proceedings of the 2013 International Symposium on Memory Management (ISMM), 2013.
- A. Georges, D. Buytaert, and L. Eeckhout. Statistically Rigorous Java Performance Evaluation. Proceedings of the 22nd Annual ACM SIGPLAN Conference on Object-Oriented Programming, Systems, Languages, and Applications (OOPSLA), 2007.
- T. Mytkowicz, A. Diwan, M. Hauswirth, and P. F. Sweeney. Producing Wrong Data Without Doing Anything Obviously Wrong! Proceedings of the 14th International Conference on Architectural Support for Programming Languages and Operating Systems (ASPLOS), 2009.
Appendix A. Source files
| File | Contents |
|---|---|
benchmarking_examples/cache/mod.rs | The Cache trait |
benchmarking_examples/cache/arena.rs | Index arena implementation |
benchmarking_examples/cache/rc_list.rs | Reference-counted implementation |
benchmarking_examples/lists/mod.rs | The SinglyList trait |
benchmarking_examples/lists/boxed.rs | Boxed node, recursive deallocation |
benchmarking_examples/lists/enum_node.rs | Enum node, recursive deallocation |
benchmarking_examples/lists/boxed_drop.rs | Boxed node, iterative deallocation |
benchmarking_examples/lists/enum_drop.rs | Enum node, iterative deallocation |
benchmarking_examples/benchmark.rs | The measurement program |
rust-interview-lab/BENCHMARKS.md | Captured program output |
Appendix C. Merging k sorted lists, the long version
Warning. This is the first version of chapter 10, kept whole. It walks through eleven programs for one merge, and most of them are near copies of each other. It is very ugly. It stays in the book for brave readers who want every variant and every borrow trick. Chapter 10 teaches the same material with three programs.
This chapter covers
- Merging two sorted linked lists by moving nodes, not copying values
- Three strategies for merging many lists, and why two of them are much faster than the third
- Borrowing two elements of one
Vecat the same time, and ways to avoid needing to - The dummy head, the tail pointer, and
Option::insert - Eleven implementations of the same task, compared
Chapter 9 built singly linked lists and moved their nodes between owners with take. Here you have k linked
lists, each already sorted, and you want one sorted list that contains all their nodes.
Section C.2 compares three orders of merging. Sections C.3 to C.8 implement them in eleven programs, and section C.9 lists the time and memory each program needs.
Two letters appear throughout. k is the number of lists. N is the total number of nodes across all of them.
C.1 Merging two sorted lists
Every program in this chapter merges two sorted lists at a time. Compare the front nodes of the two lists. Move the smaller one to the end of the output. Repeat until one list is empty, then attach the other list whole. Figure C.1 works through an example.
With linked lists, “move a node to the output” does not copy the value. It detaches the node from the front of its list and links it to the end of the output. No memory is allocated.
Animation C.1 runs that merge with the code of listing C.5, one node at a time. The output starts at a
dummy node, a placeholder whose next will hold the real first node. tail always points at the last node
of the output.
C.2 Three strategies for k lists
Figure C.2 shows three ways to extend two-list merging to k lists.
One at a time. Merge list 2 into list 1, then list 3 into the result, and so on. Each merge walks the whole result so far, which keeps growing. The first nodes are walked k times, so the total is about O(kN).
Pairwise rounds. Merge the lists in pairs, like the rounds of a tournament. After the first round there are k / 2 lists, after the second k / 4, and so on. In every round, each node takes part in exactly one merge. There are about log₂ k rounds, so the total is O(N log k).
A heap of heads. Put the front node of each list into a min-heap, the structure from chapter 5. Pop the smallest, append it to the output, and push the next node from the same list. Each node goes through one push and one pop on a heap of at most k entries. Each costs O(log k), so the total is again O(N log k).
Animation C.2 counts node moves for eight lists of three nodes: pairwise rounds against one list at a time.
For 100 lists of 10,000 nodes each, N is a million. O(kN) is about 100 million steps. O(N log k) is about 7 million.
The pairwise strategy has a compact in-place form that most of the eleven versions use. It keeps the lists in a
Vec and merges positions that are a growing interval apart (figure C.3).
lists[0].In each round, the list at position i absorbs the list at i + interval. The position i runs through
0, 2 × interval, 4 × interval, and so on. The odd list out, number 4 here, waits until the interval is large enough to reach it.
C.3 First version: split_at_mut and a dummy head
The first version implements pairwise rounds with a growing interval, as in figure C.3. Its merge builds the
output after a placeholder node, called a dummy head, so that the first real node needs no special case.
Listing C.1 The node type and the interval loop (lines 1 to 34). src/bin/merge_k_sorted_lists_divide.rs
struct MergeKSorted {}
#[derive(Default)]
struct ListNode {
data: u32,
next: Option<Box<ListNode>>,
}
impl ListNode {
fn new(data: u32) -> Self {
Self { data, next: None }
}
}
type OptionalLink<T> = Option<Box<T>>;
impl MergeKSorted {
pub fn merge_k_lists(mut lists: Vec<OptionalLink<ListNode>>) -> OptionalLink<ListNode> {
if lists.is_empty() {
return None;
}
let (n, mut interval) = (lists.len(), 1);
while interval < n {
let mut i = 0;
while i + interval < n {
let (left, right) = lists.split_at_mut(i + interval);
left[i] = Self::merge_two(&mut left[i], &mut right[0]);
i += interval * 2;
}
interval *= 2;
}
lists[0].take()
}
// ...
}
ListNode is the node from chapter 9: a value, data, and next, an Option<Box<ListNode>> that is
None at the end of the list. OptionalLink<T> is a type alias, a second name for Option<Box<T>>,
so signatures stay short. MergeKSorted is an empty struct. It holds no data, and only groups the functions
under one name.
The two while loops follow figure C.3. The outer one doubles interval. The inner one steps i by
interval * 2, merging i with i + interval.
The merge needs to change two elements of the vector at once. The obvious code,
merge_two(&mut lists[i], &mut lists[i + interval]), does not compile. Both &mut borrows are borrows of the
whole vector lists, and Rust allows only one mutable borrow of a value at a time. The compiler does not
reason about the two indexes being different.
split_at_mut(i + interval) solves this. It splits the vector into two slices that do not overlap: the part before position i + interval and the
rest. It returns a mutable borrow of each. Now left[i]
and right[0] come from different slices, and borrowing both is allowed.
The merge of two lists uses a dummy head and a tail pointer (figure C.4).
tail always points at the last node, where the next one is attached.Listing C.2 Merging two lists (lines 36 to 57).
impl MergeKSorted {
// ...
fn merge_two(
left: &mut OptionalLink<ListNode>,
right: &mut OptionalLink<ListNode>,
) -> OptionalLink<ListNode> {
let mut dummy = Box::new(ListNode::new(0));
let mut tail = &mut dummy;
while let (Some(l), Some(r)) = (left.as_ref(), right.as_ref()) {
let smaller = if l.data < r.data {
&mut *left
} else {
&mut *right
};
let mut head = smaller.take().unwrap();
*smaller = head.next.take();
tail.next = Some(head);
tail = tail.next.as_mut().unwrap();
}
tail.next = left.take().or(right.take());
dummy.next
}
}
dummy is a throwaway node, and tail is a mutable reference to the last node of the output. At the start,
the output is empty, so tail points at dummy. Each pass of the loop:
while let (Some(l), Some(r)) = (left.as_ref(), right.as_ref())continues while both lists have a front node, and looks at both without moving them.smalleris a mutable reference to whichever list has the smaller front.smaller.take().unwrap()takes that front node out, and*smaller = head.next.take()puts the rest of that list back.tail.next = Some(head)attaches the node, andtail = tail.next.as_mut().unwrap()movestailonto it.
When one list runs out, left.take().or(right.take()) attaches whichever one is left. The merged list starts at
dummy.next.
The complete program is listing C.19 at the end of the chapter.
The tests check three lists, an empty input with empty lists inside it, and an odd number of lists.
$ cargo run --bin merge_k_sorted_lists_divide
[1, 1, 2, 3, 4, 4, 5, 6]
C.4 Avoiding the double borrow
split_at_mut works, but the next two versions show that the double borrow can be avoided altogether.
C.4.1 Take the right list out first
This version moves the right-hand list out of the vector before the call. With only one list still inside the vector, the merge needs only one mutable borrow of it.
It uses the same MergeKSorted, ListNode, and OptionalLink as listing C.1.
Listing C.3 The loop and the merge (lines 16 to 56). src/bin/merge_k_sorted_lists_swap.rs
impl MergeKSorted {
pub fn merge_k_lists(mut lists: Vec<OptionalLink<ListNode>>) -> OptionalLink<ListNode> {
if lists.is_empty() {
return None;
}
let (n, mut interval) = (lists.len(), 1);
while interval < n {
let mut i = 0;
while i + interval < n {
let mut right = lists[i + interval].take();
lists[i] = Self::merge_two(&mut lists[i], &mut right);
i += interval * 2;
}
interval *= 2;
}
lists[0].take()
}
fn merge_two(
left: &mut OptionalLink<ListNode>,
right: &mut OptionalLink<ListNode>,
) -> OptionalLink<ListNode> {
let mut dummy = Box::new(ListNode::new(0));
let mut tail = &mut dummy;
while let (Some(l), Some(r)) = (&left, &right) {
if r.data < l.data {
std::mem::swap(left, right);
}
let mut smallest = left.take().unwrap();
*left = smallest.next.take();
tail.next = Some(smallest);
tail = tail.next.as_mut().unwrap();
}
tail.next = left.take().or(right.take());
dummy.next
}
}
Look at the inner loop:
let mut right = lists[i + interval].take();
lists[i] = Self::merge_two(&mut lists[i], &mut right);
The right list is moved out of the vector into a local variable first. After that, the only borrow of the
vector is the one for lists[i], so the call compiles.
The merge changes too. Instead of choosing which list to take from, it makes sure left always holds the
smaller front. When the right front is smaller, std::mem::swap(left, right) exchanges the two lists. Then the
loop body always takes from left.
The complete program is listing C.20 at the end of the chapter.
C.4.2 Merge owned lists, with no dummy node
This version moves both lists out of the vector and passes them by value, so nothing is borrowed during the merge. It also drops the dummy node and tracks the empty slot where the next node belongs.
Listing C.4 The node type, the loop, and the merge (lines 1 to 43). src/bin/merge_k_sorted_lists_simple.rs
struct ListNode {
data: u32,
next: Link,
}
type Link = Option<Box<ListNode>>;
fn merge_k_lists(mut lists: Vec<Link>) -> Link {
if lists.is_empty() {
return None;
}
let (n, mut interval) = (lists.len(), 1);
while interval < n {
let mut i = 0;
while i + interval < n {
let left = lists[i].take();
let right = lists[i + interval].take();
lists[i] = merge_two(left, right);
i += interval * 2;
}
interval *= 2;
}
lists[0].take()
}
fn merge_two(mut left: Link, mut right: Link) -> Link {
let mut merged = None;
let mut tail = &mut merged;
while let (Some(l), Some(r)) = (&left, &right) {
if r.data < l.data {
std::mem::swap(&mut left, &mut right);
}
*tail = left; // hang left list on the tail
tail = &mut tail.as_mut().unwrap().next; // step past its first node
left = tail.take(); // cut the rest off again
}
*tail = left.or(right);
merged
}
This file names its link type Link, an alias for Option<Box<ListNode>>, and uses free functions
instead of methods on an empty struct. The algorithm changes in two ways.
First, merge_two takes both lists by value, not by reference. The loop moves both out of the vector with
take() before the call, so nothing is borrowed at all.
Second, there is no dummy node. tail is a &mut Link, a pointer to the empty slot where the next node
belongs. It starts at &mut merged, the slot for the first node. The loop body is three lines, each with a
comment:
*tail = left; // hang left list on the tail
tail = &mut tail.as_mut().unwrap().next; // step past its first node
left = tail.take(); // cut the rest off again
After the swap has put the smaller front in left, the whole left list is placed in the empty slot. tail then
moves to the next slot of the list’s first node. Finally tail.take() cuts everything after that first node
back off into left. One node has moved to the output.
The complete program is listing C.21 at the end of the chapter.
to_vec takes &Link and follows the nodes with list = &node.next, so the tests can read a list without
consuming it.
C.4.3 The shortest form, with Option::insert
This version keeps the owned merge and brings back the dummy head. One standard-library method attaches a node and moves the tail onto it in a single step.
Listing C.5 The node type, the merge, and the loop (lines 1 to 45). src/bin/merge_k_sorted_list_zero_copy.rs
type NodeLink = Option<Box<Node>>;
struct Node {
val: i32,
next: NodeLink,
}
fn merge_two(mut left: NodeLink, mut right: NodeLink) -> NodeLink {
let mut dummy = Box::new(Node { val: 0, next: None });
let mut tail = &mut dummy;
while let (Some(left_head), Some(right_head)) = (&left, &right) {
let smaller = if left_head.val <= right_head.val {
&mut left
} else {
&mut right
};
let mut node = smaller.take().unwrap(); // safe: checked by while let
*smaller = node.next.take();
tail = tail.next.insert(node);
}
tail.next = if left.is_some() { left } else { right };
dummy.next
}
fn merge_k(mut lists: Vec<NodeLink>) -> NodeLink {
if lists.is_empty() {
return None;
}
let list_count = lists.len();
let mut gap = 1;
while gap < list_count {
let mut index = 0;
while index + gap < list_count {
let left = lists[index].take();
let right = lists[index + gap].take();
lists[index] = merge_two(left, right);
index += gap * 2;
}
gap *= 2;
}
lists[0].take()
}
Here the node is Node, with an i32 value named val, and the link alias is NodeLink. This version keeps
the owned merge_two from listing C.4 and brings back the dummy head. It shortens the tail
bookkeeping with one method:
tail = tail.next.insert(node);
Option::insert stores a value in the option and returns a mutable reference to the stored value. So one call
attaches the node and moves tail onto it. Listing C.2 needed two lines and an unwrap for the same step.
Every node in the output is a box that was in the input, moved by pointer. The only allocation is the dummy
node, once per merge_two call. The single unwrap has a comment saying why it cannot fail. The while let has already checked that both
lists have a front node.
The complete program is listing C.22 at the end of the chapter.
main tries four cases: four lists including an empty one, duplicates across lists, no lists at all, and five
lists.
$ cargo run --bin merge_k_sorted_list_zero_copy
0 1 2 3 4 5 6 7 8 9
1 1 1 3 5
1 2 3 4 5 8 9
The empty line is the output for no lists.
C.4.4 Swap the lists by value
Listing C.5 picks the smaller list with &mut left or &mut right, and keeps a third mutable reference, tail.
This version swaps the two lists by value instead, so that left always holds the smaller front node. The only
mutable reference left is tail, which makes the loop easier to read: there is one borrow to follow instead of
three.
Listing C.8 The node type and merge_two (lines 4 to 27). src/bin/merge_k_sorted_lists_owned.rs
type Link = Option<Box<Node>>;
struct Node {
val: i32,
next: Link,
}
/// Merge two sorted lists. O(n) time, O(1) extra memory.
fn merge_two(mut left: Link, mut right: Link) -> Link {
let mut dummy = Box::new(Node { val: 0, next: None });
let mut tail = &mut dummy;
while let (Some(l), Some(r)) = (&left, &right) {
if r.val < l.val {
(left, right) = (right, left); // left now holds the smaller front
}
let mut node = left.unwrap(); // safe: checked by while let
left = node.next.take();
tail = tail.next.insert(node);
}
tail.next = left.or(right);
dummy.next
}
while let (Some(l), Some(r)) = (&left, &right) runs while both lists have a node. &left and &right are
shared references, used only to read the two front values.
If the right front is smaller, (left, right) = (right, left) swaps the two lists. The assignment moves the two
Option values; no node moves, and no reference is taken. After it, the smaller front node is always in left.
left.unwrap() moves that list out of left. It cannot fail, because the while let checked that
left is Some. node.next.take() cuts the front node off, and the rest of the list goes back into left.
tail.next.insert(node) attaches the node at the end of the output, as in listing C.5, and returns a reference to
it. That node is the new tail.
When the loop ends, one list is empty. left.or(right) returns the other one, and tail.next takes it whole.
Animation C.3 runs merge_two one line at a time on the lists of the trace below. The table beside the lists
shows what each variable owns or borrows after every line. The last part reads l after the swap, which does not
compile.
merge_two line by line. l and r borrow the front nodes until the comparison. unwrap moves the list into node, and take returns the rest to left. tail is the only mutable reference. Reading l after the swap does not compile.Trace the merge of 1 → 4 with 2 → 3 → 5:
| Pass | left | right | Swap? | Node moved | Output after dummy |
|---|---|---|---|---|---|
| 1 | 1 → 4 | 2 → 3 → 5 | no | 1 | 1 |
| 2 | 4 | 2 → 3 → 5 | yes: 2 < 4 | 2 | 1 → 2 |
| 3 | 3 → 5 | 4 | no | 3 | 1 → 2 → 3 |
| 4 | 5 | 4 | yes: 4 < 5 | 4 | 1 → 2 → 3 → 4 |
| 5 | None | 5 | the loop ends; or attaches 5 | 1 → 2 → 3 → 4 → 5 |
Each pass moves one node, so merging n nodes takes O(n) time. The merge uses O(1) extra memory, and allocates only the dummy node.
Listing C.8 merge_k (lines 29 to 47). src/bin/merge_k_sorted_lists_owned.rs
/// Merge k sorted lists in place: round 1 merges lists 1 apart, round 2 lists
/// 2 apart, and so on. O(N log k) time, O(1) extra memory.
fn merge_k(mut lists: Vec<Link>) -> Link {
if lists.is_empty() {
return None;
}
let size = lists.len();
let mut gap = 1;
while gap < size {
let mut i = 0;
while i + gap < size {
lists[i] = merge_two(lists[i].take(), lists[i + gap].take());
i += gap * 2;
}
gap *= 2;
}
lists[0].take()
}
merge_k is the loop of listing C.1. lists[i].take() moves each list out of its slot and leaves None, so
merge_two receives two owned lists.
Animation C.4 runs the merge of the trace. The last part leaves out the line after the loop.
left always holds the next node. When one list runs out, or attaches the other. Without that line, the nodes left in right are dropped.$ cargo run --bin merge_k_sorted_lists_owned
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 1, 1, 3, 5]
[]
The complete program is listing C.23 at the end of the chapter.
C.5 The same merge on an enum node
Chapter 9 also wrote a list as an enum. Two versions try the merge on that shape.
C.5.1 An enum with its own take
The node can also be an enum, as in chapter 9. A list is either Empty, or a Node with a value and the rest of
the list. This version gives that enum a take method so the merge reads like the Option versions.
Listing C.9 The node type (lines 1 to 14). src/bin/merge_k_sorted_lists_enum.rs
use ListNode::{Empty, Node};
type LinkTo<T> = Box<T>;
enum ListNode {
Node { data: u32, next: LinkTo<ListNode> },
Empty,
}
impl ListNode {
fn take(&mut self) -> ListNode {
std::mem::replace(self, Empty)
}
}
use ListNode::{Empty, Node}; lets the code write Empty instead of ListNode::Empty. The enum gets its own
take, built on mem::replace, so it can be used like Option::take.
Listing C.10 The merge (lines 38 to 76).
impl MergeKSorted {
// ...
fn merge_two(left: &mut ListNode, right: &mut ListNode) -> ListNode {
let mut left = left.take();
let mut right = right.take();
let mut merged = Empty;
let mut tail = &mut merged;
loop {
let smaller = match (&left, &right) {
(Empty, _) => {
*tail = right;
break;
}
(_, Empty) => {
*tail = left;
break;
}
(Node { data: l, .. }, Node { data: r, .. }) => {
if l <= r {
&mut left
} else {
&mut right
}
}
};
if let Node { data, next } = smaller.take() {
*smaller = *next;
*tail = Node {
data,
next: LinkTo::new(Empty),
};
if let Node { next, .. } = tail {
tail = next;
}
}
}
merged
}
}
The loop picks smaller with a match on both fronts. The two Empty arms attach the other list and leave
the loop with break. The third arm returns a mutable reference to the list with the smaller front.
This representation has a cost. Look at how a node is attached:
*tail = Node { data, next: LinkTo::new(Empty) };
With Option<Box<Node>>, a node’s box moves from the input to the output. Here, *smaller = *next moves the
rest of the input out of its box, and that box is freed. The output node is a new Node with a new Box
holding a new Empty. So this version allocates once for every node it outputs.
The complete program is listing C.24 at the end of the chapter.
C.5.2 An enum with standard traits and a recursive merge
This version uses the same enum shape but derives Default, so the standard std::mem::take works on it. Its
two-list merge is recursive.
Listing C.11 The type and its conversions (lines 1 to 24). src/bin/merge_k_sorted_lists_enum_recursive.rs
pub type NodeLink = Box<ListNode>;
#[derive(Debug, PartialEq, Eq, Default)]
pub enum ListNode {
#[default]
Empty,
Node(i32, NodeLink),
}
impl From<Vec<i32>> for NodeLink {
fn from(vec: Vec<i32>) -> Self {
vec.into_iter()
.rev()
.fold(NodeLink::default(), |acc, v| {
NodeLink::new(ListNode::Node(v, acc))
})
}
}
impl From<Vec<i32>> for ListNode {
fn from(vec: Vec<i32>) -> Self {
*NodeLink::from(vec)
}
}
This version uses standard traits in place of hand-written helpers:
#[derive(Default)]with#[default]onEmptymakes the empty list the default value. SoNodeLink::default()is a box holdingEmpty, andstd::mem::takecan be used instead of a customtake.impl From<Vec<i32>> for NodeLinkbuilds a list from a vector. It walks the vector backward withrev()and usesfoldto wrap each value around the list built so far.maincalls it asvec![1, 4, 5].into().
Listing C.12 The interval loop and a recursive merge (lines 26 to 59).
impl ListNode {
pub fn merge_k_lists(mut lists: Vec<NodeLink>) -> NodeLink {
if lists.is_empty() {
return NodeLink::default();
}
let mut interval = 1;
while interval < lists.len() {
for i in (0..lists.len()).step_by(interval * 2) {
if i + interval < lists.len() {
let right = std::mem::take(&mut lists[i + interval]);
let left = std::mem::take(&mut lists[i]);
lists[i] = NodeLink::new(Self::merge(*left, *right));
}
}
interval *= 2;
}
lists.swap_remove(0)
}
fn merge(l1: ListNode, l2: ListNode) -> ListNode {
match (l1, l2) {
(ListNode::Empty, l) | (l, ListNode::Empty) => l,
(ListNode::Node(v1, n1), ListNode::Node(v2, n2)) => {
if v1 <= v2 {
let rest = Self::merge(*n1, ListNode::Node(v2, n2));
ListNode::Node(v1, NodeLink::new(rest))
} else {
let rest = Self::merge(ListNode::Node(v1, n1), *n2);
ListNode::Node(v2, NodeLink::new(rest))
}
}
}
}
}
std::mem::take(&mut lists[i + interval]) moves the right list out and leaves the default, an empty list, in
its place. It takes the right list before the left, which avoids the double borrow as listing C.3 did.
lists.swap_remove(0) removes position 0 by moving the last element into it, which takes O(1).
merge is recursive, and takes both lists as ListNode values. The caller moves them out of their boxes with *left and *right. merge keeps the smaller front node, merges the rest into rest, and boxes only the result. The list it did not take from passes to the recursive call unboxed. The code reads almost like a definition of merging. It has two costs. It allocates a new Box for every output node. And it recurses once per output node, so
a long result uses a deep stack, as chapter 9’s recursive drop did.
The complete program is listing C.25 at the end of the chapter.
The program prints the result with {:#?}, which shows the nesting of the enum directly:
$ cargo run --bin merge_k_sorted_lists_enum_recursive
Node(
1,
Node(
1,
Node(
2,
...
C.6 A queue of lists
This version does pairwise rounds with a queue instead of intervals. A VecDeque is a double-ended queue:
you can push and pop at both ends in O(1). merge_k_lists puts all the lists in the queue. Then it pops two from the front and pushes their merge on
the back, until one list is left.
This file declares its own copy of the node type, with Link as the alias for a whole list:
use std::collections::VecDeque;
struct ListNode {
data: u32,
next: Option<Box<ListNode>>,
}
type Link = Option<Box<ListNode>>;
Listing C.13 merge_k_lists (lines 10 to 18). src/bin/merge_k_sorted_lists_pairs.rs
fn merge_k_lists(lists: Vec<Link>) -> Link {
let mut queue: VecDeque<Link> = lists.into();
while queue.len() > 1 {
let a = queue.pop_front().unwrap();
let b = queue.pop_front().unwrap();
queue.push_back(merge_two(a, b));
}
queue.pop_front().flatten()
}
Because merged lists go to the back, every original list is merged once before any merged list is merged again. So the rounds are the same as in figure C.2, and the cost is O(N log k).
queue.pop_front().flatten() needs a word of explanation. pop_front returns Option<Link>, and a Link is
itself an Option<Box<ListNode>>. So the value is an Option<Option<...>>. flatten turns it into one
Option: None if the queue was empty, or the merged list.
$ cargo run --bin merge_k_sorted_lists_pairs
1 1 2 3 4 4 5 6
C.7 A heap of list heads
This is the third strategy from figure C.2. The heap does not hold nodes. It holds pairs of (front value, list number), wrapped in Reverse to make it a min-heap, as in chapter 5. Storing the list number instead of the
node keeps the nodes in their lists, and avoids needing an ordering on nodes.
Listing C.14 merge_k_lists (lines 10 to 33). src/bin/merge_k_sorted_lists_heap.rs
fn merge_k_lists(mut lists: Vec<Link>) -> Link {
let mut heap = BinaryHeap::new();
for (i, list) in lists.iter().enumerate() {
if let Some(node) = list {
heap.push(Reverse((node.data, i)));
}
}
let mut dummy = Box::new(ListNode {
data: 0,
next: None,
});
let mut tail = &mut dummy;
while let Some(Reverse((_, i))) = heap.pop() {
let mut node = lists[i].take().unwrap();
lists[i] = node.next.take();
if let Some(next) = &lists[i] {
heap.push(Reverse((next.data, i)));
}
tail = tail.next.insert(node);
}
dummy.next
}
Each pass of the loop:
- Pops the smallest pair. Its second field,
i, says which list holds that value. - Takes the front node out of
lists[i]and puts the rest of that list back. - If the list still has a node, pushes that node’s value with
i. - Attaches the taken node to the output with
tail.next.insert(node).
When two fronts have equal values, the pairs compare by list number, so the lower-numbered list goes first.
The heap needs only the current front of each list. That makes this strategy the right one when the lists are too large to hold in memory. An example is k sorted files read line by line.
C.8 Recursion on halves
The last two versions do pairwise merging from the top down. They split the lists into two halves, merge each half recursively, and merge the two results.
Listing C.15 The node type and a recursive two-list merge (lines 1 to 21). src/bin/merge_k_sorted_lists_recursion.rs
#[derive(Debug)]
struct ListNode {
val: i32,
next: Option<Box<ListNode>>,
}
type List = Option<Box<ListNode>>;
// Merge two sorted lists
fn merge(a: List, b: List) -> List {
match (a, b) {
(None, rest) | (rest, None) => rest,
(Some(mut a), Some(mut b)) => {
if a.val > b.val {
std::mem::swap(&mut a, &mut b); // a is now the smaller head
}
a.next = merge(a.next.take(), Some(b));
Some(a)
}
}
}
List is an alias for Option<Box<ListNode>>, so a whole list is one value that is None when empty.
The first arm, (None, rest) | (rest, None) => rest, handles an empty list on either side: the other list is
the answer. In the second arm, mem::swap makes a the list with the smaller front. Then a’s node stays at
the front, and its next becomes the merge of the rest of a with b. No node is allocated; the boxes move.
Listing C.16 Two ways to split the lists (lines 24 to 47).
fn merge_k(mut lists: Vec<List>) -> List {
match lists.len() {
0 => None,
1 => lists.pop().unwrap(),
n => {
let right = lists.split_off(n / 2); // lists = left half
merge(merge_k(lists), merge_k(right))
}
}
}
// Merge k sorted lists using divide and conquer without heap allocation
fn merge_k_slice(lists: &mut [List]) -> List {
match lists.len() {
0 => None,
1 => lists[0].take(),
_ => {
let mid = lists.len() / 2;
let (left, right) = lists.split_at_mut(mid);
merge(merge_k_slice(left), merge_k_slice(right))
}
}
}
merge_k splits the vector with split_off(n / 2), which moves the second half into a new Vec. That
allocates a new vector at every level of the recursion.
merge_k_slice works on a slice, &mut [List], and splits it with split_at_mut, the method from section
C.3. Splitting a slice only creates two views of the same memory, so this version allocates nothing to split.
Listing C.17 Building a list front to back (lines 49 to 68).
// Helper: build a list from a Vec, front to back
fn from_vec(v: Vec<i32>) -> List {
let mut head = None;
let mut tail = &mut head; // points to the empty slot at the end
for val in v {
let node = Box::new(ListNode { val, next: None });
tail = &mut tail.insert(node).next; // fill the slot, move to the new node's next
}
head
}
from_vec builds the list front to back, with a slot pointer like listing C.4. tail = &mut tail.insert(node).next fills the empty slot and moves tail to the new node’s next slot.
The complete program is listing C.28 at the end of the chapter.
main merges the sample lists with merge_k, tries three edge cases, then merges the same lists again
with merge_k_slice. Both versions give the same result.
$ cargo run --bin merge_k_sorted_lists_recursion
[1, 1, 2, 3, 4, 4, 5, 6]
[]
[]
[7]
[1, 1, 2, 3, 4, 4, 5, 6]
The last version keeps only the slice-based recursion:
Its node holds an i32 named val, and List is the alias for a whole list:
use std::mem;
#[derive(Debug)]
struct ListNode {
val: i32,
next: Option<Box<ListNode>>,
}
type List = Option<Box<ListNode>>;
Listing C.18 The merge and the recursion on a slice (lines 11 to 34). src/bin/merge_k_sorted_lists_halves.rs
fn merge(a: List, b: List) -> List {
match (a, b) {
(None, rest) | (rest, None) => rest,
(Some(mut left), Some(mut right)) => {
if left.val > right.val {
mem::swap(&mut left, &mut right);
}
left.next = merge(left.next.take(), Some(right));
Some(left)
}
}
}
fn merge_k_slice(lists: &mut [List]) -> List {
match lists.len() {
0 => None,
1 => lists[0].take(),
_ => {
let mid = lists.len() / 2;
let (left_half, right_half) = lists.split_at_mut(mid);
merge(merge_k_slice(left_half), merge_k_slice(right_half))
}
}
}
create_list builds front to back with the slot pointer, advancing it with if let Some(node) = tail.
print_list collects the values as strings and joins them with " -> ".
$ cargo run --bin merge_k_sorted_lists_halves
Input lists:
[1 -> 4 -> 5]
[1 -> 3 -> 4]
[2 -> 6]
Merged list:
[1 -> 1 -> 2 -> 3 -> 4 -> 4 -> 5 -> 6]
Both recursive merges recurse once for each node of their output. For short lists that is fine. For lists with hundreds of thousands of nodes, the iterative merges of sections C.3 and C.4 are safer.
C.9 The eleven versions side by side
In the table, N is the total number of nodes and k the number of lists. The extra memory column counts memory
beyond the input Vec and the nodes. The stack column is the deepest the call stack goes, in frames.
| Program | Strategy | Time | Extra memory | Stack | Allocations | How a merge moves nodes |
|---|---|---|---|---|---|---|
merge_k_sorted_lists_divide | interval rounds | O(N log k) | O(1) | O(1) | one dummy node per merge | split_at_mut, &mut tail |
merge_k_sorted_lists_swap | interval rounds | O(N log k) | O(1) | O(1) | one dummy node per merge | right list taken first, &mut tail |
merge_k_sorted_lists_simple | interval rounds | O(N log k) | O(1) | O(1) | none | owned lists, &mut tail slot |
merge_k_sorted_list_zero_copy | interval rounds | O(N log k) | O(1) | O(1) | one dummy node per merge | owned lists, Option::insert |
merge_k_sorted_lists_owned | interval rounds | O(N log k) | O(1) | O(1) | one dummy node per merge | lists swapped by value, so tail is the only &mut (section C.4.4) |
merge_k_sorted_lists_enum | interval rounds | O(N log k) | O(1) | O(1) | one box per node per round | custom take, &mut tail |
merge_k_sorted_lists_enum_recursive | interval rounds | O(N log k) | O(1) | O(N) | one box per node per round | recursive merge on owned values |
merge_k_sorted_lists_pairs | queue of lists | O(N log k) | O(1) | O(1) | one dummy node per merge | owned lists, Option::insert |
merge_k_sorted_lists_heap | heap of heads | O(N log k) | O(k) | O(1) | none | list numbers in the heap |
merge_k_sorted_lists_recursion | recursive halves | O(N log k) | O(k) | O(N) | a Vec per split | split_off, recursive merge |
merge_k_sorted_lists_halves | recursive halves | O(N log k) | O(1) | O(N) | none | split_at_mut, recursive merge |
- Time. Every version merges in rounds or halves, so each node moves about log2 k times: O(N log k). The “one at a time” strategy of figure C.2 would be O(kN). No version here uses it.
- Extra memory. The heap holds one entry per list: O(k).
split_offmakes a newVecfor each right half, so the recursion holds O(k) of them. The queue reuses the inputVec’s buffer:VecDeque::fromaVecdoes not reallocate. - Stack. A recursive merge makes one call per node of its output. The last merge outputs all N nodes, so the stack reaches O(N) frames. In section 9.6, a recursive drop overflowed the 8 MiB main-thread stack between 260,000 and 270,000 nodes.
- Allocations. A dummy node costs one allocation per merge, k - 1 in total. The enum versions free a box and allocate a new one for every node they move, about N log k in all. The tail-slot version allocates nothing.
C.10 The complete files
Each file below is shown whole, in the order the chapter used it.
Listing C.19 First version: split_at_mut and a dummy head. src/bin/merge_k_sorted_lists_divide.rs
struct MergeKSorted {}
#[derive(Default)]
struct ListNode {
data: u32,
next: Option<Box<ListNode>>,
}
impl ListNode {
fn new(data: u32) -> Self {
Self { data, next: None }
}
}
type OptionalLink<T> = Option<Box<T>>;
impl MergeKSorted {
pub fn merge_k_lists(mut lists: Vec<OptionalLink<ListNode>>) -> OptionalLink<ListNode> {
if lists.is_empty() {
return None;
}
let (n, mut interval) = (lists.len(), 1);
while interval < n {
let mut i = 0;
while i + interval < n {
let (left, right) = lists.split_at_mut(i + interval);
left[i] = Self::merge_two(&mut left[i], &mut right[0]);
i += interval * 2;
}
interval *= 2;
}
lists[0].take()
}
fn merge_two(
left: &mut OptionalLink<ListNode>,
right: &mut OptionalLink<ListNode>,
) -> OptionalLink<ListNode> {
let mut dummy = Box::new(ListNode::new(0));
let mut tail = &mut dummy;
while let (Some(l), Some(r)) = (left.as_ref(), right.as_ref()) {
let smaller = if l.data < r.data {
&mut *left
} else {
&mut *right
};
let mut head = smaller.take().unwrap();
*smaller = head.next.take();
tail.next = Some(head);
tail = tail.next.as_mut().unwrap();
}
tail.next = left.take().or(right.take());
dummy.next
}
}
/// Build a list from a slice, front to back.
fn from_slice(values: &[u32]) -> OptionalLink<ListNode> {
let mut head = None;
for &value in values.iter().rev() {
let mut node = Box::new(ListNode::new(value));
node.next = head;
head = Some(node);
}
head
}
/// Collect a list's values into a vector.
fn to_vec(mut list: &OptionalLink<ListNode>) -> Vec<u32> {
let mut values = Vec::new();
while let Some(node) = list {
values.push(node.data);
list = &node.next;
}
values
}
fn main() {
let lists = vec![
from_slice(&[1, 4, 5]),
from_slice(&[1, 3, 4]),
from_slice(&[2, 6]),
];
let merged = MergeKSorted::merge_k_lists(lists);
println!("{:?}", to_vec(&merged));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_three_lists() {
let lists = vec![
from_slice(&[1, 4, 5]),
from_slice(&[1, 3, 4]),
from_slice(&[2, 6]),
];
let merged = MergeKSorted::merge_k_lists(lists);
assert_eq!(to_vec(&merged), vec![1, 1, 2, 3, 4, 4, 5, 6]);
}
#[test]
fn handles_empty_input_and_empty_lists() {
assert!(MergeKSorted::merge_k_lists(vec![]).is_none());
let merged = MergeKSorted::merge_k_lists(vec![None, from_slice(&[2]), None]);
assert_eq!(to_vec(&merged), vec![2]);
}
#[test]
fn handles_an_odd_number_of_lists() {
let lists: Vec<_> = (0..5)
.map(|i| from_slice(&[i, i + 10]))
.collect();
let merged = MergeKSorted::merge_k_lists(lists);
assert_eq!(to_vec(&merged), vec![0, 1, 2, 3, 4, 10, 11, 12, 13, 14]);
}
}
Listing C.20 Take the right list out first. src/bin/merge_k_sorted_lists_swap.rs
struct MergeKSorted {}
#[derive(Default)]
struct ListNode {
data: u32,
next: Option<Box<ListNode>>,
}
impl ListNode {
fn new(data: u32) -> Self {
Self { data, next: None }
}
}
type OptionalLink<T> = Option<Box<T>>;
impl MergeKSorted {
pub fn merge_k_lists(mut lists: Vec<OptionalLink<ListNode>>) -> OptionalLink<ListNode> {
if lists.is_empty() {
return None;
}
let (n, mut interval) = (lists.len(), 1);
while interval < n {
let mut i = 0;
while i + interval < n {
let mut right = lists[i + interval].take();
lists[i] = Self::merge_two(&mut lists[i], &mut right);
i += interval * 2;
}
interval *= 2;
}
lists[0].take()
}
fn merge_two(
left: &mut OptionalLink<ListNode>,
right: &mut OptionalLink<ListNode>,
) -> OptionalLink<ListNode> {
let mut dummy = Box::new(ListNode::new(0));
let mut tail = &mut dummy;
while let (Some(l), Some(r)) = (&left, &right) {
if r.data < l.data {
std::mem::swap(left, right);
}
let mut smallest = left.take().unwrap();
*left = smallest.next.take();
tail.next = Some(smallest);
tail = tail.next.as_mut().unwrap();
}
tail.next = left.take().or(right.take());
dummy.next
}
}
/// Build a list from a slice, front to back.
fn from_slice(values: &[u32]) -> OptionalLink<ListNode> {
let mut head = None;
for &value in values.iter().rev() {
let mut node = Box::new(ListNode::new(value));
node.next = head;
head = Some(node);
}
head
}
/// Collect a list's values into a vector.
fn to_vec(mut list: &OptionalLink<ListNode>) -> Vec<u32> {
let mut values = Vec::new();
while let Some(node) = list {
values.push(node.data);
list = &node.next;
}
values
}
fn main() {
let lists = vec![
from_slice(&[1, 4, 5]),
from_slice(&[1, 3, 4]),
from_slice(&[2, 6]),
];
let merged = MergeKSorted::merge_k_lists(lists);
println!("{:?}", to_vec(&merged));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_three_lists() {
let lists = vec![
from_slice(&[1, 4, 5]),
from_slice(&[1, 3, 4]),
from_slice(&[2, 6]),
];
let merged = MergeKSorted::merge_k_lists(lists);
assert_eq!(to_vec(&merged), vec![1, 1, 2, 3, 4, 4, 5, 6]);
}
#[test]
fn handles_empty_input_and_empty_lists() {
assert!(MergeKSorted::merge_k_lists(vec![]).is_none());
let merged = MergeKSorted::merge_k_lists(vec![None, from_slice(&[2]), None]);
assert_eq!(to_vec(&merged), vec![2]);
}
#[test]
fn handles_an_odd_number_of_lists() {
let lists: Vec<_> = (0..5)
.map(|i| from_slice(&[i, i + 10]))
.collect();
let merged = MergeKSorted::merge_k_lists(lists);
assert_eq!(to_vec(&merged), vec![0, 1, 2, 3, 4, 10, 11, 12, 13, 14]);
}
}
Listing C.21 Owned lists, no dummy node. src/bin/merge_k_sorted_lists_simple.rs
struct ListNode {
data: u32,
next: Link,
}
type Link = Option<Box<ListNode>>;
fn merge_k_lists(mut lists: Vec<Link>) -> Link {
if lists.is_empty() {
return None;
}
let (n, mut interval) = (lists.len(), 1);
while interval < n {
let mut i = 0;
while i + interval < n {
let left = lists[i].take();
let right = lists[i + interval].take();
lists[i] = merge_two(left, right);
i += interval * 2;
}
interval *= 2;
}
lists[0].take()
}
fn merge_two(mut left: Link, mut right: Link) -> Link {
let mut merged = None;
let mut tail = &mut merged;
while let (Some(l), Some(r)) = (&left, &right) {
if r.data < l.data {
std::mem::swap(&mut left, &mut right);
}
*tail = left; // hang left list on the tail
tail = &mut tail.as_mut().unwrap().next; // step past its first node
left = tail.take(); // cut the rest off again
}
*tail = left.or(right);
merged
}
fn from_slice(values: &[u32]) -> Link {
let mut head = None;
for &data in values.iter().rev() {
head = Some(Box::new(ListNode { data, next: head }));
}
head
}
fn to_vec(mut list: &Link) -> Vec<u32> {
let mut values = Vec::new();
while let Some(node) = list {
values.push(node.data);
list = &node.next;
}
values
}
fn main() {
let lists = vec![
from_slice(&[1, 4, 5]),
from_slice(&[1, 3, 4]),
from_slice(&[2, 6]),
];
println!("{:?}", to_vec(&merge_k_lists(lists)));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_three_lists() {
let lists = vec![
from_slice(&[1, 4, 5]),
from_slice(&[1, 3, 4]),
from_slice(&[2, 6]),
];
assert_eq!(to_vec(&merge_k_lists(lists)), vec![1, 1, 2, 3, 4, 4, 5, 6]);
}
#[test]
fn handles_empty_input_and_empty_lists() {
assert!(merge_k_lists(vec![]).is_none());
let merged = merge_k_lists(vec![None, from_slice(&[2]), None]);
assert_eq!(to_vec(&merged), vec![2]);
}
#[test]
fn handles_an_odd_number_of_lists() {
let lists: Vec<_> = (0..5)
.map(|i| from_slice(&[i, i + 10]))
.collect();
assert_eq!(
to_vec(&merge_k_lists(lists)),
vec![0, 1, 2, 3, 4, 10, 11, 12, 13, 14]
);
}
}
Listing C.22 The shortest form, with Option::insert. src/bin/merge_k_sorted_list_zero_copy.rs
type NodeLink = Option<Box<Node>>;
struct Node {
val: i32,
next: NodeLink,
}
fn merge_two(mut left: NodeLink, mut right: NodeLink) -> NodeLink {
let mut dummy = Box::new(Node { val: 0, next: None });
let mut tail = &mut dummy;
while let (Some(left_head), Some(right_head)) = (&left, &right) {
let smaller = if left_head.val <= right_head.val {
&mut left
} else {
&mut right
};
let mut node = smaller.take().unwrap(); // safe: checked by while let
*smaller = node.next.take();
tail = tail.next.insert(node);
}
tail.next = if left.is_some() { left } else { right };
dummy.next
}
fn merge_k(mut lists: Vec<NodeLink>) -> NodeLink {
if lists.is_empty() {
return None;
}
let list_count = lists.len();
let mut gap = 1;
while gap < list_count {
let mut index = 0;
while index + gap < list_count {
let left = lists[index].take();
let right = lists[index + gap].take();
lists[index] = merge_two(left, right);
index += gap * 2;
}
gap *= 2;
}
lists[0].take()
}
fn build(values: &[i32]) -> NodeLink {
let mut head = None;
for &val in values.iter().rev() {
head = Some(Box::new(Node { val, next: head }));
}
head
}
fn print(mut current: &NodeLink) {
while let Some(node) = current {
print!("{} ", node.val);
current = &node.next;
}
println!();
}
fn main() {
let lists = vec![
build(&[1, 4, 7]),
build(&[2, 5, 8]),
build(&[0, 3, 6, 9]),
None,
];
print(&merge_k(lists)); // 0 1 2 3 4 5 6 7 8 9
print(&merge_k(vec![build(&[1, 1, 5]), build(&[1, 3])])); // 1 1 1 3 5
print(&merge_k(vec![])); // (empty line)
let five = vec![
build(&[9]),
build(&[1, 5]),
build(&[3]),
build(&[2, 8]),
build(&[4]),
];
print(&merge_k(five)); // 1 2 3 4 5 8 9
}
Listing C.23 The lists swapped by value. src/bin/merge_k_sorted_lists_owned.rs
//! Merge k sorted lists: the gap loop, and a merge that swaps the two lists by
//! value so that `left` always holds the smaller front node.
type Link = Option<Box<Node>>;
struct Node {
val: i32,
next: Link,
}
/// Merge two sorted lists. O(n) time, O(1) extra memory.
fn merge_two(mut left: Link, mut right: Link) -> Link {
let mut dummy = Box::new(Node { val: 0, next: None });
let mut tail = &mut dummy;
while let (Some(l), Some(r)) = (&left, &right) {
if r.val < l.val {
(left, right) = (right, left); // left now holds the smaller front
}
let mut node = left.unwrap(); // safe: checked by while let
left = node.next.take();
tail = tail.next.insert(node);
}
tail.next = left.or(right);
dummy.next
}
/// Merge k sorted lists in place: round 1 merges lists 1 apart, round 2 lists
/// 2 apart, and so on. O(N log k) time, O(1) extra memory.
fn merge_k(mut lists: Vec<Link>) -> Link {
if lists.is_empty() {
return None;
}
let size = lists.len();
let mut gap = 1;
while gap < size {
let mut i = 0;
while i + gap < size {
lists[i] = merge_two(lists[i].take(), lists[i + gap].take());
i += gap * 2;
}
gap *= 2;
}
lists[0].take()
}
fn build(values: &[i32]) -> Link {
let mut head = None;
for &val in values.iter().rev() {
head = Some(Box::new(Node { val, next: head }));
}
head
}
fn to_vec(mut current: Link) -> Vec<i32> {
let mut out = Vec::new();
while let Some(node) = current {
out.push(node.val);
current = node.next;
}
out
}
fn main() {
let lists = vec![
build(&[1, 4, 7]),
build(&[2, 5, 8]),
build(&[0, 3, 6, 9]),
None,
];
println!("{:?}", to_vec(merge_k(lists))); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
println!(
"{:?}",
to_vec(merge_k(vec![build(&[1, 1, 5]), build(&[1, 3])]))
); // [1, 1, 1, 3, 5]
println!("{:?}", to_vec(merge_k(vec![]))); // []
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_two_lists_keeping_duplicates() {
let merged = merge_two(build(&[1, 1, 5]), build(&[1, 3]));
assert_eq!(to_vec(merged), vec![1, 1, 1, 3, 5]);
}
#[test]
fn merges_with_empty_lists() {
assert_eq!(to_vec(merge_two(None, build(&[2, 4]))), vec![2, 4]);
assert_eq!(to_vec(merge_two(build(&[2, 4]), None)), vec![2, 4]);
assert!(merge_two(None, None).is_none());
assert!(merge_k(vec![]).is_none());
assert!(merge_k(vec![None, None]).is_none());
}
#[test]
fn merges_an_odd_number_of_lists() {
let five = vec![
build(&[9]),
build(&[1, 5]),
build(&[3]),
build(&[2, 8]),
build(&[4]),
];
assert_eq!(to_vec(merge_k(five)), vec![1, 2, 3, 4, 5, 8, 9]);
}
#[test]
fn merges_many_long_lists() {
let lists: Vec<Link> = (0..7)
.map(|k| {
build(
&(0..100)
.map(|i| i * 7 + k)
.collect::<Vec<_>>(),
)
})
.collect();
let expected: Vec<i32> = (0..700).collect();
assert_eq!(to_vec(merge_k(lists)), expected);
}
}
Listing C.24 An enum node with its own take. src/bin/merge_k_sorted_lists_enum.rs
use ListNode::{Empty, Node};
type LinkTo<T> = Box<T>;
enum ListNode {
Node { data: u32, next: LinkTo<ListNode> },
Empty,
}
impl ListNode {
fn take(&mut self) -> ListNode {
std::mem::replace(self, Empty)
}
}
struct MergeKSorted {}
impl MergeKSorted {
pub fn merge_k_lists(mut lists: Vec<ListNode>) -> ListNode {
if lists.is_empty() {
return Empty;
}
let (n, mut interval) = (lists.len(), 1);
while interval < n {
let mut i = 0;
while i + interval < n {
let (left, right) = lists.split_at_mut(i + interval);
left[i] = Self::merge_two(&mut left[i], &mut right[0]);
i += interval * 2;
}
interval *= 2;
}
lists[0].take()
}
fn merge_two(left: &mut ListNode, right: &mut ListNode) -> ListNode {
let mut left = left.take();
let mut right = right.take();
let mut merged = Empty;
let mut tail = &mut merged;
loop {
let smaller = match (&left, &right) {
(Empty, _) => {
*tail = right;
break;
}
(_, Empty) => {
*tail = left;
break;
}
(Node { data: l, .. }, Node { data: r, .. }) => {
if l <= r {
&mut left
} else {
&mut right
}
}
};
if let Node { data, next } = smaller.take() {
*smaller = *next;
*tail = Node {
data,
next: LinkTo::new(Empty),
};
if let Node { next, .. } = tail {
tail = next;
}
}
}
merged
}
}
fn main() {
let mut lists = Vec::new();
for values in [vec![1, 4, 5], vec![1, 3, 4], vec![2, 6]] {
let mut head = Empty;
for data in values.into_iter().rev() {
head = Node {
data,
next: LinkTo::new(head),
};
}
lists.push(head);
}
let merged = MergeKSorted::merge_k_lists(lists);
let mut current = &merged;
while let Node { data, next } = current {
print!("{data} ");
current = next;
}
println!();
}
Listing C.25 An enum with standard traits and a recursive merge. src/bin/merge_k_sorted_lists_enum_recursive.rs
pub type NodeLink = Box<ListNode>;
#[derive(Debug, PartialEq, Eq, Default)]
pub enum ListNode {
#[default]
Empty,
Node(i32, NodeLink),
}
impl From<Vec<i32>> for NodeLink {
fn from(vec: Vec<i32>) -> Self {
vec.into_iter()
.rev()
.fold(NodeLink::default(), |acc, v| {
NodeLink::new(ListNode::Node(v, acc))
})
}
}
impl From<Vec<i32>> for ListNode {
fn from(vec: Vec<i32>) -> Self {
*NodeLink::from(vec)
}
}
impl ListNode {
pub fn merge_k_lists(mut lists: Vec<NodeLink>) -> NodeLink {
if lists.is_empty() {
return NodeLink::default();
}
let mut interval = 1;
while interval < lists.len() {
for i in (0..lists.len()).step_by(interval * 2) {
if i + interval < lists.len() {
let right = std::mem::take(&mut lists[i + interval]);
let left = std::mem::take(&mut lists[i]);
lists[i] = NodeLink::new(Self::merge(*left, *right));
}
}
interval *= 2;
}
lists.swap_remove(0)
}
fn merge(l1: ListNode, l2: ListNode) -> ListNode {
match (l1, l2) {
(ListNode::Empty, l) | (l, ListNode::Empty) => l,
(ListNode::Node(v1, n1), ListNode::Node(v2, n2)) => {
if v1 <= v2 {
let rest = Self::merge(*n1, ListNode::Node(v2, n2));
ListNode::Node(v1, NodeLink::new(rest))
} else {
let rest = Self::merge(ListNode::Node(v1, n1), *n2);
ListNode::Node(v2, NodeLink::new(rest))
}
}
}
}
}
fn main() {
let lists: Vec<NodeLink> = vec![
vec![1, 4, 5].into(),
vec![1, 3, 4].into(),
vec![2, 6].into(),
];
let merged = ListNode::merge_k_lists(lists);
println!("{:#?}", merged);
}
Listing C.26 A queue of lists. src/bin/merge_k_sorted_lists_pairs.rs
use std::collections::VecDeque;
struct ListNode {
data: u32,
next: Option<Box<ListNode>>,
}
type Link = Option<Box<ListNode>>;
fn merge_k_lists(lists: Vec<Link>) -> Link {
let mut queue: VecDeque<Link> = lists.into();
while queue.len() > 1 {
let a = queue.pop_front().unwrap();
let b = queue.pop_front().unwrap();
queue.push_back(merge_two(a, b));
}
queue.pop_front().flatten()
}
fn merge_two(mut a: Link, mut b: Link) -> Link {
let mut dummy = Box::new(ListNode {
data: 0,
next: None,
});
let mut tail = &mut dummy;
while let (Some(x), Some(y)) = (&a, &b) {
let pick = if x.data <= y.data { &mut a } else { &mut b };
let mut node = pick.take().unwrap();
*pick = node.next.take();
tail = tail.next.insert(node);
}
tail.next = a.or(b);
dummy.next
}
fn main() {
let mut lists = Vec::new();
for values in [vec![1, 4, 5], vec![1, 3, 4], vec![2, 6]] {
let mut head = None;
for data in values.into_iter().rev() {
head = Some(Box::new(ListNode { data, next: head }));
}
lists.push(head);
}
let mut cur = merge_k_lists(lists);
while let Some(node) = cur {
print!("{} ", node.data);
cur = node.next;
}
println!();
}
Listing C.27 A heap of list heads. src/bin/merge_k_sorted_lists_heap.rs
use std::{cmp::Reverse, collections::BinaryHeap};
struct ListNode {
data: u32,
next: Option<Box<ListNode>>,
}
type Link = Option<Box<ListNode>>;
fn merge_k_lists(mut lists: Vec<Link>) -> Link {
let mut heap = BinaryHeap::new();
for (i, list) in lists.iter().enumerate() {
if let Some(node) = list {
heap.push(Reverse((node.data, i)));
}
}
let mut dummy = Box::new(ListNode {
data: 0,
next: None,
});
let mut tail = &mut dummy;
while let Some(Reverse((_, i))) = heap.pop() {
let mut node = lists[i].take().unwrap();
lists[i] = node.next.take();
if let Some(next) = &lists[i] {
heap.push(Reverse((next.data, i)));
}
tail = tail.next.insert(node);
}
dummy.next
}
fn main() {
let mut lists = Vec::new();
for values in [vec![1, 4, 5], vec![1, 3, 4], vec![2, 6]] {
let mut head = None;
for data in values.into_iter().rev() {
head = Some(Box::new(ListNode { data, next: head }));
}
lists.push(head);
}
let mut cur = merge_k_lists(lists);
while let Some(node) = cur {
print!("{} ", node.data);
cur = node.next;
}
println!();
}
Listing C.28 Recursion on halves, two ways to split. src/bin/merge_k_sorted_lists_recursion.rs
#[derive(Debug)]
struct ListNode {
val: i32,
next: Option<Box<ListNode>>,
}
type List = Option<Box<ListNode>>;
// Merge two sorted lists
fn merge(a: List, b: List) -> List {
match (a, b) {
(None, rest) | (rest, None) => rest,
(Some(mut a), Some(mut b)) => {
if a.val > b.val {
std::mem::swap(&mut a, &mut b); // a is now the smaller head
}
a.next = merge(a.next.take(), Some(b));
Some(a)
}
}
}
// Merge k sorted lists using divide and conquer
fn merge_k(mut lists: Vec<List>) -> List {
match lists.len() {
0 => None,
1 => lists.pop().unwrap(),
n => {
let right = lists.split_off(n / 2); // lists = left half
merge(merge_k(lists), merge_k(right))
}
}
}
// Merge k sorted lists using divide and conquer without heap allocation
fn merge_k_slice(lists: &mut [List]) -> List {
match lists.len() {
0 => None,
1 => lists[0].take(),
_ => {
let mid = lists.len() / 2;
let (left, right) = lists.split_at_mut(mid);
merge(merge_k_slice(left), merge_k_slice(right))
}
}
}
// Helper: build a list from a Vec, front to back
fn from_vec(v: Vec<i32>) -> List {
let mut head = None;
let mut tail = &mut head; // points to the empty slot at the end
for val in v {
let node = Box::new(ListNode { val, next: None });
tail = &mut tail.insert(node).next; // fill the slot, move to the new node's next
}
head
}
// Helper: turn a list back into a Vec for printing
fn to_vec(mut cur: &List) -> Vec<i32> {
let mut out = Vec::new();
while let Some(node) = cur {
out.push(node.val);
cur = &node.next;
}
out
}
fn main() {
let lists = vec![
from_vec(vec![1, 4, 5]),
from_vec(vec![1, 3, 4]),
from_vec(vec![2, 6]),
];
let merged = merge_k(lists);
println!("{:?}", to_vec(&merged)); // [1, 1, 2, 3, 4, 4, 5, 6]
// Edge cases
println!("{:?}", to_vec(&merge_k(vec![]))); // []
println!("{:?}", to_vec(&merge_k(vec![None, None]))); // []
println!("{:?}", to_vec(&merge_k(vec![from_vec(vec![7])]))); // [7]
// The same lists, merged by splitting a slice instead of the vector
let mut lists = [
from_vec(vec![1, 4, 5]),
from_vec(vec![1, 3, 4]),
from_vec(vec![2, 6]),
];
println!("{:?}", to_vec(&merge_k_slice(&mut lists))); // [1, 1, 2, 3, 4, 4, 5, 6]
}
Listing C.29 Recursion on a slice. src/bin/merge_k_sorted_lists_halves.rs
use std::mem;
#[derive(Debug)]
struct ListNode {
val: i32,
next: Option<Box<ListNode>>,
}
type List = Option<Box<ListNode>>;
fn merge(a: List, b: List) -> List {
match (a, b) {
(None, rest) | (rest, None) => rest,
(Some(mut left), Some(mut right)) => {
if left.val > right.val {
mem::swap(&mut left, &mut right);
}
left.next = merge(left.next.take(), Some(right));
Some(left)
}
}
}
fn merge_k_slice(lists: &mut [List]) -> List {
match lists.len() {
0 => None,
1 => lists[0].take(),
_ => {
let mid = lists.len() / 2;
let (left_half, right_half) = lists.split_at_mut(mid);
merge(merge_k_slice(left_half), merge_k_slice(right_half))
}
}
}
fn create_list(arr: &[i32]) -> List {
let mut head = None;
let mut tail = &mut head;
for &val in arr {
*tail = Some(Box::new(ListNode { val, next: None }));
if let Some(node) = tail {
tail = &mut node.next;
}
}
head
}
fn print_list(mut list: &List) {
let mut elements = Vec::new();
while let Some(node) = list {
elements.push(node.val.to_string());
list = &node.next;
}
println!("[{}]", elements.join(" -> "));
}
fn main() {
let mut lists = vec![
create_list(&[1, 4, 5]),
create_list(&[1, 3, 4]),
create_list(&[2, 6]),
];
println!("Input lists:");
for list in &lists {
print_list(list);
}
let merged = merge_k_slice(&mut lists);
println!("\nMerged list:");
print_list(&merged);
}
Summary
- Merging two sorted lists moves the smaller front node to the output until one list is empty, in O(a + b).
- Merging k lists one at a time costs O(kN). Pairwise rounds and a heap of heads both cost O(N log k).
- Two
&mutborrows of elements of oneVecdo not compile.split_at_mutsplits it into two slices, or you can move one list out withtake()first. - A dummy head and a tail pointer attach nodes to the end of a list.
Option::insertattaches and advances in one call. - With
Option<Box<Node>>, merging moves boxes and allocates nothing. The enum versions allocate a new box for every node. - A heap of
(value, list number)pairs needs only the front of each list, which suits lists read from disk. - Recursive merges are short but recurse once per output node.
Exercises
- Write the “one at a time” merge and time it against listing C.22 on 200 lists of 5,000 nodes each.
- Make
merge_twofrom listing C.5 generic over anyT: Ord. - Write a k-way merge of sorted text files with the heap strategy, keeping only one line per file in memory.
- Count allocations in listings C.9 and C.12 with a custom global allocator that increments a counter, and confirm the difference described in section C.5.1.