On why await shouldn't be a method

The discussion in Await Syntax Discussion Summary is centered around what that the Await Syntax Write Up calls the ​Error Handling Problem. In a fictive program that queries a database, one could imagine having code like the following:

let conn = (await open_db("http://user@localhost:1234"))?;
let stmt = (await prepare_statement(conn, "SELECT * FROM posts"))?;
let posts = (await stmt.execute())?;
for post in posts { /* ... */ }

It is the use of parenthesis around await that is seen as problematic. They are required due to the use of ? which we want to apply to the resolved value, not the Future we await.

One simple solution would be to move the ? down to where the resolved value is used:

let conn = await open_db("http://user@localhost:1234");
let stmt = await prepare_statement(conn?, "SELECT * FROM posts");
let posts = await stmt?.execute();
for post in posts? { /* ... */ }

That works for cases like the above where the values are chained from line to line.

The write up says that part of the error handling problem is the extra parenthesis. It goes on to suggest that one could add a magic ,await() or .await!() method so that the above becomes:

let conn = open_db("http://user@localhost:1234").await()?;
let stmt = prepare_statement(conn, "SELECT * FROM posts").await()?;
let posts = stmt.execute().await()?;
for post in posts { /* ... */ }

The careful reader will of course notice that the number of parenthesis hasn’t changed compared to the first version. So it cannot really be the parenthesis as such that cause the problem. Instead, I think the problem is that await async_expr make waiting on the asynchronous expression a bigger deal than some think it should be.

On the face of it, await is something that takes a Future<T> and gives you a T. Hiding await in a method call might seem nice at first, but I believe it’s a mistake since I believe await() works differently than normal methods. Making it look like a regular method would knowingly introduce a corner case to an otherwise normal part of the syntax. Please don’t do that — Rust is big enough that we don’t need more corner cases.

So why am I claiming that await() is not a normal method? 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. You go down the call stack when you call a function and you go up the stack when you return.

Awaiting is vastly different. When you await async_expr, you’re suspending the current function and instead of going down the call stack, the thread of execution goes up the stack to an event loop. Instead of calling it await, keyword should perhaps have been called return_and_please_invoke_me_later :slight_smile:

Arbitrary things can happen when you call await — you don’t know what code will execute next and you don’t know if the suspended function will ever be invoked again. Seeing it in this light should make it clear that it’s nothing like a method call (or attribute access). It’s a different (new!) thing.

16 Likes

The above is mostly based on my experience with async/await in Python where the new syntax was created after the community had experienced with co-routines implemented via yield. There it’s clear how the thread of control goes up and down the call stack. In case Rust async/await works completely differently, then I’ll be happy to be enlightened :slight_smile:

1 Like

This “is” how await is implemented. But I’d argue it doesn’t matter that much.

Ignoring the implementation, what does await mean semantically? It is an operation that takes a Future<Output=T> and returns a T. Ok, so what makes it different from <FnOnce() -> T>::call_once? For that, we look at the trait definitions (simplified and handwavey):

pub trait FnOnce(Args) -> Output {
    extern "rust-call" fn call_once(self, args: Args) -> Output;
}

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, waker: &Waker) -> Poll<Self::Output>;
}

FnOnce::call_once has to be invoked one time to get the resulting value, Future::poll has to be invoked multiple times. Ok, let’s write await!!

macro_rules! await {
    ($future:expr) => {{
        let mut fut = $future;
        let fut = pin_mut!(fut);
        loop {
            let waker = waker_from_async_context!();
            if let Poll::Ready(t) = fut.poll(waker) {
                break t;
            }
        }
    }}
}

This is a legal (though very naive) implementation of the await operation. await doesn’t bake into its definition the state machine that is used to create asynchronous running code. (That’s async's job.)

The same kind of parallelism enabled by async happens in “synchronous” code if you’re using threads, no matter how green they are. awaiting a Future or calling a FnOnce semantically do the same thing: they run some deferred computation, and if it “blocks”, they park the current “thread” of execution. If your threads are sufficiently green or your OS sufficiently loaded, multiple “threads” of execution may run on the same OS thread and one “thread” of execution may run on different OS threads.

All await “is” is a way to enable an explicit form of “green thread”-like cooperative multithreading, which the compiler is theoretically free to do for you if it wanted. (In practice, Rust won’t because it wants to give you the power to make that choice.)

This is why I describe it as an operation on a Future, or as a method with a funky calling convention. What that code sees as the “hardware stack” actually isn’t; it’s the memory location of the pinned future. (So actually, it doesn’t get unwound.) It can get suspended just the way a hardware thread is, but it does so cooperatively rather than preemptively.

Respectfully, I think saying await isn’t an operation on a value that implements Future missing the forest for the trees. async/await is its own model of cooperative asynchronicity, not just a transformation into a subset of generators run for their side effects.

11 Likes

Isn't that similar to what yielding would do? It's still exposed as a function in POSIX for example: pthread_yield().

3 Likes

I don't think POSIX is a place we should be looking to for API inspiration, given it is also host to very un-Rust-like APIs, like ioctl.

You can make a similar vacuous argument about return operating on values to produce a ! (in a way, return is await instantiated at T = !). At the end of the day, a sane threading model will ret to implement await, and you don't want to hide your rets in innocuous syntax.

3 Likes

Awesome, thanks for confirming that I'm not too far off.

I think I agree with you here: I do see await as something you can apply to a Future; to get a value back. However, I see it as an operation that has similar "gravitas" as return or break:

  • It affects the control flow of my application
  • Control goes up the stack in a way similar to return
  • Unlike return, I don't know what code is invoked next

Method calls and field access simply don't do those things in Rust. However, keywords like break, continue, and return do. This was also pointed out by @mcy:

I also think it would be a mistake to mix these things and say they're the same.

As the write up mentions, a postfix syntax could potentially damage the image of Rust, something I completely agree with. Personally, I was attracted to Rust because of the great taste I saw in the design of the language. Creating a special method which isn't a normal call at all will seriously detract from the beauty and consistency of the language.

4 Likes

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