Cancellation
In Rust async:
Cancellation means dropping a future before it completes.
That’s it.
There is no “cancel token” built into the language.
If a future is:
- Dropped → it is cancelled
- Never polled again
- Its destructor (
Drop) runs
Where cancellation comes from
Common sources:
- Dropping a
JoinHandle - A
select!branch winning - A timeout expiring
- Shutdown signals
- Parent task exiting
let handle = tokio::spawn(do_work());
drop(handle); // task cancelled
Cancellation is cooperative
Important rule:
Async Rust cancellation is cooperative, not forced.
That means:
- The runtime does not stop your code mid-instruction
- Cancellation only happens at
.awaitpoints - Your code must be written to be safely droppable
Cancellation safety (critical concept)
A future is cancellation-safe if:
Dropping it at any
.awaitdoes not leave the program in a broken state.
Not cancellation-safe
async fn write_two_steps(socket: &mut TcpStream) -> io::Result<()> {
socket.write_all(b"HELLO").await?;
socket.write_all(b"WORLD").await?;
Ok(())
}
If cancelled after writing HELLO:
- Protocol is broken
- Peer sees partial message
Cancellation-safe version
async fn write_message(socket: &mut TcpStream) -> io::Result<()> {
let msg = b"HELLOWORLD";
socket.write_all(msg).await?;
Ok(())
}
One await → atomic at protocol level.
Destructors (Drop) run on cancellation
This is huge.
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("cleaning up");
}
}
async fn task() {
let _guard = Guard;
tokio::time::sleep(Duration::from_secs(10)).await;
}
If task is cancelled:
_guardis dropped- Cleanup runs
This is your main tool for cleanup.
Timeouts are just cancellation
Timeouts are implemented by cancelling the future.
use tokio::time::{timeout, Duration};
let result = timeout(Duration::from_secs(1), async {
do_work().await
}).await;
If time expires:
- The inner future is dropped
timeoutreturnsErr(Elapsed)
Handling timeouts cleanly
match timeout(Duration::from_secs(1), do_work()).await {
Ok(Ok(value)) => println!("success: {value}"),
Ok(Err(e)) => println!("task error: {e}"),
Err(_) => println!("timed out"),
}
Nested Results:
- Inner → task error
- Outer → timeout error
select! = manual cancellation control
select! races futures and cancels losers.
use tokio::select;
async fn slow() {
tokio::time::sleep(Duration::from_secs(5)).await;
println!("slow done");
}
async fn fast() {
tokio::time::sleep(Duration::from_secs(1)).await;
println!("fast done");
}
#[tokio::main]
async fn main() {
select! {
_ = slow() => {}
_ = fast() => {}
}
}
When fast completes:
slowis dropped (cancelled)
Graceful cancellation with signals
Using a cancellation channel
use tokio::sync::watch;
async fn worker(mut shutdown: watch::Receiver<bool>) {
loop {
tokio::select! {
_ = shutdown.changed() => {
println!("shutting down");
break;
}
_ = do_work() => {}
}
}
}
This allows:
- Explicit shutdown
- Cleanup paths
CancellationToken (Tokio utility)
use tokio_util::sync::CancellationToken;
let token = CancellationToken::new();
let child = token.child_token();
tokio::spawn(async move {
tokio::select! {
_ = child.cancelled() => println!("cancelled"),
_ = do_work() => {}
}
});
token.cancel();
- Structured cancellation
- Tree-based propagation
Blocking and cancellation
Blocking code
std::thread::sleep(Duration::from_secs(10));
- Cannot be cancelled
- Freezes executor thread
Async sleep
tokio::time::sleep(Duration::from_secs(10)).await;
- Cancellation-safe
- Droppable
Cancellation and streams
Streams are cancelled by:
- Dropping the stream
- Exiting loop early
while let Some(item) = stream.next().await {
if item == 42 {
break; // stream cancelled
}
}
Best practices
- Minimize
.awaitin critical sections - Use RAII for cleanup
- Make protocol steps atomic
- Prefer
select!over flags - Assume cancellation can happen anytime
Mental model to remember forever
Cancellation = dropping a future at an
.await.
Or:
“If it can be awaited, it can be cancelled.”