Channels
Communicate between threads by sending owned values through std::sync::mpsc channels — the message-passing alternative to shared state.
- Create a channel with mpsc::channel and split it into Sender and Receiver
- Send values across a channel and receive them
- Clone Sender for multiple producer threads
- Explain the trade-offs between bounded and unbounded channels
Shared state with Mutex requires every thread to agree on locking discipline. Channels offer a different model: threads communicate by sending owned values to each other. The sender gives up ownership of the value; the receiver gains it. There's no shared memory to coordinate — the channel owns data in transit.
This is the "do not communicate by sharing memory; instead, share memory by communicating" philosophy, made explicit in Rust's type system.
std::sync::mpsc
The standard library provides a multi-producer, single-consumer (mpsc) channel:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel(); // tx = transmitter, rx = receiver
thread::spawn(move || {
tx.send(String::from("hello from thread")).unwrap();
});
let message = rx.recv().unwrap();
println!("{}", message);
}mpsc::channel() returns a (Sender<T>, Receiver<T>) pair. The type T is inferred from the first send. rx.recv() blocks until a message arrives; it returns Result<T, RecvError>, where RecvError means all senders have been dropped.
The channel takes ownership of the sent value. The thread that sends String::from("hello") can no longer use it after send(). The receiver gets exclusive ownership. This is ownership-based message passing — no mutex needed.
Iterating over messages
recv() gives you one message. To drain a channel until it closes, iterate the receiver:
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 0..5 {
tx.send(i).unwrap();
}
// tx dropped here — channel closes
});
for received in rx { // iterates until tx is dropped
println!("{}", received);
}When all Sender handles are dropped, the channel closes. Iterating rx then terminates naturally — no sentinel value needed.
Multiple producers
The channel is multi-producer: clone the Sender for each producer thread:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
let handles: Vec<_> = (0..4).map(|i| {
let tx = tx.clone(); // each thread gets its own Sender
thread::spawn(move || {
tx.send(format!("message from thread {}", i)).unwrap();
})
}).collect();
drop(tx); // drop the original — only clones remain
for msg in rx {
println!("{}", msg);
}
for h in handles { h.join().unwrap(); }
}Note the drop(tx) before the iteration loop: if the original tx is still alive, the channel never closes (there's always at least one sender). Dropping it lets the iteration terminate when all clones are gone.
Forgetting to drop the original Sender is a common mistake. If any Sender clone is kept alive longer than intended, the receiver will block forever waiting for messages that never come.
Non-blocking receive
recv() blocks the calling thread. Use try_recv() to check without blocking:
match rx.try_recv() {
Ok(msg) => println!("got: {}", msg),
Err(mpsc::TryRecvError::Empty) => println!("no message yet"),
Err(mpsc::TryRecvError::Disconnected) => println!("all senders dropped"),
}try_recv is useful in event loops where you don't want to block waiting for work.
Bounded vs unbounded channels
mpsc::channel() creates an unbounded channel — the sender never blocks, and messages accumulate in a queue. If the producer is faster than the consumer, memory grows without bound.
mpsc::sync_channel(n) creates a bounded (synchronous) channel with a buffer of size n:
let (tx, rx) = mpsc::sync_channel(10); // buffer of 10 messagesWith a bounded channel, send() blocks when the buffer is full — providing backpressure. This prevents runaway memory use and is the right default for pipelines where you want the producer to slow down when the consumer can't keep up.
Check your understanding
Knowledge check
- 1.After calling tx.send(value), can the sending thread still use value?
- 2.When does iterating over a Receiver terminate?
- 3.A bounded channel (sync_channel) will block the sender when the buffer is full, providing backpressure.
Do it yourself
Build a pipeline: one producer thread reads numbers 1–20, a worker thread filters odds and sends them to a collector, and the main thread collects and prints the results.
cargo new channel-pipeline
# Edit src/main.rs with two channels: producer -> filter -> main
cargo runObserve how ownership flows through the pipeline — no locks needed, and no data is copied.
Where to go next
Channels and threads give you straightforward parallelism for CPU-bound work. For IO-bound work — network servers, file operations, many small tasks — async/await is a better fit. Next: an introduction to async Rust and the Tokio runtime.
Shared state
Share data safely across threads using Arc for reference counting and Mutex or RwLock for interior mutability — and understand Send and Sync.
Async and await
Write non-blocking IO with async/await syntax, understand the Future trait conceptually, and run async tasks with the Tokio runtime.