When synchronous channels break async
You are building a web scraper. One async task fetches URLs and extracts text. Another async task parses that text and stores results. You need to pass data between them. You grab std::sync::mpsc::channel, create a sender and receiver, and spawn two tasks. You run the program. It hangs immediately. The executor stops making progress. You blocked the runtime with a synchronous channel.
The problem is not the channel itself. The problem is how the channel interacts with the async runtime. Standard library channels block the thread when they are full or empty. Async runtimes like Tokio or async-std manage a pool of tasks on a limited number of threads. If a task blocks the thread, every other task waiting for that thread also stops. The whole system freezes.
Async channels solve this by returning futures instead of blocking. When a send or receive cannot complete immediately, the operation yields control back to the executor. The executor runs other tasks until the condition changes. This keeps the runtime responsive and allows thousands of tasks to share a few threads efficiently.
Async channels are futures
In async Rust, a channel operation is a future. The send method returns a future that resolves when the message is accepted. The recv method returns a future that resolves when a message is available. You must await these futures. If you forget to await, you get a future that does nothing, and your program behaves incorrectly.
The channel also enforces a capacity. A bounded channel has a fixed buffer size. If the buffer is full, send yields until space opens up. If the buffer is empty, recv yields until a message arrives. This capacity acts as backpressure. It prevents producers from overwhelming consumers and keeps memory usage predictable.
Tokio provides tokio::sync::mpsc for this purpose. The name stands for multi-producer single-consumer. You can clone the sender to share it across multiple tasks. The receiver is unique and cannot be cloned. This matches the common pattern where many tasks produce data and one task consumes it.
use tokio::sync::mpsc;
/// Sends a message through an async channel and awaits completion.
async fn send_message(tx: mpsc::Sender<String>, msg: String) {
// send returns a future; await yields if the channel is full.
if tx.send(msg).await.is_err() {
// The receiver was dropped. No point in sending more.
println!("Receiver dropped, stopping producer.");
}
}
/// Receives messages until the channel closes.
async fn receive_messages(mut rx: mpsc::Receiver<String>) {
// recv returns a future; await yields if the channel is empty.
// The loop ends when all senders are dropped and the buffer is empty.
while let Some(msg) = rx.recv().await {
println!("Received: {}", msg);
}
}
Convention aside: always check the result of send. In examples, you see tx.send(msg).await.unwrap(). In production code, the receiver might drop while the sender is still running. Checking is_err() lets the sender detect this and shut down gracefully. Dropping the error silently hides bugs where the consumer dies unexpectedly.
Don't ignore the send error. A silent drop means data loss and a producer that spins forever.
How the executor coordinates
When you call tx.send(msg).await, the runtime checks the channel buffer. If there is space, the message is written, and the future resolves immediately. The task continues without yielding. This is the fast path.
If the buffer is full, the future cannot resolve yet. The runtime registers a waker with the channel and returns Pending. The executor puts the task to sleep and switches to another task. Later, when a consumer reads a message, the channel wakes the sleeping sender. The executor schedules the sender to run again. The sender resumes, writes the message, and completes.
This coordination happens without blocking threads. The sender sleeps, the consumer runs, the sender wakes. The thread is never stuck waiting. This is why async channels scale to many tasks.
The receiver works similarly. rx.recv().await checks the buffer. If a message is available, it returns Some(msg). If the buffer is empty and senders are still alive, it registers a waker and yields. When a sender writes a message, the receiver wakes up. If all senders are dropped and the buffer is empty, recv returns None. This signals that no more messages will ever arrive.
The channel closes automatically when the last sender is dropped. You do not need to call a close method. This makes lifecycle management simple. The receiver loop ends naturally when producers finish.
Treat the channel capacity as a backpressure valve. If you set capacity to zero, every send waits for a recv. This creates a rendezvous channel where producers and consumers must meet exactly. If you set capacity too high, you lose backpressure and risk memory pressure. Pick a capacity that matches your consumer's processing speed.
Realistic pattern: multiple producers
The mpsc channel supports multiple producers by cloning the sender. Each clone is independent. They all write to the same buffer. The receiver sees messages from all senders interleaved. This is useful for fan-out patterns where multiple workers produce results for a single aggregator.
use tokio::sync::mpsc;
use tokio::task;
/// Worker task that produces messages.
async fn worker(id: usize, tx: mpsc::Sender<String>) {
for i in 0..5 {
let msg = format!("Worker {} item {}", id, i);
// Clone tx is not needed here; we move the clone into the closure if needed.
// Here we just use the tx passed in.
if tx.send(msg).await.is_err() {
break;
}
// Simulate work.
task::yield_now().await;
}
}
#[tokio::main]
async fn main() {
// Create a channel with capacity 10.
let (tx, mut rx) = mpsc::channel(10);
// Spawn three workers, each getting a clone of the sender.
let tx1 = tx.clone();
let tx2 = tx.clone();
let tx3 = tx; // Move the original into the third worker.
task::spawn(worker(1, tx1));
task::spawn(worker(2, tx2));
task::spawn(worker(3, tx3));
// Drop the original sender in main. The channel stays open because clones exist.
drop(tx);
// Collect all messages.
while let Some(msg) = rx.recv().await {
println!("Got: {}", msg);
}
println!("All workers finished. Channel closed.");
}
Convention aside: clone the sender, not the receiver. The receiver is unique by design. If you need multiple consumers, you must implement your own distribution logic or use a broadcast channel. Cloning the sender is cheap and safe. The runtime handles reference counting internally.
The receiver loop ends when the last sender drops. In the example, tx is moved into worker(3). tx1 and tx2 are clones. The channel closes only after all three workers finish and drop their senders. The main task sees None and exits. This pattern avoids manual synchronization. The channel lifecycle matches the producer lifecycle.
Design your task lifecycle around the channel closing. If you need the receiver to wait for a specific condition instead of sender drops, use a different synchronization primitive.
Pitfalls and compiler errors
The biggest pitfall is using std::sync::mpsc inside async code. The compiler does not stop you. std::sync::mpsc::recv is a blocking call. If you call it in an async task, you block the executor thread. The program hangs. This is a runtime error, not a compile error. You must discipline yourself to use async channels in async contexts.
Another pitfall is sending types that are not Send. Async channels require messages to implement the Send trait. This ensures messages can safely cross thread boundaries. If you try to send a Rc<T>, the compiler rejects you with E0277 (the trait bound Rc<T>: Send is not satisfied). Rc is not thread-safe. Use Arc<T> instead, which is Send and Sync.
use std::rc::Rc;
use tokio::sync::mpsc;
async fn bad_send() {
let (tx, _rx) = mpsc::channel(10);
let data = Rc::new("hello");
// This fails to compile.
// tx.send(data).await.unwrap();
}
The error message points out that Rc does not implement Send. Replace Rc with Arc to fix this. The compiler helps you here. You cannot accidentally send non-thread-safe data across async channels.
Unbounded channels are another trap. mpsc::unbounded_channel creates a channel with infinite capacity. Senders never block. This sounds convenient, but it removes backpressure. If producers are faster than consumers, the buffer grows until you run out of memory. Use bounded channels unless you have a specific reason for unbounded. Even then, monitor memory usage.
Convention aside: prefer bounded channels. They enforce flow control. If you find yourself increasing the capacity to fix performance, you likely have a consumer bottleneck. Fix the consumer instead of masking the problem with a larger buffer.
Bounded channels enforce backpressure. Unbounded channels enforce memory leaks.
Decision: which channel to use
Use tokio::sync::mpsc when you are inside a Tokio runtime and need to pass messages between async tasks without blocking the executor. Use std::sync::mpsc when you are writing synchronous code or wrapping a blocking I/O operation inside spawn_blocking. Use flume when you need a channel that works in both synchronous and asynchronous code without tying your crate to a specific runtime. Use crossbeam::channel when you need high-performance synchronous channels in a multi-threaded application that does not use async.
Reach for tokio::sync::mpsc in async Rust. It integrates with the executor and yields correctly. Reach for std::sync::mpsc only in sync code or blocking wrappers. Reach for flume when you are writing a library that must support multiple runtimes or sync/async mixed usage. Reach for crossbeam when performance is critical and you are in a sync multi-threaded context.
Trust the borrow checker on channel types. If the compiler complains about Send, fix the type. Do not reach for unsafe to bypass the bound. The bound exists for a reason.