Dropping a Tokio JoinHandle Detaches the Task, It Doesn't Cancel It
Drop a future in Rust and it stops. Drop a JoinHandle and nothing happens at all: the task carries on, burning CPU and holding its connections, with nobody left to read its result. That asymmetry catches people out because everything else in async Rust trains you to expect "drop means cancel".
The thirty-second demonstration
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
loop {
println!("still working");
sleep(Duration::from_millis(200)).await;
}
});
drop(handle);
sleep(Duration::from_secs(1)).await;
}
You get about five lines of "still working" even though the handle is gone. The task was handed to the runtime at spawn; the handle is only a ticket for collecting the result later. Dropping the ticket does not cancel the job.
Why it works this way
A spawned task is owned by the runtime, not by the caller. The future is not nested inside whatever called spawn, so there is no parent future whose drop would tear it down. The handle is a way to wait for the output or to ask for cancellation, nothing more.
It matches std::thread::spawn, where dropping the join handle detaches the thread. The odd one out is the plain future, which only runs while something polls it.
Quick detour: this is also why a task that panics after its handle was dropped is quiet. The panic is caught by the runtime, the output is thrown away, and all you see is the panic message from the hook. Nobody ever gets a JoinError.
Actually cancelling it
Call abort() on the handle, or take an AbortHandle if you want to cancel without holding the JoinHandle itself:
let handle = tokio::spawn(work());
let abort = handle.abort_handle();
// later, from anywhere that holds `abort`
abort.abort();
match handle.await {
Ok(v) => println!("finished: {v:?}"),
Err(e) if e.is_cancelled() => println!("cancelled"),
Err(e) => println!("panicked: {e}"),
}
Aborting the task drops its future, so destructors inside it run. Awaiting the handle afterwards gives you Err(JoinError), and is_cancelled() tells you it was your abort rather than a panic.
Making drop cancel, if that is what you want
If you want RAII-style behaviour, wrap the handle in a guard:
use tokio::task::JoinHandle;
struct AbortOnDrop<T>(JoinHandle<T>);
impl<T> Drop for AbortOnDrop<T> {
fn drop(&mut self) {
self.0.abort();
}
}
For a group of tasks, JoinSet already does this: dropping the set aborts every task in it. That is usually the better tool than a hand-written guard, and it keeps you from leaking a task per request.
Abort is a request with limits
Cancellation in tokio is cooperative. The task is cancelled at its next .await that yields. Three cases are worth knowing:
- Busy loops: a task that never awaits cannot be cancelled until it does, so a tight CPU loop ignores
abort(). - Blocking work: a closure already running under
spawn_blockingcannot be aborted once started; it runs to the end. - Half-finished work: cancellation can land between two awaits, so any state you changed across an await may be left half-done.
The last one is the same hazard as a dropped future inside select!, just triggered from outside. If a task writes a file or holds a lock across awaits, design it so being stopped at any await point leaves things consistent.
What to check in your own code
- Any
tokio::spawnwhose handle is discarded: is it meant to outlive the caller? - Per-connection or per-request tasks: what stops them when the client goes away?
- Timeouts:
tokio::time::timeout(dur, handle)drops the wait on the handle, not the task. After a timeout the task is still running unless you callabort().
That last one is the classic. Wrapping a handle in a timeout feels like it bounds the work, but it only bounds how long you wait. The task is happily still going.