Async Cancellation
blog.yoshuawuyts.com
blog.yoshuawuyts.com
[1] Notes on structured concurrency, or: Go statement considered harmful https://vorpus.org/blog/notes-on-structured-concurrency-or-g...
[2] Timeouts and cancellation for humans https://vorpus.org/blog/timeouts-and-cancellation-for-humans...
[3] Tutorial -- Trio documenation https://trio.readthedocs.io/en/stable/tutorial.html
One motivation for completion futures that either wasn't mentioned or is an unexpected side effect of point #1 (compatibility with C++) is that async code in C/C++ can take non-owning references into buffers. For example, on Linux if you issue an async read() call using io_uring and you later get cancelled, you have to tell the kernel somehow that it needs to not touch the buffer after Rust frees it. There are ways to do this, such as giving the kernel ownership of the buffers using IORING_REGISTER_BUFFERS, but having the kernel own the I/O buffers can make things awkward. (Async C++ folks have shown me patterns that would require copies in this case.) Have you all given any thought as to the best practices here? It's a tough decision, as the only real solution I can think of involves making poll() unsafe, which is unsavory (NB: not necessarily wrong).
There is also need to be able to handle the race condition where the task completes successfully during cancellation in any case, or else you will get completions which you don't know to handle. So the support for cancellation must already handle processing of the completion events until the cancellation is acknowledged, even if the userspace submission of the cancellation happened long ago.
This mean io cancellation will be fairly cheap but not necessarily totally wait free. Is that really that big of a problem? It seems you make a lot of problems for yourself if waiting for acknowledgement isn't good enough.
AFAIK there's no better way than to have the library that wraps io_uring also own the buffers instead of letting the user own them, so that it can also control when / if they're dropped.
This doesn't mean that functions using async/await would have to be unsafe, just functions that manually implement poll. I still don't love that, though.
Async-await does not obviate this problem. `let (value, _) = futures::future::select(f1, f2).await.factor_first();` has the same problem, one of the futures is going to be dropped before it resolves.
That's why calling poll would be marked unsafe. When you call poll() manually it's on you to uphold the invariants.
> Async-await does not obviate this problem. `let (value, _) = futures::future::select(f1, f2).await.factor_first();` has the same problem, one of the futures is going to be dropped before it resolves.
Yes, you also need to reform select.
Matthias247 has done work to flesh out what this looks like: https://github.com/Matthias247/rfcs/pull/1
By making a new kind of async function that cannot be called from existing async functions, I see. I started reading it with the expectation that RTCFutures would automatically spawn() themselves to enforce the RTC requirement instead of introducing a third function color. I'm pessimistic a third function color will catch on, but let's see how it goes.
Pros are that it's very explicit - there are no unexpected places for the code "stop", which is useful if there are effects that aren't easily modeled RAII style.
Cons are that it's very explicit, having to me manually passed around everywhere, with all the possibility of forgetting.
I guess "panic on cancel" wouldn't be very popular :P and I have no idea if/how the cancellation token scheme allocates.
The async-rs/stop-token [1] library does exactly this.
This post is just the first in a series. In a follow-up post I'm planning to zoom in on the uses and design of cancellation tokens.
Also it's really interesting that the original F# approach to .NET Cancellation was to handle them automatically in async { } blocks, handling and for the most passing them automatically, and after a lot of usage patterns seen in the real world they decided that the overhead of all the automated checks wasn't worth it enough and the new in F# 10 task { } blocks follow the rest of .NET in preferring entirely manual CancellationTokens.
// process1 does nothing and takes 20s to complete
// if cancelled, it will print a message
private Mono<Void> process1() {
return Mono
.just(new Object())
.delayElement(Duration.ofSeconds(20))
.then()
.doOnCancel(() -> log.info("I have just been canceled"));
}
// assume it does the same as process1
private Mono<Void> process2() {
...
}
private Mono<Object> cancellationSignal() {
return Mono
.just(new Object())
.delayElement(Duration.ofSeconds(10))
.doOnNext(n -> log.info("oops, sending cancellation"));
}
// executes process1, then process2
// if cancellationSignal happens before they finish they will be safely cancelled, you will only receive message from process1
private Mono<Void> compositeSequential() {
return process1()
.then(process2())
// timeout() is just one way to cause cancellation (in this case on an arbitrary signal)
.timeout(cancellationSignal());
}
// executes both processes concurrently
// you will receive messages from both processes as they are both getting the cancellation
private Mono<Void> compositeConcurrent() {
return Flux
.just(process1(), process2())
.flatMap(identity())
.then()
.timeout(cancellationSignal());
}* https://cr.openjdk.java.net/~rpressler/loom/loom/sol1_part2....
* https://wiki.openjdk.java.net/display/loom/Getting+started
* https://wiki.openjdk.java.net/display/loom/Structured+Concur...
futures::poll!(task.cancel());
[0]: https://docs.rs/async-task/4.0.3/src/async_task/task.rs.html...(async-std -> async-global-executor -> async-executor -> async-task)
C++ appears to be going to use it as its "main"/"most fundamental" asynchronous algorithms model: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p230...
Seems quite a bit more flexible at least than Rust futures, but I am not sure I understand how they exactly differ.
Surely you can still accidentally write code that accidentally puts an `await` in a section that must run atomically. You still need to learn and think about the surprising cancellation behaviour.
Edit: It also looks good (better maybe) on mobile. Props.
In C# it feels 95% done and the remaining 5% can lead to some significant confusion.
I think that at least the trait integration stuff is taking some steps forward these days, and hopefully the cancellation stuff too, which this blog post is indicative of.
I've also seen un-intuitive perf issues with await where replacing it with manual continuations sped things up a lot; again, I think it's very rare, but when it happens it happens.
And yeah like the other comment mention, ConfigureAwait 0_o
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...