Rust Cancellation Safety
Cancellation safety is a function surviving a drop at an await point without losing work. A future can be dropped at any await point.
Dropping a future drops its locals
Cancellation means a future is dropped before it finishes — a timeout expiring, a select! branch losing the race, an abort: all work the same way. It can strike at any .await point, and the locals of the dropped future are dropped with it, as this Drop guard proves. It is normal control flow — no error, no panic — and the compiler cannot check your code for it:
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) { println!("dropped: {}", self.0); }
}
async fn job() {
let _guard = Guard("the job's local state");
sleep(Duration::from_secs(60)).await; // cancelled here
}
timeout(Duration::from_millis(10), job()).await.ok();
println!("timeout returned");recv() is cancel-safe
A function is cancel-safe when being dropped mid-wait loses nothing. A channel recv() is safe — a message is either delivered whole or still waiting in the channel. This wait times out, and the message is still there for the next try:
let (tx, mut rx) = mpsc::channel::<&str>(1);
tokio::spawn(async move {
sleep(Duration::from_millis(30)).await;
tx.send("still here").await.unwrap();
});
let early = timeout(Duration::from_millis(10), rx.recv()).await;
println!("first try timed out = {}", early.is_err());
println!("second try got = {}", rx.recv().await.unwrap());Keep half-finished state outside the future
The fix pattern is simple. Keep the working state in the struct. Or keep it in the caller. Then a dropped future leaves it behind. It stays intact and resumable. Here the buffer lives in main, and the future only borrows it:
async fn gather(buffer: &mut Vec<i32>) {
buffer.push(1);
sleep(Duration::from_millis(10)).await;
buffer.push(2);
}
let mut kept = Vec::new();
timeout(Duration::from_millis(5), gather(&mut kept)).await.ok();
println!("kept = {kept:?}"); // the 1 survived the cancellationExample
// Cargo.toml: tokio = { version = "1", features = ["full"] }
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(10);
let sender = tokio::spawn(async move {
for n in 1..=3 {
tx.send(n).await.unwrap();
sleep(Duration::from_millis(20)).await;
}
});
println!("got {}", rx.recv().await.unwrap());
sender.abort();
let _ = sender.await;
match rx.recv().await {
Some(n) => println!("got {n}"),
None => println!("channel closed: the rest never arrived"),
}
}Key points
- Reading a line is the classic unsafe case.
- Bytes may already sit in a local buffer.
- Those bytes are lost if the future dies.
- The future must live until the line is complete
- In select!, a future inside a loop gets rebuilt on every round.
- A future written inline restarts from zero each time.
- So build it once outside the loop.
- Or make sure restarting it is harmless.
- Tokio documents cancellation safety in its API docs — look for the "Cancellation safety" section before putting a function inside select!
- A timeout is cancellation with a deadline.
- timeout(...) works by dropping the slow future.
In this example, a task sends the numbers 1, 2 and 3, pausing 20 milliseconds after each. Main receives the 1, then aborts the task while it sleeps. The task's future is dropped at that await point. So 2 and 3 are never sent. The sender inside the future is dropped too. That drop closes the channel. That is why the second recv returns None. It does not wait. Cancellation took the whole future. It took everything it owned. It took them in one drop. Nothing here is an error. The program simply has to be written carefully. It must be written knowing this can happen.
Note
So everything in this lesson applies to timeouts too.
Try it Yourself
Edit the code, then press Run.
Running is simulated · Rust Playground
Exercise
A task is cancelled while it is halfway through reading a line into a local buffer. What happens to the bytes already read?