Guide contents

Lesson 8 of 44

Cancellation, abort, and Panics

Cancellation means stopping a piece of async work before it finishes. In Rust async code, cancellation is dropping: when a future is dropped, it never runs again. Tasks add a second route, abort, and a second way to fail, a panic. This lesson covers all three and the rules that make code correct when it is cancelled.

Item What it does
Dropping a future cancels it; its local variables are destroyed
JoinHandle::abort, AbortHandle::abort ask the runtime to cancel a spawned task
is_finished reports whether a task has completely ended
JoinError::is_cancelled, is_panic, into_panic, try_into_panic tell the two failure reasons apart and give access to the panic value
Cancel safety a property of a future: dropping it and starting again loses nothing
Drop guard a value whose Drop code cleans up when the future is cancelled

Several examples use futures::poll!, a macro that polls a future exactly once and returns Poll::Ready(value) or Poll::Pending. It needs cargo add futures. Examples with sleeps use the paused clock (start_paused = true, which needs Tokio's test-util feature): sleeps complete in virtual time as soon as every task is idle, so the output never depends on real time. Time: sleep, timeout, and interval covers it.

Example 1: Dropping a future that never ran

A future does nothing until it is polled (Futures and async/await). Dropping it before the first poll therefore runs none of its code, and its body never creates the values inside it.

Rust
struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("{}: dropped", self.0);
    }
}

#[tokio::main]
async fn main() {
    let future = async {
        let _inside = Noisy("inside the future");
        println!("the body is running");
    };

    drop(future);
    println!("future dropped, body never ran");
}
Output
future dropped, body never ran

Example 2: Dropping a suspended future cancels it at the .await

A future that has started and is waiting can be dropped too. Everything it owns at that point is destroyed, and the code after the .await never runs. futures::poll! polls the future once so that it reaches its .await.

Rust
use std::time::Duration;
use tokio::time::sleep;

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("{}: dropped", self.0);
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let mut future = Box::pin(async {
        let _guard = Noisy("guard");
        println!("step 1");
        sleep(Duration::from_secs(1)).await;
        println!("step 2 (never printed)");
    });

    let first_poll = futures::poll!(future.as_mut());
    println!("first poll: {first_poll:?}");

    drop(future);
    println!("future dropped");
}
Output
step 1
first poll: Pending
guard: dropped
future dropped

Step 1 ran synchronously during the first poll. The future then returned Pending at the sleep. Dropping it destroyed _guard and ended the future. Cancellation can only happen where a future is suspended, which is at an .await. Code between two .await points always runs to its end.

Example 3: abort cancels a spawned task

For a spawned task, JoinHandle::abort asks the runtime to cancel it. The task stops at the .await where it is suspended, its variables are destroyed, and awaiting the handle returns a JoinError whose is_cancelled is true.

Rust
use std::time::Duration;
use tokio::time::sleep;

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("{}: dropped", self.0);
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let handle = tokio::spawn(async {
        let _guard = Noisy("task guard");
        println!("task: sleeping");
        sleep(Duration::from_secs(3600)).await;
        println!("task: woke up (never printed)");
    });

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

    let result = handle.await;
    println!("cancelled: {}", result.unwrap_err().is_cancelled());
}
Output
task: sleeping
task guard: dropped
cancelled: true

The main task's short sleep gave the spawned task time to start and reach its own sleep. The abort then cancelled it long before the hour passed. The guard was dropped before the handle resolved: Tokio guarantees that a task's destructors have finished before the handle reports the end of the task.

Example 4: Three outcomes of a task

Awaiting a JoinHandle has three cases: the task returned a value, it was cancelled, or it panicked. A match covers all of them.

Rust
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::sleep;

async fn report(name: &str, handle: JoinHandle<u32>) {
    match handle.await {
        Ok(value) => println!("{name}: returned {value}"),
        Err(error) if error.is_cancelled() => println!("{name}: cancelled"),
        Err(error) if error.is_panic() => println!("{name}: panicked"),
        Err(_) => unreachable!("a JoinError is either cancelled or a panic"),
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let finishes = tokio::spawn(async { 7 });
    let cancelled = tokio::spawn(async {
        sleep(Duration::from_secs(60)).await;
        8
    });
    let panics = tokio::spawn(async {
        if true {
            panic!("task failed");
        }
        9
    });

    sleep(Duration::from_millis(1)).await;
    cancelled.abort();

    report("finishes", finishes).await;
    report("cancelled", cancelled).await;
    report("panics", panics).await;
}
Output
finishes: returned 7
cancelled: cancelled
panics: panicked

Example 5: Aborting a task that has not started

If a task has not been polled yet, abort cancels it before its first line runs. On the current-thread runtime the spawned task cannot run until main awaits, so this is deterministic.

Rust
#[tokio::main(flavor = "current_thread")]
async fn main() {
    let handle = tokio::spawn(async {
        println!("task: started (never printed)");
    });

    handle.abort();
    let result = handle.await;
    println!("cancelled: {}", result.unwrap_err().is_cancelled());
}
Output
cancelled: true

Example 6: abort takes effect at the next .await

abort does not interrupt running code. The task keeps running until it reaches an .await where it must wait, and it is cancelled there. The following task aborts itself, then continues with plain code. Everything without an .await still runs.

Rust
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::task::AbortHandle;
use tokio::time::sleep;

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let (send_handle, receive_handle) = oneshot::channel::<AbortHandle>();

    let handle = tokio::spawn(async move {
        let me = receive_handle.await.unwrap();
        println!("task: calling abort on itself");
        me.abort();
        println!("task: abort called, still running");
        let total: u32 = (1..=10).sum();
        println!("task: plain code after abort computed {total}");
        sleep(Duration::from_secs(1)).await;
        println!("task: after the await (never printed)");
    });

    send_handle.send(handle.abort_handle()).unwrap();

    let result = handle.await;
    println!("cancelled: {}", result.unwrap_err().is_cancelled());
}
Output
task: calling abort on itself
task: abort called, still running
task: plain code after abort computed 55
cancelled: true

The oneshot channel carries the task's own AbortHandle into the task (channels are covered in oneshot, broadcast, and watch). After me.abort() the task ran two more lines. The cancellation happened at the sleep.

A loop that never awaits therefore cannot be aborted. It needs an .await inside it, for example tokio::task::yield_now().await, so that the runtime gets a chance to cancel it.

Example 7: Aborting a finished task does nothing

If the task ends before the abort is processed, the handle reports the normal result. The docs put it as "might complete as usual". Calling abort again is allowed and has no additional effect.

Rust
use tokio::task;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let handle = tokio::spawn(async { 7 });
    let abort_handle = handle.abort_handle();

    while !abort_handle.is_finished() {
        task::yield_now().await;
    }

    handle.abort();
    handle.abort();
    println!("result: {:?}", handle.await);
}
Output
result: Ok(7)

Example 8: AbortHandle and is_finished

JoinHandle::abort_handle returns an AbortHandle. It can abort the task and check is_finished but cannot wait for the result. It is Clone, and dropping it does not abort the task. It is useful when the JoinHandle is owned by code that awaits it and another part of the program needs the ability to stop the task.

is_finished returns true only when the task has fully ended, including its destructors. Right after abort it can still be false, because cancellation is carried out later by the runtime.

Rust
use std::time::Duration;
use tokio::task;
use tokio::time::sleep;

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(3600)).await;
    });
    let stopper = handle.abort_handle();
    let another = stopper.clone();

    sleep(Duration::from_millis(1)).await;
    println!("finished before abort: {}", stopper.is_finished());

    another.abort();
    while !stopper.is_finished() {
        task::yield_now().await;
    }
    println!("finished after abort: {}", stopper.is_finished());

    println!("cancelled: {}", handle.await.unwrap_err().is_cancelled());
}
Output
finished before abort: false
finished after abort: true
cancelled: true

Example 9: What a JoinError says

JoinError has is_cancelled, is_panic, an id for the task, and a Display text. The text includes the task id, and ids are not stable between runs, so this example replaces the id with <id> before printing.

Rust
use std::time::Duration;
use tokio::time::sleep;

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let cancelled = tokio::spawn(async {
        sleep(Duration::from_secs(60)).await;
    });
    let panicked = tokio::spawn(async {
        panic!("disk is full");
    });

    sleep(Duration::from_millis(1)).await;
    cancelled.abort();

    for handle in [cancelled, panicked] {
        let error = handle.await.unwrap_err();
        let text = error.to_string().replace(&error.id().to_string(), "<id>");
        println!("is_cancelled={} is_panic={} text={}", error.is_cancelled(), error.is_panic(), text);
    }
}
Output
is_cancelled=true is_panic=false text=task <id> was cancelled
is_cancelled=false is_panic=true text=task <id> panicked with message "disk is full"

A JoinError for a panic includes the message when the payload is a string. JoinError implements std::error::Error, and converts into std::io::Error (with the messages "task was cancelled" and "task panicked"), so ? works in a function that returns io::Result.

Example 10: Reading the panic value

into_panic consumes the error and returns the value that was passed to the panic as Box<dyn Any + Send>. It panics itself when the error is a cancellation, so check is_panic first, or use try_into_panic, which returns Err(self) instead. The payload type depends on how the panic was raised: a string literal gives &'static str, a format string with arguments gives String, and panic_any gives any type.

Rust
use std::any::Any;
use std::panic::panic_any;

#[derive(Debug)]
struct ErrorCode(u32);

fn describe(payload: Box<dyn Any + Send>) -> String {
    if let Some(text) = payload.downcast_ref::<&str>() {
        format!("&str: {text}")
    } else if let Some(text) = payload.downcast_ref::<String>() {
        format!("String: {text}")
    } else if let Some(code) = payload.downcast_ref::<ErrorCode>() {
        format!("custom type: {code:?}")
    } else {
        String::from("unknown payload")
    }
}

#[tokio::main]
async fn main() {
    let literal = tokio::spawn(async { panic!("plain message") });
    let formatted = tokio::spawn(async {
        let attempt = 3;
        panic!("failed on attempt {}", attempt);
    });
    let custom = tokio::spawn(async { panic_any(ErrorCode(42)) });

    for handle in [literal, formatted, custom] {
        let error: tokio::task::JoinError = handle.await.unwrap_err();
        match error.try_into_panic() {
            Ok(payload) => println!("{}", describe(payload)),
            Err(error) => println!("not a panic: {error}"),
        }
    }
}
Output
&str: plain message
String: failed on attempt 3
custom type: ErrorCode(42)

The payload is only reachable through the JoinError. A JoinError from a panic keeps the payload until into_panic or try_into_panic takes it.

Example 11: A panic stays inside its task

The runtime catches the panic at the task boundary, so other tasks and the runtime continue. After the panic, the runtime still accepts new tasks. Destructors of the values the panicking task owned run during the unwind.

Rust
struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("{}: dropped", self.0);
    }
}

#[tokio::main]
async fn main() {
    let panicking = tokio::spawn(async {
        let _guard = Noisy("panicking task's guard");
        let values: Vec<u32> = Vec::new();
        values[0]
    });
    let healthy = tokio::spawn(async { 21 * 2 });

    println!("panicking task is a panic: {}", panicking.await.unwrap_err().is_panic());
    println!("healthy task returned {}", healthy.await.unwrap());

    let later = tokio::spawn(async { "runtime still works" });
    println!("{}", later.await.unwrap());
}
Output
panicking task's guard: dropped
panicking task is a panic: true
healthy task returned 42
runtime still works

This isolation depends on unwinding. A program compiled with panic = "abort" in its Cargo profile ends the whole process on the first panic, and no JoinError is produced. The Rust reference for the panic strategy describes that setting.

Example 12: A dropped handle hides the panic from the code

If the handle of a task is dropped, nothing can read the JoinError. The panic is still reported once, by the process-wide panic hook, which by default prints to standard error. A custom hook can show the same event on standard output. The hook is set with std::panic::set_hook; AbortHandle::is_finished is used to wait for the task without keeping its JoinHandle.

Rust
use tokio::task;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    std::panic::set_hook(Box::new(|info| {
        let message = info.payload().downcast_ref::<&str>().copied().unwrap_or("non-string payload");
        println!("panic hook: {message}");
    }));

    let handle = tokio::spawn(async {
        panic!("detached task failed");
    });
    let watcher = handle.abort_handle();
    drop(handle);

    while !watcher.is_finished() {
        task::yield_now().await;
    }
    println!("task ended, no code saw a JoinError");

    let _ = std::panic::take_hook();
}
Output
panic hook: detached task failed
task ended, no code saw a JoinError

Without the hook, the only trace in a real program would be the line on standard error. Keep the handle and check its result when a failure must be handled.

Example 13: Cancel safety, an unsafe function

A future is cancel safe when dropping it at any .await and starting the operation again loses nothing. The definition from the select! documentation: if you have a future that has not yet completed, it must be a no-op to drop that future and recreate it.

process_next below is not cancel safe. It removes an item from the queue before the .await, so if the future is dropped while it waits, the removed item is gone. The helper cancel_after_first_poll polls a future once, which runs it to its first .await, and then drops it.

Rust
use std::collections::VecDeque;
use std::future::Future;
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::sleep;

async fn process_next(queue: &Mutex<VecDeque<u32>>) {
    let item = queue.lock().unwrap().pop_front();
    if let Some(item) = item {
        sleep(Duration::from_millis(10)).await;
        println!("processed {item}");
    }
}

async fn cancel_after_first_poll<F: Future>(future: F) {
    let mut future = Box::pin(future);
    let _ = futures::poll!(future.as_mut());
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let queue = Mutex::new(VecDeque::from([1, 2, 3]));

    cancel_after_first_poll(process_next(&queue)).await;

    println!("queue after cancel: {:?}", queue.lock().unwrap());
}
Output
queue after cancel: [2, 3]

Item 1 was taken from the queue and then dropped with the future. It was never processed.

Example 14: Cancel safety, the fix

Moving the state change after the last .await makes the future safe to drop. The item stays in the queue until the work is done, so a cancelled attempt leaves the queue unchanged and a later attempt processes the same item again.

Rust
use std::collections::VecDeque;
use std::future::Future;
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::sleep;

async fn process_next(queue: &Mutex<VecDeque<u32>>) {
    let item = queue.lock().unwrap().front().copied();
    if let Some(item) = item {
        sleep(Duration::from_millis(10)).await;
        queue.lock().unwrap().pop_front();
        println!("processed {item}");
    }
}

async fn cancel_after_first_poll<F: Future>(future: F) {
    let mut future = Box::pin(future);
    let _ = futures::poll!(future.as_mut());
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let queue = Mutex::new(VecDeque::from([1, 2, 3]));

    cancel_after_first_poll(process_next(&queue)).await;
    println!("queue after cancel: {:?}", queue.lock().unwrap());

    process_next(&queue).await;
    println!("queue after a full run: {:?}", queue.lock().unwrap());
}
Output
queue after cancel: [1, 2, 3]
processed 1
queue after a full run: [2, 3]

The trade-off: after a cancel the work for 1 may have been partly done and will be done again, so the work should be safe to repeat. select!: Racing Futures lists which Tokio APIs are cancel safe.

Example 15: A drop guard runs on every exit path

Code written after an .await does not run when the future is cancelled. Cleanup that must always happen goes into a Drop implementation. A value with such an implementation is a drop guard. Its drop runs when the future finishes, when the task is aborted, when the future is dropped, and while a panic unwinds.

The following program counts requests that are in progress. The manual version decrements after the .await and leaks a count when the task is aborted. The guarded version decrements in Drop.

Rust
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::time::sleep;

static MANUAL: AtomicUsize = AtomicUsize::new(0);
static GUARDED: AtomicUsize = AtomicUsize::new(0);

async fn manual_request() {
    MANUAL.fetch_add(1, Ordering::SeqCst);
    sleep(Duration::from_secs(60)).await;
    MANUAL.fetch_sub(1, Ordering::SeqCst);
}

struct InFlight;

impl InFlight {
    fn start() -> Self {
        GUARDED.fetch_add(1, Ordering::SeqCst);
        InFlight
    }
}

impl Drop for InFlight {
    fn drop(&mut self) {
        GUARDED.fetch_sub(1, Ordering::SeqCst);
    }
}

async fn guarded_request() {
    let _in_flight = InFlight::start();
    sleep(Duration::from_secs(60)).await;
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let manual = tokio::spawn(manual_request());
    let guarded = tokio::spawn(guarded_request());

    sleep(Duration::from_millis(1)).await;
    println!("in flight: manual {}, guarded {}", MANUAL.load(Ordering::SeqCst), GUARDED.load(Ordering::SeqCst));

    manual.abort();
    guarded.abort();
    let _ = manual.await;
    let _ = guarded.await;

    println!("after abort: manual {}, guarded {}", MANUAL.load(Ordering::SeqCst), GUARDED.load(Ordering::SeqCst));
}
Output
in flight: manual 1, guarded 1
after abort: manual 1, guarded 0

The manual counter stays at 1 forever. The guarded counter returns to 0 because the guard was dropped with the future. Locks, permits, and connection-pool slots use the same pattern; their guard types already implement Drop.

Example 16: Drop cannot await

Drop::drop is a plain function, so it cannot .await. Cleanup that only needs synchronous calls (an atomic, a flag, a channel send that does not wait) fits directly. Cleanup that needs async work can start a new task from drop. The new task runs independently of the cancelled one.

Rust
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::time::sleep;

struct Session {
    name: &'static str,
    cleaned: Option<oneshot::Sender<()>>,
}

impl Drop for Session {
    fn drop(&mut self) {
        let name = self.name;
        let cleaned = self.cleaned.take();
        tokio::spawn(async move {
            sleep(Duration::from_millis(5)).await;
            println!("{name}: async cleanup finished");
            if let Some(cleaned) = cleaned {
                let _ = cleaned.send(());
            }
        });
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let (cleaned_sender, cleaned_receiver) = oneshot::channel();

    let handle = tokio::spawn(async move {
        let _session = Session { name: "session-1", cleaned: Some(cleaned_sender) };
        sleep(Duration::from_secs(3600)).await;
    });

    sleep(Duration::from_millis(1)).await;
    handle.abort();
    let _ = handle.await;
    println!("task aborted");

    cleaned_receiver.await.unwrap();
    println!("cleanup observed");
}
Output
task aborted
session-1: async cleanup finished
cleanup observed

tokio::spawn works inside drop here because the drop runs on a runtime thread. A cleanup task started while the whole runtime is shutting down has no guarantee to run, since shutdown drops every task. When cleanup must complete, use an explicit shutdown step that waits for it instead (Graceful Shutdown).

Example 17: Aborting a task does not abort the tasks it spawned

A task's future owns its JoinHandles. Aborting the parent drops them, and dropping a JoinHandle detaches the child. The child continues. To cancel children, the parent needs to abort them itself, or a drop guard on the parent's side must do it.

Rust
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::time::sleep;

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let child_finished = Arc::new(AtomicBool::new(false));

    let parent = {
        let child_finished = Arc::clone(&child_finished);
        tokio::spawn(async move {
            let child = tokio::spawn(async move {
                sleep(Duration::from_millis(100)).await;
                child_finished.store(true, Ordering::SeqCst);
            });
            child.await
        })
    };

    sleep(Duration::from_millis(10)).await;
    parent.abort();
    println!("parent cancelled: {}", parent.await.unwrap_err().is_cancelled());

    sleep(Duration::from_millis(200)).await;
    println!("child finished anyway: {}", child_finished.load(Ordering::SeqCst));
}
Output
parent cancelled: true
child finished anyway: true

JoinSet (see JoinSet and Structured Concurrency) aborts its tasks when it is dropped, which removes this problem.

Example 18: Shutting down the runtime cancels every task

When a runtime shuts down, for example when main returns, all unfinished tasks are cancelled and their values are dropped. The order is: main's future ends, then the runtime drops the remaining tasks.

Rust
use std::time::Duration;
use tokio::time::sleep;

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("{}: dropped", self.0);
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    tokio::spawn(async {
        let _guard = Noisy("background task guard");
        sleep(Duration::from_secs(3600)).await;
    });

    sleep(Duration::from_millis(10)).await;
    println!("main returns");
}
Output
main returns
background task guard: dropped

The guard's drop message appears after "main returns", during runtime shutdown. Work that must finish before the program exits needs to be awaited or coordinated before main returns.

Ways a future or task is stopped

Mechanism Applies to Effect Reported to the awaiter
Drop the future any future stops at its current .await, destroys its variables not applicable, the future is gone
JoinHandle::abort or AbortHandle::abort a spawned task stops at its next .await that waits Err(JoinError) with is_cancelled
Drop the JoinHandle a spawned task nothing stops; the task is detached never
Runtime shutdown all tasks of the runtime every unfinished task is dropped handles resolve as cancelled if still awaited
A branch losing select! or a timeout futures inside a task the losing future is dropped not applicable
abort on a spawn_blocking task blocking closures no effect once started (may prevent a task that has not started) normal result

Interview points

  • Cancellation in async Rust is dropping a future. It can only take effect where the future is suspended, at an .await.
  • JoinHandle::abort schedules a cancellation and returns immediately. is_finished becomes true only after the task and its destructors have ended.
  • abort after the task completed has no effect. Repeated abort calls are the same as one.
  • A task with no .await in a loop cannot be aborted; insert yield_now().await or other awaits.
  • Dropping a JoinHandle detaches the task; it does not cancel it. Aborting a parent does not abort the tasks it spawned.
  • AbortHandle can abort and check is_finished, is Clone, and does not abort on drop.
  • A JoinError is either a cancellation or a panic. into_panic panics for a cancellation; try_into_panic returns Err instead.
  • A panic is caught at the task boundary. With panic = "abort" it ends the process.
  • A future is cancel safe if dropping it and recreating it loses no data. Removing an item and then awaiting is the typical violation.
  • Cleanup that must run on cancellation belongs in Drop. Drop cannot await.
  • spawn_blocking closures cannot be aborted once running.

Summary

  • Dropping a future cancels it at its current .await; nothing after that point runs.
  • abort cancels a task at its next waiting .await; the handle then resolves to Err(JoinError) with is_cancelled() true, unless the task already finished.
  • JoinError::is_panic, into_panic, and try_into_panic expose a panic and its payload (&str, String, or a custom type).
  • A panic ends only its own task; destructors run during the unwind; other tasks continue.
  • Cancel safe means that dropping and recreating the future loses nothing; move state changes after the last .await or keep progress outside the future.
  • Drop guards run on completion, abort, drop, and panic; use them for counters, locks, and slots.
  • Aborting a parent leaves detached children running; runtime shutdown drops all tasks.