Cancellation Safety
In Rust, cancelling an asynchronous operation is performed by dropping its Future. Whenever a future is dropped (for example, when a timeout expires or another branch of a select! macro finishes first), execution of that future stops at whichever .await point it was suspended at, and the compiler runs Drop on all local variables currently alive inside that state-machine variant.
Cancellation safety is the property of an async function or method determining whether dropping its future mid-execution inside a loop (select!) loses partially read data or leaves external state inconsistent.
How Dropping a Future Cancels It at an .await Point
async fn process_order() {
let _guard = ScopeCleanup("order-tx");
step_1().await; // <--- Suspended here (`Poll::Pending`) when timeout fires!
step_2().await; // <--- Never reached!
}
When Timeout Drops `process_order()` Future:
1. `step_1()` child future is dropped first.
2. `_guard` (`ScopeCleanup("order-tx")`) runs its `Drop::drop` destructor!
3. `step_2()` never executes!
use std::future::pending;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
struct CleanupOnCancel(&'static str);
impl Drop for CleanupOnCancel {
fn drop(&mut self) {
println!(" [Drop] cleaned up resource {:?}", self.0);
}
}
async fn long_running_operation() {
let _resource = CleanupOnCancel("db-transaction-lock");
println!("operation started; now awaiting external signal...");
pending::<()>().await;
println!("unreachable after pending");
}
fn main() {
{
let mut fut = pin!(long_running_operation());
let mut cx = Context::from_waker(Waker::noop());
// Poll once: starts operation and suspends at `pending().await`:
let poll_res = fut.as_mut().poll(&mut cx);
println!("poll result: {:?} -> now cancelling by dropping `fut`!", poll_res);
} // `fut` is dropped here, running `CleanupOnCancel::drop` deterministically!
println!("main continues after cancellation");
}
operation started; now awaiting external signal...
poll result: Pending -> now cancelling by dropping `fut`!
[Drop] cleaned up resource "db-transaction-lock"
main continues after cancellation
Cancellation-Safe vs. Non-Cancellation-Safe Operations
When using a future repeatedly inside a loop { select! { ... } }, dropping an unselected branch mid-execution only causes data loss if the future had already consumed partial data into its own local variables before hitting a second .await:
| Operation Pattern | Cancellation-Safe in select!? |
Reason & How to Fix |
|---|---|---|
rx.recv().await (Channel receive) |
Yes | Single atomic poll: either pops 1 message (Poll::Ready) or leaves queue untouched (Poll::Pending) |
stream.read(&mut buf).await (Single syscall read) |
Yes | Reads available bytes in a single poll without internal .await buffering |
reader.read_exact(&mut buf).await |
No! | Loops across multiple .await reads; if cancelled after reading 4 of 8 bytes, those 4 bytes are dropped with the future! |
Multi-step async fn (let msg = rx.recv().await; send(msg).await;) |
No! | If cancelled while waiting on send(msg).await, msg was already popped from rx and is dropped! |
How to Make a Multi-Step Future Cancellation-Safe in a Loop
Instead of re-creating a multi-step future on every iteration of loop { select! { _ = multi_step() => ... } }, create and pin the future once outside the loop (let mut op = pin!(multi_step());) and pass &mut op to select!. That way, when another branch wins an iteration, op is not dropped—it retains its in-progress state for the next loop iteration!