Cancel safety in Rust's Future
Rust’s async ecosystem is built around Future, and asynchronous programming has become increasingly common in servers, networking, databases, and many other systems applications. However, unlike threads or async features from other languages, Rust futures may be cancelled. This means async program may need to be written carefully, since cancellation can interrupt operations.
Future cancellation can be the problem
use std::{collections::BTreeSet, sync::Arc, time::Duration};
use axum::{
extract::{Json, Path, State},
http::StatusCode,
routing::get,
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use uuid::Uuid;
const MAX_FILES: usize = 2;
#[derive(Clone)]
struct AppState {
files: Arc<Mutex<BTreeSet<Uuid>>>,
}
#[derive(Deserialize)]
struct SaveRequest {
text: String,
}
#[derive(Serialize)]
struct SaveResponse {
id: String,
}
async fn save(
State(state): State<AppState>,
Json(req): Json<SaveRequest>,
) -> Result<Json<SaveResponse>, StatusCode> {
let id = Uuid::new_v4();
let filename = id.to_string();
let mut files = state.files.lock().await;
if files.len() >= MAX_FILES {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
if let Err(err) = tokio::fs::write(&filename, req.text).await {
let _ = tokio::fs::remove_file(&filename).await;
eprintln!("write failed: {err}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
// Simulate lagging
tokio::time::sleep(Duration::from_secs(1)).await;
files.insert(id);
Ok(Json(SaveResponse {
id: id.to_string(),
}))
}
// ...This web server provides create/get/delete operations for text resources under a quota limit of 2 files.
In the normal execution path of the save function, a new text file is created for the generated ID, and the file ID is inserted into the in-memory set. However, if the HTTP request is cancelled during the sleep — for example due to a connection error or client disconnect — execution of the save future is cancelled immediately, while the file itself remains on disk. As a result, orphaned files are created without being tracked by the server state. This allows users to occupy resources beyond the intended quota limit.
This demonstrates an important property of async Rust: if a cancelable Future is written without considering cancellation behavior, the program can easily become inconsistent.
Why is Future cancellable?
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub struct Context<'a> { /* private fields */ }
impl<'a> Context<'a> {
pub const fn waker(&self) -> &'a Waker { ... }
}
pub struct Waker { /* private fields */ }
impl Waker {
pub fn wake(self) { ... }
pub fn wake_by_ref(&self) { ... }
}
pub enum Poll<T> {
Ready(T),
Pending,
}The core components of Rust’s Future trait are poll and wake.
To execute a Future, a runtime such as Tokio calls its poll method. Depending on the state of the operation, poll returns either Poll::Ready or Poll::Pending. When Poll::Pending is returned, the Future implementation is responsible for calling wake when it may be able to make progress again. After being woken, the runtime calls poll again. This process repeats until the Future returns Poll::Ready.
Unlike an ordinary function, a Future is not guaranteed to run to completion unless the runtime keeps polling it. Even with a proper runtime, execution is usually guaranteed in normal cases, but there are some exceptions where a Future may be stopped before completion and dropped.
Therefore, when writing code that uses Futures, you must either ensure that the Future is not cancelled, or make it safe to cancel. This is especially easy to miss with async fn, because it looks like an ordinary function. To behave like an ordinary(blocking) function, the Future produced by the async function must not be dropped before completion.
Common sources of cancellation
Tokio macros
tokio::select!: It returns the output of the first future that completes and drops the remaining futures.tokio::try_join!: It drops the remaining futures when one future returnsErr.
Handlers
When a Future is passed to an external function, that may be dropped depending on the implementation. This is especially important for server handlers, as some server libraries drop the handler Future when the client connection is closed.
Test results (2s sleep in handler future):
| Framework | Version | Whether request future dropped on disconnection |
|---|---|---|
| Axum | 0.8.9 | Dropped |
| Salvo | 0.93.0 | Dropped |
| Warp | 0.4.3 | Dropped |
| Tonic | 0.14.6 (gRPC) | Dropped |
| Actix Web | 4.13.0 | Not dropped |
| Rocket | 0.5.1 | Not dropped |
| Poem | 3.1.12 | Not dropped |
How to make Future cancel-safe
tokio::spawn
The JoinHandle returned by tokio::spawn is cancel-safe. Dropping the JoinHandle does not stop the spawned task.
async fn save(
State(state): State<AppState>,
Json(req): Json<SaveRequest>,
) -> Result<Json<SaveResponse>, StatusCode> {
tokio::spawn(async move {
...
})
.await
.unwrap_or_else(|_| Err(StatusCode::INTERNAL_SERVER_ERROR))
}For save function example, you can rewrite it like this.
CancellationToken
async fn some_handler() -> Result<&'static str, StatusCode> {
let cancellation_token = CancellationToken::new();
let _drop_guard = cancellation_token.clone().drop_guard();
tokio::spawn(async move {
for i in 0..5 {
if cancellation_token.is_cancelled() {
break;
}
println!(">ㅅ< {i}");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
Ok("Hello")
})
.await
.unwrap_or_else(|_| Err(StatusCode::INTERNAL_SERVER_ERROR))
}If cancellation is required, tokio-util’s CancellationToken and DropGuard can be used to gracefully stop a spawned task when the outer future is dropped.
Channels
Async methods of tokio channels such as tokio::sync::oneshot and tokio::sync::mpsc are cancel-safe.
How to make the program safe
All futures in the program should satisfy at least one of the following:
- Ensure the future is never dropped.
- Make the future cancel-safe.
For example, the main future automatically satisfies the first condition, so cancellation usually does not need to be considered there. For other futures, first option is usually better if it is possible.
For a future to be cancel-safe, every future it awaits must also be cancel-safe, which is difficult to guarantee. In complex logic, wrapping work in spawn may be the only practical solution, but spawning every function is inefficient.
A good rule of thumb is to allow cancellation only at a few well-defined boundaries, and keep the internal logic non-cancelled whenever possible.
Caution
Avoiding direct cancellation of Futures is generally preferred, but cancelling the job itself is still an important problem to handle. If cancellation is not handled properly, a program may continue wasting resources on work that is no longer needed. Additionally, if a spawned task returns a Result, its output must be handled. Otherwise, errors may become silent and go unnoticed. This is because Rust does not “throw” exceptions. In languages like JavaScript that do, an unhandled exception in asynchronous code becomes “Uncaught Error”.
Better design?
trait CancelSafeFuture: Future {}One possible alternative design would be marker traits for cancellation-safe futures, where combinators such as select! only accept those futures. However, unlike traits such as Send or Sync, cancellation-safety would likely be impossible to infer automatically.
Future in Other languages
In some languages, users cannot control the execution loop directly, so future-like operations are never cancelled. In JavaScript, the user does not control the event loop. Once a callback is registered, it will be executed when the operation completes. In Go, the user does not control the scheduler. A goroutine will resume when its asynchronous operation completes. There is tokio::select! -like operation in Go but only channel operation(send / receive) can be selected. Go also does not distinguish async and sync code at the language level.