Rust Channels: Message Passing Between Threads
On the Concurrency page, threads shared data by locking it with Arc<Mutex<T>>. That works, but there’s another way that is often simpler: instead of sharing data, threads send messages to each other.
Think of a channel like a pipe with two ends. One thread drops values into the sender end. Another thread takes them out of the receiver end, in the same order. Nobody shares anything: each value moves from one thread to the other. The Go community has a saying that Rust borrows here: “Do not communicate by sharing memory; instead, share memory by communicating.”
Your first channel
Section titled “Your first channel”The standard library’s channel lives in std::sync::mpsc. The name means multiple producer, single consumer: many senders, one receiver.
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
thread::spawn(move || { let message = String::from("hello from the worker"); tx.send(message).unwrap(); });
let received = rx.recv().unwrap(); println!("main got: {received}");}Output:
main got: hello from the workerStep by step:
mpsc::channel()returns a pair: aSenderand aReceiver. By tradition they’re calledtx(transmit) andrx(receive).movehandstxto the new thread, the same way you moved data into threads on the Concurrency page.tx.send(value)puts a value into the channel. It returns a Result, because it fails if the receiver is already gone.rx.recv()waits until a value arrives. It returnsErronly when every sender is gone and the channel is empty.
Notice we didn’t need join(). recv() already waits for the message, so main can’t finish too early.
Receiving many values
Section titled “Receiving many values”A Receiver works as an iterator. A for loop over it keeps calling recv() for you, and the loop ends by itself when all senders have been dropped:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
thread::spawn(move || { for i in 1..=3 { tx.send(i * 10).unwrap(); } // tx is dropped here, when the thread ends });
for value in rx { println!("got {value}"); } println!("channel closed, all done");}Output:
got 10got 20got 30channel closed, all doneThere’s only one sender here, so the values arrive in exactly the order they were sent. This “the loop stops when the senders are gone” rule is important. Keep it in mind for the next section.
Multiple producers: clone the sender
Section titled “Multiple producers: clone the sender”To let several threads send, give each one its own copy of the sender with tx.clone(). All copies feed the same channel:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for id in 1..=3 { let tx = tx.clone(); thread::spawn(move || { tx.send(format!("worker {id} reporting")).unwrap(); }); } drop(tx); // drop the original, or the loop below never ends
let mut messages: Vec<String> = rx.iter().collect(); messages.sort(); for m in messages { println!("{m}"); }}Output:
worker 1 reportingworker 2 reportingworker 3 reportingTwo things to notice:
drop(tx)matters. Each thread drops its clone when it finishes, butmainstill holds the original. As long as one sender exists, the receiver keeps waiting, and the program would hang forever. Dropping it says “main won’t send anything”.- The threads finish in any order. Messages can arrive as worker 2, worker 1, worker 3. We collect them and sort them so the printed output is always the same. Remove the
sort()and run it a few times to see the order change.
Not waiting forever: try_recv and recv_timeout
Section titled “Not waiting forever: try_recv and recv_timeout”recv() blocks until something arrives. Sometimes you don’t want to wait. Two cousins help:
try_recv()returns immediately: a value,Empty(nothing yet), orDisconnected(all senders gone).recv_timeout(duration)waits, but only up to a time limit. If nothing arrives in time, you getTimeout.
use std::sync::mpsc;use std::thread;use std::time::Duration;
fn main() { let (tx, rx) = mpsc::channel::<i32>();
// Nothing sent yet: try_recv returns right away. println!("{:?}", rx.try_recv());
thread::spawn(move || tx.send(7).unwrap()).join().unwrap();
// A value is waiting, so this returns it without waiting. println!("{:?}", rx.recv_timeout(Duration::from_secs(1)));
// The sender is gone and the channel is empty. println!("{:?}", rx.try_recv());}Output:
Err(Empty)Ok(7)Err(Disconnected)We join() the thread before receiving, so the value is already waiting and the output is always the same.
try_recv() is handy in a loop that has other work to do, like a game loop that checks for new messages once per frame. recv_timeout() is good for “give up if the worker takes too long”.
Bounded channels with sync_channel
Section titled “Bounded channels with sync_channel”A normal channel() has no size limit. If the sender is much faster than the receiver, messages pile up in memory. mpsc::sync_channel(n) makes a channel that holds at most n messages. When it’s full, send() waits until the receiver takes one out. This is called backpressure: a slow consumer automatically slows down a fast producer.
use std::sync::mpsc;use std::thread;
fn main() { // Room for only 2 messages at a time. let (tx, rx) = mpsc::sync_channel(2);
let producer = thread::spawn(move || { for i in 1..=5 { tx.send(i).unwrap(); // blocks while the buffer is full } });
let total: i32 = rx.iter().sum(); producer.join().unwrap(); println!("total = {total}");}Output:
total = 15The producer can never get more than two numbers ahead of main. With sync_channel(0), there is no buffer at all: each send() waits until the receiver is ready to take the value, like handing over a package in person.
A small worker pool
Section titled “A small worker pool”Now let’s put it together. A worker pool is a few threads that take jobs from a shared queue and send back results. It uses two channels: one for jobs, one for results.
There’s one catch: a Receiver can’t be cloned (it’s single consumer). So the workers share the job receiver behind Arc<Mutex<_>>, and take turns pulling from it:
use std::sync::mpsc;use std::sync::{Arc, Mutex};use std::thread;
fn main() { let (job_tx, job_rx) = mpsc::channel::<u64>(); let (result_tx, result_rx) = mpsc::channel::<(u64, u64)>();
// Receivers can't be cloned, so the workers share one behind Arc<Mutex<_>>. let job_rx = Arc::new(Mutex::new(job_rx));
let mut workers = Vec::new(); for _ in 0..3 { let job_rx = Arc::clone(&job_rx); let result_tx = result_tx.clone(); workers.push(thread::spawn(move || loop { let job = job_rx.lock().unwrap().recv(); match job { Ok(n) => result_tx.send((n, n * n)).unwrap(), Err(_) => break, // no more jobs } })); } drop(result_tx);
for n in 1..=6 { job_tx.send(n).unwrap(); } drop(job_tx); // tells the workers there's no more work
let mut results: Vec<(u64, u64)> = result_rx.iter().collect(); for w in workers { w.join().unwrap(); } results.sort(); for (n, square) in results { println!("{n} squared is {square}"); }}Output:
1 squared is 12 squared is 43 squared is 94 squared is 165 squared is 256 squared is 36How it shuts down cleanly:
drop(job_tx)closes the job channel. Each worker’srecv()then returnsErr, and the worker breaks out of its loop.- When a worker ends, its
result_txclone is dropped. We already dropped the original, so once all three workers finish, the result channel closes. result_rx.iter()then stops, and we sort the results because workers finish in any order.
A small detail: job_rx.lock().unwrap().recv() is written in one let statement on purpose. The lock is released at the end of that statement, so the other workers can grab the next job while this one is still squaring its number.
Channels vs Arc<Mutex<T>>
Section titled “Channels vs Arc<Mutex<T>>”Both are safe. They just fit different shapes of problem:
| Use a channel when… | Use Arc<Mutex<T>> when… |
|---|---|
| Work flows in one direction: producers make things, a consumer handles them | Many threads need to read and update the same value (a counter, a cache) |
| You want a queue of jobs, events or log lines | The data is big and copying or moving it around would be wasteful |
| Each value has one owner at a time | Threads need to look at the current state, not a stream of updates |
A good rule of thumb: if you can describe your program as “these threads tell that thread something”, use a channel. If it’s “these threads all edit this thing”, use a Mutex. Many real programs use both, like our worker pool.
Common mistakes
Section titled “Common mistakes”Using a value after sending it. send() takes ownership, the same as passing a value to a function. After sending, the value belongs to the receiver:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
thread::spawn(move || { let message = String::from("hi"); tx.send(message).unwrap(); println!("sent {message}"); });
println!("{}", rx.recv().unwrap());}error[E0382]: borrow of moved value: `message` --> src/main.rs:10:25 | 8 | let message = String::from("hi"); | ------- move occurs because `message` has type `String`, which does not implement the `Copy` trait 9 | tx.send(message).unwrap(); | ------- value moved here10 | println!("sent {message}"); | ^^^^^^^ value borrowed here after moveThis is Rust protecting you: if the sender could still change message, both threads would touch the same data. Print before sending, or send a .clone().
Moving the sender into a loop of threads. The first thread takes tx, so there’s nothing left for the second:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for id in 1..=3 { thread::spawn(move || { tx.send(id).unwrap(); }); }
for value in rx { println!("{value}"); }}error[E0382]: use of moved value: `tx` --> src/main.rs:8:23 | 5 | let (tx, rx) = mpsc::channel(); | -- move occurs because `tx` has type `std::sync::mpsc::Sender<i32>`, which does not implement the `Copy` trait... 8 | thread::spawn(move || { | ^^^^^^^ value moved into closure here, in previous iteration of loopThe fix is let tx = tx.clone(); at the top of the loop, as in the multiple-producers example.
A program that never ends. This one has no compiler error, which makes it sneaky. If you loop over a receiver (for x in rx) while some sender is still alive, often the original tx in main, the loop waits forever. When your program “just hangs”, check that every sender gets dropped.
Next steps
Section titled “Next steps”std::sync::mpsc covers a lot, but you’ll meet two popular alternatives:
- crossbeam-channel adds multi-consumer channels (receivers you can clone) and a
select!macro to wait on several channels at once. tokio::sync::mpscis the channel for async code. If you’re usingasync/.await(see Async/Await), use it instead of the std one, because itsrecv().awaitdoesn’t block the whole thread.
mpsc::channel()gives you a(Sender, Receiver)pair.send()moves a value in,recv()waits for the next one.- Clone the sender to have many producers. There is only ever one receiver.
- Looping over a receiver ends when all senders are dropped, so remember to
drop()the original. try_recv()andrecv_timeout()let you check without waiting forever.sync_channel(n)limits the queue tonmessages.- Messages from several threads arrive in any order: sort or sum them if you need stable output.
- Use channels when data flows from thread to thread. Use
Arc<Mutex<T>>when threads share and edit one value.
Quick check
Section titled “Quick check”Quick check
1. What does "mpsc" stand for?
2. How do you let three threads send into the same channel?
3. A for value in rx loop never finishes. What is the most likely cause?
4. What does send() do when a sync_channel(2) already holds 2 messages?
Score: 0 / 4
Read more
Section titled “Read more”- Using Message Passing to Transfer Data Between Threads — The Rust Book: the official introduction to channels, with more examples.
std::sync::mpsc— standard library docs: every method onSender,SyncSenderandReceiver.- Channels — Rust by Example: a short, runnable example with several producer threads.
- From Single-Threaded to Multithreaded Server — The Rust Book: builds a full thread pool with channels and
Arc<Mutex<Receiver>>, step by step. tokio::sync::mpsc— docs.rs: the async channel you’ll use with Tokio, and how it differs from the std one.