Message passing (channels / mpsc)Concept
Explanation
A channel is a queue-like pipe connecting two or more threads: one side
sends values into it, the other side receives them, and the standard
library's std::sync::mpsc module (multiple-producer, single-consumer)
provides the classic form — any number of cloned Sender handles can feed
values in, but only one Receiver drains them, in the order they were
sent. Sending a value moves it through the channel: ownership transfers
from the sending thread to whichever thread eventually receives it, so
there is no moment where both threads hold the data at once (see
Move semantics).
This is the concurrency model summed up by the slogan "don't communicate by sharing memory; share memory by communicating" — instead of multiple threads reaching into the same location and coordinating through locks (see Shared-state concurrency), each piece of data has a single owner at any moment and moves between threads through an explicit handoff. This sidesteps an entire category of concurrency bugs: there's no lock to forget, no critical section to keep short, and no way for two threads to observe the data mid-mutation, because the data is simply never in two places' hands simultaneously.
Channels are a natural fit for pipeline- and worker-pool-shaped programs:
a producer thread generates work items and sends them, a pool of consumer
threads receives and processes them, and results can flow back through a
second channel. Because Sender and Receiver are ordinary values, they
compose with everything else in the ownership system — cloning a Sender
for each producer thread, moving a Receiver into the one consuming
thread, and letting Drop close the channel automatically once every
Sender has gone out of scope (the receive loop then ends naturally
instead of needing a manual shutdown signal).
Message passing and shared-state concurrency aren't mutually exclusive — real programs often use both, picking whichever fits a given piece of data. A value that's naturally produced by one thread and consumed by another (a job queue, a stream of sensor readings, a pipeline stage) suits a channel; a value that many threads need to read and update concurrently (a shared counter, a connection pool) suits a Mutex or RwLock instead.
Basic usage example
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel(); // <- tx sends into the channel, rx receives from it
thread::spawn(move || {
tx.send("done").unwrap(); // <- ownership of the value moves into the channel
});
println!("{}", rx.recv().unwrap()); // blocks until a value arrives
Best practices & deeper information
Message passing between threads
An order-processing service reads incoming orders off one channel and fans results back out through another, keeping the intake and processing stages fully decoupled.
use std::sync::mpsc;
use std::thread;
struct Order { id: u64, total_cents: u64 }
let (order_tx, order_rx) = mpsc::channel::<Order>();
let (result_tx, result_rx) = mpsc::channel::<u64>();
thread::spawn(move || {
for order in order_rx { // <- receives each Order as ownership is handed across
result_tx.send(order.id).unwrap(); // <- forwards a result through a second channel
}
});
order_tx.send(Order { id: 1, total_cents: 2500 }).unwrap();
drop(order_tx); // closes the channel so the receiving loop above can end
for id in result_rx {
println!("processed order {id}");
}
Why this way: chaining two single-purpose channels keeps the intake and processing stages independently testable and replaceable, matching the Rust Book's framing of channels as the default choice for handing work between threads before reaching for shared state.
Multi-threading
A worker pool distributes incoming jobs to a fixed number of threads by
cloning the Sender once per worker, while a single shared Receiver
(wrapped for multi-consumer access) hands each job to whichever worker
asks for it next.
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
let (tx, rx) = mpsc::channel::<u32>();
let rx = Arc::new(Mutex::new(rx)); // <- one Receiver, shared so multiple worker threads can pull from it
let mut workers = Vec::new();
for worker_id in 0..4 {
let rx = Arc::clone(&rx);
workers.push(thread::spawn(move || {
while let Ok(job) = rx.lock().unwrap().recv() { // <- lock only long enough to pull one job
println!("worker {worker_id} handling job {job}");
}
}));
}
for job in 0..8 {
tx.send(job).unwrap();
}
drop(tx);
for worker in workers {
worker.join().unwrap();
}
Why this way: mpsc::Receiver isn't Sync, so sharing the receiving
end across several worker threads needs a Mutex around it even though the
data flowing through the channel is still handed off by value — the
std::sync::mpsc docs
describe this single-consumer design, and wrapping the receiver is the
standard pattern for a multi-worker pool built on it.
Sharing state across threads
A background exporter thread needs a way to be told to stop cleanly — sending a dedicated shutdown message down the same channel used for real work avoids introducing a second synchronization primitive just for signaling.
use std::sync::mpsc;
use std::thread;
enum Task {
Export(String),
Shutdown, // <- a plain message, no separate flag or condition variable needed
}
let (tx, rx) = mpsc::channel();
let exporter = thread::spawn(move || {
for task in rx {
match task {
Task::Export(name) => println!("exporting {name}"),
Task::Shutdown => break, // <- stops the loop on request
}
}
});
tx.send(Task::Export("report.csv".into())).unwrap();
tx.send(Task::Shutdown).unwrap();
exporter.join().unwrap();
Why this way: modeling shutdown as a message keeps all inter-thread
coordination going through the same channel instead of adding an
AtomicBool flag the receiver has to poll separately — the
Rust Book
treats channels as a general-purpose way to coordinate, not just to
transfer data.