Skip to content

Channels

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"]
Multiple producers, one consumer

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 E0599
use 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.

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);
}

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 channel

This 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 ~100ms

The 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 channel
recv_timeout got 7
recv_timeout: channel is empty and sending half is closed

Use 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”
SituationPrefer
Ownership of a value should transfer to exactly one placeChannel
A pipeline: stage A produces, stage B consumesChannel
Many threads need to read (and occasionally write) the same long-lived valueArc<Mutex<T>> / Arc<RwLock<T>>
A single shared counter or flagstd::sync::atomic
Producer is faster than consumer and must be slowed downsync_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.

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);
}
}
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);
}
}

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.

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.

  • “Cloning rx gives you multiple consumers.” Receiver<T> has no Clone impl 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.
  • “send on a channel with no receiver panics.” It returns an Err (carrying the value back to you) — no receiver just means the channel is closed on that end, handled the same Result-based way as every other fallible operation in Rust.

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.

What does mpsc stand for in std::sync::mpsc?
What happens to a value after tx.send(value) is called?
Why must the original tx be dropped (drop(tx)) before collecting from rx.iter()?
What does this print?
use std::sync::mpsc;
fn main() {
    let (tx, rx) = mpsc::channel::<i32>();
    drop(tx);
    match rx.recv() {
        Ok(v) => println!("got {v}"),
        Err(e) => println!("recv failed: {e}"),
    }
}
What does this fail to compile with?
use std::sync::mpsc;
fn main() {
    let (tx, rx) = mpsc::channel::<i32>();
    tx.send(1).unwrap();
    let rx2 = rx.clone();
    println!("{:?} {:?}", rx.recv(), rx2.recv());
}
Why does sync_channel(0) make a producer's send() block until the consumer calls recv()?