22.2Message passing: channels
Threads that can't talk to each other aren't much use. There are two broad ways for threads to share information: pass messages between them, or share access to the same memory. This lesson covers the first, which a famous slogan from the Go language recommends as the default: "Do not communicate by sharing memory; instead, share memory by communicating." Rust's tool for it is the channel, and it fits the ownership system so naturally that the compiler eliminates a whole class of bugs for free.
A channel has two ends
A channel is a one-way pipe between threads. It has a transmitter end (where you send values in) and a receiver end (where they come out). The standard library's mpsc::channel creates a connected pair. The name mpsc stands for "multiple producer, single consumer", many transmitters can feed one receiver, which we'll use shortly.
Here's one thread sending a value to another:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let message = String::from("hello from the thread");
tx.send(message).unwrap();
});
let received = rx.recv().unwrap();
println!("got: {received}");
}got: hello from the thread
mpsc::channel() returns the pair (tx, rx), transmitter and receiver, by convention. The spawned thread (a move closure, lesson 22.1, so it owns tx) calls tx.send(message) to put a value into the channel. Meanwhile main calls rx.recv(), which blocks, waits, until a value arrives, then returns it. Both send and recv return Result: send fails if the receiver is gone, recv fails if all transmitters are gone, so there's nothing left to wait for. We unwrap both here.
send moves ownership, and that's the magic
Here's the part that makes channels delightful in Rust specifically. When you send a value, you give it away: send takes ownership of the value and moves it down the channel (chapter 8). After sending, the sending thread no longer owns it and cannot use it. Try:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let message = String::from("hello");
tx.send(message).unwrap();
println!("sent: {message}"); // error: message was moved into send
});
println!("{}", rx.recv().unwrap());
}error[E0382]: borrow of moved value: `message`
--> src/main.rs:9:25
|
7 | let message = String::from("hello");
| ------- move occurs because `message` has type `String`, which does not implement the `Copy` trait
8 | tx.send(message).unwrap();
| ------- value moved here
9 | println!("sent: {message}");
| ^^^^^^^^^ value borrowed here after move
This is the same E0382 "use after move" you've seen since chapter 8, now doing concurrency-safety work. Think about the bug it's preventing. If the sending thread could keep using message after sending it, both threads would have access to the same String at the same time, the receiver and the sender, each free to read or modify it. That's a data race waiting to happen. But send moved ownership, so the sender gave the value up, and there's exactly one owner at all times. The receiver gets the value; the sender can't touch it. The ownership rule that prevented double frees in chapter 8 prevents shared-access races here, with no new machinery.
Key insight
Channels make concurrency safe by transferring ownership, not by sharing it. A value is owned by the sender, then by the channel, then by the receiver, never by two threads at once. So there's no shared mutable state to race over, and the chapter-8 move rules guarantee it. "Share memory by communicating" works in Rust because send is a move: the compiler proves, at compile time, that a sent value is never touched again by the sender.
The receiver as an iterator
You'll usually send many values, not one. The receiver can be treated as an iterator (chapter 19): iterating it yields each value as it arrives and ends naturally when all transmitters have been dropped and no more values can come.
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for s in ["one", "two", "three"] {
tx.send(String::from(s)).unwrap();
thread::sleep(Duration::from_millis(10));
}
// tx is dropped here when the closure ends
});
for received in rx {
println!("got: {received}");
}
}got: one
got: two
got: three
The for received in rx loop pulls values out one at a time as the thread sends them, printing each. When the spawned thread finishes, its tx is dropped; with no transmitters left, the channel is closed, the iterator ends, and the loop exits. No "are we done?" flag, no counting, the channel closing is the end signal, exactly as None ends an iterator.
Multiple producers
The "mp" in mpsc means you can have many senders feeding one receiver. Clone the transmitter (it's designed to be cloned), give each clone to a different thread, and all of them funnel into the same rx:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 1..=3 {
let tx = tx.clone(); // each thread gets its own transmitter
thread::spawn(move || {
tx.send(format!("message from worker {id}")).unwrap();
});
}
drop(tx); // drop the original so the channel can close
for received in rx {
println!("{received}");
}
}message from worker 1
message from worker 3
message from worker 2
Each of the three threads sends one message through its own cloned tx; all three arrive at the single rx. The order is whatever the OS scheduling produces (here 1, 3, 2, yours may differ), the non-determinism from lesson 22.1. One subtlety: we drop(tx) (the original transmitter) after the loop, because the for received in rx loop only ends when every transmitter is gone. If the original tx stayed alive in main, the receiver would wait forever for a message that's never coming. Dropping it lets the channel close once the workers finish.
Best practice
Prefer message passing (channels) over shared state when threads can be organized as producers and consumers handing work or results to each other. It sidesteps the hardest concurrency bugs entirely, there's no shared mutable data to protect, because ownership moves with each message. Reach for shared state (next lesson) only when the problem genuinely requires multiple threads touching the same data, like a shared counter or cache, rather than passing values along.
Quiz time
Question #1
What are the two ends of a channel called, and what does each do?
Show solution
The transmitter (tx) sends values into the channel with tx.send(value); the receiver (rx) takes them out with rx.recv() (which blocks until a value arrives) or by iterating. mpsc::channel() returns the connected pair. "mpsc" means multiple producer, single consumer: you can clone the transmitter for many senders, but there's one receiver.
Question #2
Why can't the sending thread use a value after passing it to tx.send(value)?
Show solution
Because send takes ownership of the value and moves it into the channel (chapter 8). After the move the sender no longer owns it, so using it is a "use after move" compile error (E0382). This is exactly the safety guarantee: it ensures the value isn't accessible to both the sender and the receiver at once, which would be a data race. Ownership transfer makes shared access impossible.
Question #3
In the multiple-producer example, why is drop(tx) needed before the receiving loop?
Show solution
The for received in rx loop ends only when all transmitters have been dropped (so no more messages can arrive). The worker threads drop their cloned transmitters when they finish, but the original tx in main would stay alive, keeping the channel open and making the receiver wait forever for a message that never comes. drop(tx) releases that last transmitter so the channel can close once the workers are done.
Channels move data between threads so nothing is shared. But sometimes threads genuinely must share the same data, a counter, a cache, a configuration. The next lesson (22.3) covers that harder case with Mutex for safe access and Arc for shared ownership across threads.