Blog post: Async Cancellation

This is a comment thread for a series of posts on async cancellation:


In this post we'll look at async Rust's async primitives, and cover how cancellation works for those primitives today. We'll then proceed to look at ways in which we can ensure we do not end up with dangling resources. And finally we'll take a look at what the current direction of async Rust means for async cancellation.

8 Likes

I've only skimmed the post, hopefully I can read it in full later. One thing popped out to me initially though:

For the purpose of this post we need to distinguish between two types of async primitives in Rust: futures and tasks

I'm not certain that you can limit talks about cancellation to just these two primitives. As an example imagine a fully userspace network stack, dropping a TcpStream requires sending a FIN packet and waiting for a response, even if the TcpStream is currently idle and not being used in a Future. It might be that the stack can move the ownership into a shared pool of "closing" connections synchronously on drop and handle the async communication there, but other stacks might prefer to leave this running under the existing task to allow for things like an allocation-less implementation.

I haven't really had a chance to got my hands wet with async programming (yet), but one thing that came to me immediately is that it seems there's assumption that all tasks are born equal.

This might be the case from the reactor perspective(plain indexes or something), however from the application's structured perspective, usually there's a strict ownership relationship between them. There's tasks, child tasks and grand children tasks and so on.

Just similar to how rayon removed most of the use cases dealing with JoinHandle of threads, some task abstraction can and should remove most of use cases dealing with async runtime spawn or JoinHandle directly, managing the ownership forest of tasks. Whether the root tasks is detached-on-drop should not matter to the child tasks, since child tasks will be cancelled whenever its parent is cancelled by definition.

1 Like

Shouldn't that behavior be configurable by a wrapper? For example, even if drop is sync, you can make a wrapper which will move that resource to a pool which can be processed by a background thread closing them, or even put it into a pool to be reused.

There's a related problem in .NET on a network connection configuration. The idea is to build a chain of wrappers which allow wide customization.

So my point here is that there should be a minimal set of primitives (futures and tasks, as Yoshua wrote) and provide a set of tools if it applies to alter the behavior.

Without having looked closely at your blog post, I would like to encourage you to review Some thoughts on asynchronous API design in a post-async/await world — njs blog and the design documentation for the Trio concurrency library, particularly the bits about timeouts and cancellation. These are both talking in the context of Python, but I would think the essentials would carry over, and in my experience Trio provides better ergonomics for cancellable operations than anything else I've tried.

1 Like

Thanks for writing the article. I look forward to reading subsequent parts.

When you write about triggering cancellation at a distance, will you discuss Golang's Context? I wrote a Rust version: https://crates.io/crates/permit.

You can create a permit and call permit::Permit::new_sub to create a subordinate permit. Revoking or dropping a permit also revokes its subordinates, recursively.

You can check if a permit is revoked or await until it is revoked. Usually, you would want to await on either an IO operation completing or the permit getting revoked. You can do that with tokio::select!, futures::select!, or my safina_select. Using these does not yield concise code.

1 Like

Yes, I'm planning to cover the same problem space Golang's Context type addresses in my next post [:

1 Like

I think one important aspect to address that influenced Golang's context design is network propagation in a fan-out microservice.

External User makes 1 request to Service A, which makes 3 concurrent requests to Service B, each of which make 1 request to Service C. If the connection to External User dies, then Service C needs to be informed to stop its processing!

Now that I've typed it out - it sounds like this can be ably addressed with drop guards and a connection pool provided by the RPC library.

Very interesting blog post. Do you think that @withoutboats' poll_drop / poll_drop_ready proposal would solve the need for a non-cancellable Future trait? I feel those are two solutions addressing the same fundamental issue.

Now that I think about it, async destructors and non-cancellable futures are somewhat the same solution. It's just the method used to cancel the future what changes. For async destructors, you would cancel a future by stopping to call poll and begining to call poll_drop. While for non-cancellable futures you could call some cancel() method and continue to call poll until the future returns ready. Either way, you must continue to call poll / poll_drop and finish the futures execution before it gets dropped.

With this in mind, I think stopping to poll a future may have been the wrong abstraction for future cancellation. In the other hand, changing the cancellation model now would be a masive breaking change in the ecosystem, and async destructors can fix the cancellation/completion problem without breaking anything.

2 Likes