Async Rust: An example of future cancellation

Read the following code and guess the output.

It has two concurrent tasks. The first task sets a cancellation token after 150ms. The second task accepts a variable initialized to 0, increments it twice with 100ms sleeps in between, and finally resets it to 0.

Then we have a tokio::select! that returns the first branch that completes, and cancels the second branch.

use std::sync::{Arc, Mutex};
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() {
let cancellation_token = CancellationToken::new();
let token = cancellation_token.clone();
let cancelled = tokio::spawn(async move {
// X1
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
token.cancel();
});
let a = Arc::new(Mutex::new(0));
tokio::select! {
_ = cancelled => {
println!("cancellation token is set.");
}
_ = long_task(a.clone()) => {
}
};
println!("a ={}", a.lock().unwrap());
}
async fn long_task(state: Arc<Mutex<i32>>) {
println!("long task started...");
{
let mut lock = state.lock().unwrap();
*lock += 1;
}
// Y1
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
{
let mut lock = state.lock().unwrap();
*lock += 1;
}
// Y2
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
{
// clean up (reset to 0)
let mut lock = state.lock().unwrap();
*lock = 0;
}
println!("long_task has ended.");
}

If you guessed an answer other than 2, you need to read about cancellation safety. The Tokio documentation also talks about it extensively.

Why is the output 2?

Because at 150ms, the spawned task completes, causing tokio::select! to select that branch and immediately drop the long_task future before it can reach the cleanup step. Note that long_task is dropped not because it checks the CancellationToken, but because tokio::select! automatically drops all non-winning futures.

This behavior can be dangerous: what if the remaining code was cleaning up a resource (like running a database cleanup query or releasing a lock) rather than resetting a variable? You could leave your system in a broken state.

Execution Sequence

  • 0 ms: long_task starts, sets a = 1, and yields at Y1 (sleep 100ms).
  • 100 ms: Y1 finishes. long_task sets a = 2 and yields at Y2 (sleep 100ms).
  • 150 ms: X1 finishes. The cancelled task completes, and its JoinHandle resolves.
  • 150 ms: tokio::select! receives the completed branch and drops long_task while it is sleeping at Y2. The final cleanup block is never executed, leaving a = 2.

Leave a Reply