Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 31: Threads and Message Passing

You will understand

  • Why thread::spawn requires move
  • Channels as ownership handoff, not shared mailboxes
  • thread::scope for safe temporary parallelism

Reading time

45 min
+ 25 min exercises
Thread Lifetime

Why `thread::spawn` Needs Owned Data

main thread buf lives on main stack spawned thread may still run main scope ends dangling borrow `move` fixes this by transferring ownership into the closure.
Message Passing

A Channel Send Is an Ownership Handoff

sender channel receiver String String before send: sender owns after recv: receiver owns No ambiguous shared ownership. The value crosses the boundary once.

Step 1 - The Problem

Concurrency begins with a basic tension: you want more than one unit of work to make progress, but the same memory cannot be used carelessly by all of them.

In C and C++, thread creation is easy and lifetime mistakes are easy too. A thread may outlive the stack frame it borrowed from. A pointer may still point somewhere that used to be valid. A shared queue may “work” in testing and then fail under scheduler timing you did not anticipate.

The failure mode is not abstract. This is what an unsafe shape looks like:

void *worker(void *arg) {
    printf("%s\n", (char *)arg);
    return NULL;
}

int main(void) {
    pthread_t tid;
    char buf[32] = "hello";
    pthread_create(&tid, NULL, worker, buf);
    return 0; // buf's stack frame is gone, worker may still run
}

The bug is simple: the spawned thread was handed a pointer into a stack frame that can disappear before the thread reads it.

Message passing is a second version of the same problem. If two threads both believe they still own the same value after a send, you have either duplication of responsibility or unsynchronized sharing. Both lead to bugs.

Step 2 - Rust’s Design Decision

Rust makes two strong decisions here.

First, an unscoped thread must own what it uses. That is why thread::spawn requires a 'static future or closure environment in practice: the new thread may outlive the current stack frame, so borrowed data from that frame is not acceptable.

Second, sending a value over a channel transfers ownership of that value. Rust refuses the design where a send is “just a copy of a reference unless you remember not to mutate it.” That would reintroduce the same aliasing and lifetime problems under a more polite API.

Rust did accept some cost:

  • You must think about move.
  • You must understand why 'static appears at thread boundaries.
  • You often restructure code rather than keeping implicit borrowing.

Rust refused other costs:

  • no tracing GC to keep borrowed values alive for threads
  • no hidden runtime ownership scheme
  • no “hope the race detector catches it later” model

Step 3 - The Mental Model

Plain English rule: a spawned thread must either own the data it uses or borrow it from a scope that is guaranteed to outlive the thread.

For channels, the rule is just as simple: sending a value means handing off responsibility for that value.

If the compiler rejects your thread code, it is usually protecting one of two invariants:

  • no thread may outlive the data it borrows
  • no value may have ambiguous ownership after being handed across threads

Step 4 - Minimal Code Example

use std::thread;

fn main() {
    let values = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("{values:?}");
    });

    handle.join().unwrap();
}

Step 5 - Line-by-Line Compiler Walkthrough

use std::thread;

fn main() {
    let values = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("{values:?}");
    });

    handle.join().unwrap();
}

Line by line, the compiler sees this:

  1. values is an owned Vec<i32> in main.
  2. move || { ... } tells the compiler to capture values by value, not by reference.
  3. Ownership of values moves into the closure environment.
  4. thread::spawn takes ownership of that closure environment and may execute it after main continues.
  5. Because the closure owns values, there is no dangling borrow risk.
  6. join() waits for completion and returns a Result, because the thread may panic.

If you remove move, the closure tries to borrow values from main. Now the compiler must consider the possibility that the thread runs after main reaches the end of scope. That would mean a thread still holds a reference into dead stack data. Rust rejects that shape before the program exists.

You will typically see an error in the E0373 family for “closure may outlive the current function, but it borrows…” The exact wording varies slightly across compiler versions, but the design reason does not.

Step 6 - Three-Level Explanation

move on a thread closure means “the new thread gets its own stuff.” Without that, the new thread would be trying to borrow from the current function, which might finish too soon.

Use thread::spawn when the thread’s lifetime is logically independent. Use thread::scope when the thread is just temporary parallel work inside a parent scope and should be allowed to borrow local data safely.

Channels are the idiomatic tool when ownership handoff is the design. Shared state behind locks is the tool when many threads must observe or update the same long-lived state.

thread::spawn forces a strong boundary because the thread is scheduled independently by the OS. Rust cannot assume when it will run or when the parent stack frame will end. The 'static requirement is not about “must live forever.” It means “contains no borrow that could become invalid before the thread is done.”

Message passing composes well with ownership because a send is a move. The type system can reason about exactly one owner before the send and exactly one owner after the send. That makes channel-based concurrency a natural extension of Rust’s single-owner model.

Scoped Threads

Sometimes a thread does not need to escape the current scope. In that case, requiring ownership of everything would be unnecessarily strict.

use std::thread;

fn main() {
    let mut values = vec![1, 2, 3, 4];

    thread::scope(|scope| {
        let (left, right) = values.split_at_mut(2);

        scope.spawn(move || left.iter_mut().for_each(|x| *x *= 2));
        scope.spawn(move || right.iter_mut().for_each(|x| *x *= 10));
    });

    assert_eq!(values, vec![2, 4, 30, 40]);
}

thread::scope changes the proof obligation. The compiler now knows every spawned thread must complete before the scope exits, so borrowing from local data is safe if the borrows are themselves non-overlapping and valid.

That is a very Rust design move: make the safe case explicit, then let the compiler exploit the stronger invariant.

Channels and Backpressure

The standard library gives you std::sync::mpsc, which is adequate for many cases and great for understanding the model.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        tx.send(String::from("ready")).unwrap();
    });

    let message = rx.recv().unwrap();
    assert_eq!(message, "ready");
}

The most important thing here is not the API. It is the ownership event:

  • sender owns the String
  • send moves the String into the channel
  • receiver becomes the new owner when recv returns it

For high-throughput or more feature-rich cases, many production codebases use crossbeam-channel because it supports better performance characteristics and richer coordination patterns. The design lesson stays the same: moving messages is often cleaner than sharing data structures.

Bounded channels matter because they encode backpressure. If a producer can always enqueue without limit, memory becomes the pressure valve. That is usually the wrong valve.

Step 7 - Common Misconceptions

Common Mistake Thinking move “copies” captured values into a thread. It does not. It moves ownership unless the captured type is Copy.

Wrong model 1: “If I call join(), borrowing into thread::spawn should be fine.”

Why it forms: humans read top to bottom and see the join immediately after spawn.

Why it is wrong: thread::spawn does not know, at the type level, that you will definitely join before the borrowed data dies. The API is intentionally conservative because the thread is fundamentally unscoped.

Correction: use thread::scope when borrowing is logically correct.

Wrong model 2: “'static means heap allocation.”

Why it forms: many examples use String, Arc, or owned data.

Why it is wrong: 'static is about the absence of non-static borrows, not where bytes live.

Correction: a moved Vec<T> satisfies a thread::spawn boundary without becoming immortal.

Wrong model 3: “Channels are for copying data around.”

Why it forms: in other languages, channel sends often look like passing references around casually.

Why it is wrong: in Rust, the valuable property is ownership transfer.

Correction: think “handoff,” not “shared mailbox with hidden aliases.”

Step 8 - Real-World Pattern

You will see two recurring shapes in real Rust repositories:

  1. request or event ownership is moved into worker tasks or threads
  2. bounded queues are used to express capacity limits, not just communication

Tokio-based servers, background workers, and data-pipeline code often use channels to decouple ingress from processing. The important design pattern is not the exact crate. It is that work units become owned values crossing concurrency boundaries.

CLI and search tools take the same approach. A parser thread may produce paths or work items, and worker threads consume them. That structure reduces lock contention and makes shutdown behavior easier to reason about.

Step 9 - Practice Block

Code Exercise

Write a program that:

  • creates a bounded channel
  • spawns two producers that each send five strings
  • has one consumer print messages in receive order
  • exits cleanly when both producers are done

Code Reading Drill

Read this and explain who owns job at each step:

#![allow(unused)]
fn main() {
use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();
let tx2 = tx.clone();

thread::spawn(move || tx.send(String::from("a")).unwrap());
thread::spawn(move || tx2.send(String::from("b")).unwrap());

for job in rx {
    println!("{job}");
}
}

Spot the Bug

What will the compiler object to here, and why?

use std::thread;

fn main() {
    let name = String::from("worker");
    let name_ref = &name;

    let handle = thread::spawn(|| {
        println!("{name_ref}");
    });

    handle.join().unwrap();
}

Refactoring Drill

Take a design that shares Arc<Mutex<Vec<Job>>> across many worker threads and replace it with a channel-based design. Explain what got simpler and what got harder.

Compiler Error Interpretation

If you see an error saying the closure may outlive the current function but borrows a local variable, translate it into plain English: “this thread boundary requires owned data, but I tried to smuggle a borrow through it.”

Step 10 - Contribution Connection

After this chapter, you can start reading:

  • worker-pool code
  • producer-consumer pipelines
  • test helpers that use threads to simulate concurrent clients
  • code that uses thread::scope for temporary parallelism

Approachable first PRs include:

  • replace unbounded work queues with bounded ones where backpressure is needed
  • convert awkward shared mutable state into message passing
  • improve shutdown or join handling in threaded tests

In Plain English

Threads are separate workers. Rust insists that each worker either owns its data or borrows it from a scope that is guaranteed to stay alive long enough. That matters to systems engineers because concurrency bugs are often timing bugs, and timing bugs are the most expensive class of bugs to debug after deployment.

What Invariant Is Rust Protecting Here?

No thread may observe memory through a borrow that can become invalid before the thread finishes using it. For channels, ownership after a send must be unambiguous.

If You Remember Only 3 Things

  • thread::spawn is an ownership boundary, so move is usually the correct mental starting point.
  • thread::scope exists because some threads are temporary parallel work, not detached lifetimes.
  • Channels are most useful when you think of them as ownership handoff plus backpressure, not just communication syntax.

Memory Hook

An unscoped thread is a courier leaving the building. If you hand it a borrowed office key instead of the actual package, you are assuming the office will still exist when the courier arrives.

Flashcard Deck

QuestionAnswer
Why does thread::spawn usually need move?Because the spawned thread may outlive the current scope, so captured data must be owned rather than borrowed.
What does 'static mean at a thread boundary?The closure environment contains no borrow that could expire too early.
When should you prefer thread::scope over thread::spawn?When child threads are temporary work that must finish before the current scope exits.
What happens to a value sent over a channel?Ownership moves into the channel and then to the receiver.
Why are bounded channels important?They encode backpressure and prevent the queue from turning memory into an unbounded shock absorber.
Why is a post-spawn join() not enough to justify borrowing into thread::spawn?Because the API itself does not encode that promise; the compiler must type-check the thread boundary independently.
What kind of compiler error often appears when a thread closure borrows locals?E0373-style “closure may outlive the current function” errors.
What is the design difference between message passing and shared mutable state?Message passing transfers ownership of work units; shared mutable state requires synchronization around aliased data.

Chapter Cheat Sheet

NeedToolReason
Independent background thread`thread::spawn(move
Borrow local data in temporary parallel workthread::scopeScope proves child threads finish in time
Hand work items from producer to consumerchannelOwnership transfer is explicit
Prevent unbounded producer growthbounded channelBackpressure is part of the design
Wait for a spawned threadJoinHandle::join()Surfaces panic as Result