Skip to content
rusthacks.dev
Concurrency

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.

7 min read
Summarize in AI
Flat illustration for the post “Tokio Select and Cancellation Safety Explained With Traps”, composed around async.

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.

rust
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"),
    }
}
Text
  job: step 1 done
  dropped resource a
timeout wins

Step 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:

rust
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:?}");
}
Text
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:

OperationCancel safe?Why
mpsc::Receiver::recv, broadcast::recv, watch::changedYesA message is removed only when recv returns it
sleep, interval.tick(), timeoutYesNothing consumed
TcpListener::acceptYesA connection is taken only on completion
AsyncReadExt::readYesEither read some bytes and returned, or read none
AsyncReadExt::read_exact, read_to_end, read_lineNoMay have read part of the data into a local buffer that is dropped
AsyncWriteExt::write_allNoMay have written part; the caller cannot know how much
mpsc::Sender::sendYes (message returned if dropped before complete)Capacity reservation is released
Mutex::lockYesLock is held only on completion
Your own async fn with two or more awaits and state in betweenUsually noLocal 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:

rust
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);
}
Text
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:

rust
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");
}
Text
Some((1, 2)), interrupted 2 times

This 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 a loop, look up whether the future is cancel safe, and read the "Cancel safety" section of its documentation.
  • For your own async fn used in a branch, ask: if this were dropped after each .await in 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.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.