Threads & Message Passing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. Rust’s standard library exposes operating-system threads directly: one std::thread per OS thread, with no green threads or runtime. What Rust adds is compile-time checking — through ownership and the Send/Sync traits — that rules out data races, which the community calls fearless concurrency. This page covers starting threads and communicating by message passing; sharing memory between threads is on Shared State, Send/Sync & Atomics, and async tasks on Async/Await & Futures.

Spawning threads

std::thread::spawn takes a closure, runs it on a new thread, and returns a JoinHandle<T>. join() waits for the thread and returns Result<T, Box<dyn Any + Send>>: Ok with the closure’s return value, or Err with the panic payload if the thread panicked.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("indexer: batch {i}");
            thread::sleep(Duration::from_millis(5));
        }
        42 // the thread's result
    });

    println!("main thread keeps working");
    let indexed = handle.join().expect("indexer thread panicked");
    println!("indexed {indexed} books");
}

When main returns, the process exits and every other thread is killed mid-flight, so join the threads whose work must finish. A JoinHandle that is dropped without joining detaches the thread.

move closures

A spawned thread may outlive the function that spawned it, so its closure must be 'static: it cannot borrow local variables. Without move, the compiler reports E0373 ("closure may outlive the current function, but it borrows titles`") and suggests `move, which transfers ownership into the thread:

use std::thread;

fn main() {
    let titles = vec!["Dune".to_string(), "Emma".to_string(), "Kindred".to_string()];
    let handle = thread::spawn(move || {
        titles.into_iter().map(|t| t.len()).sum::<usize>() // the thread owns `titles` now
    });
    println!("total length {}", handle.join().unwrap());
    // println!("{titles:?}"); // would not compile: `titles` was moved into the thread (E0382)
}

To keep using data in the spawning thread as well, clone it before the move, share it with Arc, or use scoped threads.

thread::Builder

thread::Builder names a thread (shown in panic messages, debuggers and profilers) and sets its stack size; spawn returns an io::Result instead of panicking if the OS refuses. thread::available_parallelism() reports how many threads can usefully run at once:

use std::thread;

fn main() -> std::io::Result<()> {
    let workers = thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
    let handle = thread::Builder::new()
        .name("cover-thumbnailer".into())
        .stack_size(4 * 1024 * 1024)
        .spawn(move || format!("{} running with {workers} cores available", thread::current().name().unwrap_or("?")))?;
    println!("{}", handle.join().unwrap());
    Ok(())
}

Scoped threads: borrowing from the parent

std::thread::scope (1.63) creates a scope in which threads can borrow local data, because the scope guarantees that every thread spawned inside it is joined before scope returns. This is the easiest way to parallelise work over a local slice:

use std::thread;

fn main() {
    let ratings = [5, 3, 4, 4, 2, 5, 1, 5];
    let mut stats = (0u32, 0u32);

    thread::scope(|s| {
        let (left, right) = ratings.split_at(ratings.len() / 2);
        let a = s.spawn(|| left.iter().sum::<u32>());   // borrows `ratings` -- no move, no Arc
        let b = s.spawn(|| right.iter().sum::<u32>());
        stats.0 = a.join().unwrap() + b.join().unwrap();
        s.spawn(|| stats.1 = ratings.len() as u32);     // may even borrow mutably
    }); // all threads joined here

    println!("sum {} over {} ratings", stats.0, stats.1);
}

The borrow checker applies inside the scope as usual: several threads may share &ratings, but only one may hold &mut stats. For data-parallel loops over large collections, Rayon builds on the same idea with a work-stealing pool (see Data Parallelism with Rayon).

Message passing with channels

"Do not communicate by sharing memory; share memory by communicating." A channel has a sending half and a receiving half; values sent are moved to the receiver, so there is never shared access to them. std::sync::mpsc provides multi-producer, single-consumer channels:

  • mpsc::channel() returns an unbounded (Sender<T>, Receiver<T>): send never blocks;

  • mpsc::sync_channel(n) returns a bounded (SyncSender<T>, Receiver<T>): send blocks when n messages are queued, which gives backpressure (with n = 0, every send waits for a matching receive — a rendezvous).

sequenceDiagram participant P1 as Importer thread 1 participant P2 as Importer thread 2 participant C as Channel (queue) participant R as Catalogue writer P1->>C: send(Book "Dune") P2->>C: send(Book "Emma") C-->>R: recv() returns Dune P1->>C: send(Book "Kindred") C-->>R: recv() returns Emma Note over P1,P2: senders dropped when importers finish C-->>R: recv() returns Kindred C-->>R: recv() returns Err: all senders gone, loop ends
use std::sync::mpsc;
use std::thread;

#[derive(Debug)]
struct Book {
    title: String,
    source: &'static str,
}

fn main() {
    let (tx, rx) = mpsc::sync_channel::<Book>(16); // bounded: importers wait if the writer falls behind

    let mut importers = Vec::new();
    for (source, titles) in [("csv", vec!["Dune", "Emma"]), ("json", vec!["Kindred"])] {
        let tx = tx.clone(); // one Sender per producer
        importers.push(thread::spawn(move || {
            for t in titles {
                tx.send(Book { title: t.to_string(), source }).expect("writer hung up");
            }
        })); // this clone of tx is dropped when the thread ends
    }
    drop(tx); // drop the original, or the loop below never ends

    let writer = thread::spawn(move || {
        let mut count = 0;
        for book in rx {               // iterates until every Sender is dropped
            println!("store {} (from {})", book.title, book.source);
            count += 1;
        }
        count
    });

    for i in importers {
        i.join().unwrap();
    }
    println!("stored {} books", writer.join().unwrap());
}

Receiving: recv() blocks and returns Err(RecvError) once all senders are gone; try_recv() never blocks; recv_timeout(d) waits at most d; iterating a Receiver (or rx.iter()) loops until disconnection. Sending returns Err when the receiver has been dropped, giving the producer a chance to stop. Forgetting to drop the original Sender — as noted in the example — is the classic reason a consumer loop never terminates.

For multiple consumers, a select over several channels, or more performance, the crossbeam-channel crate offers MPMC channels; in async code use tokio::sync::mpsc, broadcast and watch (see Async Runtimes & Tokio).

Request and response: a thread that owns the catalogue

Because a channel moves values, a thread can own some state outright and serve requests for it, with no lock at all. Each request carries its own reply Sender, so the caller gets an answer on a private channel. This is the actor pattern; tokio tasks use the same shape with tokio::sync::oneshot for the reply:

use std::collections::HashMap;
use std::sync::mpsc::{self, Sender};
use std::thread;

#[derive(Debug, PartialEq)]
enum LendError {
    UnknownIsbn,
    AlreadyLent { member: String },
}

enum Command {
    Lend { isbn: String, member: String, reply: Sender<Result<(), LendError>> },
    Return { isbn: String },
    Report { reply: Sender<Vec<(String, Option<String>)>> },
}

/// Starts the catalogue thread. It owns the loan table; the only way to reach it is the returned Sender.
fn spawn_catalogue(isbns: &[&str]) -> (Sender<Command>, thread::JoinHandle<usize>) {
    let mut loans: HashMap<String, Option<String>> = isbns.iter().map(|i| ((*i).to_string(), None)).collect();
    let (tx, rx) = mpsc::channel::<Command>();
    let handle = thread::Builder::new()
        .name("catalogue".into())
        .spawn(move || {
            let mut handled = 0;
            for cmd in rx {
                handled += 1;
                match cmd {
                    Command::Lend { isbn, member, reply } => {
                        let result = match loans.get_mut(&isbn) {
                            None => Err(LendError::UnknownIsbn),
                            Some(Some(holder)) => Err(LendError::AlreadyLent { member: holder.clone() }),
                            Some(slot) => {
                                *slot = Some(member);
                                Ok(())
                            }
                        };
                        let _ = reply.send(result); // the caller may have given up; that is not our problem
                    }
                    Command::Return { isbn } => {
                        if let Some(slot) = loans.get_mut(&isbn) {
                            *slot = None;
                        }
                    }
                    Command::Report { reply } => {
                        let mut rows: Vec<_> = loans.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
                        rows.sort();
                        let _ = reply.send(rows);
                    }
                }
            }
            handled // the loop ends when every Sender<Command> has been dropped
        })
        .expect("failed to spawn the catalogue thread");
    (tx, handle)
}

fn lend(catalogue: &Sender<Command>, isbn: &str, member: &str) -> Result<(), LendError> {
    let (reply, answer) = mpsc::channel();
    catalogue
        .send(Command::Lend { isbn: isbn.into(), member: member.into(), reply })
        .expect("catalogue thread stopped");
    answer.recv().expect("catalogue thread dropped the reply")
}

fn main() {
    let (catalogue, handle) = spawn_catalogue(&["9780441013593", "9780141439587"]);

    let desks: Vec<_> = ["ana", "ben"]
        .into_iter()
        .map(|member| {
            let catalogue = catalogue.clone(); // Sender is Clone: one per front desk
            thread::spawn(move || lend(&catalogue, "9780441013593", member))
        })
        .collect();
    let results: Vec<_> = desks.into_iter().map(|d| d.join().unwrap()).collect();
    assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); // exactly one desk wins the race

    assert_eq!(lend(&catalogue, "0000000000000", "ana"), Err(LendError::UnknownIsbn));
    catalogue.send(Command::Return { isbn: "9780441013593".into() }).unwrap();

    let (reply, answer) = mpsc::channel();
    catalogue.send(Command::Report { reply }).unwrap();
    println!("{:?}", answer.recv().unwrap()); // both books available again

    drop(catalogue); // last Sender gone: the catalogue thread's loop ends
    println!("catalogue handled {} commands", handle.join().unwrap()); // 5
}

Since Rust 1.72 Sender is also Sync, so it can be shared by reference (for example inside an Arc or a static) instead of cloned; Receiver is Send but neither Sync nor Clone, which is what makes the channel single-consumer.

A fixed pool of worker threads

Spawning one thread per item wastes time on thread creation and oversubscribes the CPU. A pool starts a fixed number of workers that take jobs from a shared queue. With std alone, the workers share the single Receiver through Arc<Mutex<...>> — the design the Book’s final project uses for its web server — and send results back on a second channel. Dropping the job Sender is the shutdown signal, and Drop joins the workers so none is left running:

use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

type Job = Box<dyn FnOnce() + Send + 'static>;

struct ThreadPool {
    jobs: Option<Sender<Job>>,
    workers: Vec<JoinHandle<()>>,
}

impl ThreadPool {
    fn new(size: usize) -> ThreadPool {
        assert!(size > 0, "a pool needs at least one worker");
        let (jobs, rx) = mpsc::channel::<Job>();
        let rx: Arc<Mutex<Receiver<Job>>> = Arc::new(Mutex::new(rx));
        let workers = (0..size)
            .map(|id| {
                let rx = Arc::clone(&rx);
                thread::Builder::new()
                    .name(format!("worker-{id}"))
                    .spawn(move || {
                        loop {
                            // The guard is a temporary: the lock is released before the job runs.
                            let next = rx.lock().expect("job queue poisoned").recv();
                            match next {
                                Ok(job) => job(),
                                Err(_) => break, // all Senders dropped: shut down
                            }
                        }
                    })
                    .expect("failed to spawn worker")
            })
            .collect();
        ThreadPool { jobs: Some(jobs), workers }
    }

    fn execute(&self, f: impl FnOnce() + Send + 'static) {
        self.jobs.as_ref().expect("pool is running").send(Box::new(f)).expect("all workers have stopped");
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.jobs.take()); // close the queue first, or the workers wait forever
        for w in self.workers.drain(..) {
            w.join().expect("worker panicked");
        }
    }
}

/// ISBN-13 check: weights 1, 3, 1, 3, ... over the 13 digits; the sum must be a multiple of 10.
fn isbn13_valid(isbn: &str) -> bool {
    let digits: Vec<u32> = isbn.chars().filter_map(|c| c.to_digit(10)).collect();
    digits.len() == 13 && digits.iter().zip([1, 3].iter().cycle()).map(|(d, w)| d * w).sum::<u32>() % 10 == 0
}

fn main() {
    let incoming = ["978-0-441-01359-3", "978-0-14-143958-7", "978-0-00-000000-1", "978-0-8070-8369-5"];
    let (results_tx, results_rx) = mpsc::channel();
    {
        let pool = ThreadPool::new(thread::available_parallelism().map_or(2, |n| n.get().min(4)));
        for isbn in incoming {
            let results_tx = results_tx.clone();
            pool.execute(move || {
                let worker = thread::current().name().unwrap_or("?").to_string();
                results_tx.send((isbn, isbn13_valid(isbn), worker)).unwrap();
            });
        }
    } // pool dropped here: queue closed, every worker joined
    drop(results_tx);

    let mut results: Vec<_> = results_rx.iter().collect(); // arrival order depends on scheduling
    results.sort();
    for (isbn, ok, worker) in results {
        println!("{isbn}: {} (checked on {worker})", if ok { "valid" } else { "INVALID" });
    }
}

Two details matter. The let next = rx.lock()...recv(); statement drops the MutexGuard at the semicolon, so other workers can wait for the next job while this one runs; writing while let Ok(job) = rx.lock().unwrap().recv() would keep the guard alive for the whole loop body and serialise the pool. And the pool must close the queue before joining, otherwise join waits for workers that are themselves waiting for jobs. For production use, Rayon’s pool (CPU-bound work) or an async runtime (I/O-bound work) is usually the better choice.

Timeouts and non-blocking receives

A consumer that must also do periodic work — flush a batch, print progress, notice shutdown — cannot block in recv() forever. recv_timeout returns RecvTimeoutError::Timeout when nothing arrived in time and RecvTimeoutError::Disconnected when every sender is gone; try_recv returns TryRecvError::Empty immediately:

use std::sync::mpsc::{self, RecvTimeoutError, TryRecvError};
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel::<String>();
    let importer = thread::spawn(move || {
        for title in ["Dune", "Emma", "Kindred"] {
            thread::sleep(Duration::from_millis(30));
            tx.send(title.to_string()).unwrap();
        }
    });

    assert_eq!(rx.try_recv(), Err(TryRecvError::Empty)); // nothing yet: does not block

    let mut batch = Vec::new();
    loop {
        match rx.recv_timeout(Duration::from_millis(10)) {
            Ok(title) => batch.push(title),
            Err(RecvTimeoutError::Timeout) => {
                if !batch.is_empty() {
                    println!("flushing {batch:?}");
                    batch.clear();
                }
            }
            Err(RecvTimeoutError::Disconnected) => break, // importer finished and dropped tx
        }
    }
    println!("flushing the rest: {batch:?}");
    importer.join().unwrap();
}

Thread-local storage

thread_local! declares a static with one independent copy per thread, initialised lazily on first access in each thread. Combine it with Cell or RefCell for per-thread mutable state such as caches, counters or a reusable buffer:

use std::cell::{Cell, RefCell};
use std::thread;

thread_local! {
    static PROCESSED: Cell<u32> = const { Cell::new(0) };
    static SCRATCH: RefCell<String> = RefCell::new(String::with_capacity(256));
}

fn normalise(title: &str) -> String {
    PROCESSED.set(PROCESSED.get() + 1);
    SCRATCH.with_borrow_mut(|buf| {         // reuse this thread's buffer
        buf.clear();
        buf.extend(title.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase));
        buf.clone()
    })
}

fn main() {
    let h = thread::spawn(|| {
        normalise("Dune!");
        normalise("Emma.");
        PROCESSED.get()
    });
    normalise("Kindred");
    println!("worker processed {}, main processed {}", h.join().unwrap(), PROCESSED.get()); // 2, 1
}

The const { ... } initialiser avoids the lazy-initialisation check when the value is a constant. Thread-locals are destroyed when their thread exits; in async code a task may move between threads at every .await, so use task-local storage (tokio::task_local!) instead.

Panics in threads

A panic unwinds and ends only the panicking thread. The parent sees it as Err from join(); if it unwraps that, the panic propagates. Locks held by the panicking thread become poisoned. With panic = "abort" in the profile, a panic in any thread aborts the whole process.

use std::thread;

fn main() {
    let worker = thread::spawn(|| {
        let isbn = "978x";
        isbn.parse::<u64>().expect("invalid ISBN") // panics in the worker
    });
    match worker.join() {
        Ok(n) => println!("parsed {n}"),
        Err(payload) => {
            let msg = payload.downcast_ref::<String>().map(String::as_str).unwrap_or("unknown panic");
            println!("worker panicked: {msg}");
        }
    }
    println!("main continues");
}

Parking, sleeping and yielding

thread::sleep(d) blocks for at least d; thread::yield_now() hints that other threads should run; thread::park() blocks the current thread until another thread calls handle.thread().unpark() — a low-level building block for custom synchronisation. Most programs should use channels, Condvar or Barrier instead.

Compared with other languages

Java’s Thread and executors, C++'s std::thread and std::jthread, and C11’s thrd_create (or POSIX threads) offer the same OS threads; the difference is that Rust rejects at compile time the programs that would share non-thread-safe data or let a thread outlive borrowed data. See Java: Concurrency Basics, C++: Threads & Synchronization and C: Threads.

Pitfalls

  • Forgetting the original Sender. A consumer that iterates a Receiver ends only when every Sender is dropped, including the one kept by the thread that created the channel. Drop it after cloning, or move it into the last producer.

  • Joining before draining a bounded channel. With sync_channel(n), a producer blocks once n messages are queued. If the main thread joins the producer before it starts receiving, both wait forever. Receive first, or receive on another thread.

  • Holding a lock while waiting. Calling recv() or join() while holding a MutexGuard blocks everyone else that needs the lock; scope guards tightly (see the worker pool above).

  • Detached threads at exit. Returning from main ends the process; work in threads that were never joined is lost and their destructors do not run. Keep the JoinHandle of every thread whose work matters.

  • Unwrapping send and join everywhere. send fails when the receiver is gone, and join returns the panic payload of a panicked thread. In long-running programs, treat both as signals to shut down cleanly rather than propagating a panic through every thread.

  • A thread per item. Spawning thousands of OS threads costs memory (each has its own stack, 2 MiB by default for spawned threads) and scheduling time. Bound the number of threads with a pool, Rayon or thread::scope over chunks.

  • Sleeping to synchronise. thread::sleep makes tests slow and flaky; wait on the event itself — join, a channel, a Barrier or a Condvar.

  • Thread-locals in async code. A task can resume on a different worker thread after every .await, so a value read from thread_local! before an .await may belong to another thread afterwards.

Common errors

Error Cause and fix

E0373 closure may outlive the current function

add move, or use thread::scope to borrow

E0277 Rc<..> cannot be sent between threads safely

use Arc; more generally, the value is not Send

E0277 RefCell<..> cannot be shared between threads safely

use Mutex/RwLock or atomics; the type is not Sync

E0382 use of moved value after move

clone before spawning, or share with Arc

E0499 cannot borrow loans as mutable more than once

two scoped threads both mutate the same local; give each thread its own part (split_at_mut, chunks_mut) or use a Mutex

E0599 no method named clone found for Receiver

a Receiver has a single owner; share it through Arc<Mutex<Receiver<T>>> (as the pool does) or use an MPMC channel

consumer loop never ends

a Sender is still alive — drop the original after cloning

Each of these is a compile-time check, so it is worth recognising the shapes. Borrowing a local in a spawned thread is E0373, because thread::spawn requires a 'static closure:

use std::thread;

fn main() {
    let titles = vec![String::from("Dune"), String::from("Emma")];
    let handle = thread::spawn(|| titles.len()); // error[E0373]: closure may outlive the current function
    println!("{}", handle.join().unwrap());
}

Sharing a non-Sync type is E0277, even when it is wrapped in an Arc, because Arc<T> is only Send when T is Send + Sync:

use std::cell::RefCell;
use std::sync::Arc;
use std::thread;

fn main() {
    let cache = Arc::new(RefCell::new(Vec::<String>::new()));
    let worker_cache = Arc::clone(&cache);
    let h = thread::spawn(move || worker_cache.borrow_mut().push("Dune".into())); // error[E0277]: `RefCell<Vec<String>>` cannot be shared between threads safely
    h.join().unwrap();
}

Scoped threads may borrow, but the usual borrowing rules still apply across threads: two threads cannot both hold &mut loans, which is E0499:

use std::thread;

fn main() {
    let mut loans = Vec::new();
    thread::scope(|s| {
        s.spawn(|| loans.push("Dune"));
        s.spawn(|| loans.push("Emma")); // error[E0499]: cannot borrow `loans` as mutable more than once at a time
    });
    println!("{loans:?}");
}

The fix is to split the data so that each thread borrows a disjoint part, then combine the results after the scope:

use std::thread;

fn main() {
    let mut due_days = [14u32, 7, 21, 14, 28, 7];
    thread::scope(|s| {
        for chunk in due_days.chunks_mut(2) {
            s.spawn(move || chunk.iter_mut().for_each(|d| *d += 7)); // each thread owns one &mut chunk
        }
    });
    println!("{due_days:?}"); // [21, 14, 28, 21, 35, 14]
}

A Receiver cannot be cloned, which is how the type system enforces single consumer (E0599):

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel::<String>();
    let second = rx.clone(); // error[E0599]: no method named `clone` found for struct `std::sync::mpsc::Receiver<T>`
    tx.send("Dune".into()).unwrap();
    drop(second);
}

Version notes

  • thread::available_parallelism was stabilised in 1.59, JoinHandle::is_finished in 1.61, and scoped threads (thread::scope, Builder::spawn_scoped) in 1.63. Code that predates them uses num_cpus and crossbeam’s scoped threads.

  • In 1.67 the implementation of std::sync::mpsc was replaced by a port of crossbeam-channel, making it faster without changing the API; 1.72 made Sender Sync.

  • The with_borrow, with_borrow_mut, set, get, take and replace methods on LocalKey for Cell/RefCell thread-locals arrived in 1.73; older code wraps every access in KEY.with(|v| ...).

  • A multi-consumer std::sync::mpmc channel exists but is still unstable (nightly-only) on Rust 1.99; on stable, use crossbeam-channel for MPMC.