One of the items on our Async 2027 Roadmap is to come up with some kind of asynchronous cleanup mechanism, like async Drop. There are some tricky design questions to making this work well, and we need to start thinking about these now if we want to have something ready by 2027.

In this post, I'd like to explore a low level mechanism for how we might implement async cancellation. The goal is to explore both how an async executor1 would interact with cancellation, as well as to make sure that this mechanism would support reasonable surface-level semantics. You can think of this as a kind of compilation target for higher level features, similar to how the Rust compiler lowers async fn into coroutines.

If you haven't read my last post on Cancellation and Async State Machines, I'd encourage you to do so. That post provides a kind of theoretical background for what we'll implement in this post.

Introducing poll_cancelπŸ”—

Lately I've been working on a prototype implementation of async/await, as well as changes to Future and related traits, that supports more flexible cancellation. I'd like to discuss this prototype, the tradeoffs made, and what I've learned about cancellation from the exercise. Note that what I'm presenting here is Ξ±-equivalent to several previous proposals, including Boats' poll_drop_ready RFC and a proposal by tvalloton on IRLO. My main contribution here is a prototype implementation that lets us write examples and explore their behavior.

A Cancellable FutureπŸ”—

The core of the idea is to extend the Future trait with a new poll_cancel that has a default implementation. The new trait would look like this:

pub trait Future {
    type Output;
    
    fn poll(self: Pin<&mut Self>, cx: Context) -> Poll<Self::Output>;
    
    fn poll_cancel(self: Pin<&mut Self>, cx: Context) -> Poll<()> {
        Poll::Ready(())
    }
}

In this new trait, poll has the same semantics as before. The new poll_cancel method performs two operations. First, it transitions the future's state machine from its normal execution path to the correct cancellation state. Second, poll_cancel continues to advance the state machine until the cancellation is complete.

The fact that poll and poll_cancel return different types highlights that fact that cancellation is a different exit from the future. A cancelled future returns no value, so poll_cancel returns Poll<()> instead of Poll<Self::Output> This matches what we saw in my previous post where we had a different final state for a future that was cancelled versus one that completed normally.

There are some attractive properties about this approach. The default implementation of poll_cancel leads to the same behavior that we have for cancellation today, where cancelling a future just means synchronously dropping it. This suggests we can get a nice migration path, although adding a new default method to a trait is technically a breaking change.

There are significant shortcomings, which I'll discuss further down. But first, I'd like to look at how poll_cancel works with async and await.

Cancellation with async and awaitπŸ”—

Most people writing async Rust should not have to deal with poll directly. Most of the time we use higher level constructs like async and await instead. The nice thing about async and await in Rust is that there's nothing particularly magical about them.2 The can be thought of as desugaring into lower level constructs, and this desugaring happens in a way that you could mostly implement them both as macros.3 The primary benefit for building them into the language is that we can have nicer syntax and nicer diagnostics.

The fact that we can think of async and await as macros that desugars into lower level concepts means we can experiment with cancellation by writing a new set of macros that that call poll_cancel in the appropriate place. Most of the action will be in the changes we make to await.

The goal here is to come up with a desugaring that has predictable cancellation behavior that is also usually the desired behavior.

The somewhat surprising thing to me is that await mostly just forwards calls to poll, but doesn't have a lot of interesting future behavior. The interesting behavior (such as making sure a Waker gets called sometime in the future) all happens in hand-written Future impls. We can see this in the approximate desugaring of await from the Rust Language Docs:

match operand.into_future() {
    mut pinned => loop {
        let mut pin = unsafe { Pin::new_unchecked(&mut pinned) };
        match Pin::future::poll(Pin::borrow(&mut pin), &mut current_context) {
            Poll::Ready(r) => break r,
            Poll::Pending => yield Poll::Pending,
        }
    }
}

This block of code runs when some code higher up the call stack calls our poll method. What this block of code is doing is basically calling the awaited future's poll function in a loop. If that future returns Pending, we yield Pending. From this code, the compiler will generate a Future::poll function that returns Pending when the function would yield Pending.

This happens deeper than in the compiler than we can do with macros, but we can approximate something different. Originally, the compiler actually generated an object that implemented Generator (now Coroutine) and the standard library had a wrapper that adapted the Generator into a Future. We'll use this approach for our prototype.

We'll want to handle cancellation similarly to how polling is handled, where await also forwards calls to poll_cancel along the await chain until we arrive at a future that knows how to do something interesting with cancellation.

Looking at how we might extend the desugaring of await to support poll_cancel, we need to distinguish whether we're on the cancel path or the normal execution path so we can call either poll_cancel or poll depending on the context. We'll punt on this and assume we have a magic is_cancelled variable that can tell us this, which is similar to the current_context variable in the previous desugaring.

So let's see how this first step looks:

match operand.into_future() {
    mut pinned => loop {
        let mut pin = unsafe { Pin::new_unchecked(&mut pinned) };
        if !is_cancelled {
            match Pin::future::poll(Pin::borrow(&mut pin), &mut current_context) {
                Poll::Ready(r) => break r,
                Poll::Pending => yield Poll::Pending,
            }
        } else {
            match Pin::future::poll_cancel(Pin::borrow(&mut pin), &mut current_context) {
                Poll::Ready(()) => panic!("What do I do after cancelling?"),
                Poll::Pending => yield Poll::Pending,
            }
        }
    }
}

It's like before, but we check if we are cancelled first. If we are not, we continue with the previous behavior, calling poll and breaking out or the loop if the future is Ready or yielding Pending otherwise.

If we are cancelled we do almost the same thing, except we call poll_cancel instead. If the cancellation is Pending, we yield again. But if the cancellation is complete, we have to decide what to do next. In the normal case, we have break r, which passes r out to the surrounding context, which is expecting a value of whatever type r is. We can't do the same thing when the cancellation is complete because while r might be type (), we can't rely on that. For now we panicked, since that type checks, but this obviously doesn't work.

We can get some inspiration from our state machines we saw earlier. Cancellation effectively means we have two exit states for the function: normal return and cancelled. But functions in Rust only have one exit state4, so we need to reify this into some data type that shows which final state you'd be in if you could have multiple final states. It turns out the Rust standard library has one we can use for this purpose: Result.5 So to report that an async fn or async block was successfully cancelled, we can return something like Err(Cancelled) and Ok(T) in the success case. Factoring this into our approximate await desugaring gives us:

match operand.into_future() {
    mut pinned => loop {
        let mut pin = unsafe { Pin::new_unchecked(&mut pinned) };
        if !is_cancelled {
            match Pin::future::poll(Pin::borrow(&mut pin), &mut current_context) {
                Poll::Ready(Ok(r)) => break r,
                Poll::Pending => yield Poll::Pending,
            }
        } else {
            match Pin::future::poll_cancel(Pin::borrow(&mut pin), &mut current_context) {
                Poll::Ready(()) => return Err(Cancelled),
                Poll::Pending => yield Poll::Pending,
            }
        }
    }
}

In the desugaring of async {}, we'll also need to wrap all the normal exit paths with Ok().

The Generator AdapterπŸ”—

In the previous section I gave a rough sketch of how to desugar async and await into generators in a way that supports cancellation. Now I want to fill in some of the details by looking at how this resulting generator becomes a future.

If we were implementing this for real in Rust, we'd probably just have the compiler implement Future directly, like it currently does for async blocks. But, using generators lets us implement and experiment with this in a crate without having to modify the compiler.6

So, if we did everything right in the previous section, we should end up with a compiler-generated generator that implements Generator<(Context, bool), Yield = (), Return = Result<T, Cancelled>, where T is the output type of the Future and Cancelled is just a marker tag like struct Cancelled. The argument to the resume function, (Context, bool), is a tuple containing the Context as well as a bool indicating whether the future is cancelled. This bool would get bound to the is_cancelled variable in the await desugaring above.7

Now we can make these into futures as follows:8

impl<O, G> Future for G
where
    G: core::ops::Generator<PollState, Yield = (), Return = Result<O, Cancelled>>,
{
    type Output = O;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        match self.resume((cx, false)) {
            GeneratorState::Yielded(()) => Poll::Pending,
            GeneratorState::Complete(Ok(v)) => Poll::Ready(v),
            GeneratorState::Complete(Err(Cancelled)) => panic!("child future cancelled itself"),
        }
    }

    fn poll_cancel(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        match self.resume((cx, true)) {
            GeneratorState::Yielded(()) => Poll::Pending,
            GeneratorState::Complete(Ok(_)) => {
                panic!("future completed after being cancelled")
            }
            GeneratorState::Complete(Err(Cancelled)) => Poll::Ready(()),
        }
    }
}

Our implementation needs to cover both poll and poll_cancel, but they are both pretty similar. Each one forwards the call to the generator's resume method and then adapts the result into something expected by the surrounding async code.

Generators only have a resume method, but in this post we've extended Future to have two methods. So when we go from a call to poll or poll_cancel to a call to resume, we need to tell resume which version it is. We do this by passing an extra boolean, which the generator uses to determine whether it should go along the normal execution path or the cancellation path.

Generators return either Yielded or Complete, which for futures correspond to Pending and Ready. Because we've made resume return a Result to indicate whether the future was cancelled, we have some more cases to check. We don't want to bubble the Result out to user code; we want to keep it hidden inside the monad. From the user's perspective, this is still just a future that evalutes to a T, not a fallible future.

So we have this invariant, that in poll, resume should never return an Err(Cancelled) and in poll_cancel, resume should never return Ok. The first case would mean that the future cancelled itself, which is not the way cancellation works in Rust. The second case would mean the cancellation failed, that after being cancelled the future completed normally. In this design we're also choosing not to model that case.9 In an ideal world, the compiler would be able to prove both of these cases are unreachable, or we'd design the API so that these cases aren't even possible to write. Honestly, this is one of the aspects of this design that I'm least satisfied with. I'd like to experiment with different factorings that would let us get rid of the panics.

Anyway, that's the rough idea of how this design works. I haven't written the complete implementation here because I find prose more informative than code, but I do have a prototype implementation at https://github.com/eholk/explicit-async-cancellation if you want to see the full details.

But for now, let's see what this lets us do.

ScenariosπŸ”—

My prototype includes a macro called async_cancel!, which is similar to async {} blocks, except with support for cancellation handlers. This is meant to be paired with the awaitc! macro, which is analogous to .await, but with support for cancellation handlers.10 Because these are not built in syntax, they are ugly and hard to read in the examples I've prototyped so far. So in this section, I'll write out examples as if async and await supported cancellation handlers in the way described above.

First, I want to introduce a convenience called on_cancel. This gives us a way to run asynchronous code along the cancellation path. This is important to show that everything actually works how we want, but I'm not really a fan of the API and would prefer it not be the standard way to run code on cancellation. Think of this as a placeholder for something like defer {} blocks or async Drop.11 I've implemented on_cancel as an extension method on futures that takes a future and runs that future on the parent future's cancellation path. That's a little confusing to read, but in code it looks like this:

async {
    do_something().await;
    println!("all done!");
}.on_cancel(async {
    println! ("cancelled!");
}).await;

In my examples, I'll also make liberal use of futures like pending() and ready(), which never complete and immediately complete respectively.

A Cancellation-aware ExecutorπŸ”—

The first thing we need is an executor that is aware of cancellation. We'll make a simple one that runs a single task, similar to block_on. If for some reason the executor is dropped before the root task completes, then in the executor's drop function will call poll_cancel on the root task until it's complete. In pseudo-code, our executor looks something like this (actual code is here):

impl<T> Executor<T> {
    /// Run the root task to completion
    fn run(&mut self) -> T {
        loop {
            match self.poll_once() {
                Poll::Pending => continue,
                Poll::Ready(result) => return result,
            }
        }
    }
    
    /// Poll the root task once
    fn poll_once(&mut self) -> Poll::Pending {
        let context = self.context();
        self.root_task.poll(context)
    }
    
    // Definition of `context` is omitted
}

impl<T> Drop for Executor<T> {
    fn drop(&mut self) {
        let context = self.context();
        while let Poll::Pending = self.root_task.poll_cancel(context) {}
    }
}

This gives us just enough to experiment with cancellation behavior. We can run simple futures like this:

fn main() {
    let root_task = async {
        42
    };
    let mut exec = Executor::new(root_task);
    let result = exec.run();
    println!("the root task returned {result}");
}

This program would run and print out

the root task returned 42

We have some more power though. Rather than using run to poll to completion, we can use poll_once some number of times to leave the future in an incomplete state. If the executor is dropped before the future is complete, it will run the cancellation path in the executor's drop function.

Here's a basic example showing cancellation:

fn main() {
    let root_task = async {
        pending().await;
        println!("all done!");
    }.on_cancel(async {
        println!("the task did not finish")
    });
    
    let mut exec = Executor::new(root_task);
    exec.poll_once(); // pending
    exec.poll_once(); // still pending
    drop(exec); // just give up
}

In this example, the root task blocks on pending(), which will never finish. But we attached a cancellation handler that runs when the executor is dropped before finishing the future. Running this program produces:

the task did not finish

So we have the basics of cancellation support and cancellation handlers. Now lets see how this composes with more interesting futures.

Cancellation-aware CombinatorsπŸ”—

I'm using "combinators" here to mean futures which combine or otherwise transform other futures in interesting ways.12 By this definition, we've already seen the on_cancel combinator, which lets you override the cancellation behavior of a future.

Let's consider another one: race. We'll use a very simplified version of race, which looks like a.race(b). This takes a future a and a future b and runs them both concurrently. When one finishes, race will cancel the other and return the value from the one that finished first.

The code for this looks horrible, so I'll leave it out of the post and focus mainly on how it looks to use it.

Here's an example using race with a cancellation handler:

fn main() {
    let root_task = pending().on_cancel(async {
        println!("future `a` was cancelled");
    }).race(async {
        42
    });
    
    let mut exec = Executor::new(root_task);
    let result = exec.run();
    
    println!("result: {result}");
}

In this example, our root task consists of a race between pending() and async { 42 }. The pending() future never finishes. We've attached a cancellation handler to it so we can see some indication that it was cancelled. So the race combinator sees that the second future returns 42 while the first is still pending. Before returning, it runs the first future's cancellation handler, printing future `a` was cancelled. Then it returns 42 as the overall value of the race future. This program's output is:

future `a` was cancelled
result: 42

Cancel during CancellationπŸ”—

The poll_cancel mechanism we're discussing is able to support what I earlier called idempotent cancellation.13 This means that if you cancel a future whose cancellation process has already started then the cancellation process continues as before.

To get a feel for how this works, let's look at a rather contrived example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
fn main() {
    // we'll use `done` to create a future that blocks until some other code
    // sets the `done` to true.
    let done = &RefCell::new(false);
    let root_task = async { 
        // we're going to race `a` and `b`, so we'll create those two futures
        // separately.
        let a = async { 42 };
        // when b cancels, we want a cancellation handler that can print a
        // message for us the first time it's polled. We'll use
        // `cancel_started` to track that.
        let mut cancel_started = false;
        let b = pending().on_cancel(poll_fn(|_| {
            if !cancel_started {
                // print a message if it's our first time through.
                println!("begin cancelling `b`");
                cancel_started = true;
            }
            // Only complete if someone has set `done` to true.
            if *done.borrow() {
                println!("cancellation of `b` complete");
                Poll::Ready(())
            } else {
                Poll::Pending
            }
        }));
        
        a.race(b).on_cancel(async {
            println!("cancelling `race` future");
        }).await;
    }.on_cancel(async {
        println!("cancelling root future");
    });
    
    // Poll the futures a few time, then let the executor shut down
    let mut executor = Executor::new(root_task);
    let _ = executor.poll();
    let _ = executor.poll();
    let _ = executor.poll();
    *done.borrow_mut() = true;
}

The behavior here is pretty subtle, so let's see the output and break down why we get this behavior. The output from this program is:

begin cancelling `b`
cancelling root future
cancelling `race` future
cancellation of `b` complete

The core of this program is that we race two futures (line 28), one that returns immediately (line 8), and one that never completes (line 13). We've attached a bunch of cancellation handlers at various points so we can observe the behavior and the order that things happen in.

The cancellation handler on b is pretty complex, but the idea here is create a future that waits until some flag is set. We wanted to simulate something that takes a little bit of time to complete, but not an unbounded amount, so that we can interrupt the cancellation.

So, we start running, the async { 42 } completes immediately and then race has to start cancelling b. This shows up in the line begin cancelling `b` . This cancellation does not complete, even though we poll a few more times, because no one has set done to true.

The next step is to trigger the second cancellation of b. We do this by letting the executor go out of scope without completing, which means the destructor calls poll_cancel on the root task. This is when we see cancelling root future appear. This gets passed on to the race future because of the way we've desugared await, so we see the program print cancelling `race` future. In the implementation of race, its poll_cancel method cancels any futures that have not either completed or been cancelled. In our case, this means we call poll_cancel on b again, but this time the call chain originates in the executor's destructor rather than the normal execution of race.

Finally, since the done flag has been set, b's cancellation can complete and we see it print out cancellation of `b` complete.

If we had instead supported recursive cancellation, we would have had the option of having b's cancellation handler terminate early. There are likely cases where both options would make sense, but here we've chosen to use idempotent cancellation semantics across the board.

Cancel during UnwindπŸ”—

This one is left as an exercise for the reader (or a future blog post here), but I don't see any fundamental reason why we can't do it.14 The gist of the idea is that anywhere we call poll, we'd want to wrap that in catch_unwind. If the poll function panics, we'd want to catch that, then call the future's poll_cancel method to completion, and then call resume_unwind to continue unwinding.

It will be annoying to have to do a poll, catch_unwind, poll_cancel, resume_unwind dance everywhere, but the basic idea should work.

There are other challenges though. One is that the poll_cancel functions will need to be written to be aware of the fact that they might be called during unwinding, which means the internal state for the future might be inconsistent.

EvaluationπŸ”—

Writing this post gave me the chance to thoroughly explore this design. I would say overall I think this design has enough shortcomings that I don't want to advocate it as the solution for async cancellation handlers. I still think this is useful because the shortcomings can help us find a design with fewer, or at least more acceptable, compromises. The fact that I've been able to implement this as a prototype means we can easily pivot and explore variations.

That said, I wouldn't have written so much about this design if I didn't think it had some merit. So now I'd like to discuss what I see as some of the greatest strengths and shortcomings.

StrengthsπŸ”—

In my mind, the biggest strength is that it feels like a relatively small extension to async Rust, but it still gives a lot of benefits. It's basically one new method on the Future trait, as well as a minor change to the way async and await desugar. We can provide a default implementation of poll_cancel which preserves the status quo semantics for cancellation and therefore makes the migration path pretty easy in most cases. Of course, we're going to come back to this in the Weaknesses section because it's not all roses.

This design makes it clear what the responsibilities are for well-behaved executors (and executor-like things, like future combinators) to make sure cancellation behavior makes sense.

I think this design also works well with the requirement that futures are pinned. For example, and alternate approach could be adding a method like fn cancel(self) -> impl Future<Output = ()>. The problem is that once a future has been pinned, you can't pass it as self. Instead, the signature would have to be something like fn cancel<'a>(self: Pin<&'a mut Self>) -> impl Future<Output = () + 'a, which I think is going to be annoying for executors to work with in practice. Cancelling in place strikes me as significantly simpler.

All of the benefits I've talked about in this post are available without what strike me as significantly more extensive language changes. For example, this gives us some way to run code on cancellation paths without needing complete support for async Drop. Of course, this leads to significant shortcomings that we'll see in Weaknesses. On the bright side, I think something like the poll_cancel API can serve as a compilation target for cancellation, the same way that poll is a compilation target for await.

WeaknessesπŸ”—

The weaknesses in this design range from what to me seems rather tolerable to some that I find completely unacceptable.

On the more tolerable end of the spectrum, there's the fact that this API feels a little fragile. We have a requirement that once you call poll_cancel on a future you can never call poll again, but the compiler can't do anything to prevent you from doing that. This kind of requirement isn't unprecedented though. For example, with futures you already aren't supposed to call poll again after the future has completed, but the compiler doesn't stop you from doing that. In both cases, we can mitigate this by treating await as the normal interface to poll and poll_cancel and guaranteeing that those generate correct code. Calling poll and poll_cancel directly would then be considered an advanced use case, so we can tolerate more complex requirements there.15

I'm slightly more concerned about the migration path. As a strength, I mentioned that the default impl of poll_cancel means without any additional action, futures will retain their present-day behavior. In many cases, this is perfectly fine, but it's probably the wrong default for future combinators. For example, suppose you were using an async IO crate that supported asynchronously cancelling operations in flight, but you put one of those futures behind an older version of race that did not yet support poll_cancel. In this case, when the race future is cancelled, it would fall back on the default implementation, which says "ok, all good, nothing left to do," without calling poll_cancel on the IO operation. The result would be that the programmer has to be extremely careful to make sure that everything in their call chain handles cancellation correctly. Cancellation would be best effort, at best. You definitely could not rely on this for safety!

One possible way to avoid this might be to introduce poll_cancel through a CancellableFuture trait instead. Doing this in a way that's backwards-compatible would be tricky though.

Related to this shortcoming, poll_cancel puts a heavy burden on executor and future combinator authors. It's already tricky to write a state machine that calls poll. Having to add poll_cancel calls to that state machine as well is going to be a lot of error-prone work. We might be able to factor some of this work into common libraries that make it easier though.

But to me the most critical shortcoming of this design is that it it's easy to forget to cancel a future. Fortunately, as long as your future is always behind an await, you should be okay. On the other hand, there are common patterns that would now be error-prone. For example, consider the following example with FuturesUnordered:

let mut futures = FuturesUnordered::new();
futures.push(async { do_something().await; });
futures.push(async { do_something_else().await; });
futures.next().await;
drop(futures);

Here we've added two futures to a FuturesUnordered collection. When we call next(), it will poll both futures until one of them completes, and then the next() future will complete. This means that futures is still holding on to a partially completed future. But, when we drop(futures), there's no way to run poll_cancel because drop must complete synchronously. So, our only option right now is to just not cancel the future.

I suppose one way to work around this shortcoming is to try to argue that FuturesUnordered is a bad API. Maybe I could redefine what we mean by structured concurrency to say that FuturesUnordered is unstructured and the cancellation mechanism we've described here only works for structured concurrency. If I were to take this approach, our example would look more like this when using a redesigned FuturesUnordered collection:

FuturesUnordered::with(async |futures| {
    futures.push(async { do_something().await; });
    futures.push(async { do_something_else().await; });
    futures.next().await;    
}).await;

This solves the problem by making it so that FuturesUnordered::with does no work until its awaited, so there is never any partially completed future that's not under an await point. It's less than ideal for a few reasons though. Stylistically, it adds more rightward drift. But more importantly, this API makes it hard to put a FuturesUnordered in another data structure, which can be quite useful in many situations. Plus, in my subjective opinion, the original version feels more Rusty.

Without a solution, I think this issue will make cancellation handlers so unreliable as to not be useful. In fact, they will likely do more harm than good. This leaves me convinced that we need some more general solution, like async Drop. The key thing is to have some mechanism for the compiler to make sure, in an async function, that any values that need cancelled are cancelled. To be honest, I'm a bit disappointed by this realization. I haven't personally seen a design for async Drop that I love16, so I was hoping that something like poll_cancel would give us most of the benefits of async Drop without having to wrestle with as many complex design issues.

That said, I think a design like poll_cancel complements a higher level feature like async Drop. Even if we have a async Drop, we need to figure out how these get run and whether we can get the properties we want in order to build on them. I think a variation on poll_cancel would give us a useful lower level target to build a more powerful feature like async Drop on top of.

Related WorkπŸ”—

If you've been following this space for a while, the ideas I've discussed here probably sound very familiar. I wanted to take the time to both acknowledge the work that's come before, but also highlight the ways in which my proposal here differs from earlier work.

One of the earliest versions I'm aware of is the (now abandoned) poll_drop_ready RFC from Boats. One of the biggest differences is that the RFC focuses a lot on compiler-generated async drop glue to call poll_drop_ready and make sure things are cleaned up well, while I've left that completely out of scope for this post. I appreciated the RFC's careful consideration of issues around pinning and fusing poll_drop_ready. I've not really thought about these issues in my post, but I think we will need to if we move forward with this or a similar design. I also appreciated that the RFC called out that the synchronous drop would still be called after poll_drop_ready returns Ready(()). That feature was implicit in my design as well, but I think it is better to call it out. The most important distinction, however, is that I have focused mainly on cancellation semantics in this post (that is, what if a future is not polled to completion?), while it seems that poll_drop_ready is called as part of the parent future completing normally through poll. In other words, it seems executors are not intended to call poll_drop_ready directly. This has some implications on when the programmer can assume poll_cancel/poll_drop_ready will be called.

There was another proposal on IRLO to add poll_cancel to the Future trait that is syntactically exactly the same as I've described here. The semantics look essentially the same as I've describe here as well, with perhaps some minor variations. For example, in my design I've imagined you do not have to call poll_cancel on a future that's never been polled.17 I think the guarantees on the contract in the IRLO post are stronger than I was hoping we'd need here---I imagined we could get away with saying something like "a well-behaved executor should..." rather than "you must." In particular, I didn't have the requirement that "A polled future may not be dropped without poll_cancel returning ready," and instead imagined such a thing would be impolite but not illegal. I think the biggest contribution I've made in my post is showing how to adjust the desugaring of async and await to work with poll_cancel, giving us an answer to how "to generate a state machine that can keep track of a future in mid cancellation as a possible state."

Another excellent contribution in this area is A case for CancellationTokens. One of the things I really like about the post is the review of the major options in this space, including request_cancellation, poll_cancel, async fn cancel and cancellation tokens. If you haven't read it yet, that section alone is worth the read! The main idea behind cancellation tokens is to have some bit of state that's carried along the await chain and futures can check whether they've been cancelled and activate the correct behavior in that case. It has some nice benefits around composability, and seems to be better at traversing code that is not cancellation-aware, which is a major shortcoming of poll_cancel as I've describe it here. One thing I find interesting is that although on the surface cancellation tokens and poll_cancel look like extremely different mechanisms, they have more in common than it appears. For example, the extra is_cancelled flag we added in the async and await desugaring looks an awful lot like a cancellation token. I think it'd be worth exploring this connection in more depth.

The last idea I want to explore is request_cancellation, which seems to have been first introduced in some early async vision notes by Niko Matsakis. This is framed as a replacement Future trait called Async which includes a request_cancellation method. The idea is that after calling request_cancellation on a future subsequent calls to poll would proceed along the cancellation path rather than the normal execution path. This has a couple of strengths. It avoids the possibility of calling poll after calling poll_cancel. More importantly though, request_cancellation can be used to support recursive cancellation. After writing this post, I'm actually pretty excited about request_cancellation because it seems strictly more powerful than poll_cancel.

ConclusionπŸ”—

In this post we've made an in-depth exploration of how a poll_cancel API would support cancellation handlers in Rust. The design includes a prototype implementation which allows us to write real programs to get a feel how cancellation behaves. In the course of doing this, we realized that poll_cancel has some significant shortcomings and is probably not the best mechanism for cancellation handlers going forward. But, we also see promise for related proposals to address the specific shortcomings we've identified.


1

I'm using executor broadly here to basically mean "any code that calls poll on futures directly." This obviously includes async runtimes, but also includes many future combinators like race or join.↩

2

This is a little bit of a lie. They desugar into generators and yield expressions, which do involve a fair amount of compiler magic to implement. The key thing here is that we don't have to do much additional magic if we can rely on the compiler to give us support for generators.↩

3

Indeed, in the early days of async Rust, await! was in fact implemented as a macro.↩

4

Well, not quite. Anything can panic, which you can treat as another final state for a function.↩

5

Option would work just as well.↩

6

TC has also shown that we can emulate coroutines using async/await, so it's probably even possible to do all of this on stable Rust.↩

7

We could also add a Context::is_cancelled() method and just pass one parameter. There are a lot of ways to plumb this around.↩

8

This is pseudo code. I'm assuming the pinning stuff just works. Also, my actual implementation had some transmute crimes that I've left out here for clarity.↩

9

This "complete after cancel" case is one that could reasonably happen. For example, maybe you sent a request to a server, started to cancel it, but before you could the server sent back a response saying the request was completed. One possible behavior is to just drop the return value and say the cancellation was actually successful. In code this would mean replacing the panic!("future completed after being cancelled") line with Poll::Ready(()). The design in this post doesn't do this, but futures themselves are empowered to handle this case however they see fit.↩

10

If this were Scheme, I'd call this macro something like await/c or await/cancel, but Rust doesn't let us use / in identifiers.↩

11

Incidentally, I'm also not entirely in love with defer {} and async Drop, but I think async Drop in particular solves a lot of problems I don't know how to solve otherwise.↩

12

Sometimes I also find it helpful to think of combinators as mini executors, since combinators and executors both call poll functions on other futures directly.↩

13

I don't think it would take too much to extend this to support recursive cancellation, but that's left for another post or an exercise for the reader. I think they key thing is you need some way to tell how many times you've been cancelled. One way is to add a depth or count parameter to poll_cancel. Another is to have cancelling a future destroy the old future and create a new future that represents the cancellation of the old one, which could itself be cancelled.↩

14

Whether we want to do it is a fair question though.↩

15

This is somewhat related to fusing futures and iterators. I haven't really touched on what happens if you call poll_cancel after the future is cancelled, but I think Boats' earlier proposed RFC on poll_drop_ready makes a pretty good case that poll_cancel should require fused semantics -- that is, that you can call poll_cancel again after it completes and nothing bad happens.↩

16

For example, I haven't seen a good way to run async destructors without introducing implicit await points. I like that right now we have the property that you can see anywhere an async fn might suspend by looking for await. Although, if I'm totally honest, this may not actually be that useful of a property.↩

17

The reason for this was to try to make it so we could get away with only having to deal with poll_cancel in the desugaring of await. Given the issue with FuturesUnordered, I don't think we can get away with only calling poll_cancel as part of await and will probably need some kind of compiler-generated drop glue cancellation path. Thus, it's probably simpler and better overall to have poll_cancel called even on futures that haven't been polled yet.↩