Rust Channels and mpsc Tutorial
A practical tour of Rust's std::sync::mpsc channels: senders, receivers, backpressure, and patterns for safe message passing between threads.
What you'll learn
- ✓How mpsc channels move ownership across threads
- ✓When to use bounded vs unbounded channels
- ✓How disconnection and recv errors work
- ✓A worker-pool pattern with channels
- ✓Where to reach for crossbeam or tokio instead
Prerequisites
- •Basic Rust threading familiarity
What and Why
Rust’s standard library ships a multi-producer, single-consumer channel in std::sync::mpsc. Channels are the idiomatic way to coordinate threads without sharing mutable state. Instead of locking a Mutex, you send a message and the receiver owns it. This sidesteps a whole category of race conditions: the type system enforces that only one thread holds the value at a time.
You’ll reach for channels when you need to fan work out to a thread, collect results back from workers, or build pipelines where each stage handles a distinct concern.
Mental Model
A channel is a typed queue with two halves. The Sender<T> half can be cloned, so many threads can push into the queue. The Receiver<T> half cannot be cloned, so exactly one thread drains it. When the last Sender is dropped, the receiver sees a clean disconnect and recv returns Err.
There are two flavors. channel() is unbounded: senders never block, but memory can grow without limit if the consumer falls behind. sync_channel(n) is bounded with capacity n: senders block when full, giving you backpressure for free.
Hands-on Example
Here’s a tiny worker pool that processes jobs and returns results.
use std::sync::mpsc;
use std::thread;
fn main() {
let (job_tx, job_rx) = mpsc::sync_channel::<u32>(4);
let (res_tx, res_rx) = mpsc::channel::<u32>();
// Shared receiver via a single dispatcher thread keeps mpsc single-consumer.
let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx));
for id in 0..3 {
let rx = job_rx.clone();
let tx = res_tx.clone();
thread::spawn(move || {
loop {
let job = {
let guard = rx.lock().unwrap();
guard.recv()
};
match job {
Ok(n) => tx.send(n * n).unwrap(),
Err(_) => break, // senders dropped
}
}
println!("worker {id} exiting");
});
}
drop(res_tx); // ensure result channel closes when workers finish
for n in 1..=8 {
job_tx.send(n).unwrap();
}
drop(job_tx);
let mut results: Vec<u32> = res_rx.iter().collect();
results.sort();
println!("results: {results:?}");
}
The sync_channel(4) gives backpressure: if workers are slow, the producer pauses at the fifth send. Dropping job_tx after the loop signals workers to exit. Dropping the original res_tx is what eventually lets res_rx.iter() end.
Common Pitfalls
Holding the receiver too long. Locking job_rx, doing CPU work, then unlocking serializes the pool. Lock, take the message, drop the guard, then process.
Forgetting to drop the sender. If you keep a Sender alive in scope, recv will block forever waiting for messages that never come. Use explicit drop(tx) or scope it.
Unbounded channels as default. They feel friendly but hide producer-consumer mismatches. A leaky pipeline can swallow gigabytes before you notice. Prefer sync_channel with a thoughtful capacity.
send errors ignored. send returns Err when the receiver is gone. Unwrapping is fine for prototypes; for real services you usually want to log or shut down gracefully.
Using mpsc when you need MPMC. Standard mpsc is single-consumer. If you want multiple consumers, either wrap the receiver in Arc<Mutex<_>> (as above) or use crossbeam_channel, which natively supports MPMC and is faster.
Practical Tips
Pick capacity from real measurements. A common starting point is “twice the number of workers”; tune up if producers stall, down if memory grows.
Prefer typed messages. Send a enum Job { Compute(u32), Shutdown } instead of a raw number when workers need multiple commands. This keeps the protocol explicit.
Use recv_timeout for shutdown coordination if you can’t drop the sender (for example, in long-lived services).
For async code, do not use std::sync::mpsc inside tokio::spawn; reach for tokio::sync::mpsc instead. The std channel blocks the executor thread.
Benchmark with crossbeam_channel before you reach for fancier solutions. It’s a drop-in replacement that often doubles throughput and removes the single-consumer restriction.
Wrap-up
Channels are the cleanest way to pass owned data between threads in Rust. With mpsc, the type system guarantees safe handoffs, while sync_channel adds the backpressure you need for resilient pipelines. Start with bounded channels, drop your senders explicitly, and consider crossbeam or tokio variants when your patterns outgrow the standard library. The result is concurrent code that reads almost like sequential code, plus a compiler that catches the hard mistakes before they ship.
Related articles
- Rust Rust Concurrency: Channels, Mutex, and Arc Patterns
Learn Rust concurrency with channels, Mutex, Arc, and thread-safe patterns. Covers mpsc, shared state, thread pools, and real-world examples.
- Rust Rust Async Programming with Tokio: Getting Started
Learn async Rust with Tokio from scratch. Covers async/await syntax, spawning tasks, channels, TCP servers, and common pitfalls with working examples.
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.
- Go Go Channel and Select Statement Patterns
Master Go channels and select statements with patterns for fan-out, fan-in, timeouts, cancellation, and pipeline composition.