Async Channels and select!
Async/Await with Tokio covered spawning tasks; this tutorial covers making them talk. Once you have more than one task, you need two things: a way to pass messages between them, and a way to wait on several events at once. Tokio's async channels handle the first, and select! handles the second. The naive instinct, reaching for a Mutex<Vec<T>> as a shared queue, works but fights the runtime; message passing is the idiomatic async pattern, and picking the right channel for the communication shape is most of the skill.
Picking the Right Channel
Tokio offers four channels, and the choice is dictated by how many senders and receivers there are, and how many messages flow. Using the wrong one is the most common async-design mistake:
| Channel | Shape | Use for |
|---|---|---|
mpsc | many senders → one receiver | a work queue, an event stream into one consumer |
oneshot | one sender → one receiver, single value | a task returning one result back to its spawner |
broadcast | many senders → many receivers, every receiver sees every message | fan-out events (shutdown signal, config reload) |
watch | one sender → many receivers, only the latest value matters | sharing current state (latest config, health status) |
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel::<Job>(32); // bounded: backpressure at 32 in flight
// producer task(s)
tokio::spawn(async move {
tx.send(Job::new()).await.unwrap(); // .await — blocks if the buffer is full
});
// single consumer
while let Some(job) = rx.recv().await {
process(job).await;
}
The bounded mpsc::channel(n) gives you backpressure: when the buffer is full, send().await waits, so a fast producer can't outrun a slow consumer and exhaust memory. unbounded_channel() exists but removes that safety, prefer bounded unless you can prove the producer is naturally rate-limited.
oneshot: A Single Reply
When a task needs to hand one result back, oneshot is the precise tool, lighter than an mpsc with capacity 1, and it encodes "exactly one value" in the type. The classic shape is a request that carries its own reply channel:
use tokio::sync::oneshot;
let (resp_tx, resp_rx) = oneshot::channel();
tokio::spawn(async move {
let result = expensive_query().await;
let _ = resp_tx.send(result); // send consumes the sender — can only fire once
});
let answer = resp_rx.await.unwrap(); // await the single reply
oneshot::Sender::send takes self by value, so it's structurally impossible to send twice, the type enforces the "one reply" contract. This pattern (bundle a oneshot sender into each mpsc message) is how you build an async request/response actor: the actor reads requests off an mpsc and answers each via its embedded oneshot.
select!: Waiting on Several Things at Once
tokio::select! races multiple async operations and runs the branch for whichever completes first, cancelling the rest. It's how a task waits for "a message or a shutdown signal or a timeout" without blocking on any single one:
use tokio::time::{sleep, Duration};
loop {
tokio::select! {
Some(job) = rx.recv() => {
process(job).await;
}
_ = &mut shutdown => {
println!("shutting down");
break;
}
_ = sleep(Duration::from_secs(30)) => {
println!("idle timeout, sending heartbeat");
}
}
}
The first branch to become ready wins; the other futures are simply dropped. This is what makes graceful shutdown and timeouts natural in async code, you don't poll, you express "whichever happens first" and let the runtime wake you.
Gotcha: when one
select!branch completes, the others are cancelled mid-flight, their futures are dropped at whatever.awaitpoint they'd reached, and any work in progress is lost. If a branch was halfway throughrx.recv()or a partial read, that progress vanishes. This is fine for idempotent or restartable work, but dangerous if a branch was mutating shared state partway through. Only put cancellation-safe futures in aselect!; if a future must run to completion, do it outside theselect!(or spawn it as its own task) so a losing branch can't tear it down halfway.
Cancellation Is Just Drop
Async cancellation in Rust isn't a special signal, it's Drop (Drop, RAII, and Resource Cleanup). A future stops making progress the moment nothing polls it, which happens when you drop it: a losing select! branch, a JoinHandle you abort, or a timeout wrapper expiring.
use tokio::time::{timeout, Duration};
// if the operation isn't done in 5s, its future is dropped — that IS the cancellation
match timeout(Duration::from_secs(5), fetch_data()).await {
Ok(data) => println!("got {data:?}"),
Err(_elapsed) => println!("timed out"),
}
Because cancellation is drop, any cleanup a cancelled task needs must live in a Drop impl (a guard), not in code after the .await, since that code may never run. This is the async payoff of the guard pattern: a Drop-based guard releases resources correctly even when its task is cancelled mid-await, whereas trailing cleanup code is simply skipped.
Channels vs Shared State
| You want | Reach for |
|---|---|
| A work queue, one consumer | mpsc (bounded for backpressure) |
| One task to return one result | oneshot |
| Broadcast an event to all listeners | broadcast |
| Share the latest value of some state | watch |
| Wait on several events, first wins | select! |
| Bound an operation by time | timeout(...) |
| Shared mutable state across async tasks | async Mutex/RwLock (held briefly, never a sync one across .await) |
The guiding principle is the Go-flavored one that fits Rust's ownership model: prefer passing ownership through a channel over sharing mutable state behind a lock. Moving a value through an mpsc sidesteps the lock contention, the held-across-.await deadlock risk from Interior Mutability, and most data races by construction. Reach for a shared async lock only for genuinely shared state that several tasks must read and write in place.
Key Takeaways
- Pick the channel by communication shape:
mpsc(many→one queue),oneshot(one→one single reply),broadcast(every receiver sees every message),watch(latest value only). Wrong channel choice is the most common async-design mistake. - Prefer bounded
mpsc::channel(n)for backpressure, a full buffer makessend().awaitwait, so a fast producer can't exhaust memory.oneshot::sendtakesself, encoding "exactly one reply" in the type. tokio::select!races futures and runs the first ready branch, the natural way to wait on "message or shutdown or timeout" without polling.- A losing
select!branch is cancelled mid-flight and its in-progress work is dropped; only put cancellation-safe futures in aselect!, and run must-complete work outside it. - Async cancellation is
Drop: a future stops when nothing polls it. Put cleanup in aDropguard, not in code after the.await, which a cancelled task may never reach. - Prefer passing ownership through a channel over sharing state behind a lock; it avoids contention, held-across-
.awaitdeadlocks, and most data races by construction.