This repo contains notes and links related to my RustConf 2025 talk, Cancelling async Rust.
- Slides
- Video
- Written version of the talk on my blog
- Follow me on Bluesky and Mastodon
Links to my blog:
For more in-depth discussion of issues related to cancellation, see these two Oxide RFDs:
- RFD 397 Challenges with async/await in the control plane
- RFD 400 Dealing with cancel safety in async Rust
The inert or passive nature of futures is a consequence of Rust async targeting embedded environments and other scenarios without memory allocation.
Think about it: If you want to run futures actively, the runtime must drive those futures -- for that, you must be able to move them off the stack and into the background. In full-fledged computing environments, that can be done by moving the future to the heap through e.g. Box::pin
. Tokio does this when you call tokio::task::spawn
. But in embedded no-alloc environments, the heap isn't available. The only options are:
- Storing futures on the stack.
- Moving them to static storage which must be declared at compile time.
Option 2 is of very limited use, so option 1 is the only viable path for arbitrary futures. Thus, futures are passive.
Rather than cancellation at any await point, what if you could allow cancellation at specific await points? This can be done via a select
operation using a cancellation token.
The tokio-util crate has an implementation of CancellationToken
. But note that by itself, this doesn't prevent cancellation at other await points. You must use other means (e.g. spinning up tasks) to avoid cancellation at other spots.
I've written an implementation of cancellation tokens as well. My implementation allows arbitrary data to be sent along with the cancellation message, but only permits one receiver (it uses an MPSC channel underneath).
As outlined in the talk, Tokio mutexes are very difficult to use correctly. The recommended alternative is to use a message-passing design, where each bit of mutable state has a single owner running within an independent task, and the owner communicates with the rest of the system through channels. This is often called the actor model.
The advantage of the actor model is that it is highly resilient to cancellation issues. The only way to cancel in-progress work is to abort the task, which might make some state invalid but will also likely tear down the task, tearing down this invalid state. (The exception here is state that's external to the task, such as external processes or services.)
For more discussion, see the Tokio tutorial on channels.
I mentioned in the talk that Tokio's tasks are active and don't get cancelled on drop. However, it is possible to cancel a Tokio task at the next await point using an abort handle. If your goal in creating a task is to run some cancel-unsafe code, be sure to not create any abort handles pointing to the task.
Tasks are also aborted (and therefore cancelled) on runtime shutdown (if using #[tokio::main]
, this corresponds with process shutdown). If you don't want that to happen, you'll need to take the Tokio runtime's lifecycle into your own hands. (For example, find a way to block shutdown until all tasks are completed).
A term often bandied about in these conversations is structured concurrency. Put succinctly, structured concurrency means that if a parent future creates a child future, the parent future waits for children to complete before it completes.
With structured concurrency, cancellation can be handled in two ways:
- If the parent future is cancelled, all child futures are immediately cancelled as well. This is how Rust futures work today (and what the talk is about!)
- Cancellation of the parent future waits until child futures have exited. This is not supported by Rust and Tokio today, though the
async-scoped
library has an implementation of something similar.
Another way to do structured concurrency is to use a nursery pattern as seen in Trio. In Rust terms, that would be akin to the following strategy:
- Create a Tokio runtime.
- Start tasks on the runtime.
- On exit, wait until all tasks are completed.
This has some beneficial properties with respect to cancellation. See Timeouts and cancellation for humans by Trio's author, Nathaniel J. Smith, for more discussion.
As mentioned in the talk, one way to cancel synchronous Rust code is to panic. But panicking has many of the same issues with invariant violations that async cancellations do.
To lower the likelihood of bugs, Rust's std::sync::Mutex
implements a poisoning scheme. If a panic occurs while the mutex is held, the next time a thread acquires the mutex it will be in the poisoned state. It is possible to clear a mutex's poisoned state in case you've restored invariants.
Tokio's mutexes do not implement poisoning, either for panics or for async cancellations, which makes them doubly dangerous.
One leading proposal to systematically address async cancellation issues in Rust is async drop. Currently, Drop
impls must only contain synchronous code. With async drop, the Drop
impl can also contain async code.
The ability to run async code on drop would address some of the issues brought up in the talk (e.g. running cleanup code on drop), but it wouldn't address issues where dropping the future is an invalid operation and should not be allowed (e.g. Tokio mutex-caused temporarily invalid state).
For more on async drop, see this documentation.
It is often said that Rust has linear types, but in reality that is not true: Rust has what is known as affine types. The difference is that affine types can be used at most once, while linear types must be used exactly once1.
Unforgettable and undroppable types are two different kinds of linear types.
- Unforgettable types are types for which the
Drop
implementation is guaranteed to be called. In other words, it isn't possible to call functions likestd::mem::forget
orBox::leak
on these types. - Undroppable types are types that cannot be arbitrarily dropped. Instead, these types can only be dropped in some kind of controlled fashion, enforced through encapsulation. (Think of undroppable types are those where there isn't a zero-argument
impl Drop
, or in other words where theDrop
impl must be passed in an argument.)
Unforgettable types would address many of the issues brought up in the talk, while undroppable types would address all of them. But they're both quite complex to fit into Rust. For more discussion, see this post by withoutboats.
Footnotes
-
What about
#[must_use]
? That is a lint which makes it less likely that you'll drop types without using them, but it isn't a strong guarantee provided by the type system. ↩