When threads need to talk
You are building a web scraper. The main thread fetches a list of URLs. You want three worker threads to download the pages in parallel. The problem is getting the URLs from the main thread into the workers without locking everything up or crashing. Shared memory with mutexes works, but it introduces complexity. You have to protect the list, handle locking, and manage indices. It's easy to deadlock or corrupt state.
Rust offers a cleaner alternative for this pattern. You pass data through a channel. The main thread pushes URLs into the channel. The workers pull them out. The channel handles synchronization and data movement. You never touch shared state. This is the producer-consumer pattern, and Rust implements it with std::sync::mpsc.
The pneumatic tube model
mpsc stands for multiple producer, single consumer. Think of a pneumatic tube system in a hospital. Nurses at different stations drop samples into the tube. A lab technician at the end pulls samples out one by one. The tube is the channel. It moves data across thread boundaries safely.
The channel has two ends. The sender is the tube opening where you drop items. The receiver is the collection bin where items arrive. Multiple senders can exist. You can clone the sender and hand it to different threads. Only one receiver exists. This asymmetry simplifies the design. The receiver coordinates the consumption. The senders just push work.
Data flows one way. You cannot send from the receiver side. The compiler enforces this direction. If you try to reverse the flow, the code won't compile. This prevents logic errors where a consumer tries to push data back through the same pipe.
Minimal example
Here is the basic setup. You create a channel, spawn a thread, send a value, and receive it.
use std::sync::mpsc;
use std::thread;
fn main() {
// Create the channel. tx is the sender, rx is the receiver.
let (tx, rx) = mpsc::channel();
// Spawn a thread. The move closure takes ownership of tx.
thread::spawn(move || {
let val = String::from("hello from thread");
// Send the value. This moves val into the channel.
tx.send(val).unwrap();
});
// Receive the value. This blocks until data arrives.
let received = rx.recv().unwrap();
println!("Got: {received}");
}
The move keyword is essential here. Closures capture variables by reference by default. The thread might outlive the main function. If the closure borrows tx, the borrow checker rejects the code. The thread could access tx after main returns and tx is dropped. move forces the closure to take ownership of tx. The sender moves into the thread's stack. The main thread can no longer use it.
If you forget move, the compiler rejects the code with a lifetime error. The thread handle requires the closure to own its captures. This is a safety guarantee. You cannot accidentally send data through a dangling sender.
How the compiler enforces the flow
The channel types are distinct. Sender<T> and Receiver<T> are different types. You cannot swap them. The compiler knows which end sends and which end receives.
When you call tx.send(val), the value val moves into the channel. You cannot use val afterward. This is the same as moving a value into a function. The channel takes ownership. If you try to use val after sending, you get E0382 (use of moved value). This ensures the data exists in exactly one place. It's either in your variable or in the channel. It's never duplicated.
The receiver side mirrors this. rx.recv() returns the value. The value moves out of the channel and into your variable. The channel no longer holds it. This ownership transfer happens atomically. The sender and receiver coordinate internally. You don't need to manage locks.
The unbounded trap
There is a hidden cost to mpsc. The channel is unbounded. It grows until your system runs out of memory. The internal implementation uses a linked list. Every send allocates a node. If producers send faster than the consumer receives, the queue grows indefinitely.
This is a design choice for simplicity. Bounded channels require backpressure. The sender must block when the queue is full. Unbounded channels never block on send. They always succeed. This makes the producer logic easier. The producer just pushes and moves on.
The tradeoff is memory safety. Under load, an unbounded channel can cause an out-of-memory crash. If your scraper hits a burst of URLs and the workers are slow, the channel fills up. Your process consumes gigabytes of RAM. The OS kills it.
Monitor your memory usage. If you suspect bursty workloads, consider a bounded alternative. The standard library does not provide bounded channels. You need a crate like crossbeam for that. For simple scripts and controlled workloads, unbounded is fine. For production systems with unpredictable input, unbounded is a risk.
Unbounded channels are a memory leak in disguise. Measure your throughput before deploying.
Multiple producers and the drop signal
The "mp" in mpsc means multiple producers. You can clone the sender. Each clone is a valid sender pointing to the same channel.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
// Spawn multiple threads, each getting a clone of the sender.
for id in 0..5 {
// Clone the sender for each thread.
let tx_clone = tx.clone();
thread::spawn(move || {
let msg = format!("Message from thread {id}");
tx_clone.send(msg).unwrap();
});
}
// Drop the original sender so rx knows when all threads are done.
drop(tx);
// Collect results.
for received in rx.iter() {
println!("Got: {received}");
}
}
The community convention is to drop the original sender immediately after cloning it for threads. This signals the receiver that no more senders will be created. When all senders are dropped, the channel closes. The receiver knows it has received everything.
If you don't drop the original sender, rx.iter() might hang forever. The iterator waits for more data. It thinks the original sender might still send. Dropping tx removes that possibility. The receiver detects that zero senders remain. It stops waiting.
The drop(tx) call is explicit. Rust doesn't drop variables automatically until they go out of scope. The scope of tx is the whole main function. Without drop, tx lives until the end. The channel stays open. The receiver blocks.
Drop the original sender. The receiver is waiting for the signal.
Blocking, timeouts, and non-blocking checks
recv() blocks the current thread. If the channel is empty, the thread pauses. It wakes up when data arrives. This is usually what you want. The consumer waits for work. It doesn't spin the CPU.
Blocking is a feature, not a bug. Use it to synchronize threads. The producer pushes work. The consumer blocks until work is available. The system idles efficiently.
Sometimes you need more control. try_recv() checks the channel without blocking. It returns Ok(value) if data is available. It returns Err(TryRecvError::Empty) if the channel is empty. It returns Err(TryRecvError::Disconnected) if all senders are dropped.
// Non-blocking receive. Returns Ok if data, Err if empty or disconnected.
match rx.try_recv() {
Ok(msg) => println!("Got: {msg}"),
Err(mpsc::TryRecvError::Empty) => println!("No data yet"),
Err(mpsc::TryRecvError::Disconnected) => println!("Channel closed"),
}
Use try_recv() when you have other work to do while waiting. You can poll the channel, do some processing, and poll again. This avoids blocking the thread for too long.
recv_timeout() offers a middle ground. It blocks for a maximum duration. If no data arrives, it returns a timeout error.
use std::time::Duration;
// Wait up to 1 second.
match rx.recv_timeout(Duration::from_secs(1)) {
Ok(msg) => println!("Got: {msg}"),
Err(mpsc::RecvTimeoutError::Timeout) => println!("Timed out"),
Err(mpsc::RecvTimeoutError::Disconnected) => println!("Channel closed"),
}
Timeouts are useful for health checks and shutdown logic. You can wait for a heartbeat message. If it doesn't arrive, you assume the system is stuck.
Blocking is a synchronization tool. Use it to coordinate, not to stall.
Pitfalls and error handling
Channels can fail. send() returns a Result. If the receiver is dropped, send() returns Err(SendError). The channel is disconnected. Sending to a disconnected channel is a no-op. The value is dropped.
If you unwrap() the result of send(), your thread panics when the receiver drops. This is common in examples but risky in production. The receiver might drop early due to an error or shutdown. The sender panics. The panic propagates. The thread dies.
Handle the error gracefully. Log the failure. Stop sending.
/// Sends a message and handles disconnection.
fn send_message(tx: mpsc::Sender<String>, msg: String) {
// send returns a Result. The Err variant means the receiver is gone.
match tx.send(msg) {
Ok(()) => println!("Sent"),
Err(e) => eprintln!("Failed to send: {e}"),
}
}
The convention is to use expect() instead of unwrap() for send(). expect() documents the failure mode. tx.send(msg).expect("Receiver dropped") tells readers why the unwrap exists. It clarifies that the panic is intentional if the receiver is gone.
Deadlocks are possible. If a thread holds a lock and tries to send, and another thread holds the receiver and tries to acquire the lock, you deadlock. Channels don't prevent deadlocks. They just move data. You still need to reason about ordering.
Panic propagation is another risk. If a thread panics, its sender is dropped. The receiver detects disconnection. Other threads might continue sending. They get SendError. The system degrades. Handle panics in threads. Use catch_unwind if you need resilience.
Treat the channel as a contract. Senders promise to produce. The receiver promises to consume. Break the contract, and the system fails.
Choosing the right channel
Rust has many concurrency tools. Pick the one that matches your needs.
Use mpsc when you need a zero-dependency, blocking queue for simple producer-consumer patterns.
Use crossbeam::channel when you require bounded channels to prevent memory exhaustion or need higher throughput.
Use Arc<Mutex<T>> when threads must share mutable state with complex access patterns rather than passing discrete messages.
Use tokio::sync::mpsc when your application is asynchronous and you need channels that integrate with the async runtime.
Use std::sync::mpsc for scripts, tests, and internal utilities where external crates are overkill.
Use bounded channels from crossbeam for production systems with unbounded input or strict memory limits.
Pick the tool that matches your concurrency model. Don't force async into a sync world.