When tasks move at different speeds
You are building a log aggregator. One task reads lines from a file. Another task parses JSON. A third task writes to a database. They run at completely different speeds. The reader finishes in milliseconds. The writer takes seconds. If you pipe them together with standard threads, the reader blocks the whole program. If you share a global vector, you need locks and risk panics. You need a communication pipe that yields control instead of freezing.
Async channels solve this. They let independent tasks pass messages back and forth without sharing memory or locking threads. The tokio::sync::mpsc module gives you multi-producer, single-consumer channels built specifically for async runtimes. Unlike standard library channels that block a thread when full or empty, async channels yield control back to the executor. The sender parks itself until space opens up. The receiver parks until a message arrives. Nothing spins. Nothing blocks.
Treat the channel capacity as a circuit breaker. Set it low enough to catch bugs, high enough to handle bursts.
The conveyor belt model
Think of a factory conveyor belt. Workers drop packages on one end. A single assembler picks them up on the other. If the belt fills up, the workers step back and wait for space. If the belt empties, the assembler pauses until the next package arrives. The belt itself is the channel. The packages are your messages. The workers are your senders. The assembler is your receiver.
The belt has a fixed length. That length is the channel capacity. When you create a channel with mpsc::channel(10), you are building a belt that holds exactly ten packages. If you try to push an eleventh package, the worker stops moving and waits. The runtime notices the worker is waiting and switches to another task. When the assembler picks up a package, space opens up. The runtime wakes the worker. The belt keeps moving.
This mechanism is called backpressure. It forces fast producers to slow down when consumers fall behind. It prevents memory from filling up with unprocessed data. It keeps your program stable under load.
Drop the sender to close the pipe. The receiver will know exactly when to stop.
A working example
Here is the smallest complete setup. It creates a bounded channel, spawns a task to send messages, and receives them in the main task.
use tokio::sync::mpsc;
/// Demonstrates basic async channel usage with a bounded capacity
#[tokio::main]
async fn main() {
// Create a bounded channel that holds up to 5 messages
// Bounded channels enforce backpressure automatically
let (mut tx, mut rx) = mpsc::channel::<String>(5);
// Spawn a background task that takes ownership of the sender
// The task runs concurrently with main
tokio::spawn(async move {
// send() returns a Result and yields if the channel is full
// We unwrap here because we control the capacity and expect success
tx.send("first message".to_string()).await.unwrap();
tx.send("second message".to_string()).await.unwrap();
// When this task ends, the sender is dropped
// The channel will close for the receiver
});
// Receive messages in a loop until the sender drops
// recv() returns None when all senders are gone
while let Some(msg) = rx.recv().await {
println!("Got: {msg}");
}
}
The code compiles and runs without blocking. The #[tokio::main] macro sets up the async runtime. tokio::spawn hands the sender to a new task. The while let loop drains the receiver. When the spawned task finishes, the original tx is dropped. The channel closes. rx.recv() returns None. The loop exits.
Spawn workers, not threads. Let the runtime schedule the waiting.
Step through the runtime
When you call tx.send(value).await, three things can happen. First, if the internal buffer has space, the value gets pushed and the function returns immediately. Second, if the buffer is full, the current task is marked as blocked. The runtime saves its state and switches to another ready task. Third, when the receiver pulls a value out, the runtime marks the sender as ready again. The next time the scheduler picks it up, the send completes.
The receiver side works the same way. rx.recv().await checks the buffer. If a message exists, it returns Some(value). If the buffer is empty, the task yields. The runtime moves on. When a sender pushes a new value, the receiver wakes up.
This cooperative scheduling is why async channels never freeze your program. They trade CPU cycles for concurrency. Instead of burning a thread waiting for I/O or a lock, they hand the CPU to something else. The cost is a tiny bit of bookkeeping. The reward is thousands of concurrent tasks on a single thread.
If you forget the .await on send() or recv(), the compiler rejects you with a trait bound error. The functions return Future types, not values. You must drive the future to completion.
Convention note: cloning the sender is cheap. tx.clone() just bumps an internal reference counter. It does not copy the buffer or allocate new memory. The community expects you to clone freely when spawning multiple producers.
Pick the channel that matches your flow. Async needs async channels.
Real-world worker pattern
Production code rarely sends two messages and exits. It usually runs a long-lived consumer that processes a stream of work items. Here is a realistic pattern that handles multiple producers, graceful shutdown, and simulated processing time.
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
/// Runs a background consumer that processes tasks until the channel closes
async fn run_consumer(mut rx: mpsc::Receiver<String>) {
// Loop until recv() returns None, which happens when all senders drop
while let Some(task) = rx.recv().await {
// Simulate work without blocking the runtime thread
// sleep() yields control so other tasks can run
sleep(Duration::from_millis(50)).await;
println!("Processed: {task}");
}
println!("Channel closed. Consumer shutting down.");
}
/// Entry point that sets up producers and a single consumer
#[tokio::main]
async fn main() {
// Capacity of 10 balances memory usage and producer throughput
let (tx, rx) = mpsc::channel::<String>(10);
// Spawn the consumer task before sending any messages
// This prevents deadlocks if the channel fills up immediately
tokio::spawn(run_consumer(rx));
// Simulate multiple independent producers generating work
for i in 0..15 {
// Clone the sender for each producer task
// Each clone shares the same underlying channel
let tx_clone = tx.clone();
tokio::spawn(async move {
// Await the send to respect backpressure
tx_clone.send(format!("task-{i}")).await.unwrap();
});
}
// Drop the original sender so the channel closes after clones finish
// Without this, the receiver would wait forever
drop(tx);
// Keep the main task alive long enough to observe output
// In real apps, you would use join handles or signals instead
sleep(Duration::from_secs(2)).await;
}
The drop(tx) line is intentional. Every clone keeps the channel open. The receiver only sees None when the last sender disappears. Dropping the original sender after spawning clones is a standard pattern. It signals that no more producers will be created. The consumer can shut down cleanly.
Convention note: while let Some(msg) = rx.recv().await is the idiomatic loop. Do not wrap it in loop { match rx.recv().await... }. The compiler optimizes the while let form, and it reads cleaner.
If your program hangs, check your channel capacity first. A full pipe stops the whole factory.
Common traps
Async channels are straightforward until they are not. The runtime hides complexity, but it does not hide logic errors.
Forgetting to drop the original sender is the most frequent mistake. You spawn ten tasks, each holding a cloned sender. You expect the receiver to exit after they finish. It never does. The channel stays open because the original tx still lives in main. The receiver waits forever. Add drop(tx) after spawning, or collect JoinHandles and await them.
Using std::sync::mpsc inside async code causes silent stalls. The standard library channels block the current OS thread when full or empty. If you call tx.send() in an async task, you freeze the entire runtime thread. Other tasks cannot run. The program appears deadlocked. The compiler will not catch this. It is a runtime behavior mismatch. Always use tokio::sync::mpsc for async code.
Ignoring SendError panics your task. tx.send() returns Result<(), SendError<T>>. If the receiver drops while you are sending, the send fails. Calling .unwrap() on a failed send panics the task. Tokio catches task panics by default, but they still print to stderr and can mask real bugs. Handle the result explicitly in production code.
Mixing bounded and unbounded channels breaks backpressure. mpsc::unbounded_channel() creates a pipe with infinite capacity. Sends never block. This sounds convenient until your producer generates data faster than the consumer can process it. Memory grows until the OS kills your process. Bounded channels force you to respect consumer speed. Use bounded by default. Switch to unbounded only when you have measured that backpressure hurts your specific workload.
Treat the SAFETY comment as a proof. If you can't write it, you don't have one. (Not applicable here, but remember: channels are safe by design. You rarely need unsafe around them.)
Choosing the right pipe
Rust's ecosystem offers several channel types. Picking the wrong one causes deadlocks, memory leaks, or unnecessary complexity. Match the tool to the data flow.
Use tokio::sync::mpsc when you need async tasks to communicate without blocking threads and want backpressure to protect memory. Use std::sync::mpsc when you are writing synchronous code or interacting with legacy blocking libraries that expect thread-safe channels. Use tokio::sync::broadcast when multiple consumers need to receive the exact same message and you can tolerate dropped messages under heavy load. Use tokio::sync::oneshot when you only need to send a single value and want the receiver to know immediately when the sender finishes. Use tokio::sync::mpsc::unbounded_channel when you want to guarantee sends never block and can afford unbounded memory growth for short-lived bursts. Use tokio::sync::mpsc::channel with a small capacity when you want to catch producer-consumer mismatches early during development.
Pick the channel that matches your flow. Async needs async channels.