Skip to content

← Async From First Principles step 21 of 25

Medium Framework

Cancellation is just: stop polling and drop

Rust has no future.cancel(). No cancellation exception, no signal, no token you have to remember to check. Cancelling a future is dropping it, and that is the entire API.

pub struct Guard { /* logs "drop:<name>" when dropped */ }
pub async fn body(log: Log);
pub fn cancel_log(polls_before_cancel: u32) -> Vec<String>

cancel_log drives body for at most polls_before_cancel polls; if it has not finished, it appends "cancelling" and drops it. body writes "started", creates a guard named inner, awaits, writes "resumed", creates a guard named late, awaits, writes "finished".

cancel_log(0) == ["cancelling"]
cancel_log(1) == ["started", "cancelling", "drop:inner"]
cancel_log(3) == ["started", "resumed", "cancelling", "drop:late", "drop:inner"]
cancel_log(5) == ["started", "resumed", "finished", "drop:late", "drop:inner"]

Four facts are visible in those four lines.

Zero polls means nothing ran. Not “started and then stopped” — the body never executed a single statement. Laziness again, from item 16.2.

Cancellation runs destructors. The future owns its state machine, and the locals live across an .await are fields of it. Dropping the future drops them, so Drop fires, so your RAII cleanup happens. This is why async Rust needs no finally: the same mechanism that closes your files in synchronous code closes them when a task is cancelled.

Drop order is reverse declaration order. late is created second and dropped first. That is not an async rule; it is the ordinary Rust rule, and it applies unchanged inside a suspended state machine.

Unreached statements leave no trace. cancel_log(3) never writes "finished" and never creates anything after the second await. Cancelled work is not partially undone — it simply never happened.

The part people get wrong

Cancellation is not preemptive. A future can only be cancelled at an .await point, because that is the only place poll returns and control comes back to whoever might drop it. Between two awaits, a future is uninterruptible. A CPU loop, a thread::sleep, a blocking read() — none of them can be cancelled, no matter how urgently the caller wants it. There is no mechanism that could do it: nobody is running who could act.

Compare with a thread. You cannot safely kill a thread either, which is why std::thread has no kill. Async cancellation is strictly better than that — it is synchronous, immediate, and deterministic — but only at the points where the future has chosen to yield.

The observation trap

The log lives in an Rc<RefCell<Vec<String>>> that both the caller and the future hold. Read it while the future is still alive and you see the state before its destructors run. You have to drop the future first:

drop(fut);
Rc::into_inner(log).unwrap().into_inner()

Rc::into_inner also documents the invariant: it returns Some only if this was the last handle, so it fails loudly if the future is somehow still around. That is the bug shipped in the starter — it reads the log one line too early and loses every drop: entry.

Two neighbours

std::mem::forget(fut) is safe and skips every destructor: no drop: lines at all, and if the guard were closing a file the file would stay open. Leaking is safe in Rust; use-after-free is not. That is a deliberate and sometimes surprising line.

clippy::let_underscore_future (restriction group, off by default) exists because let _ = some_future(); drops the future immediately — which, now that you have read this far, you can see is a complete cancellation of work the author almost certainly intended to happen.

Loading visualization…