Tag Archives: async

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.

Visualization of ‘futurelock’

Async rust has a few parts that doesn’t feel ‘rusty’ at all. Rust is pretty good at “forcing” local reasoning but async cancellation (drop of Future etc.) leads to non-local reasoning which leads to hard to follow sequence of events that leads to subtle bugs. I recently learnt about futurelock from this excellent blog post.

The RFD (Request For Discussion) from Oxide that describe futurelock (https://rfd.shared.oxide.computer/rfd/0609) is easy to read by an intermediate Rust programmer. Reading this RFD made me a little bit nervous about async which I though I knew decently well.

I created this diagram that summarizes the sequence of events in the RFD that eventually leads to the deadlock/futurelock. You can refer to this diagram when re-reading the RFD. It helps a lot.

I also dug a little deeper into the mechanism of Mutex waking up the relevant tasks when it is unlocked. In the past, I’ve written state machines with callbacks and I think about async in state-machine terms. Future and Waker works together to implment state-machine with callback.

Here is another diagram which shows how Waker is used to implement callback like mechanism for the example in the RFD.