400 - Dealing with cancel safety in async Rust | RFD | Oxide

Oxide Computer Company

19 min read Original article ↗

If you’re writing async APIs or returning futures, you need to think about how your users are going to approach cancel safety. The general strategy is using a combination of two approaches:

Making code cancel-safe

There is sadly no silver bullet for making code cancel-safe. This section lists out a number of designs that authors can use and reviewers can look for, and example case studies. The hope is that the vast majority of cancel-safety issues can be addressed via these patterns.

Split up complex operations

In select! loops we observed that Tokio’s mpsc::Sender::send is not cancel-safe, and can lead to data loss.

There is a solution to this, which we can figure out from looking at the source code for Sender::send. Sender also exposes a reserve method, which returns a permit that can be used to send a value. reserve is mostly cancel-safe: dropping it makes you lose your place in line, but is otherwise okay.

Example: A corrected spawn_send_task

The select! loops section had an example spawn_send_task function which was identified as buggy. By using Sender::reserve, this code can be made correct.

This version of the code is a bit more complex. It reads as:

Code sample
fn spawn_send_task(sender: tokio::sync::mpsc::Sender<String>) {
let strings: Vec<String> = vec![
"foo".to_owned(),
"bar".to_owned(),
"baz".to_owned(),
];

tokio::task::spawn(async move {
// interval.tick() completes execution at 0s, 1s, 2s...
let mut interval = tokio::time::interval(Duration::from_secs(1));
let mut strings = strings.into_iter();

// std::vec::IntoIter implements ExactSizeIterator, which has a len()
// method which tells us if the iterator is non-empty. If your
// iterator doesn't implement ExactSizeIterator, you can instead
// use iterator.peekable(), and peek() to see if any values remain.
while strings.len() > 0 {
tokio::select! {
permit = sender.reserve() => {
match permit {
Ok(permit) => {
let value = strings.next().unwrap();
// send() is synchronous because the async part
// (waiting for a slot to be available) has
// already been completed by the time the permit is
// acquired.
permit.send(value);
}
Err(_) => {
println!("receiver closed");
break;
}
}
}
_ = interval.tick() => {
println!("interval tick");
}
}
}
});
}

There is also a way to solve this issue that doesn’t make you lose your place in line. This is covered in Example: spawn_send_task redux.

Reserve and related patterns

mpsc::Sender::reserve is an example of a common pattern, where the general sequence of async operations is:

  1. An initial async operation that is at least mostly cancel-safe.

  2. A synchronous operation and/or a followup async operation, that isn’t cancel-safe[4].

Some convenience methods like Sender::send combine both steps. These APIs can be easier to use sometimes. But providing these kinds of convenience APIs can lead to users accidentally introducing bugs, as seen in select! loops.

If you’re writing such APIs, consider not providing convenience methods like Sender::send, and instead mentioning cancel safety in the documentation. While it can be a bit surprising at first (a sender that can’t just send values?), not providing such convenience methods makes it easier to train users into writing correct code. Whether this is a good idea or not depends on the situation.

Now let’s look at a case study which shows how to split up complex operations in practice.

Case study: Oxide serial console

The Oxide control plane provides a serial console, with an interface to web and command-line clients. Nexus has a function called proxy_instance_serial_ws, which proxies data back and forth between Propolis and client. In omicron#3356, it was discovered that proxy_instance_serial_ws used tokio::select! in a cancel-unsafe manner.

Specifically, this function selected against four different futures:

  1. A StreamExt::next future.

  2. A future returned from a method called InstanceSerialConsoleHelper::recv.

  3. An instance of SinkExt::send.

  4. InstanceSerialConsoleHelper::send, which was a wrapper around SinkExt::send.

Out of these four futures, only future 1 was cancel-safe.

  • Future 2 was not cancel-safe because it read a value off of a stream, then processed it using further awaits in the middle. If this future was cancelled in the middle, the value could have been read off the stream but not processed, and lost.

  • Future 3 was not cancel-safe because SinkExt::send is not cancel-safe, for the same reason that mpsc::sender::Send is not cancel-safe.

  • Future 4 was not cancel-safe because it was a wrapper over SinkExt::send.

Note

StreamExt::next is not documented to be cancel-safe, and its cancel safety depends on how the underlying Stream behaves. However, a Stream that couldn’t handle interruptions while generating its values would clearly be buggy. After all, the entire point of a Stream is to iterate over values in situations where the next item isn’t immediately available[5].

With SinkExt::send, no matter how well-written the underlying Sink is, the operation is not cancel-safe. In other words, the cancel unsafety is inherent to the send operation.

These issues were tackled separately, by splitting up complex operations in two different ways.

Making InstanceSerialConsoleHelper::recv cancel-safe
Description, splitting up a complex operation
  1. The method first retrieved the next message from an incoming Stream, using StreamExt::next.

  2. After that, recv processed the incoming message.

    • Most of the time, the message would simply be returned to the user.

    • However, if the message was an in-band control instruction to migrate to another server, recv would actually perform that reconnection—an operation with two more await points. After that, the message would be returned to the user.

Step 1 is cancel-safe, since it is just StreamExt::next. However, the migration in step 2 is not cancel-safe, since cancelling the recv future in the middle would mean that an in-progress migration might not be completed and the message might be lost. Here’s the way we chose to solve the cancel-safety issue:

  • Split recv into steps 1 and 2. Perform step 1 in recv(), making it return a future rather than the message.

  • Make the future returned from recv, called InstanceSerialConsoleMessage, perform step 2 and return the message[6].

  • Document that the future returned by step 1 is not cancel-safe and must be awaited for system correctness. (As an improvement, we can check for this explicitly by setting a flag in InstanceSerialConsoleHelper, but the natural flow of using messages leads to correctness so it hasn’t been necessary.)

Operating on the helper then becomes:

let mut helper: InstanceSerialConsoleHelper = /* ... */;

tokio::select! {
res = helper.recv() => {
let message = res?.await?;
// operate on message
}
// ... other select branches
}

What if the parent function is cancelled?

As covered in Overview of cancellation, cancellation propagates from parent futures to child futures. A natural question to ask is, what if the parent function is cancelled while at the await point in let message = res?.await?;?

The answer to that is that the parent function also owns and is responsible for the client and server streams. If the parent function is cancelled, the stream is going to be closed anyway, so any state that becomes invalid is on its way to being destroyed.

Alternative solutions

These solutions aim to make all of recv cancel-safe, rather than splitting recv into cancel-safe and cancel-unsafe parts. This can be done through careful state management.

One alternative is to spawn a background task to perform the migration. propolis#438 implements this approach.

Another alternative is to resume partial progress by storing in-progress migration messages on self:

  • On receiving a migration message, store a copy of it in a field on self, say, self.migration_message (an Option<T>), before operating on it.

  • After completing the migration, set self.migration_message to None.

  • At the start of recv(), if self.migration_message is Some, process self.migration_message rather than reading a message from the incoming Stream.

Either of these would have been a fine way to solve this problem as well, but they weren’t chosen for domain-specific reasons.

Making SinkExt::send cancel-safe
Description, using the reserve pattern

The SinkExt::send method performs three operations in sequence:

  1. Call the Sink::poll_ready method until it completes.

  2. Call the (synchronous) Sink::start_send method.

  3. After that succeeds, call the Sink::poll_flush method until it completes.

This isn’t cancel-safe for the same reason that mpsc::Sender::send isn’t cancel-safe: the value might not be sent and instead be lost. But also, a reserve-based solution works just as well here as it does with mpsc::Sender::send!

The upstream futures crate doesn’t provide a reserve-pattern API, so we wrote our own (documentation). This API returns a Permit which indicates that step 1 above has been completed. This Permit is then used to send a message, performing steps 2 and 3.

use cancel_safe_futures::prelude::*;

let mut sink = /* ... */;

tokio::select! {
res = sink.reserve() => {
let permit = res?;
res.send(value).await?;
// operate on message
}
// ... other select branches
}

The Permit holds on to a mutable reference to the sink, so the sink can’t be used for other purposes while the permit is active[7].

Making InstanceSerialConsoleHelper::send cancel-safe

InstanceSerialConsoleHelper::send was a wrapper around SinkExt::send that didn’t do anything else. To address cancel-safety issues with this method, we removed it and instead made InstanceSerialConsoleHelper implement Sink. Then, the reserve method implemented above can be used for this select branch as well.

Resume from partial progress

If an async function performs several asynchronous steps in succession, it can often be made robust against cancellation issues in select! loops by storing and resuming partial progress.

The basic idea here is to store the fact that some progress has happened, either

  • internally in a field, or

  • externally via a &mut parameter.

Progress can then be resumed from the point at which the function is called again.

For an example showing how to store partial progress internally, see Making InstanceSerialConsoleHelper::recv cancel-safe (an alternative solution).

For an example showing how to externally track partial state, see Case study: AsyncWriteExt::write_all_buf below.

Case study: AsyncWriteExt::write_all_buf

For synchronous code, the standard library’s std::io::Write has a write_all method that attempts to write an entire buffer into a writer. This method works for simple cases, but since there’s no reporting of partial progress, it does not provide the ability to recover from errors—or even know how much has been written out. This means that many users have to hand-roll their own version of write_all, which is easy to get wrong[8].

When this method is ported to async code, users not only have to worry about errors, they also need to think about situations where a write operation is interrupted by a different branch of a select! firing. This issue was solved in Tokio’s AsyncWriteExt through some clever API design.

First, a trait called bytes::Buf was designed to enable recording partial progress. This trait is an abstract way to represent a read-only buffer of bytes, and has three important methods:

  1. fn remaining(&self) → usize: Reports the number of remaining bytes. (There’s also a has_remaining(&self) → bool method.)

  2. fn chunk(&self) → &[u8]: Returns the next chunk of bytes.

  3. fn advance(&mut self, cnt: usize): Advances the buffer by a particular count of bytes.

A Cursor over any sort of contiguous memory buffer implements the bytes::Buf trait[9].

Then, AsyncWriteExt::write_all_buf accepts any &mut B where B: bytes::Buf. It reports partial progress to the Buf implementer: advance is called on it with however many bytes were successfully written.

This means that code that calls write_all_buf inside a select! loop is correct:

async fn write(writer: &mut W, data: &[u8]) -> std::io::Result<()>
where
W: AsyncWrite + Unpin,
{
// Cursor<&[u8]> implements the bytes::Buf trait.
let mut cursor = Cursor::new(data);
while cursor.has_remaining() {
tokio::select! {
res = writer.write_all_buf(&mut cursor) => {
res?;
}
// ... some other branch
}
}

Ok(())
}

Use cooperative (explicit) cancellation

In Task aborts, we saw that aborting Tokio tasks is problematic because a task could potentially be cancelled in the middle of a cancel-unsafe operation. However, it often is a domain requirement to cancel tasks. One way to solve this is by using cooperative, or explicit, cancellation channels.

The cancel-safe-futures library maintained by Oxide has a coop_cancel module that implements cooperative cancellation with a "fan-in" model: many potential cancelers and one receiver.

See the coop_cancel documentation for more, including examples.

Avoid tokio::sync::Mutex

Tokio comes with a Mutex that is specifically designed for use in asynchronous contexts. The official recommendation is to prefer std::sync::Mutex if locks are not held across await points, and tokio::sync::Mutex only if locks are held across awaits.

However, given that:

  • Almost all uses of mutexes are to temporarily violate code invariants within a critical section, restoring them by the end of the operation.

  • Mutexes are almost always shared between futures (otherwise why would a mutex be used at all), which means that the "cancellation blast radius" of a future holding a mutex extends to other futures.

  • std::sync::Mutex has a notion of lock poisoning in case a panic occurs within the critical section. Tokio’s mutexes don’t, either for cancellations or for panics.

What this suggests is that if a future that is currently holding on to a mutex is cancelled, the state guarded by the mutex is likely invalid. This is such a big problem that this guide recommends avoiding tokio::sync::Mutex.

Alternatives to Tokio mutexes

The recommended alternative to a Tokio mutex is a message-passing design, also known as the actor model. Message-passing designs is described in this Tokio tutorial. Rather than having futures access some common shared state, this scheme has a single manager task that has full (non-shared) ownership of this state. This task receives requests in serial order, via an MPSC channel, and sends responses over (typically) oneshot channels.

Another option is to switch to std::sync::Mutex, and not hold locks across await points. Be aware, however, that the worker thread is fully occupied while waiting for the lock. This is not a problem if the lock is uncontended and only held for short periods of time.

If you really must use a Tokio mutex

It isn’t impossible to write correct code that manages Tokio mutexes, just very difficult. Some approaches you could take:

  1. Declare that your code isn’t cancel-safe.

  2. Ensure that invariants are always restored between await points. Restoring valid state between await points is okay[10].

  3. Allow state to become invalid, but clean it up at the start of every critical section (i.e. every time the lock is acquired).

All of these options require careful analysis.

Spawn background tasks to perform cancel-unsafe operations

Unlike a future which has no existence outside of its owner, a task is owned by the executor (typically Tokio). This means that in some situations, a background task can be used to create a cancel-safe interface around a cancel-unsafe future.

For example, consider a next method that does something cancel-unsafe:

struct MyHandler {
inner: /* some stream of data */,
}

impl MyHandler {
// This is cancel-unsafe because message will be dropped if
// the future is cancelled in the middle of the operation.
async fn next(&mut self) -> MyMessage {
let message = self.inner.next().await;
process_message(&message).await;
message
}
}

As described in Marking APIs as cancel-unsafe, a method called next must be cancel-safe. One way to achieve that is to spin up a background task to process the message:

Code sample
struct MyHandler {
inner: /* some stream of data */
join_handle: Option<MyJoinHandle>,
}

impl MyHandler {
async fn next(&mut self) -> MyMessage {
// If an existing background task exists, wait for
// that to complete.
if let Some(join_handle) = &mut self.join_handle {
let message = join_handle.await_completion().await;
self.join_handle = None;
return message;
}

let message = self.inner.next().await;
let join_handle = MyJoinHandle::new(message);

// Set self.join_handle to Some before the next await point.
// This enables resumption if this future gets cancelled.
self.join_handle = Some(join_handle);

let join_handle = self.join_handle.as_mut().unwrap();
message = join_handle.await_completion().await;
self.join_handle = None;
message
}
}

struct MyJoinHandle {
handle: JoinHandle<MyMessage>,
}

impl MyJoinHandle {
fn new(message: MyMessage) -> Self {
let handle = tokio::task::spawn(async move {
process_message(&message).await;
message
});
Self { handle }
}

async fn await_completion(&mut self) -> MyMessage {
// A simple `self.handle.await` results in "cannot move out of
// `self.handle` which is behind a mutable reference". Need to
// use `&mut self.handle` explicitly to guide the type checker.
let handle = &mut self.handle;
// Can also return an error rather than expecting.
handle.await.expect("task panicked")

// After this method completes, the handle should never be
// polled again. If it is polled again, then it will panic.
// That is managed in `MyHandle::next` by setting `join_handle`
// to None immediately after returning from this method.
}
}

This approach works well, but has some downsides:

  1. There’s extra ceremony involved with carefully setting up the join handle. This is easy to get wrong. (Is there an abstraction we can write to make this easier?)

  2. If cancellation is actually desired in some situations, then the task has to be manually cancelled (either via aborts or by using an explicit cancellation channel).

  3. tokio::task::spawn requires a 'static bound, so it can’t borrow data from self or elsewhere. Whether this is okay, an inconvenience, or a deal-breaker, depends on the situation[11].

Use alternate MPSC channel modes

Tokio provides bounded MPSC channels. While bounded channels are a good default since they add backpressure to a system, they additionally come with the issue that their send method is asynchronous (it blocks if the channel is full).

In several real-world cases, it has been observed that the only source of asynchronicity in part of a system is the use of bounded MPSC channels. Removing this source of asynchronicity can make the design of part of a system much simpler.

Consider using these alternative, synchronous channel modes:

  1. try_send on bounded channels: A bounded channel’s Sender provides a synchronous try_send method, which returns an error if the channel is full. This method can be used to externalize backpressure to clients, e.g. by returning an HTTP 429 Too Many Requests error if the channel is full.

  2. Watch channels: If you only care about the last value sent to a channel, consider using a Tokio watch channel. A watch channel is a single-producer, multi-consumer channel which only stores one value and discards the current value on receiving a new one, eliminating backpressure concerns.

  3. Unbounded channels: A final option that is not recommended, but still available, is to use unbounded MPSC channels. Unbounded channels do not have a capacity limit, and their send method is synchronous. Using unbounded channels has a significant downside, namely the lack of backpressure. Among other things, this can result in memory consumption blowing up if the unbounded channel isn’t emptied. How this concern trades off against cancel-safety issues is a case-by-case decision.

For a practical case study, see Case study: wicketd’s installinator progress tracker.

Case study: wicketd’s installinator progress tracker

In Omicron, the service running on the technician port is called wicketd. This service manages recovery and offline updates, and part of that management is recording progress reports sent by another component: the installinator [rfd345].

Previously, the report tracker’s report_progress method used bounded channels to send reports. This had several consequences:

  • The code that sent reports had to be asynchronous.

  • Since this code used a mutex that was held across an await point, that mutex had to be a Tokio mutex rather than std::sync::Mutex.

  • Other code that used the mutex also had to be asynchronous.

The report_progress method wasn’t cancel-safe, since an aborted report could lead to the update be stuck in an Invalid state. (This is exactly the sort of issue with Tokio mutexes that resulted in the recommendation to avoid them).

The solution

In Omicron PR #3950 we switched to using a watch channel. We used the fact that progress reports were cumulative; only the last progress was interesting to the tracker.

This had ripple effects, all positive:

Previously…​

In Omicron #3579, we first tried a couple of attempts to make this code cancel-safe. However, both of the attempts had subtle flaws related to cancellation. As a result, we settled on switching to an unbounded channel.

While the benefit of cancel safety outweighed potential backpressure issues at the time, we later realized that a watch channel would have the same benefits without any backpressure-related issues.

An alternative would have been to rewrite this code into a message-passing style, but this was a more expedient way to achieve cancel safety.

Perform cleanup separately

Consider a method on a database connection pool that executes a future within the context of a database transaction:

use std::future::Future;

struct TransactionContext {
// ...
}

struct ConnectionPool {
// ...
}

impl ConnectionPool {
fn execute_transaction<F, Fut>(future_fn: F) -> Result<T, E>
where
F: FnOnce(TransactionContext) -> Fut,
Fut: Future<Output = Result<R, E>>,
{
let cx: TransactionContext = /* ... */;
// ... begin transaction

match future_fn(cx).await {
Ok(value) => {
// ... commit transaction
Ok(value)
}
Err(error) => {
// ... rollback transaction
Err(error)
}
}
}
}

If execute_transaction is cancelled in the middle of being run, the database connection represented by the transaction context might be left in an inconsistent state. For example, it might be in a state where a transaction has begun but not ended.

For cancel correctness, it is important that ConnectionPool perform cleanup on the connection at the time it is returned to the pool, or at the time a new connection is allocated. For example, if the connection is in the middle of a transaction, it should be rolled back.

Marking APIs as cancel-unsafe

Many async functions are likely to be cancel-unsafe, with no easy way to change them. How can APIs that aren’t cancel-safe be designated, so consumers use them with care? This can be done through a combination of naming and documentation.

Names and function signatures

Cancel-unsafe APIs should have names and function signatures where it "feels like" using them in select! statements is wrong. Getting these semiotics right is a matter of experience and judgment, but here are some general guidelines:

  • Don’t name cancel-unsafe methods next() or recv(). It is natural to use these sorts of methods in select! loops.

  • Don’t name methods reserve() or acquire() unless they’re mostly cancel-safe. This pattern-matches against the Tokio methods that are similar.

  • Use names that indicate an irrevocable action that shouldn’t be repeated. For example, a method named self.http_post_data(), by referring to an HTTP POST, clearly indicates an action that shouldn’t be recreated in a select! loop.

  • Use the type system if possible. Consider a method like fn execute(self), where self isn’t cloneable. It is not going to be possible to use it in a select! loop. Instead, users must create the future returned by the method outside of the select! loop.

    • Note that a method like execute(self) can’t be used in a select! loop, but it can can still be called once and cancelled (e.g. with a timeout). Often this is okay since the entire operation is aborted in that case.

Documentation

Documenting the cancel safety of your APIs to a reasonable degree helps users understand what can go wrong if a future is cancelled. It’s recommended that each method have a "cancel safety" section associated with it.

Documenting cancel safety for every single async API can be too much of a lift, but it’s worth doing this at least for the most commonly used methods.