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(())
}
Source: std::thread::Builder
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);
}
Source: std::thread::scope
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>):sendnever blocks; -
mpsc::sync_channel(n)returns a bounded(SyncSender<T>, Receiver<T>):sendblocks whennmessages are queued, which gives backpressure (withn = 0, every send waits for a matching receive — a rendezvous).
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" });
}
}
Source: The Book: From Single-Threaded to Multithreaded Server and The Book: Graceful Shutdown and Cleanup
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();
}
Source: std: Receiver::recv_timeout
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
}
Source: std::thread_local
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");
}
Source: std::thread: The threading model
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 aReceiverends only when everySenderis 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 oncenmessages 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()orjoin()while holding aMutexGuardblocks everyone else that needs the lock; scope guards tightly (see the worker pool above). -
Detached threads at exit. Returning from
mainends the process; work in threads that were never joined is lost and their destructors do not run. Keep theJoinHandleof every thread whose work matters. -
Unwrapping
sendandjoineverywhere.sendfails when the receiver is gone, andjoinreturns 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::scopeover chunks. -
Sleeping to synchronise.
thread::sleepmakes tests slow and flaky; wait on the event itself —join, a channel, aBarrieror aCondvar. -
Thread-locals in async code. A task can resume on a different worker thread after every
.await, so a value read fromthread_local!before an.awaitmay belong to another thread afterwards.
Common errors
| Error | Cause and fix |
|---|---|
E0373 closure may outlive the current function |
add |
E0277 |
use |
E0277 |
use
|
E0382 use of moved value after |
clone before spawning, or
share with |
E0499 cannot borrow |
two scoped
threads both mutate the same local; give each thread its own part ( |
E0599 no method named |
a |
consumer loop never ends |
a |
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:?}");
}
Source: Error E0499 and std::thread::scope
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]
}
Source: std: slice::chunks_mut
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);
}
Source: Error E0599 and std::sync::mpsc::Receiver
Version notes
-
thread::available_parallelismwas stabilised in 1.59,JoinHandle::is_finishedin 1.61, and scoped threads (thread::scope,Builder::spawn_scoped) in 1.63. Code that predates them usesnum_cpusand crossbeam’s scoped threads. -
In 1.67 the implementation of
std::sync::mpscwas replaced by a port ofcrossbeam-channel, making it faster without changing the API; 1.72 madeSenderSync. -
The
with_borrow,with_borrow_mut,set,get,takeandreplacemethods onLocalKeyforCell/RefCellthread-locals arrived in 1.73; older code wraps every access inKEY.with(|v| ...). -
A multi-consumer
std::sync::mpmcchannel exists but is still unstable (nightly-only) on Rust 1.99; on stable, usecrossbeam-channelfor MPMC.
References
-
The Rust Programming Language: Using Threads to Run Code Simultaneously
-
The Rust Programming Language: Using Message Passing to Transfer Data Between Threads
-
The Rust Programming Language: Extensible Concurrency with the Send and Sync Traits
-
The Rust Programming Language: From Single-Threaded to Multithreaded Server and Graceful Shutdown and Cleanup
-
E0373, E0277, E0382, E0499 and E0599 in the Rust Compiler Error Index
-
Rust release notes (1.59, 1.61, 1.63, 1.67, 1.72, 1.73)