On why await shouldn't be a method

Was this a typo? Every other sentence in your post appears to be arguing for prefix syntax.

2 Likes

Oh, you're right! I've fixed it now, thanks!

My argument is that this just isn't true.

In a threaded environment (which Rust's Send/Sync exists to make safe), you don't know what code is being run next. In the middle of your synchronous method call, the OS can decide to preempt your thread, store its stack somewhere, and switch to a different thread of execution.

await is a cooperative way of giving up control, whereas OS threads are a preemptive way. OS threads even run the same risk of never returning to your code (say, the process was terminated hard and the OS just reclaims your resources without a signal).

How does the semantics of await differ from calling a function when you're running on a preemptively threaded thread of execution? Honestly, I don't know, and if there is a semantic difference, my position could change.

My position basically boils down to: the magic is the async context, not the await call.

11 Likes

What prevents the use of green threads to implement async? Is async actually any different than "cooperatively scheduled green threads"?

I think async could be implemented by stack presumption rather than ret unwinding. In fact, most of the async "stack" is in the pinned Future anyway, and not on the hardware stack.

It might be also said that now function in Rust may not return only in case when the whole program is terminated, but with this change it may also not return in case when it’s await()

I don't think this quite holds either. Let me enumerate the reasons for which I believe a function might not return in current stable Rust:

  • The function panics:
    • panic=abort: the whole process aborts.
    • panic=unwind: the function exits via unwinding landing pads.
  • The execution thread is a daemon, and all non-daemon threads terminate.
  • The process aborts or is otherwise abruptly terminated.
  • The function returns !, thus it either:
    • loops forever,
    • panics, or
    • calls another function that returns !.
  • The OS has a bug and leaks your thread, or thread starvation via critical sections locks up OS resources.

And note also that any function call can not return by virtue of looping forever (halting problem). It doesn't matter what the types are, if any back edges exist in the function's control flow, it might loop forever, thus not returning.

A Future that is never resumed after the first poll has three reasons why that may be (and here I also give the Fn equivalent):

  • The awaited resource never becomes available (infinite blocking call),
  • The Future is Dropped (the call stack unwinds), or
  • The Future is leaked (this is a reactor bug; the closest synchronous analog would be thread starvation).

In async, the reactor becomes your "cooperative green thread" organizer rather than OS threads. Either one could have bugs, but looking at it semantically, I think it makes sense to ignore these.

1 Like

Ah, now I see what you mean, thanks for elaborating! I agree that there would be no observable difference for the code running in these two scenarios.

However, my argument was that expr.await() looks like you're jumping into code from which you'll later return. I find this misleading since that's not what happens.

I guess the question is then if it's important what happens beneath the surface? I lean towards "yes" since I think it's important to be able to have a clear (and correct!) mental model of what is going on.

2 Likes

It’s very important to remember that await may never return (if the Future is dropped (cancelled), it won’t be polled again), and this is a totally fine, legitimate usage.

This non-returning is also different from infinite recursion or unwinding via panic. It does require special consideration.

2 Likes

I doubt that any experienced programmer would disagree. However, Rust has many features where a programmer new to Rust will make incorrect inferences (which is probably why there are so many queries about novel Rust features like borrowing and lifetimes in URLO).

The general answer seems to be education about Rust, together with helpful tooling. await could be handled similarly. Even if await is not truly a method, its invocation could look like a method, just as format!(…) looks like a macro but isn't.

1 Like

I give this, and that Dropping a Future between polls is different than panic=unwinding through it, but semantically it's "just" another unwinding mechanism. Note that I do mention this in this enumeration of non-returning scenarios.

I just want to clarify here: format! is a macro. It's just a procedural macro that's built into the compiler. Nothing it does cannot be done by a crate-provided procedural macro, and if you do a cargo expand you'll see what format! expands to.

This is different from when people say await! wouldn't be a macro, but rather a macro-like-syntactic-construct. format! really is just another macro at this point.

1 Like

Thank you for posting this; I was seriously considering starting a thread entitled "On why await should be a method" and now I don't have to! As you can guess, I disagree with your position pretty strongly, but you've done a great job of pulling out and describing the specific thing that I disagree with, so hopefully we can get a nice targeted dialectic going here.

I am going to argue that the appropriate mental model for await is the one signaled by expr.await(), in which, as you put it, you’re jumping into code from which you will later return.

First, I hope you will agree with me that this does describe the local control flow behavior of await. Control leaves the function that invokes await, runs code defined by the Future object to which await was applied, and eventually returns to the point where await appeared, producing a value which is consumed by the larger expression. This is exactly what a method call does. As @CAD97 and @kornel point out, there are several reasons why control might never return to the point where await appears, but this is also true of a normal method or function call.

Second, I acknowledge that something very different is happening under the surface, but I don’t think it’s important to remind the reader of that every time they see an await. In fact I think it is important not to remind the reader of that every time they see an await. I think this because async and await were invented specifically as an alternative to syntaxes that make the deep behavior explicit, e.g. this mashup of Rust sync syntax with JavaScript promises (example shortened from https://github.com/inejge/await-syntax/blob/master/bin/wlan/wlantool/src/main.rs#L52 ):

fn do_phy(cmd: opts::PhyCmd, wlan_svc: WlanSvc) -> Future<Result<(), Error>> {
    match cmd {
        opts::PhyCmd::List => {
            wlan_svc.list_phys().then(
                |response| { println!("response: {:?}", response); Ok(()) },
                |err| err.context("error getting response")
            )
        }
        // ...
    }
}

Any syntax that is less explicit about the implementation than this is concealing at least part of what’s going on. However, relative to any of the proposed syntaxes for await, it is much harder to read, understand, and modify. The order of operations is obscured, and you have to pay careful attention to all of the implicit returns. What we hope to achieve by making async fn a language feature, is to make it so you only have to think about asynchronous operations in these terms if you’re debugging the reactor core. More people will need reminding that an async fn is compiled differently than a regular fn, and has to be used differently; I would argue that that is the point of having an async prefix on the function head.

Most people should, in fact, be able to get away with thinking about await as if it’s almost a normal method call. The remaining thing they need to keep in mind is that it’s a point where concurrent code, from a different thread of execution, could run for a while. Multithreaded code is painful to write in many languages because you have to worry about this possibility everywhere. I’m old enough to remember cooperative-only multitasking OSes (MultiFinder, early Windows), where it was less painful but still a problem because there was a long list of OS primitives that implicitly yielded control to other processes. A selling point of await is that it’s the only thing that may yield control to other processes. But you do still have to keep it in mind.

I think the important thing, for making it easy for people to keep this in mind, is just that await is a short, memorable word. (I’m not a fan of using a sigil for this operation because all the available sigils have low mnemonic value.) I don’t think any of await { future } or (await future) or future.await() or future.await!() expresses “this can run concurrent code” more clearly than any other.

So, in summary, I come down on the side of future.await() because that expresses the local control and data flow properties of await most clearly and doesn’t make it look like there’s deep magic going on. Because we know from experience with explicit continuation-passing asynchrony that it’s too hard to see the deep magic and the surface task at the same time.

11 Likes

Okay, but I might only agree that there should be a more correct formulation: in current Rust functions may not return only in case when their thread was terminated.

Cases like forever looping, panic unwinding, and propagating never type always implies that thread which executes function sooner or later would be terminated.

Recovering from panics looks like exception, but:

  1. we shouldn't consider it seriously as it's not common and even not recommended functionality
  2. recovered from panic thread cannot be seen as completely terminated
  3. when thread is recovered from panic outside of function context it still remains terminated in its context

Therefore, .await() syntax introduces a corner case into function semantics

1 Like

In Rust, as in all other languages I know, calling a function (or invoking a method) means that the thread of execution jumps into the code of the function. A new stack frame is created, the function body executes and when it returns, you’re back to where you started.

JavaScript is not my "main" language but it was pretty easy to learn the rule that lines in code don't always match code execution path.

Seeing it in this light should make it clear that it’s nothing like a method call

Maybe your IDE can highlight this keyword for you and problem is solved?

await as a prefix kills pretty important feature - nested awaits. If I'm wrong - please explain how to create nested await calls with your proposed syntax.

And my point in the same vein would be that async adds when their Future was Dropped to that list, and that this isn't that much of an addition to the semantics (and importantly, it's on async to add this, not await); it's the "green thread" being terminated cooperatively. I.e. it's told to clean up its resources rather than having them preemptively taken from it.

async is very special/magic. One of its effects is being able to await. But all await does in the model I'm looking at it from is tell the async context to unwrap the impl Future. How that happens is async's job, then the await operation completes.

3 Likes

IMO, developing such mental model would be much harder than just remembering a single sigil.

It not just leaves that function, but does that multiple times until the value of Future isn't ready. That's the core principle of how async/await works, and it's IMO isn't compatible with method syntax.

I think that hiding all of the burden related to async code is far more important than method chaining which is constantly discussed in await-related threads. But I don't think that .await() syntax is compact enough, and it definitely would be highlighted differently in IDE, so I think it still would be hard to not notice it.

2 Likes

I like the using a keyword for this since this makes it clear to me that the code in question is making use of language features that I couldn't have implemented myself.

So struct is a keyword since I cannot otherwise express the concept of creating a new struct — but Try is just a trait like any other except that the compiler (which the compiler will use when you apply the ? operator).

I remember feeling "cheated" many years ago when I learned that strings in Java were special since they've overloaded the + operator to do string concatenation... Even if it's not actually true, I love the fact that even built-in types in Rust behave just like my own types.

If I understand correctly, an await() method would be special somehow so that I couldn't actually define it myself in a library? Or is the idea that Future will implement a trait recognized by the compiler?

Basically, if I could have written it myself (regardless of how cumbersome it might be), then it's fine to call it a method. But if the method is doing something that I could not otherwise have expressed, well then I would use that as a criteria to separate the operation from the real of methods.

The only other place I know of for things "outside" of what we can express in normal code is, well, language syntax, i.e., a new keyword.

4 Likes

I think a case could be made that async fn already establishes that there’s going to be control-flow “magic” (and you can’t make async fn in user code), so anything unusual happening in the function body is a fair game.

But OTOH we already have a precedent of ! in macro invocations. In C function calls and macro calls are syntactically identical, but Rust chose to make the call site draw attention the fact that a macro call is more than just a function call. .await() call is more than a method call, so .await!() to me logically makes most sense.

4 Likes

I have to disagree here.

await leaves the context of the async fn using it exactly once, when it starts execution of the awaited future. If that awaited future yields, control flow returns to the executor, and then in the future, returns to the yield point. (e.g. async/await could be implemented by stackfull coroutines that just jump execution directly around rather than going up and down the hardware stack.) The one problem to that model, I will admit, is that IIUC, items that are held on the stack but not over the await point will be dropped on the first yield. (We should really check that this drop order has been RFC'd; I'd personally be more comfortable dropping them before the await such that it doesn't depend on whether the future yields at all!)

(Note: this could be very wrong as it depends on very tricky specifics of how generators work, which makes it all the more important it gets specified.)

Given the expressed interests of the community and various arguments, I personally think postfix type dispatched magic macro member of the Future trait makes the most sense, and postfix "macro-like-syntactic-structure" may potentially be forward-compatible with that. But it's also asking a bit much.

The problem with macros as well is that there still is a concept of "I could write this" now that procedural macros are a thing and e.g. format_args! like built-in macros are less special (modulo #[lang] items).

3 Likes

This duality of await, behaving like a function call from the semantic outer perspective but not actually representing a call to an await function, prompted me to prefer syntaxes that logically bound the await to the call itself. The best syntax would be call-based and clearly associate the appearance of the async keyword with the call itself, and none of its arguments (neither the future, nor any argument position in the call to allow for extensions to generator arguments later). I don’t think the macro variant does a good job of staying true to the function call aspect as a macro invocation is associated with a compile-time effect in my head and not a runtime one. My own proposal of future(await) did a very bad job of the second aspect.

But, ultimately, there are different types of functions already: unsafe and extern "C" are not the same function types as normal ones. This makes it somewhat easier for me to accept await as new function type, maybe surprisingly an inherent one, but still aligned to other features that exist in the language already. The parallel with unsafe fn even goes as far as being callable within unsafe code, whereas async is callable in async code! It’s not far from a new (imaginary) trait in the prelude:

trait Await {
    type Output;
    /// Implemented by compiler magic.
    extern "await-call" fn r#await(self) -> Self::Output;
}

impl<F: Future> Await for F { type Output = F::Output; }
8 Likes

Drop order in async context should be the same as a normal function for Drop types, they will be implicitly dropped at end of scope. I’m not certain about Copy types, they could be dropped before the yield point without being visible, unless they !implement an auto-trait that will affect the generated struct (mainly Send or Sync).