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 is fn(self) -> Ta 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 awaited 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 has impl Future for many types that have Deref<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 an fn bar(self), and that trait is implemented for &'_ mut U then I can also call that bar on any object T that implements DerefMut to U (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.

Published on
Last updated