all_lessons/Rust/17 · Concurrent programslesson 18 / 23

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.

The thesis, here
Concurrency is where the law earns the most, because the second name belongs to a thread whose timing nobody controls. Rust adds no concurrency model; it gives each regime the law allows a type, so the compiler can check that every place two threads reach is in one. What no type can check is progress, and this lesson marks where that line runs.
Linear position
Forced by: Send and Sync tell the compiler which values may cross, or be shared between, threads. Given those safe building blocks, how do we structure real concurrent programs — sharing, messaging, borrowing from a parent — and what does the promise still not cover?
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?
The plan
Seven moves. (1) The refused program, and the three answers the law leaves. (2) Move: a channel. (3) Share: a lock, with the data inside it. (4) Lend: a scope that joins before it returns. (5) Every claim, checked on every interleaving. (6) One atomic counter, and globals. (7) What the promise does not cover.

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
Reading the error
E0373 names the borrowed variable and the frame the closure might outlive; the note says why — 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…RegimeRust's toolCheckedPrice
one thread at a time; the sender loses its nameno alias at alla channel (§2)compile time: the move rulea queue operation per message
several threads, some writingexclusive, taking turnsArc<Mutex<T>> (§3)run time: the locklock, unlock, and waiting
threads that end before the owner's region doesordinary borrowsthread::scope (§4)compile time: regionsthe 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.

Road not taken · a guard whose destructor joins
The obvious API returns a guard that joins the thread when dropped — RAII, as C++ would write it. Lesson 03's "leaks are safe" makes it unsound: 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).

All interleavings
Steps, separated by ;: load x · store x (writes the loaded value + 1) · fetch_add x · lock A · unlock A · send c · recv c s (s += the value). Pick a preset or edit the threads (T3 is optional). Green: the answer of running the threads one after another; amber: any other; red: deadlock. The lanes replay one witness.
interleavings
—
distinct states
—
different answers
—
deadlock
—
Show the core JS
// Every schedule from state s: which endings it reaches, and in how many ways.
function visit(s) {
  var k = key(s), hit = memo.get(k);
  if (hit) return hit;                              // same state, same future
  if (memo.size >= LIMIT_STATES) { truncated = true; return { c: {}, first: {}, dd: Infinity, dt: -1 }; }
  var node = { c: {}, first: {}, dd: Infinity, dt: -1 }, moved = false, done = true;
  for (var t = 0; t < T; t++) {
    if (s.pc[t] < p.threads[t].length) done = false;
    if (!enabled(p, s, t)) continue;                // waiting for a held lock or an empty channel
    moved = true;
    var sub = visit(step(p, s, t));                   // every enabled thread may go next
    for (var o in sub.c) {
      node.c[o] = (node.c[o] || 0) + sub.c[o];        // schedules through this child
      if (!(o in node.first)) node.first[o] = t;      // lexicographically first witness
    }
    if (sub.dd + 1 < node.dd) { node.dd = sub.dd + 1; node.dt = t; }
  }
  if (!moved) {                                       // nobody can move: the schedule ends
    var out = done ? outcomeKey(p, s) : DEAD;
    node.c[out] = 1;
    if (!done) node.dd = 0;                           // unfinished threads, all blocked
  }
  memo.set(k, node);
  return node;
}

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).

What the explorer does not model
Orderings weaker than 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 RustSmallest example, and what helps
Deadlockpreset (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 hangsan Rc cycle (Lesson 15), mem::forget, a live Sender that keeps recv waiting (§2).
Poisoninga thread panics while holding a lock (below).
StarvationRwLock leaves priority between waiting readers and writers to the OS.
Overflow, exhaustionoverflow 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

"Fearless concurrency means no deadlocks and no races"
Safe Rust rules out data races, which are undefined. Deadlocks and race conditions are defined, so they stay: preset (d) deadlocks in 2 steps; the load-then-store counter loses most of its updates (§5–§7).
"Atomics make the program correct"
Each atomic operation is indivisible; a sequence is not. load then store leaves room for another thread between. Use one read-modify-write, or one critical section (§6).
"Channels avoid all sharing problems"
A moved message cannot race, but a thread can still wait forever: for a message only a lock-holder can send (preset g), or on a recv a forgotten Sender keeps open (§2).
"A Mutex protects the code between lock and unlock"
It protects the data it wraps, by type: the only path to the value is the guard, whose lifetime is the critical section (§3). Other data touched there is not protected.
"A poisoned mutex is broken"
It is a message: a holder panicked mid-update. into_inner still returns the data; whether the invariant survived is yours to decide (§7).
"Sharing with threads needs Arc"
Only with threads that may outlive you. Scoped threads borrow, shared or exclusive, because thread::scope joins them before it returns (§4).

Checkpoint exercise

Try it
In the widget, at one iteration, type three transfers over three accounts: T1 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?

Takeaway
With a second thread the checker cannot compare regions in program order, so it accepts only arrangements safe in every order, and the law leaves three: move the data (a channel — 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

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).