How to Implement Cooperative Cancellation in Async Rust

Implement cooperative cancellation in async Rust using tokio::select! to run concurrent tasks and stop them immediately upon completion or timeout.

The flickering search result

You're building a search bar. The user types "rust". Your code fires off a network request to fetch results. The user backspaces and types "rustacean". A second request goes out. The first request takes a bit longer. It returns. Your UI updates with results for "rust". A millisecond later, the second request returns. The UI updates again with results for "rustacean". The user sees the wrong results flash on screen before the correct ones appear.

This is the classic race condition in async UIs. You need to kill the first request the moment the second one starts. You can't just ignore the result; you need to stop the work entirely to save bandwidth, CPU, and database load. In Rust, you don't kill threads. You drop futures.

Cancellation is dropping

Rust's async model is built on futures. A future is a state machine that produces a value eventually. The runtime polls futures to make progress. If a future is dropped, the state machine is destroyed. Execution stops.

Cancellation in Rust is cooperative. The runtime doesn't have a magic kill switch that yanks a task out of existence. Instead, cancellation happens when the future representing the task is dropped. The runtime drops the future, the Drop implementation runs, and the task halts.

Think of a restaurant kitchen. A customer cancels an order. The waiter shouts "Cancel the salmon!" The chef hears it, stops chopping, and discards the salmon. The chef doesn't vanish. The chef just stops working on that dish. In Rust, the chef is the async task. The cancel signal is dropping the future. When the future is dropped, the task stops.

This design keeps Rust safe. You can't accidentally cancel a task in the middle of a critical section without running cleanup code. The Drop implementation always runs. If you hold a lock, Drop releases it. If you hold a file handle, Drop closes it. Cancellation safety depends on writing correct Drop implementations.

Racing futures with select!

The most common way to implement cancellation is tokio::select!. This macro races multiple futures. It waits for the first one to complete. When one completes, select! drops the futures in all other branches. Those futures are cancelled.

use tokio::time::{sleep, Duration};

/// Races two tasks. The faster one wins.
/// The slower one is cancelled immediately.
async fn race_tasks() {
    // Create a fast task that finishes quickly.
    let fast_task = async {
        sleep(Duration::from_millis(100)).await;
        println!("Fast task completed");
    };

    // Create a slow task that takes longer.
    let slow_task = async {
        sleep(Duration::from_millis(500)).await;
        println!("Slow task completed");
    };

    // select! polls both tasks.
    // When fast_task finishes, select! returns.
    // slow_task is dropped and never prints.
    tokio::select! {
        _ = fast_task => println!("Fast won"),
        _ = slow_task => println!("Slow won"),
    }
}

#[tokio::main]
async fn main() {
    race_tasks().await;
}

The output is:

Fast task completed
Fast won

The slow task never prints. It was dropped. The sleep inside the slow task was cancelled. The future for slow_task was destroyed, so the await never resumed.

Walkthrough

When select! runs, it polls every branch. Each branch yields a future. The runtime checks if any future is ready. If a future is ready, select! executes that branch and returns. The futures in the other branches are dropped immediately.

Dropping a future runs its Drop implementation. For sleep, the Drop implementation removes the timer from the reactor. The timer stops firing. The task stops.

This works for any future. If you have a loop, dropping the future stops the loop. If you have a network request, dropping the future closes the connection. The cleanup is automatic because Rust guarantees Drop runs.

Convention aside: select! is biased by default. It checks branches in order. If multiple branches are ready at the same time, the first one wins. This bias improves performance by avoiding extra checks. If you need fairness, use biased(false).

tokio::select! {
    biased(false); // Disable bias for fair scheduling.
    _ = task_a => println!("A"),
    _ = task_b => println!("B"),
}

With biased(false), select! shuffles the branches or uses a random strategy to avoid starvation. Use this when you have many tasks and don't want the first branch to starve the others.

Real-world patterns

Timeouts are a frequent use case. You want to run a task, but cancel it if it takes too long. tokio::time::timeout wraps a future and cancels it if the duration expires.

use tokio::time::{timeout, Duration};

/// Fetches data with a timeout.
/// Cancels the fetch if it takes longer than 2 seconds.
async fn fetch_with_timeout() {
    let data_future = async {
        // Simulate a slow network request.
        sleep(Duration::from_secs(10)).await;
        "Data".to_string()
    };

    // Wrap the future in a timeout.
    // If data_future doesn't complete in 2 seconds,
    // it is cancelled and timeout returns an error.
    let result = timeout(Duration::from_secs(2), data_future).await;

    match result {
        Ok(data) => println!("Got data: {}", data),
        Err(_) => println!("Timed out"),
    }
}

The timeout function returns a Result<T, Elapsed>. If the future completes in time, you get Ok(value). If the timeout expires, the future is dropped, and you get Err(Elapsed).

Convention aside: timeout is just syntactic sugar for select!. It races your future against a sleep. If the sleep wins, your future is dropped. You can implement the same pattern manually with select! if you need custom logic.

For spawned tasks, use JoinHandle::abort. When you spawn a task, you get a handle. Calling abort on the handle signals the task to be cancelled. The task is dropped at the next yield point.

use tokio::time::{sleep, Duration};

/// Spawns a task and aborts it after 3 seconds.
async fn abort_spawned_task() {
    // Spawn a loop that runs forever.
    let handle = tokio::spawn(async {
        loop {
            println!("Working...");
            sleep(Duration::from_secs(1)).await;
        }
    });

    // Let the task run for a bit.
    sleep(Duration::from_secs(3)).await;

    // Request cancellation.
    // The task will be dropped at the next await point.
    handle.abort();

    // Wait for the task to finish dropping.
    // This ensures resources are cleaned up.
    let _ = handle.await;
}

Calling abort doesn't stop the task instantly. The task continues until it hits an await. At that point, the runtime checks the abort flag and drops the future. This is still cooperative. The task can't be killed mid-computation.

Convention aside: Always await the handle after aborting. let _ = handle.await; ensures the task has fully dropped. If you don't await, the task might still be running in the background, holding resources. The underscore discards the result, which is usually an error because the task was aborted.

Pitfalls and compiler errors

Cancellation safety is the biggest pitfall. If your async function holds a resource and gets cancelled, the resource must be released. If Drop doesn't release it, you leak.

use tokio::sync::Mutex;

/// BAD: Leaks the mutex lock on cancellation.
async fn leaky_task(mutex: &Mutex<i32>) {
    // Lock the mutex.
    let mut guard = mutex.lock().await;

    // If cancelled here, guard is dropped.
    // MutexGuard::Drop releases the lock.
    // This is safe because MutexGuard handles cleanup.
    
    // However, if you use a raw resource without Drop,
    // you leak.
    do_work().await;
}

Most standard types like MutexGuard implement Drop correctly. They release resources when dropped. Custom types need careful Drop implementations. If you write a custom async resource, test cancellation. Drop the future mid-operation and check if resources are freed.

Convention aside: Use the scopeguard crate for complex cleanup. It runs a closure when a guard is dropped, even if the task is cancelled. This is safer than manual cleanup in Drop.

use scopeguard::guard;

async fn safe_task() {
    // Acquire a resource.
    let resource = acquire_resource();
    
    // Create a guard that releases the resource on drop.
    let _guard = guard(resource, |r| release_resource(r));

    // Work can be cancelled safely.
    // The guard runs release_resource in Drop.
    do_work().await;
}

Another pitfall is moving values into select!. If you move a value into a branch, you can't use it after select!. The compiler rejects this with E0382 (use of moved value).

let data = String::from("hello");

tokio::select! {
    _ = async {
        // data is moved into this future.
        println!("{}", data);
    } => {}
}

// Error: E0382 use of moved value `data`.
// println!("{}", data);

The future owns the value. If the future is dropped, the value is gone. If you need to use the value after select!, clone it or borrow it.

Bias can cause starvation. If you have many branches and the first one is always ready, the others never run. This happens with channels. If a channel has messages, the receive branch is always ready. Other branches starve.

tokio::select! {
    // If this channel always has messages,
    // other branches never run.
    msg = rx.recv() => { ... },
    _ = other_task => { ... },
}

Fix this with biased(false) or by restructuring the logic. If you need to prioritize one branch, bias is correct. If you need fairness, disable bias.

Decision matrix

Use tokio::select! when you have multiple local futures and want to race them, cancelling the losers automatically. Use tokio::time::timeout when you need to cancel a single future if it takes too long. Use JoinHandle::abort when you spawned a task and need to cancel it from a different task or scope. Use a cancellation channel when you need to propagate a cancellation signal deep into a hierarchy of tasks without dropping the top-level future.

Trust the borrow checker. It ensures values are moved correctly into futures. Trust Drop. It ensures cleanup runs on cancellation. Treat cancellation as a normal control flow path. Write code that handles being dropped gracefully.

Where to go next