Skip to content

sunshowers/cancelling-async-rust

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 

Repository files navigation

Cancelling async Rust

This repo contains notes and links related to my RustConf 2025 talk, Cancelling async Rust.

Links to my blog:

Notes

For more in-depth discussion of issues related to cancellation, see these two Oxide RFDs:

Why are futures passive?

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:

  1. Storing futures on the stack.
  2. 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.

Cooperative cancellation: cancellation tokens

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).

Actor model as an alternative to Tokio mutexes

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.

Task aborts

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).

Structured concurrency

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.

Relationship to panic safety

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.

Async drop

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.

Unforgettable and undroppable types

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 like std::mem::forget or Box::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 the Drop 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

  1. 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.

About

Notes on my RustConf 2025 talk: Cancelling async Rust

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published