When threads need to talk
You are building a background worker that processes image uploads. The main thread accepts HTTP requests, extracts the image bytes, and hands them off to a worker thread that compresses and saves them. The worker finishes and needs to report success or failure back to the main thread. You cannot just pass a &mut Vec<u8> across threads. Rust's type system forbids sharing mutable state across thread boundaries without explicit synchronization. You also do not want to lock a global mutex on every single upload. Lock contention will destroy your throughput.
Threads in Rust are isolated by design. They get their own stack. They cannot see each other's local variables. If they need to exchange data, they must use a communication primitive that the compiler can verify. Channels are that primitive. They give you a safe, ordered, lock-free way to move data between threads.
The channel concept
A channel is a secure conveyor belt. One side drops messages onto the belt. The other side picks them up. The belt guarantees that messages arrive in the exact order they were sent. It also guarantees that only the designated sides can interact with it. You cannot peek at the belt. You cannot reorder items. You cannot accidentally drop a message without the compiler catching you.
Channels split into three patterns based on how many producers and consumers you need. MPSC stands for multi-producer, single-consumer. MPMC stands for multi-producer, multi-consumer. Broadcast stands for one-to-many distribution. Rust's standard library ships with MPSC and Broadcast. The ecosystem relies on crossbeam-channel for MPMC. Each pattern solves a different coordination problem.
Single consumer, multiple producers
Rust's std::sync::mpsc module implements the MPSC pattern. The name is slightly misleading if you are coming from other languages. The mpsc in Rust actually supports multiple producers out of the box. The single-consumer part is the hard limit. Only one thread can call recv() on the receiver end.
use std::sync::mpsc;
use std::thread;
/// Spawns a worker that sends a message back to the main thread.
fn spawn_worker() -> mpsc::Receiver<String> {
// Create the channel. tx is the sender, rx is the receiver.
let (tx, rx) = mpsc::channel();
// Move tx into the new thread so it owns the sender.
thread::spawn(move || {
// Send returns Result to catch a closed channel.
tx.send(String::from("processing complete")).unwrap();
});
// Return the receiver to the caller.
rx
}
fn main() {
let rx = spawn_worker();
// recv() blocks the current thread until a message arrives.
let msg = rx.recv().unwrap();
println!("Worker said: {msg}");
}
The move keyword on the closure is mandatory. The closure needs to own tx because it will outlive the current scope. If you omit move, the compiler rejects the code with E0382 (use of moved value). The thread would try to access tx after main drops it, which is a data race waiting to happen.
When tx.send() runs, it places the value into an internal queue. If the queue is full, the call blocks until space opens up. If you drop every copy of tx, the channel closes. The next call to rx.recv() returns Err(mpsc::RecvError). This is how you signal shutdown. The receiver knows exactly when no more messages will ever arrive.
Keep the original sender alive if you want the channel to stay open. Drop it intentionally when you are ready to tell the consumer to finish. Treat the sender's lifetime as a control signal.
Multi consumer with crossbeam
MPSC works great when one thread collects results. It fails when you need multiple threads to pull work from the same queue. Imagine a load balancer distributing database queries across three worker threads. All three workers need to read from the same task queue. std::sync::mpsc cannot do this. The receiver is not Clone.
The crossbeam-channel crate solves this. It implements MPMC with lock-free internals and zero-copy semantics. You add it to your Cargo.toml and import the types directly.
use crossbeam_channel::unbounded;
use std::thread;
/// Distributes numeric tasks to multiple consumer threads.
fn run_mpmc_pipeline() {
// unbounded creates a channel that never blocks on send.
let (tx, rx) = unbounded();
// Spawn three consumers that all share the same receiver.
let mut handles = vec![];
for id in 0..3 {
// Clone the receiver so each thread gets its own handle.
let rx_clone = rx.clone();
handles.push(thread::spawn(move || {
// recv() blocks until a message is available.
for msg in rx_clone.iter() {
println!("Worker {id} processed: {msg}");
}
}));
}
// Send tasks from the main thread.
for i in 0..10 {
tx.send(i).unwrap();
}
// Drop the original sender to close the channel.
drop(tx);
for handle in handles {
handle.join().unwrap();
}
}
Each cloned rx pulls messages from the shared queue. The channel distributes messages fairly. No worker starves. The iter() adapter on the receiver is idiomatic. It wraps recv() in a loop and stops automatically when the channel closes. This avoids manual while let Ok(msg) = rx.recv() boilerplate.
Production code usually prefers bounded() over unbounded(). A bounded channel enforces backpressure. If consumers fall behind, tx.send() blocks. This prevents memory exhaustion when producers outpace consumers. Pick the bound size based on your expected burst size. Keep the channel size tight. A bloated queue hides latency problems.
Broadcasting to everyone
Sometimes you do not want to distribute work. You want to notify multiple listeners about the same event. Configuration reloads, UI theme changes, and system health updates all follow this pattern. Every listener needs a copy of the message. The std::sync::broadcast module handles this.
use std::sync::broadcast;
use std::thread;
/// Broadcasts configuration updates to multiple subscribers.
fn run_broadcast_demo() {
// channel(10) creates a broadcast channel with a buffer of 10.
let (tx, rx) = broadcast::channel(10);
// Spawn two subscribers.
let rx1 = rx.clone();
let rx2 = rx.clone();
let h1 = thread::spawn(move || {
for result in rx1.iter() {
match result {
Ok(msg) => println!("Subscriber 1 got: {msg}"),
Err(broadcast::RecvError::Lagged(n)) => {
println!("Subscriber 1 missed {n} messages");
}
Err(broadcast::RecvError::Closed) => break,
}
}
});
let h2 = thread::spawn(move || {
for result in rx2.iter() {
match result {
Ok(msg) => println!("Subscriber 2 got: {msg}"),
Err(_) => break,
}
}
});
// Send updates.
tx.send("theme=dark").unwrap();
tx.send("lang=en").unwrap();
drop(tx);
h1.join().unwrap();
h2.join().unwrap();
}
Broadcast channels clone the message for every receiver. This means the message type must implement Clone. If a receiver falls behind and the buffer overflows, recv() returns Err(Lagged(n)). The n tells you exactly how many messages were dropped. You can choose to skip them, panic, or reconnect. The channel does not block the sender. It discards old messages to keep up with the fastest receiver.
Convention dictates that you keep a handle to the original rx or explicitly drop it. If every receiver drops, the channel closes. If you want to simulate a long-running event bus, keep one receiver alive in a background task. Treat broadcast channels as fire-and-forget notifications. They are not for critical data delivery.
Pitfalls and compiler signals
Channels look simple until you hit the edge cases. The most common trap is blocking forever on recv(). If you forget to drop the sender, the receiver hangs. The thread consumes CPU waiting for a message that will never come. Always structure your code so that dropping the sender is the natural end of a lifecycle. Use recv_timeout() during development to catch deadlocks early.
Another trap is sending types that cannot cross thread boundaries. Channels require Send on the message type. If you try to send a Rc<T> or a raw pointer, the compiler rejects it with E0277 (trait bound not satisfied). Rc<T> uses reference counting that is not thread-safe. Swap it for Arc<T> and the error disappears. The compiler is protecting you from data races. Trust the error message.
Non-blocking checks are useful when you need to poll without suspending the thread. try_recv() returns Ok(msg) if a message is ready, Err(TryRecvError::Empty) if the queue is quiet, and Err(TryRecvError::Disconnected) if the channel closed. Use it in event loops or game ticks. Do not spin-loop try_recv() in production. It burns CPU cycles. Use recv() or recv_timeout() instead.
Convention aside: always write tx.clone() explicitly when handing a sender to a new thread. Writing tx.clone() makes it obvious that you are sharing ownership. Writing tx without clone moves it, which closes the channel for everyone else. The explicit form prevents accidental early closure.
Which channel pattern fits your code
Use std::sync::mpsc when you have one consumer collecting results from multiple producers. Use std::sync::mpsc when you want zero dependencies and predictable blocking behavior. Use crossbeam-channel when you need multiple consumers pulling from the same queue. Use crossbeam-channel when you want lock-free performance and fair message distribution. Use std::sync::broadcast when every listener must receive a copy of every message. Use std::sync::broadcast when occasional message loss is acceptable and you need fire-and-forget notifications. Reach for shared state with Arc<Mutex<T>> when readers and writers need random access to a single value instead of a message stream.
Pick the pattern that matches your data flow. Do not force a broadcast channel into a work queue. Do not use MPMC when a single consumer can handle the load. Match the primitive to the topology. The compiler will thank you.