Message Passing with Channels
When two threads need to cooperate, you have two basic strategies: let them share memory and protect it with locks, or let them pass messages to each other and never share the underlying data directly. Rust’s standard library favors the second approach with channels — a thread-safe pipe with a sending end and a receiving end. Because sending a value through a channel moves it, the compiler guarantees that only one thread ever owns the data at a time, which rules out data races on that value without a single lock. This lesson covers the std::sync::mpsc channel type from the ground up: the mental model, the syntax, worked examples, and the mistakes almost everyone hits the first time.
Overview: How Channels Work
Think of a channel as a pipe with two distinct ends. Calling mpsc::channel() gives you back a tuple (tx, rx): tx is a Sender<T>, the end you write into, and rx is a Receiver<T>, the end you read from. The name mpsc stands for multiple producer, single consumer: you can clone Sender<T> as many times as you like to give several threads a way to send, but there is only ever one Receiver<T> and it cannot be cloned.
The key idea to internalize is that sending a value is exactly like passing it by value anywhere else in Rust: it moves. If you call tx.send(value), the variable value is consumed — its ownership transfers into the channel, and then to whichever thread eventually calls rx.recv(). This is why channels compose so naturally with ownership: there is never a moment where two threads hold a reference to the same piece of data, so there is nothing for the borrow checker or a data race to fight over. Contrast this with shared-memory concurrency (covered in the Mutex and Arc lessons), where the same memory is genuinely accessed from multiple threads and needs a lock to stay safe.
Receiving works two ways. rx.recv() blocks the calling thread until a value arrives, returning Result<T, RecvError>. If every clone of the Sender has been dropped and no message is coming, recv() returns Err immediately instead of blocking forever — this is how a channel signals it is closed. There is also a non-blocking try_recv(), which returns Err(TryRecvError::Empty) right away if nothing is ready yet. Finally, Receiver<T> implements IntoIterator, so you can write for value in rx to keep receiving values until the channel closes — this is usually the cleanest way to drain a channel.
Syntax
The general shape of working with a channel looks like this:
let (tx, rx) = mpsc::channel();
tx.send(value).unwrap(); // moves `value` into the channel
let received = rx.recv().unwrap(); // blocks until a value arrives
for value in rx {
// iterate until every Sender is dropped and the channel closes
}
| Item | What it does |
|---|---|
mpsc::channel() |
Creates an unbounded channel; returns (Sender<T>, Receiver<T>). |
mpsc::sync_channel(n) |
Creates a bounded channel that holds at most n messages; send blocks once it is full, giving you backpressure. |
Sender<T>::send(value) |
Moves value into the channel; returns Err only if the Receiver has been dropped. |
Sender<T>::clone() |
Creates another handle to the same channel so multiple threads can send. |
Receiver<T>::recv() |
Blocks until a value arrives or every Sender is dropped (then returns Err). |
Receiver<T>::try_recv() |
Returns immediately, Ok(value) or Err if nothing is ready. |
for x in rx |
Iterates, blocking between items, until the channel closes. |
Examples
Example 1: A single message from a spawned thread
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let message = String::from("hello from the spawned thread");
tx.send(message).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
}
Output:
Got: hello from the spawned thread
The move closure takes ownership of tx, so the sender lives inside the spawned thread. That thread builds a String, then send moves it into the channel — the local variable message is no longer usable after that line inside the closure. Meanwhile, the main thread calls rx.recv(), which blocks until the spawned thread’s send makes a value available, then prints it.
Example 2: Sending several messages and iterating the receiver
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let messages = vec![
String::from("one"),
String::from("two"),
String::from("three"),
];
for msg in messages {
tx.send(msg).unwrap();
thread::sleep(Duration::from_millis(10));
}
});
for received in rx {
println!("Got: {}", received);
}
}
Output:
Got: one
Got: two
Got: three
Here the spawned thread sends three separate String values, pausing briefly between each. The main thread treats rx as an iterator with for received in rx, printing each value as it arrives. When the spawned thread’s closure finishes, its owned tx is dropped, which closes the channel and ends the for loop automatically — no explicit "stop" signal is needed.
Example 3: Multiple producers with a cloned sender
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 0..3 {
let tx_clone = tx.clone();
thread::spawn(move || {
tx_clone.send(format!("message from producer {}", id)).unwrap();
});
}
drop(tx);
let mut received: Vec<String> = rx.iter().collect();
received.sort();
for msg in received {
println!("{}", msg);
}
}
Output:
message from producer 0
message from producer 1
message from producer 2
Each loop iteration calls tx.clone() to get its own handle to the same channel, then moves that clone into a new thread. Because three threads are racing to send, the arrival order isn’t guaranteed, so the example collects everything into a Vec and sorts it for a deterministic printout. Notice the explicit drop(tx): the original sender is never moved into a thread, so without dropping it manually the channel would never close and rx.iter() would block forever waiting for one more sender that will never send anything.
How It Works Step by Step
Walking through Example 1 in order:
mpsc::channel()allocates a shared queue internally and hands back aSender<String>and aReceiver<String>that both point at it.thread::spawn(move || ...)movestxinto the new thread’s closure; the main thread no longer has access totx.- Inside the new thread,
tx.send(message)movesmessageout of the closure’s local scope and pushes it onto the channel’s internal queue, waking up any thread blocked onrecv(). - Back in the main thread,
rx.recv()was already blocked waiting; as soon as the value is pushed, it unblocks, removes the value from the queue, and returnsOk(message). .unwrap()extracts theStringfrom theResult(safe here because we know the sender is still alive when we call it), andprintln!prints it.
The compiler enforces every step of this at compile time: it knows tx was moved into the closure, so any attempt to use tx again in main after the thread::spawn call is a compile error — not a runtime bug waiting to happen.
Common Mistakes
Mistake 1: Using a value after sending it
send moves its argument. Trying to use that variable afterward doesn’t compile:
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
let msg = String::from("hi there");
tx.send(msg).unwrap();
println!("still have it: {}", msg); // error: value borrowed here after move
let _ = rx.recv();
}
The fix is to decide whether you actually need the value afterward. If you do, clone it before sending — sending the clone leaves the original untouched:
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
let msg = String::from("hi there");
tx.send(msg.clone()).unwrap();
println!("still have it: {}", msg);
let received = rx.recv().unwrap();
println!("received: {}", received);
}
Output:
still have it: hi there
received: hi there
Mistake 2: Reusing a sender that was already moved into a thread
Each thread::spawn(move || ...) call takes full ownership of everything the closure captures. If two closures both try to capture the same tx, the second one fails to compile because tx was already moved into the first:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send(1).unwrap();
});
thread::spawn(move || {
tx.send(2).unwrap(); // error: use of moved value `tx`
});
for received in rx {
println!("Got: {}", received);
}
}
The fix, as in Example 3, is to clone() the sender once per producer before moving each clone into its own thread:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
let tx2 = tx.clone();
thread::spawn(move || {
tx.send(1).unwrap();
});
thread::spawn(move || {
tx2.send(2).unwrap();
});
let mut received: Vec<i32> = rx.iter().collect();
received.sort();
for value in received {
println!("Got: {}", value);
}
}
Output:
Got: 1
Got: 2
Mistake 3: Calling .unwrap() on recv() without expecting a closed channel
A closed channel (every Sender dropped with nothing sent) is a completely normal condition, not a bug — but .unwrap() treats it as one. This compiles fine, but panics the instant it runs, before printing anything:
use std::sync::mpsc;
fn main() {
let (tx, rx): (mpsc::Sender<i32>, mpsc::Receiver<i32>) = mpsc::channel();
drop(tx);
let value = rx.recv().unwrap();
println!("{}", value);
}
Output:
thread 'main' panicked at ...: called `Result::unwrap()` on an `Err` value: RecvError
Handling the Result explicitly turns a panic into ordinary control flow:
use std::sync::mpsc;
fn main() {
let (tx, rx): (mpsc::Sender<i32>, mpsc::Receiver<i32>) = mpsc::channel();
drop(tx);
match rx.recv() {
Ok(value) => println!("Got: {}", value),
Err(_) => println!("channel closed with no message"),
}
}
Output:
channel closed with no message
Best Practices
- Reach for channels when the pattern is naturally "producer computes a value, consumer uses it" — it’s usually simpler and safer than sharing memory behind a
Mutex. - Prefer
for value in rxover manually looping ontry_recv(); it blocks efficiently and stops automatically when the channel closes. - Clone the
Senderonce per producer thread; never try to move the sameSenderinto more than one closure. - Make sure every
Senderhandle, including the original, either gets moved into a thread or is explicitlydropped — a forgotten liveSenderis the most common reason a receiving loop hangs forever. - Handle
recv()‘sResultwithmatchorif letin real code; a closed channel is an expected event, not a reason to panic. - For large payloads, consider sending a
Box<T>or anArc<T>instead of the value itself, so a big struct isn’t copied through the queue. - If producers can outpace the consumer and you want to cap memory use, use
mpsc::sync_channel(n)instead of the unboundedmpsc::channel()— it makessendblock once the buffer is full.
Practice Exercises
- Spawn five threads, each sending its own index (0 through 4) doubled through a channel. Collect all five results in the main thread and print their sum. Expected output:
Sum: 20. - Rewrite Example 2 so the spawned thread sends integers
1through5instead of strings, and have the main thread print only the even values it receives. - Build a tiny "request/response" program using two channels: a worker thread receives numbers on one channel, squares them, and sends the results back on a second channel to the main thread.
Summary
- A channel is a pipe split into a
Sender<T>and aReceiver<T>, created together bympsc::channel(). sendmoves its argument into the channel, so ownership transfers from the sending thread to whichever thread eventually receives it — this is what keeps message passing free of data races.mpscmeans multiple producer, single consumer: cloneSenderfor extra producers, but there is only ever oneReceiver.recv()blocks until a value arrives or everySenderis dropped, in which case it returnsErr; iteratingfor x in rxstops automatically when the channel closes.- A common bug is leaving a live
Senderaround (not moved into a thread, not dropped) — the receiver’s loop orrecv()call then waits forever. - Prefer
match/if letover.unwrap()when receiving, and use boundedsync_channelwhen you need backpressure.
