Channels
Message passing lets threads communicate by sending owned data through channels instead of sharing mutable state. Rust's standard library provides mpsc; the ecosystem adds bounded queues, select, and more.
Recipe
Quick-reference recipe card - copy-paste ready.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send(42).unwrap();
});
println!("{}", rx.recv().unwrap());
}When to reach for this: When workers produce results, pipeline stages hand off work, or you want to avoid locks and shared mutation.
Working Example
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
let producer = thread::spawn(move || {
for i in 0..5 {
tx.send(i).unwrap();
thread::sleep(Duration::from_millis(10));
}
});
for msg in rx {
println!("received {msg}");
}
producer.join().unwrap();
}What this demonstrates:
mpsc::channelcreates an unbounded queue (memory can grow).sendmoves ownership; the sender can be cloned for multiple producers.- Iterating
rxblocks until senders are dropped and the queue is empty.
Deep Dive
How It Works
- Ownership transfer: Each message is moved, not copied (unless
Copy). No locks on the message body once sent. - Single consumer: Standard
mpschas one receiver. Multiple receivers needcrossbeam-channelor fan-out patterns. - Backpressure: Unbounded channels never block on send; bounded channels block when full.
- Disconnection:
recvreturnsErrwhen all senders are dropped.
std::sync::mpsc vs crossbeam
| Feature | std::sync::mpsc | crossbeam-channel |
|---|---|---|
| Bounded queue | no | yes |
| Multiple consumers | no | yes |
select! / try_recv batch | limited | yes |
| Ecosystem default | std only | common in production |
Rust Notes
// Multiple producers: clone the sender
let (tx, rx) = mpsc::channel();
let tx2 = tx.clone();
thread::spawn(move || { tx.send(1).unwrap(); });
thread::spawn(move || { tx2.send(2).unwrap(); });
drop(tx); // drop original so rx iterator can finish- Clone
Senderbefore moving into each thread. - Drop all senders when done so
recvcan returnDisconnected. - Prefer
crossbeam_channel::bounded(n)when producers can outpace consumers.
Gotchas
- Unbounded memory growth - Fast producers with slow consumers fill RAM. Fix: Use a bounded channel or apply backpressure.
- Forgetting to drop senders -
recvblocks forever if a clone still exists. Fix: Track sender handles and drop extras explicitly. - Sending non-Send types - Channel messages must be
Send. Fix: UseArcor restructure; do not sendRcacross threads. - Mutex inside every message - Defeats the purpose of message passing. Fix: Send owned data or ids, not shared locks.
- Panicking on
sendafter disconnect - Receiver gone meanssendfails. Fix: HandleError uselet _ = tx.send(...)with logging.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Arc<Mutex<T>> | Shared state with infrequent updates | Many producers writing independent messages |
crossbeam-channel bounded | Production pipelines with backpressure | You only need std and unbounded is fine |
Tokio mpsc | Async services | Pure sync thread code |
| Shared memory ring buffer | Ultra-low latency, fixed capacity | You need dynamic sizing or many consumers |
FAQs
Should I use std or crossbeam channels?
Start with std::sync::mpsc for learning and simple tools. Use crossbeam-channel when you need bounded queues, multiple consumers, or select.
Can I share the receiver between threads?
Not with std mpsc - only one Receiver. Split work at the sender side or use crossbeam multi-consumer patterns.
What happens if the receiver is dropped?
send returns Err(SendError(msg)) and you recover ownership of the message.
Are channels faster than mutexes?
For independent messages, channels avoid lock contention. For hot shared counters, atomics or a single mutex may be simpler.
How do I signal shutdown?
Send a sentinel message, use a bool flag in an Arc<AtomicBool>, or drop senders and let recv return Disconnected.
Can channels carry `Result`?
Yes. Many pipelines send Result<T, E> so workers report per-item failures.
sync_channel vs channel?
sync_channel(n) blocks after n outstanding messages - built-in backpressure.
How does this relate to Go channels?
Similar philosophy (share by communicating). Rust channels move ownership and are typed; bounded backpressure is explicit.
Async vs sync channels?
Do not block an async executor on std::sync::mpsc::recv. Use tokio::sync::mpsc in async code.
Testing channel code?
Use short timeouts (recv_timeout) or bounded channels in tests to avoid hangs when a bug leaves a sender alive.
Related
- Concurrency Basics - first channel example
- Mutex & RwLock - shared state alternative
- Deadlocks & Race Conditions - fewer races with messages
- Channels in Tokio - async channels
- Concurrency Best Practices - prefer message passing
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.