Channels
Message Passing with std::sync::mpsc
Section titled “Message Passing with std::sync::mpsc”Rust’s standard library provides multi-producer, single-consumer (mpsc) channels for sending values between threads. A channel has two ends:
tx— the transmitter (sender). You can clone it to create multiple producers.rx— the receiver. There is exactly one receiver per channel.
use std::sync::mpsc;
fn main() { let (tx, rx) = mpsc::channel();
tx.send(42).unwrap();
let value = rx.recv().unwrap(); // blocks until a value arrives println!("Received: {}", value);}mpsc::channel() returns the (Sender<T>, Receiver<T>) pair. The type T is inferred from the first send call.
flowchart LR P1["Producer thread 1"] -->|"tx.clone().send(msg)"| C["Channel queue"] P2["Producer thread 2"] -->|"tx.clone().send(msg)"| C C -->|"rx.recv()"| CONS["Consumer thread"]
What the Compiler Says: A Receiver Cannot Be Cloned
Section titled “What the Compiler Says: A Receiver Cannot Be Cloned”The “single-consumer” half of mpsc is not just a naming convention — Receiver<T> genuinely does not implement Clone. Only Sender<T> does:
// @expect-error E0599use std::sync::mpsc;
fn main() { let (tx, rx) = mpsc::channel::<i32>(); tx.send(1).unwrap(); let rx2 = rx.clone(); // ERROR: Receiver does not implement Clone — mpsc is single-consumer println!("{:?} {:?}", rx.recv(), rx2.recv());}error[E0599]: no method named `clone` found for struct `std::sync::mpsc::Receiver<T>` in the current scope --> src/main.rs:6:18 |6 | let rx2 = rx.clone(); // ERROR: Receiver does not implement Clone — mpsc is single-consumer | ^^^^^ method not found in `std::sync::mpsc::Receiver<i32>`If you need multiple consumers, mpsc is the wrong tool — reach for a crate like crossbeam-channel (which supports multi-consumer) or fan work back out through per-worker channels, as the “Fan-Out” pattern below does.
Sending Values Across Threads
Section titled “Sending Values Across Threads”The transmitter is Send, so you can move it into a thread closure. Once tx.send(value) is called, the value is moved into the channel — the sending thread no longer owns it.
use std::thread;use std::sync::mpsc;
fn main() { let (tx, rx) = mpsc::channel();
thread::spawn(move || { let s = String::from("hello from thread"); tx.send(s).unwrap(); // println!("{}", s); // would not compile — s was moved into the channel });
let received = rx.recv().unwrap(); println!("Main got: {}", received);}Multiple Producers
Section titled “Multiple Producers”Clone tx to create multiple senders. Each clone has its own handle to the same channel. The receiver sees messages in the order they arrive (non-deterministic across threads), so collect and sort before printing.
use std::thread;use std::sync::mpsc;
fn main() { let (tx, rx) = mpsc::channel(); let mut handles = Vec::new();
for i in 0..3 { let tx = tx.clone(); // each thread gets its own sender clone handles.push(thread::spawn(move || { tx.send(format!("message {}", i)).unwrap(); })); } drop(tx); // drop the original tx so the channel closes when all clones are gone
for h in handles { h.join().unwrap(); }
let mut msgs: Vec<String> = rx.iter().collect(); // collect until channel closes msgs.sort(); for m in &msgs { println!("{}", m); }}rx.iter() yields values until all Sender handles are dropped. Dropping the original tx (after cloning) is essential so the iterator knows when to stop.
Dropping the Last Sender Closes the Channel
Section titled “Dropping the Last Sender Closes the Channel”mpsc::channel() is unbounded — send never blocks, it just grows an internal queue. Once every Sender (including every clone) is dropped, the channel is permanently closed: recv() on an empty, sender-less channel returns Err instead of blocking forever.
use std::sync::mpsc;
fn main() { let (tx, rx) = mpsc::channel::<i32>(); drop(tx); // no more senders — the channel is now permanently closed
match rx.recv() { Ok(v) => println!("got {v}"), Err(e) => println!("recv failed: {e}"), }}recv failed: receiving on a closed channelThis is exactly the mechanism rx.iter() relies on above: it is really a loop of recv() calls that stops the moment recv() returns this Err.
sync_channel: Bounded Channels and Backpressure
Section titled “sync_channel: Bounded Channels and Backpressure”An unbounded mpsc::channel() has a real cost: a producer faster than its consumer piles up memory indefinitely. mpsc::sync_channel(n) bounds the queue to n items — once it’s full, send blocks the producer until the consumer makes room. sync_channel(0) is the extreme case: a rendezvous channel with no buffer at all, where send blocks until a receiver is actively waiting.
use std::sync::mpsc;use std::thread;use std::time::{Duration, Instant};
fn main() { // sync_channel(0) is a rendezvous channel: send() blocks until a receiver // is ready to take the value right now — there is no internal buffer. let (tx, rx) = mpsc::sync_channel::<i32>(0);
let producer = thread::spawn(move || { let start = Instant::now(); tx.send(1).unwrap(); // blocks here until main calls rx.recv() start.elapsed() });
thread::sleep(Duration::from_millis(100)); // simulate a slow consumer let value = rx.recv().unwrap(); let blocked_for = producer.join().unwrap();
// Round to the nearest 50ms bucket so the printed value is stable // across machines instead of an exact, jitter-sensitive duration. let bucket = (blocked_for.as_millis() / 50) * 50; println!("received: {value}, sender was blocked for at least ~{bucket}ms");}received: 1, sender was blocked for at least ~100msThe producer’s send measurably blocks for as long as the consumer takes to call recv() — that blocking is the backpressure. An unbounded channel would let the producer race ahead and buffer everything in memory instead; a bounded one forces the producer to slow down to the consumer’s pace, which is exactly what you want when the consumer is the bottleneck and unbounded buffering would eventually exhaust memory.
try_recv and recv_timeout: Not Blocking Forever
Section titled “try_recv and recv_timeout: Not Blocking Forever”recv() blocks indefinitely if nothing is coming. try_recv() never blocks — it returns immediately either way — and recv_timeout() blocks, but gives up after a deadline:
use std::sync::mpsc;use std::time::Duration;
fn main() { let (tx, rx) = mpsc::channel::<i32>();
// try_recv never blocks — it returns immediately if nothing is waiting. match rx.try_recv() { Ok(v) => println!("try_recv got {v}"), Err(e) => println!("try_recv: {e}"), }
tx.send(7).unwrap();
// recv_timeout blocks, but gives up after the deadline instead of forever. match rx.recv_timeout(Duration::from_millis(50)) { Ok(v) => println!("recv_timeout got {v}"), Err(e) => println!("recv_timeout: {e}"), }
drop(tx); match rx.recv_timeout(Duration::from_millis(50)) { Ok(v) => println!("recv_timeout got {v}"), Err(e) => println!("recv_timeout: {e}"), }}try_recv: receiving on an empty channelrecv_timeout got 7recv_timeout: channel is empty and sending half is closedUse try_recv when a thread has other work to do and just wants to poll for a message without stopping; use recv_timeout when waiting is fine, but forever is not — e.g., a worker that should periodically check a shutdown flag even with no messages arriving.
Channels vs. Shared State: When to Use Which
Section titled “Channels vs. Shared State: When to Use Which”| Situation | Prefer |
|---|---|
| Ownership of a value should transfer to exactly one place | Channel |
| A pipeline: stage A produces, stage B consumes | Channel |
| Many threads need to read (and occasionally write) the same long-lived value | Arc<Mutex<T>> / Arc<RwLock<T>> |
| A single shared counter or flag | std::sync::atomic |
| Producer is faster than consumer and must be slowed down | sync_channel(n) (bounded, backpressure) |
The Go proverb applies directly here: “Do not communicate by sharing memory; share memory by communicating.” A channel makes the ownership transfer explicit in the type system (the value really does move), whereas shared state requires every reader and writer to remember to take the lock — channels remove an entire class of “forgot to lock” bugs by construction.
Fan-Out: Distributing Work
Section titled “Fan-Out: Distributing Work”A single producer can send work items to a pool of threads. Each worker receives items through its own channel.
use std::thread;use std::sync::mpsc;
fn main() { let (main_tx, main_rx) = mpsc::channel::<String>(); let mut handles = Vec::new();
for worker_id in 0..3 { let (work_tx, work_rx) = mpsc::channel::<i32>(); let main_tx = main_tx.clone();
handles.push(thread::spawn(move || { let item = work_rx.recv().unwrap(); main_tx.send(format!("worker {} processed {}", worker_id, item * 2)).unwrap(); }));
work_tx.send(worker_id * 10).unwrap(); } drop(main_tx);
for h in handles { h.join().unwrap(); }
let mut results: Vec<String> = main_rx.iter().collect(); results.sort(); for r in &results { println!("{}", r); }}The Complete Playground Example
Section titled “The Complete Playground Example”use std::thread;use std::sync::mpsc;
fn main() { let (tx, rx) = mpsc::channel(); let mut handles = Vec::new();
for i in 0..5 { let tx = tx.clone(); let handle = thread::spawn(move || { let msg = format!("result from thread {}", i); tx.send(msg).unwrap(); }); handles.push(handle); } drop(tx);
for h in handles { h.join().unwrap(); }
let mut msgs: Vec<String> = rx.iter().collect(); msgs.sort(); for m in &msgs { println!("{}", m); }}Compiling…
Mental Model
Section titled “Mental Model”A channel is a Mutex-protected queue with Sender/Receiver handles wrapped around it — but you never see the lock, because the API only exposes the two operations that need it (send pushes, recv pops). The type system’s ownership rules do the rest: send takes self-by-move semantics for the value (not the sender), so “ownership transferred” is not a metaphor, it is the actual Rust move you already know from every other API.
Trade-offs
Section titled “Trade-offs”An unbounded mpsc::channel() never makes a producer wait, which is simplest to reason about but risks unbounded memory growth if the consumer falls behind. sync_channel(n) trades that simplicity for a hard memory ceiling and automatic backpressure — the producer physically cannot get more than n messages ahead. sync_channel(0) (rendezvous) is the strictest version: producer and consumer are forced to hand off in lockstep, which is useful for synchronization but throughput-limiting if either side is bursty.
Common Misconceptions
Section titled “Common Misconceptions”- “Cloning
rxgives you multiple consumers.”Receiver<T>has noCloneimpl at all — the “single-consumer” in mpsc is enforced by the type system, not just a suggestion. Multiple senders are supported (tx.clone()); multiple receivers are not. - “An unbounded channel is always the safe default.” It is safe from blocking, but not from unbounded memory growth if the producer outruns the consumer indefinitely.
sync_channel(n)is the deliberate choice once that risk is real. - “
sendon a channel with no receiver panics.” It returns anErr(carrying the value back to you) — no receiver just means the channel is closed on that end, handled the sameResult-based way as every other fallible operation in Rust.
Reading AI-generated Code
Section titled “Reading AI-generated Code”AI-generated worker-pool code often reaches for mpsc::channel() (unbounded) for a producer that can generate work far faster than the workers can consume it, without considering whether that is actually the desired behavior — under real load, the queue just grows without limit.
🐛 Spot the bug
use std::sync::mpsc;use std::thread;
fn process_urls(urls: Vec<String>) { let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || { for url in rx { // Pretend this is a slow network call. std::thread::sleep(std::time::Duration::from_millis(50)); println!("fetched: {url}"); } });
for url in urls { tx.send(url).unwrap(); // producer can enqueue millions of these instantly } drop(tx); worker.join().unwrap();}Show the answer
mpsc::channel() is unbounded, so the for url in urls { tx.send(url).unwrap(); } loop enqueues every URL — potentially millions — as fast as the CPU can move them, regardless of how slowly the single worker actually processes each one (50ms per URL here). If urls is large, this buffers an unbounded amount of pending work in memory before the worker even gets to look at it. Switching to mpsc::sync_channel(n) for a small n (say, 100) would make the producer’s send block once the queue is full, applying backpressure so memory use stays bounded and matched to the actual processing rate.