How and why await should be a method afterall
(edited ) (edited )
After having reviewed all proposals in both Github threads and the internal
thread, I find myself favoring future.await()
.
Disclaimer: This is an opinion piece, in a personal blog. Expect it to be rhetorical and opinionated at lengths, even when I tried to balance it well with technical arguments.
On top of the orthogonality of postfix, the motivating, unique edge of await
as a method over other syntax proposals might be its potential as a
self-documenting (trait) implementation:
pub trait Await {
type Output;
/// We can search for this in the docs!
fn await(self) -> Self::Output;
}
Every important aspect is mentioned here:
- The future
self
is taken by value, from our perspective. - When our current method is resumed, a value of type
Self::Output
is available. - Some other code runs in the meantime.
- This is an associated method on applicable types.
For those not favoring method call syntax, or not favoring postfix at all, I have some additional arguments for it at the end.
Complications
Unfortunately, and as the blogpost by @withoutboats correctly explains, this can not be implemented as a Rust function. Let's try to dig down the rabbit hole, and resolve if this may not be possible.
Checking our watch
Note that the additional qualification I added (Rust
) is important. There
already exist other types of functions beyond the usual type fn(T) -> U
. Both
unsafe
and extern "C"
add different kinds of additional qualifiers on the
function type, making it incompatible with the usual function types:
unsafe fn foo(a: usize) -> usize { ... }
-- no longer implements
FnOnce(usize) -> usize
And rightly so, because unsafe fn
is not simply callable but requires such a
call to occur within an unsafe
environment. Sounds familiar? It should, the
same is true for await
but requiring an async
environment.
What goes around comes around
Even more, those familiar with external c-apis may also know the pain of
setjmp
and longjmp
. What's used in many interpreters such as Lua but also
in libraries such as libpng
is mostly utilized as a poor man's exception
handler. But more important for the sake of this argument is its
specification: It performs a non-function-local goto. And for this reason, we
also find it used in green threads libraries or coroutines for C. C being C, it
comes with the usual caveats of unsafety and does not maintain any stack or
intialization invariants.
So why does it work? Because it performs exactly the required
functionality, giving up our current stack frame and moving to some other stack
state. And it is a function in C no less because by the time we get back to the
point of our setjmp
the total effect is (or should be if applied correctly)
to have restored the stack as it was when we called it. Without further
inspection of the run in-between, who can tell whether we moved up (into a
normal function) or down (to the coroutine runtime) in the meantime?
Why this points to a trait.
So now I will try to reconcile all points made above with the added safety
guarantees that a Rust program would expect. One shouldn't be able to call
await
arbitrarily and that is provided by ensuring that its type is unique.
For this, a new and otherwise unused calling convention can be introduced:
let's call it extern "await-call"
without regards to bikeshedding.
This already puts into place the next chess piece, namely it easily prevents
users from defining their own function with that calling convention, and
neither would one naively expect they be able. We also gain an actual item
that we can apply the #[lang = "await"]
attribute to. If you wondered where
the counterpart of setjmp
went, that's the internal magic of the asynchronous
runner which incidentally also ensures that the target of our longjmp
is
unique and always correctly initialized. (Internally this depends on a more
generic feature, namely yielding from the coroutine statemachine).
And everything else behaves like a function, happily executing some other libraries arbitrary code while we wait for it to return to our stack, influencing control flow without influencing control flow, a silent semantic ninja. Or more precisely, it needs to behave like an async call to another function, so we should make it just that.
#[lang = "await"]
pub trait Await {
type Output;
extern "await-call" fn await_fn(self) -> Self::Output;
}
impl<F: Future> Await for F {
type Output = F::Output;
}
This should, again, be much more than a little familiar for users with a lot of
Rust experience. For others, compare this to the definition of FnOnce
which
implements the current standard call semantics:
#[lang = "fn_once"]
pub trait FnOnce<Args> {
type Output;
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}
The 'mere' effect of compilation of future.await()
would then be to point to
the otherwise inaccessible Await::await_fn
and then insert instead the
intrinsic representation of that function call. There'd be no changes necessary
to syntax itself and even the semantics are minimally changed. And we get
auto-deref and everything we'd normally expect from similar syntax.
(edited )
Arguments in support
I'll revise some other arguments on why future.await()
seems like a very
fitting syntax and why it invokes the correct semantical meanings in my head:
- It points to a function call. This is correct insofar as some other code is running until it returns. Even though we don't know what code is running, note that this is already true for dynamic dispatch from traits for example. I would even count static dispatch (monomorphization) as not knowing what code will run. On top of the unfamiliar functionality, I do not think it helps async when what looks like a mere attribute access turns out to actually invoke code.
- If the receiver of a function can be uniquely identified then the guidelines prefer an associated method.
- All the hype about composition. Aside from the fact that comparison to other
languages largely falls flat with regards to methods with value
self
—that isfn(self) -> T
—a sample found 10–40% usage of chaning after an await largely also due to member access. The difference to regular methods is that composition is not fully associative with regular methods. That is, an await that is coupled with a following operation must also be written as a couroutine and conversely, regardless of syntax. Avoiding code reuse thus promotes coroutines to actually perform one atomic block of work only, and leave 'normal' mangling of arguments and result to standard functions. I think we'll only see this increase in usage once it stabilizes.
This is not the only advantage. Stack unwinding (when the future is dropped)
also preserves its meaning of coming onto us from a deeper call and not as a
special, different, third flow control. Also, did I mention it doesn't actually
require await
to stay a keyword? For the moment it likely should since it
affects any future possible design space of additions to await
-syntaxes.
(edited )
As a conclusion of the summary made by the language team, orthogonality was
pointed out as a strong support for a postfix syntax. Now I ask, what is more
orthogonal to existing language design features than a trait and method? Many
other operators have had or gained an equivalent operator in the language and
the similarity of f()
and f.await()
may warrant a similarty between the
corresponding traits: std::ops::FnOnce
and std::ops::Await
.
The previous section may have hinted that I am in support of interactive
generators which take arguments into their await
call but that is a
discussion for another time. But this is also more stable for other future
extensions. Should we ever decide that some item that is not a Future
is
awaitable but the types encompassed are not necessarily disjunct (exactly what
will happen when one has a type that is a Future
and an interactive generator
at the same time), no worries, we can add another prelude
trait with the same
associated method name, and existing disambiguition options for users.
Based on actual implementation
The current implementation of desugar (previously the macro) looks like the following:
// could as well be the internal definition of
// extern "await-fn" async fn await(<expr>)
{
let mut pinned = <expr>;
loop {
match ::std::future::poll_with_tls_context(unsafe {
::std::pin::Pin::new_unchecked(&mut pinned)
}) {
::std::task::Poll::Ready(x) => break x,
::std::task::Poll::Pending => {},
}
yield ();
}
}
This takes a single expression input, directly evaluates it in a new scope,
runs some code of its own and then leaves the scope it opened also dropping the
value. It does not inspect the environment anymore than any other async fn
could and takes its input by value. If this is not a function then I have
fundamentally misapplied the concept of abstraction in past code. (Semantical
strictness sidenote: We want to call this in an async fn
but not actually
have await
produce a coroutine of its own. Thus, await_fn
would not really
be an async fn
in that sense).
The current implementation also takes care to disallow yield
(which exposes
internal implementation detail of the statemachine) appearing in functions.
That would not be an issue if it would be disallowed outside the builtin extern "await-call" fn await_fn
in the first place. And to tie this back into future extensions
this could also give a concrete basis to feature gate specific other uses of
yield
one after another, by only allowing them in builtin magic such a point
at which they are ready generally. Whether this could reuse the Await
trait
or not does not matter greatly.
(added ) Note also how the await operation will poll the inner future continuously. The only exit condition is that the awaitable was completed. The awatee gets no say in that matter. This sounds like a baked-in assumption which we may need or want to change in the future and could warrant adding additional methods similar those added for other coroutine types. Utilizing the same declarative interface would greatly simplify programmer hurdles for learning them all, I assume.
Orthogonality
Now comes the point at which I spew some vague and maybe broken ideas how
await
could possibly be necesarily customizable in the future. It should be
clear how this would be nicer with functions instead of a bare keyword-field.
Don't mistake these for qualified proposals, this is only to illustrate that
the future design space does seem large enough for me to prioritize not
painting ourselves into a corner.
-
We may want to hint the coroutine with which to resume (which may not be the
await
ed one). This is prior art from C++:if await_suspend returns a coroutine handle for some other coroutine, that handle is resumed (by a call to handle.resume()) (note this may chain to eventually cause the current coroutine to resume)
And (I think) is intended to help the task scheduler when we know the awaited coroutine to itself already await some other task.
-
Generators with resumption arguments. For a call syntax, this follows instantly but in the other case we'd have to invent them anyways:
// Fictional syntax async fn gen_with_args(a: usize, b: usize) -> usize { some_condition.await(); let (c, d) = yield (a + b); more_to.await(); yield c + d; } async fn making_use_of_it() { let mut gen = gen_with_args(); // simple: `await` instead of `resume` as in sync generators let first = gen.await(1, 2).unwrap(); let second = gen.await(3, 4).unwrap(); }
-
Supports the auto-ref-deref as one expects from a field and method based syntax. This was one of the quirks with the macro version, and essentially cause many more types to implement
Future
directly but at the same time leads to some inconsistency. The standard library hasimpl Future
for many types that haveDeref<Target=impl Future> + DerefMut
but a) not for all b) an actual generic implementation would at least forbid implementation of both traits at the same time, hurting other use cases. It also seems backwards to manually fix for what auto-deref is expected to resolve automatically and in a unified manner. But the special implementation of the keyword field essentially has this effect right now. See issue #60645 for more context.But usually, if some trait
Foo
contains anfn bar(self)
, and that trait is implemented for&'_ mut U
then I can also call thatbar
on any objectT
that implementsDerefMut
toU
(see playground). If method with actual deref were to adopted, we would therefore only need the impl for<F: Future + Unpin + ?Sized> Future for &'_ mut F
to also enabled
mutex_guard.await()
and all the other special cases.
Alternatives
(added )
Besides the alternative syntax proposals for the await operation, there is one
more modification that I want to talk about after insight from the compiler
team. As eddyb
noted, the use of an external abi for Fn
is currently an
abuse of compiler internals. We might want to dedicate another function
attribute towards it instead. None of these options changes the core
assumptions themselves. They only express the idea without reusing abis, on
which the compiler already places some semantical meaning. Fortunately, we
already have reserved keywords that can help us out in unambiguous terms.
#[lang = "await"]
pub trait Await {
type Output;
/// The 'conservative' choice.
await fn await_fn(self) -> Self::Output;
}
#[lang = "await"]
pub trait Await {
type Output;
/// Clearly show connection to yielding itself for later.
yield fn await_fn(self) -> Self::Output;
}
#[lang = "await"]
pub trait Await {
type Output;
/// Unstable attribute.
#[yields] fn await_fn(self) -> Self::Output;
}
Conclusion
I hope to have shown that a method-trait may be possible, comes natural, is similar to existing internals, and most orthogonal to other features. See you on reddit.