Concurrent programs — channels, locks, scopes
Lesson 16 told the compiler which values may cross, or be shared between, threads, and thread::spawn's bounds turned a data race into a compile error. But the first program most people write — a worker thread reading a log the main thread owns — is rejected. This lesson derives what to write instead: data a second thread touches is moved to it, shared under a lock that makes exclusive access take turns, or lent for a region that provably ends first — a channel, a Mutex, a scoped thread, and one atomic counter. Then it checks those claims on every interleaving and lists what the promise still lets go wrong, deadlock first.
New idea: for each piece of data a second thread touches, choose who may reach it while the threads run: move it (a channel), share it with exclusion in time (a lock), or lend it for a region the owner provably outlives (a scope). The promise covers memory, not progress: deadlock, lost updates without a data race, leaks and poisoned locks remain.
Forces next: Every thread costs a stack and a scheduler slot; ten thousand idle connections would need ten thousand threads. How can many tasks wait at once without a thread each, and without the language shipping a runtime?
1 · One log, one worker, and the three answers
The smallest concurrent program worth writing: main holds a log, a worker thread counts its error lines, and main prints the count beside the log's length.
use std::thread;
fn main() {
let lines = vec!["ok", "error: disk", "error: net"];
let worker = thread::spawn(|| { // borrows `lines`
lines.iter().filter(|l| l.starts_with("error")).count()
});
println!("{} of {} lines", worker.join().unwrap(), lines.len());
}
error[E0373]: closure may outlive the current function, but it borrows `lines`, which is owned by the current function
--> src/main.rs:5:32
|
5 | let worker = thread::spawn(|| { // borrows `lines`
| ^^ may outlive borrowed value `lines`
6 | lines.iter().filter(|l| l.starts_with("error")).count()
| ----- `lines` is borrowed here
|
note: function requires argument type to outlive `'static`
help: to force the closure to take ownership of `lines` (and any other referenced variables), use the `move` keyword
This program joins the worker before lines ends, but spawn's signature does not promise it: dropping a JoinHandle detaches the thread, and the process ends when main returns, whatever other threads are doing. Accept the borrow, and the same program with one early return — or one panic before the join — drops the log while the worker may still read it: a use-after-free whose second name lives on another thread. Hence spawn's bound F: Send + 'static (Lesson 16): a thread that may outlive its spawner may hold only what nothing else will end. C++ accepts the shape, race included:
std::vector<std::string> lines = load_log();
int errors = 0;
std::thread worker([&] { errors += count_errors(lines); }); // captures by reference
errors += 1; // the same int, meanwhile: a data race, undefined
worker.detach(); // nobody waits now: lines may die first
spawn wants 'static, Lesson 08's "holds no borrow of anything that ends". What the message cannot know is whether main still needs lines. If not, move is the right design: the worker becomes the owner. Here it does, so the suggested edit yields a second error, whose help is to clone the whole log:
use std::thread;
fn main() {
let lines = vec!["ok", "error: disk", "error: net"];
let worker = thread::spawn(move || { // the suggested fix
lines.iter().filter(|l| l.starts_with("error")).count()
});
println!("{} of {} lines", worker.join().unwrap(), lines.len());
}
error[E0382]: borrow of moved value: `lines`
help: consider cloning the value before moving it into the closure
Each suggestion makes one line type-check; neither asks the design question — who should reach the log while the worker runs: the worker alone (§2), both through a shared owner (§3), or main, lending it (§4)?Why those three? Lesson 07's checker compares regions by walking one function in order. A second thread's steps happen when the scheduler says, so there is no order to walk, and the compiler can accept only arrangements safe in every order. Lesson 00's second question — who else can reach it, and for how long? — has three such answers under the law (many readers or one writer, never both):
| While the threads run, the data is reached by… | Regime | Rust's tool | Checked | Price |
|---|---|---|---|---|
| one thread at a time; the sender loses its name | no alias at all | a channel (§2) | compile time: the move rule | a queue operation per message |
| several threads, some writing | exclusive, taking turns | Arc<Mutex<T>> (§3) | run time: the lock | lock, unlock, and waiting |
| threads that end before the owner's region does | ordinary borrows | thread::scope (§4) | compile time: regions | the parent waits for them |
Read-only sharing needs none of them: many readers is the law's first regime, and an Arc<T> with T: Send + Sync (Lessons 15–16) suffices. The first row is the cheapest proof — a value with one name is in nobody's way — so start there: we need to hand values to another thread, one after another, and lose them.
2 · Move: a channel is the move rule across threads
A channel is a queue with two ends: mpsc::channel() returns a Sender, which can be cloned so several threads produce, and a Receiver, which cannot; messages arrive in order and send never waits for space. The signature carries the proof: send(&self, t: T) takes the message by value, so the sender's name is dead afterwards — Lesson 04's rule, across threads. Were the last line below accepted, the receiver could be changing or dropping the String while the sender prints it:
use std::sync::mpsc;
fn main() {
let (tx, _rx) = mpsc::channel();
let report = String::from("error: disk");
tx.send(report).unwrap(); // ownership moves into the channel
println!("sent {report}"); // ...so this name is dead
}
The log program as a pipeline: each worker owns half the log, and each error line — the String itself — moves from worker to channel to main:
use std::sync::mpsc;
use std::thread;
fn main() {
let mut front: Vec<String> = ["ok", "error: disk", "error: net", "ok"].map(String::from).into();
let back = front.split_off(2); // two owners now; no String was copied
let (tx, rx) = mpsc::channel();
for chunk in [front, back] {
let tx = tx.clone(); // each worker gets its own Sender
thread::spawn(move || { // ...and owns its chunk outright
for line in chunk {
if line.starts_with("error") {
tx.send(line).unwrap(); // the String itself moves on
}
}
}); // the worker's Sender is dropped here
}
drop(tx); // and main's own, or the loop never ends
let mut got: Vec<String> = rx.iter().collect();
got.sort();
println!("{got:?}");
}
["error: disk", "error: net"]
No String ever has names on two threads, so the law has nothing to forbid; the compiler checks only that messages are Send (a Sender of Rcs moved into a thread is E0277). The loop ends when recv fails, once every Sender is dropped and the queue is empty — hence drop(tx): without it main's own Sender keeps the channel open and main waits forever, a hang the compiler cannot see (§7).
The Go track's Lesson 09 teaches the same slogan — share memory by communicating, which the Rust Book credits to Go — and notes that ownership there is by convention: send a pointer and you can still race. Here it is a proof, priced at a queue operation per message; a String moves its header (Lesson 04), not its buffer. The shape stops fitting when every thread must update the same tally.
3 · Share: a lock makes exclusive access take turns
The law still allows one writer at a time, so writers must take turns. Arc supplies the shared owners (Lesson 15), Mutex the exclusion: lock() waits until no other thread holds the mutex, then returns a guard, MutexGuard<T> (inside a Result, for a reason §7 gives), which is the one exclusive name for the data while it lives, derefs to T, and unlocks when dropped — Lesson 15's RefCell guard, waiting where that one panics.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let found = Arc::new(Mutex::new(Vec::new())); // one Vec, reachable only through the lock
let mut workers = Vec::new();
for chunk in [["ok", "error: disk"], ["error: net", "ok"]] {
let found = Arc::clone(&found); // one more owner of the same Mutex
workers.push(thread::spawn(move || {
for line in chunk {
if line.starts_with("error") {
found.lock().unwrap().push(line); // lock, push, unlock at the semicolon
}
}
}));
}
for w in workers { w.join().unwrap(); }
let mut all = found.lock().unwrap().clone();
all.sort();
println!("{all:?}");
}
["error: disk", "error: net"]
The decisive choice is where the data lives: inside the mutex. No path to it skips lock(), so forgetting to lock is not a bug you can write:
use std::sync::Mutex;
fn main() {
let total = Mutex::new(0);
*total += 1; // no lock taken: is there a path to the number?
}
In C++ a std::mutex sits beside what it guards, paired by a comment (C++ 17); Go pairs lock and fields "by convention and comment" (Go 11). Rust makes the comment the type, and Lesson 16's rules keep it honest: Mutex<T> is Sync when T: Send, while MutexGuard is not Send, so the thread that locks is the one that unlocks. RwLock is the law itself at run time — many readers or one writer — and also needs T: Sync, since its readers really share.
The price: a lock and an unlock per access, and waiting — a lock works by removing interleavings, which §5 counts. Both shapes cut the data loose from main's frame. But the log already lives in main, and the worker only needs to look at it for a while. Why can a thread not borrow it?
4 · Lend: a scope that joins before it returns
Lesson 06: a borrow is sound if its region — its lifetime, in the source's word — fits inside the owner's. spawn cannot say where a thread's region ends; that is all its 'static means. So change the API until it can: thread::scope (stable since Rust 1.63) calls your closure with a scope handle and, before returning, joins every thread spawned through it that you did not join yourself. Its spawn asks only for 'scope, and what the threads borrow must outlive the scope call, so every borrow fits inside its owner's region. The borrows are ordinary, and so is the law: two scoped threads adding to one counter — a data race, if accepted — are rejected with the E0499 of two &mut borrows (Lesson 01 compiled exactly that). Give each thread its own slot, and no Arc, lock or copy of the log is needed:
use std::thread;
fn main() {
let lines = vec!["ok", "error: disk", "error: net", "ok"];
let mut counts = [0; 2]; // one slot per worker
thread::scope(|s| {
for (chunk, slot) in lines.chunks(2).zip(counts.iter_mut()) {
// chunk: &[&str], shared; slot: &mut usize, this thread's alone
s.spawn(move || *slot = chunk.iter().filter(|l| l.starts_with("error")).count());
}
}); // every thread is joined here
println!("{counts:?} of {} lines", lines.len()); // both are main's again
}
[1, 1] of 4 lines
(move moves the two references in, not the data.) If a scoped thread panics, scope joins the others and then panics itself. The price: the parent waits at the closing brace, where it wanted the counts anyway.
mem::forget(guard) is safe and skips the destructor, so the join never happens, the parent returns and drops its locals, and the thread reads freed memory, from safe code. The standard library once had exactly this shape, thread::scoped, and removed it for this reason, as the Nomicon records. thread::scope puts the join in its own body, after your closure returns: control flow, which no leaked value can skip.Three claims so far: a lock makes the steps inside it indivisible; a channel hands each value to one receiver; a consistent lock order prevents deadlock. Each is a claim about every order in which steps can run — for a small program, a finite set.
5 · Every interleaving, counted
So count it. An interleaving (or schedule) is one order in which the threads' steps can happen. The explorer runs every schedule of a tiny program of indivisible steps; a thread waiting for a held lock or an empty channel cannot move. A schedule ends when nobody can: with final values if every thread finished, in a deadlock if not. Identical states share futures, so the search remembers each. The model is one global order of steps — for atomics, what SeqCst promises (§6).
What to try. (a) at one iteration: 6 interleavings, 4 ending with x = 1 — the lost update is the common case. At four: 12,870 interleavings, only 70 reach x = 8, and the answers run down to x = 2. (c), the same steps inside a lock: 70 interleavings at four, all ending at 8 — (b)'s count, because only the order of the critical sections is left. (d) at one iteration: 2 of 6 interleavings deadlock, the shortest in 2 steps (T1 locks A, T2 locks B); at four, 1,256 of 2,932. (e) flips one lock order and the red bar is gone. (f): 1, 2, 5 and 14 interleavings for one to four messages, one answer each. (g): in one of its 2 interleavings T1 takes m, then waits for a message T2 cannot send until it gets m.
Testing samples schedules; this enumerates them. Compiled as real Rust, preset (a) at four iterations printed 8 in all 2,000 runs; with a thread::yield_now() between load and store, 1,191 of 2,000 runs lost updates (one trial; the count varies), every value one the explorer lists (tools/rust_verify/17_interleave.js, which also checks the explorer against an independent one; Apple M5, rustc 1.98.1 -C opt-level=3, 2026-09).
SeqCst, which do not promise one global order (§6); how likely a schedule is — hardware may never pick most of them, as the 2,000 runs show; bounded channels; and programs past a few dozen steps, where the count explodes. Presets (a) and (b) are real operations, and §6 prices them.6 · Atomics, one counter's worth
For one integer a lock is more machinery than the job needs. AtomicUsize::fetch_add does the read, the add and the write as one indivisible step — preset (b) — so four threads adding a hundred thousand each always make four hundred thousand:
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::thread;
fn main() {
let hits = AtomicUsize::new(0);
thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| for _ in 0..100_000 {
hits.fetch_add(1, SeqCst); // read, add, write: one indivisible step
});
}
});
println!("{}", hits.load(SeqCst));
}
400000
Split the step in two, as preset (a) does. Every access is still atomic, so there is no data race — the Nomicon's definition needs an unsynchronized access — and the compiler accepts it:
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::thread;
fn main() {
let hits = AtomicUsize::new(0);
thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| for _ in 0..100_000 {
let seen = hits.load(SeqCst); // step 1: an atomic read
hits.store(seen + 1, SeqCst); // step 2: an atomic write, perhaps of a stale value
});
}
});
println!("{}", hits.load(SeqCst));
}
On one machine (Apple M5, macOS 26.5, rustc 1.98.1 -C opt-level=3, 2026-09), 21 runs printed from 100,000 to 137,988, median 105,816; the fetch_add version printed 400,000 in each of 5 runs. This is a race condition: the result depends on the interleaving; every step is defined, and the answer is wrong. Safe Rust rules out data races, not race conditions; the Nomicon calls preventing those impossible without control of the scheduler. Each operation being atomic says nothing about the pair.
SeqCst is a memory ordering, and this lesson needs one sentence about it: a data-race-free program that uses only SeqCst atomics has a single global order of operations every thread agrees on — the explorer's model. The weaker orderings (Relaxed, Acquire, Release, AcqRel) follow C++20's rules and are out of scope; the C++ track's Lesson 18 covers them.
Globals are the last case. A static is the purest alias there is: every function on every thread can name it without being handed it. If a static Cell were allowed, two threads calling record at once would race on it, so the type of a non-mut static must be Sync:
use std::cell::Cell;
static HITS: Cell<u32> = Cell::new(0); // a global every thread can name
pub fn record() {
HITS.set(HITS.get() + 1);
}
error[E0277]: `Cell<u32>` cannot be shared between threads safely
= note: shared static variables must have a type that implements `Sync`
Mutable global state therefore goes through interior mutability that is Sync — a static AtomicU32, a static Mutex, or a OnceLock for a value written once, all accepted. static mut needs unsafe to touch, and in edition 2024 even a reference to one is denied by default — Lesson 19's ground. Every tool so far removes data races. None made the load-then-store counter right, and none can make a thread finish.
7 · What the promise does not cover
The promise is exactly "no undefined behavior". The Reference's list of behavior not considered unsafe names deadlocks, leaks of memory and other resources, exiting without running destructors, integer overflow and logic errors. Data races are undefined, so they are excluded; all of the following is defined, so it is allowed:
| Still possible in safe Rust | Smallest example, and what helps |
|---|---|
| Deadlock | preset (d), and the program below, which compiles; so does locking a mutex your own thread holds, a call that will not return. Helps: one global lock order (e), one lock at a time, no waiting on a channel under a lock (g). |
| Race condition | §6's load-then-store counter. Helps: make the whole update one step. |
| Leaks and hangs | an Rc cycle (Lesson 15), mem::forget, a live Sender that keeps recv waiting (§2). |
| Poisoning | a thread panics while holding a lock (below). |
| Starvation | RwLock leaves priority between waiting readers and writers to the OS. |
| Overflow, exhaustion | overflow is defined, not prevented; spawn panics if the OS cannot make a thread, where Builder::spawn returns an error. |
use std::sync::Mutex;
use std::thread;
fn transfer(from: &Mutex<i32>, to: &Mutex<i32>, amount: i32) {
let mut f = from.lock().unwrap(); // lock the source first...
let mut t = to.lock().unwrap(); // ...then the destination
*f -= amount;
*t += amount;
}
fn main() {
let (a, b) = (Mutex::new(100), Mutex::new(100));
thread::scope(|s| {
s.spawn(|| transfer(&a, &b, 10)); // locks A, then B
s.spawn(|| transfer(&b, &a, 10)); // locks B, then A
});
}
It compiles because nothing in it is undefined; preset (d) is this program, and the explorer finds the 2-step schedule that stops it forever. Note where the bug is: transfer is fine alone, and so is each call site. A lock order is a property of the whole program, so no rule local to one function — the only kind the checker applies — can see it. The C++ track's cure (lock ordering, std::scoped_lock) and the Go track's (no channel send while holding a lock, Go 11) carry over unchanged. Poisoning answers a different failure — a thread that dies halfway through a change:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let balance = Arc::new(Mutex::new(100));
let b = Arc::clone(&balance);
let result = thread::spawn(move || {
let mut g = b.lock().unwrap();
*g -= 30; // the first half of a transfer...
panic!("network down"); // ...and the thread dies holding the guard
}).join();
println!("worker ok: {}", result.is_ok());
match balance.lock() {
Ok(g) => println!("balance {}", *g),
Err(poisoned) => println!("poisoned; balance is {}", *poisoned.into_inner()),
}
}
worker ok: false
poisoned; balance is 70
Unwinding dropped the guard, so the lock is free, but the mutex is poisoned: later calls to lock() return Err — the Result every .unwrap() above was opening. The error is advisory, since into_inner hands over the data anyway: only the program knows whether 70 is a balance or half a transfer. Detection is imperfect, so unsafe code must not rely on it for soundness; an RwLock is poisoned only by a panicking writer.
One classic deadlock the language did remove, in edition 2024: the temporaries of an if let test — here a MutexGuard — are dropped before the else block runs.
use std::sync::Mutex;
fn main() {
let cache: Mutex<Option<u32>> = Mutex::new(None);
if let Some(hit) = *cache.lock().unwrap() { // the test creates a guard...
println!("hit {hit}");
} else {
// ...which edition 2024 drops before this block runs
println!("miss; lock free: {}", cache.try_lock().is_ok());
};
}
miss; lock free: true
Compiled with --edition 2021, the same program prints miss; lock free: false: the guard lives to the end of the whole if let, so a cache that filled itself with cache.lock() in the else block would be locking a mutex its own thread holds.
Common mistakes / failure modes
load then store leaves room for another thread between. Use one read-modify-write, or one critical section (§6).recv a forgotten Sender keeps open (§2).Mutex protects the code between lock and unlock"into_inner still returns the data; whether the invariant survived is yours to decide (§7).Arc"thread::scope joins them before it returns (§4).Checkpoint exercise
lock A; lock B; fetch_add x; unlock B; unlock A, T2 the same with B then C, T3 with C then A. Before pressing run, predict whether a deadlock is reachable and how many steps the shortest deadlocking schedule takes. Then change T3 to take A before C — every thread now locks in the order A, B, C — and predict again. Finally, write one sentence saying why a global order makes a cycle of waiting impossible. (Check: the cycle deadlocks in 6 of 234 interleavings, shortest 3 steps; the ordered version has 236 interleavings and none.)Where this points next
Every tool here is built on one unit: a thread, which the standard library maps one-to-one onto an operating-system thread, each with its own stack — by default 2 MiB for a spawned thread on Tier-1 platforms as of 2026-09, a figure the documentation calls subject to change. Two workers counting a log never notice. A server holding ten thousand idle connections, one thread each, would ask for 20,000 MiB of stack (10,000 × 2 MiB) and give the operating system ten thousand threads to schedule, nearly all only waiting. Go's answer is a scheduler inside its runtime (the Go track's Lesson 08); Rust's budget forbids one. So: can waiting be made cheap, without a thread per waiter — and without the language shipping a runtime to do it?
send takes it by value), share it with exclusion in time (Arc<Mutex<T>> — the data lives inside the lock), or lend it to threads that provably end first (thread::scope, whose join is control flow, since a joining guard could be leaked). One counter needs only fetch_add; a static must be Sync. Counting every interleaving shows the boundary: a lock or fetch_add leaves one answer, but load-then-store loses updates with no data race, opposite lock orders deadlock, and leaks, hangs, poisoning and starvation remain — all defined. Safe Rust promises memory, not progress.Interview prompts
- Why does
thread::spawnrequire'static, and what are the three ways to give a thread data? (§1 — a detached thread may outlive its spawner; so move the data in, share it through an owner with a lock, or lend it to threads a scope joins.) - Message passing versus shared state: what does each prove? (§2–§3 —
sendtakes the value by move, so one thread at a time reaches it; aMutexadmits several, one guard-holder at a time.) - Dissect
Arc<Mutex<T>>. (§3 —Arc: shared ownership;Mutex: exclusion, the data inside so nothing skipslock(); the guard derefs toT, unlocks on drop, is notSend.) - How does a scoped thread borrow a local, and why is the API a closure, not a join guard? (§4 —
scopejoins every thread before returning; a guard could be leaked by safemem::forget, skipping the join — whythread::scopedwas removed.) - Data race versus race condition: write a safe program with the second. (§6 — a data race is an unsynchronized conflicting access, undefined and rejected; a race condition is an order-dependent wrong answer, defined and accepted: atomic
loadthenstore.) - Does Rust prevent deadlock? How do you avoid one? (§5, §7 — no: it is defined behavior, and lock order is a whole-program property; keep one global order, hold one lock at a time, never wait on a channel under a lock.)
- What is mutex poisoning, and can you rely on it? (§7 — after a holder panics,
lock()returnsErr; advisory, sinceinto_inneryields the data, and imperfect, sounsafecode must not depend on it.) - When is an atomic enough? (§6 — when the update is one read-modify-write on one value, like
fetch_add; two steps or two values need one critical section.)
Companion reads: Go · 09 Channels and CSP, Go · 10 select and context, Go · 11 When to share memory, C++ · 17 Threads and races, and C++ · 18 Atomics (the orderings left out here).