Other environments utilize variations of this pattern:
Another example is in OS kernels. In most kernels, if you kill a process, any threads currently running userland code are immediately terminated, but threads which are running kernel code – because they're in the middle of a syscall – are kept alive until the syscall completes. That way they can release any kernel locks they're holding and perform other necessary cleanup. To ensure that such threads exit promptly, certain blocking calls within the kernel, such as waiting on a mutex or condition variable (including as part of blocking I/O), check whether the current thread has been signaled and return an error code if so, e.g. EINTR
in Unix kernels. The scheduler also knows how to abort those calls if they're already in progress, with the same result (error returned to the caller).
On the other hand, a partial counter-example to the pattern exists in Unix userland, in the form of pthread cancellation. Like the examples you noted, pthread cancellation is not immediate and only takes effect when the thread calls one of various OS functions that are considered "cancellation points". But by "takes effect" I don't mean the OS function returns an error code; instead, it just terminates the thread without ever returning to the caller! However, it first runs any "cleanup routines" that you registered using pthread_cleanup_push
(there's also pthread_cleanup_pop
).
Importantly, though, pthread cancellation is considered badly designed and is rarely used in practice.
In Rust
I think it's important to preserve synchronous Drop
as an option, because it provides low-level control of how coroutines run and maximal flexibility regarding allocation.
However, especially considering the CancelIoEx
issue, it sounds like some async runtimes might want to adopt a model where Drop
is simply never used for cancellation. Instead, all async functions would be expected to run to completion, and cancellation would be handled via an entirely separate mechanism.
If so, this would have important consequences! Any functions implemented using await
should preserve the "never drop" property naturally. However, some combinators would need to be changed. For example, futures
0.3's try_join!
macro polls multiple futures in parallel, and if any returns an Err
, it drops all of them. An "async-drop-safe" approach would have to be different. It could perhaps work by continuing to poll the other operations, but using a modified Context
object that indicates cancellation. Low-level "blocking" async operations could then check the context object and return an error code.
You'd also want to have a mechanism to detect accidental drops at runtime. This can be done today by just sticking a local variable that panics on drop into all your async fns. But the language could potentially make it more convenient by adding, say, a #[panic_on_drop]
attribute, or even a way to make it the default within a scope.
Anyway, this design could be implemented only for a particular async runtime, but you'd have to be careful to avoid any code that uses try_join!
or other combinators from the "standard" world. On the other hand, because future combinations are not currently in std, it's also not too late to standardize the never-drop pattern: say, add a standard is_cancelled()
method to std::task::Context
, and change futures-rs
to make what I just said the default behavior.
Is that actually a good idea, though? I don't know.
Type safety?
Even if it is implemented only for a particular async runtime, it would be nice if the compiler could guarantee that we don't accidentally call any "normal" futures code, while still allowing us to take advantage of the .await
syntax. To do this it would have to provide way to use a different trait to distinguish "our" futures.
Maybe we could have a trait like
pub trait GenericFuturePlzBikeshedName<SomeContext> {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut SomeContext) -> Poll<Self::Output>;
}
I'd like to say that Future
could then become an alias for GenericFuturePlzBikeshedName<std::task::Context>
, but that doesn't actually work as written, because Future
has a lifetime parameter, and there's no way to make MyContext
something that can accept a lifetime parameter without some cumbersome use of GATs
But it could still be a separate trait which the async
/await
desugaring recognizes as an alternative to Future
. To handle lifetimes, you'd want to allow things like
async fn foo() -> impl for<'a> GenericFuturePlzBikeshedName<MyContext<'a>>