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
- Go Go Goroutines and Channels Tutorial
A practical introduction to concurrency in Go: goroutines, channels, select, common patterns like fan-out/fan-in, and the pitfalls that cause leaks and races.
- Java Java Multithreading and Synchronization: A Practical Guide
Understand threads, the Java memory model, synchronization, locks, and concurrent collections. A practical guide to writing correct multithreaded Java code.
- Node.js Node.js Process vs Thread vs Cluster
Understand when to reach for child processes, worker threads, or the cluster module to scale Node.js workloads.
- Rust Rust Actix vs Axum Comparison
A side-by-side comparison of Actix Web and Axum, covering architecture, ergonomics, performance, ecosystem, and how to pick the right one for your project.