rust.
rust5 min read

Tokio Task Cancellation: abort, CancellationToken, and select! Drop

Three ways to cancel a tokio task — JoinHandle.abort, CancellationToken, and select! drop. When each fits, what each leaks, and the cleanup contract you actually want.

Tokio Task Cancellation: abort, CancellationToken, and select! Drop

Cancellation is the part of async Rust where the borrow checker stops helping. A task you spawned five seconds ago is running on some worker thread, holding a TCP socket, halfway through a database transaction. You want it to stop. You have three tools, and they are not interchangeable.

The short version: JoinHandle::abort is a hammer, CancellationToken is a contract, and dropping a future inside select! is the idiom most production code actually leans on. Picking the wrong one leaks file descriptors, abandons in-flight writes, or \u2014 worse \u2014 looks fine in tests and corrupts state in production.

What "cancellation" means in tokio

A tokio task is a future that the runtime polls. There is no thread to interrupt, no signal to deliver. Cancellation in tokio means one of two things: either the runtime stops polling the future (drop), or the future itself observes a flag and returns early (cooperative). Both are valid. They have different cleanup semantics.

When a future is dropped, every value it owns runs Drop. That is your cleanup hook. A tokio::net::TcpStream closes the socket on drop. A sqlx::Transaction rolls back on drop. A Mutex guard releases on drop. This is the part new tokio users miss: cancellation safety often comes for free if your types implement Drop correctly. The danger is when "correct cleanup" requires running async code, because Drop is synchronous.

Option 1: JoinHandle::abort

JoinHandle::abort() tells the runtime to stop polling the task. The next time the scheduler looks at it, the future is dropped at the current .await point. Fast, no cooperation needed.

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

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(60)).await;
        "done"
    });

    sleep(Duration::from_millis(100)).await;
    handle.abort();

    match handle.await {
        Ok(value) => println!("finished: {value}"),
        Err(e) if e.is_cancelled() => println!("cancelled"),
        Err(e) => println!("panicked: {e}"),
    }
}

Use abort when the task does not own resources that need an async cleanup step. Pure CPU work, an in-memory cache refresh, a metrics aggregator \u2014 these are fine to abort. The future drops at the next yield point, owned values run Drop, the worker moves on.

Where abort hurts: a task that holds a database transaction and writes through HTTP, where rollback requires sending a query. Sync Drop cannot run async code, so the connection gets returned to the pool with the transaction still open. Some pools detect this and reset the connection; some do not. You will not notice in unit tests and you will notice on a Tuesday at 3am.

Option 2: CancellationToken

tokio_util::sync::CancellationToken is the cooperative model. The cancelling code calls token.cancel(), the cancelled task polls token.cancelled().await or checks token.is_cancelled(). The task decides when and how to wind down.

use tokio_util::sync::CancellationToken;
use tokio::time::{sleep, Duration};

async fn worker(token: CancellationToken) {
    loop {
        tokio::select! {
            _ = token.cancelled() => {
                flush_buffer().await;
                close_connections().await;
                return;
            }
            _ = sleep(Duration::from_millis(500)) = {
                do_unit_of_work().await;
            }
        }
    }
}

Two properties make this the right tool for graceful shutdown. First, CancellationToken clones cheaply and forms a tree \u2014 a parent token cancels every child. Spawn a request handler, hand it a child token, and your top-level shutdown signal propagates to every in-flight handler without you wiring channels by hand. Second, the task gets to run async cleanup. Flush a write buffer, send a ROLLBACK, post a final metrics event \u2014 all of it on the happy path of cancellation, not in Drop.

The cost: every long-running loop has to remember to check the token. A task that calls a blocking C function or a third-party future that does not yield will not observe cancellation until that call returns. CancellationToken does not interrupt \u2014 it requests.

Practical numbers: a token check inside a hot loop adds roughly 1-2ns of overhead per iteration. Negligible compared to almost any I/O. Run it in a CPU-bound tight loop and you might notice; sprinkle it around .await points and you will not.

Option 3: select! drop

select! is the workhorse. You race the work against a cancellation source \u2014 a deadline, a token, a shutdown channel \u2014 and whichever finishes first wins. The losing branch is dropped, its destructors run, the function returns.

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

async fn handle_request(pool: &PgPool, token: CancellationToken) -> Result<(), Error> {
    let work = async {
        let mut tx = pool.begin().await?;
        sqlx::query!("INSERT INTO events (kind) VALUES ('login')")
            .execute(&mut *tx)
            .await?;
        tx.commit().await?;
        Ok::<_, Error>(())
    };

    tokio::select! {
        result = work = result,
        _ = token.cancelled() => {
            Err(Error::Cancelled)
        }
        _ = tokio::time::sleep(Duration::from_secs(5)) => {
            Err(Error::Timeout)
        }
    }
}

When the cancellation branch wins, the work future is dropped mid-flight. Every owned value's Drop runs in reverse declaration order. sqlx::Transaction::Drop queues a rollback on the connection, the connection returns to the pool, the next checkout sees a clean state. This is the pattern axum, tonic, and most tokio servers use under the hood for per-request cancellation.

The cancellation-safety trap: not every future is safe to drop mid-await. A future that buffers bytes from a socket and is dropped between reads can lose those bytes. tokio::io::AsyncReadExt::read is documented as cancellation-safe; some third-party stream wrappers are not. Read the cancellation safety section of any future you put inside select! \u2014 the tokio docs flag this on every method.

Which one fits

Pick abort when you spawned a fire-and-forget background task and a sync Drop is enough cleanup. Pick CancellationToken when you need shutdown to propagate through a tree of tasks and you want async cleanup on cancel. Pick select! drop when cancellation is request-scoped and the future's Drop chain already does the right thing.

The hierarchy I reach for in production servers: CancellationToken at the top for graceful shutdown, select! against that token inside every request handler, abort only for telemetry tasks that I genuinely do not care about cleaning up. This stacks: the top-level token cancellation propagates to every select! further down, each handler unwinds gracefully, and the runtime shuts down with no orphaned transactions.

One edge worth flagging: if you find yourself reaching for tokio::spawn_blocking and then wanting to cancel it, neither abort nor CancellationToken will interrupt the underlying thread. The spawn_blocking task runs on a thread pool that does not poll. You need cooperative cancellation inside the blocking work \u2014 a std::sync::atomic::AtomicBool checked between iterations \u2014 or you accept that the work runs to completion and only the wrapper future cancels.

References: