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.
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");
}future dropped, body never ranExample 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.
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");
}step 1
first poll: Pending
guard: dropped
future droppedStep 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.
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());
}task: sleeping
task guard: dropped
cancelled: trueThe 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.
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;
}finishes: returned 7
cancelled: cancelled
panics: panickedExample 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.
#[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());
}cancelled: trueExample 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.
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());
}task: calling abort on itself
task: abort called, still running
task: plain code after abort computed 55
cancelled: trueThe 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.
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);
}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.
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());
}finished before abort: false
finished after abort: true
cancelled: trueExample 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.
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);
}
}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.
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}"),
}
}
}&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.
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());
}panicking task's guard: dropped
panicking task is a panic: true
healthy task returned 42
runtime still worksThis 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.
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();
}panic hook: detached task failed
task ended, no code saw a JoinErrorWithout 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.
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());
}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.
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());
}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.
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));
}in flight: manual 1, guarded 1
after abort: manual 1, guarded 0The 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.
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");
}task aborted
session-1: async cleanup finished
cleanup observedtokio::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.
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));
}parent cancelled: true
child finished anyway: trueJoinSet (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.
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");
}main returns
background task guard: droppedThe 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::abortschedules a cancellation and returns immediately.is_finishedbecomes true only after the task and its destructors have ended.abortafter the task completed has no effect. Repeatedabortcalls are the same as one.- A task with no
.awaitin a loop cannot be aborted; insertyield_now().awaitor other awaits. - Dropping a
JoinHandledetaches the task; it does not cancel it. Aborting a parent does not abort the tasks it spawned. AbortHandlecan abort and checkis_finished, isClone, and does not abort on drop.- A
JoinErroris either a cancellation or a panic.into_panicpanics for a cancellation;try_into_panicreturnsErrinstead. - 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.Dropcannot await. spawn_blockingclosures cannot be aborted once running.
Summary
- Dropping a future cancels it at its current
.await; nothing after that point runs. abortcancels a task at its next waiting.await; the handle then resolves toErr(JoinError)withis_cancelled()true, unless the task already finished.JoinError::is_panic,into_panic, andtry_into_panicexpose 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
.awaitor 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.
