Tokio Select and Cancellation Safety Explained With Traps
select! races futures and drops the losers, which is only safe if they can be dropped mid way. Learn what cancellation safe means and which APIs are not.

In async Rust, dropping a future cancels it. That is the foundation of timeouts and select!, and it is also a way to lose data silently: if a future had consumed something before its first suspension point and is dropped at a later one, that something is gone. A future for which this cannot happen is called cancel safe. Documentation for each Tokio method says whether it is, and the ones that are not have caused real incidents. This post shows what happens to a cancelled future, builds a loss you can reproduce, lists which common operations are safe, and gives three patterns that make a loop around select! correct. Everything compiled on Rust 1.99, edition 2024, with tokio 1.
What cancellation is
A future makes progress only when it is polled. select! polls several futures and, when one completes, drops the others. Dropping runs destructors for whatever the future held at its current await point, and nothing else: the code after that await never runs. For a future waiting at its very first await, the drop loses nothing. For a future that has already done part of its work, the work it did is gone unless it was stored somewhere that outlives the future.
use tokio::time::{sleep, Duration};
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!(" dropped {}", self.0);
}
}
async fn slow_job() -> &'static str {
let _a = Noisy("resource a");
println!(" job: step 1 done");
sleep(Duration::from_secs(10)).await;
let _b = Noisy("resource b");
println!(" job: step 2 done");
"finished"
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
tokio::select! {
r = slow_job() => println!("job result: {r}"),
_ = sleep(Duration::from_secs(1)) => println!("timeout wins"),
}
} job: step 1 done
dropped resource a
timeout winsStep 1 ran, the timeout fired, and resource a was dropped. Step 2 never ran and resource b was never created. The cleanup was automatic, which is the good news: RAII works across cancellation. The bad news is anything that was consumed in step 1 and not put back.
A reproducible loss
Here is the classic bug. A function reads two messages from a channel and combines them. It is called inside a select! loop with a periodic tick. If the tick arrives between the two reads, the first message is gone:
use tokio::sync::mpsc;
use tokio::time::{interval, sleep, Duration};
async fn read_pair(rx: &mut mpsc::Receiver<u32>) -> Option<(u32, u32)> {
let a = rx.recv().await?;
let b = rx.recv().await?;
Some((a, b))
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let (tx, mut rx) = mpsc::channel(8);
tokio::spawn(async move {
for n in 1..=4u32 {
tx.send(n).await.unwrap();
sleep(Duration::from_millis(150)).await;
}
});
let mut tick = interval(Duration::from_millis(100));
let mut pairs = Vec::new();
loop {
tokio::select! {
p = read_pair(&mut rx) => match p {
Some(p) => pairs.push(p),
None => break,
},
_ = tick.tick() => {}
}
}
println!("pairs read: {pairs:?}");
}pairs read: []Four values were sent, 1 through 4, and the program should have printed [(1, 2), (3, 4)]. It printed an empty list. Values arrive every 150 ms and the tick fires every 100 ms, so each call to read_pair takes its first value and is then cancelled by a tick before the second one arrives. Every first value is dropped along with the future that held it, the next call starts from scratch, and no pair ever completes. When the sender finishes, recv returns None and the loop ends with nothing to show. There was no error and no panic, and the symptom (less data than was sent) is what you would see in production as a record missing per tick under load. A faster tick or a slower producer changes how much is lost, not whether it is.
Which operations are cancel safe
The rule of thumb: an operation is cancel safe if dropping it at any await point leaves the world as though it had never been called, or as though it had completed. Tokio documents this per method. The ones to know:
| Operation | Cancel safe? | Why |
|---|---|---|
mpsc::Receiver::recv, broadcast::recv, watch::changed | Yes | A message is removed only when recv returns it |
sleep, interval.tick(), timeout | Yes | Nothing consumed |
TcpListener::accept | Yes | A connection is taken only on completion |
AsyncReadExt::read | Yes | Either read some bytes and returned, or read none |
AsyncReadExt::read_exact, read_to_end, read_line | No | May have read part of the data into a local buffer that is dropped |
AsyncWriteExt::write_all | No | May have written part; the caller cannot know how much |
mpsc::Sender::send | Yes (message returned if dropped before complete) | Capacity reservation is released |
Mutex::lock | Yes | Lock is held only on completion |
Your own async fn with two or more awaits and state in between | Usually no | Local variables are lost |
The last row is the one that applies to your code. An async fn is cancel safe only if every await point can be abandoned without losing state, which a function that holds partial results in locals does not satisfy.
Fix 1: keep the state outside the future
The general cure is to make the partial result survive cancellation by storing it in a variable the loop owns. The function takes the state by mutable reference and resumes from it:
use tokio::sync::mpsc;
use tokio::time::{interval, sleep, Duration};
async fn read_pair(rx: &mut mpsc::Receiver<u32>, pending: &mut Option<u32>) -> Option<(u32, u32)> {
if pending.is_none() {
*pending = Some(rx.recv().await?);
}
let b = rx.recv().await?;
Some((pending.take().unwrap(), b))
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let (tx, mut rx) = mpsc::channel(8);
tokio::spawn(async move {
for n in 1..=4u32 {
tx.send(n).await.unwrap();
sleep(Duration::from_millis(150)).await;
}
});
let mut tick = interval(Duration::from_millis(100));
let mut pending = None;
let mut pairs = Vec::new();
let mut ticks = 0;
loop {
tokio::select! {
p = read_pair(&mut rx, &mut pending) => match p {
Some(p) => pairs.push(p),
None => break,
},
_ = tick.tick() => ticks += 1,
}
}
println!("pairs read: {pairs:?} (ticks seen: {})", ticks > 0);
}pairs read: [(1, 2), (3, 4)] (ticks seen: true)Both pairs came through, even though ticks interrupted the reads. The partial value lives in pending, outside the future, so dropping the future no longer drops the data. This is the pattern for resumable parsers: keep the buffer in the caller.
Fix 2: pin the future and select on a reference
A second way is to not drop the future at all. Create it once, pin it, and select on &mut the future. When the other branch wins, the future stays alive with its state intact and is polled again next iteration:
use std::pin::pin;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
async fn read_pair(rx: &mut mpsc::Receiver<u32>) -> Option<(u32, u32)> {
let a = rx.recv().await?;
let b = rx.recv().await?;
Some((a, b))
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let (tx, mut rx) = mpsc::channel(8);
tokio::spawn(async move {
for n in 1..=2u32 {
tx.send(n).await.unwrap();
sleep(Duration::from_millis(250)).await;
}
});
let mut ticks = 0;
let mut pair = pin!(read_pair(&mut rx));
let result = loop {
tokio::select! {
p = &mut pair => break p,
_ = sleep(Duration::from_millis(100)) => ticks += 1,
}
};
println!("{result:?}, interrupted {ticks} times");
}Some((1, 2)), interrupted 2 timesThis works for a single in-flight operation and is how you wait for one long future while doing periodic work beside it. The condition is that the same future must be polled to completion; if you drop it after the loop, the state is lost as before. The mechanics of why polling a pinned future again resumes it are in futures as state machines.
Fix 3: use cancel-safe building blocks and message framing
For network protocols, avoid read_exact in a select loop. Use a framing layer (tokio_util::codec::FramedRead) whose next() is cancel safe: it keeps its partially filled buffer inside the stream object, not in a future's locals. For writes, send whole messages through a channel to a dedicated writer task that does the write_all, outside any select. In both cases the principle is the same one: the dangerous await lives in a task that is never cancelled, and the cancellable operations around it are all safe ones.
Review checklist
- For every branch of a
select!inside aloop, look up whether the future is cancel safe, and read the "Cancel safety" section of its documentation. - For your own
async fnused in a branch, ask: if this were dropped after each.awaitin turn, what would be lost? - Prefer one-operation futures (
recv(),accept()) in branches, and put the multi-step logic in the handler body, which is not cancelled. - Add a test that cancels the future at every await point, using a paused clock and a competing short timeout, as above.
Cancellation is also the tool when you want it, to stop work on a deadline or a shutdown signal; those uses are covered in timeouts and cancelling tasks. And a related trap is the lock guard held across an await in a select branch, discussed in mutex guards across await.


