On why await shouldn't be a method

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).

From what I can tell, CAD97 is right, await is essentially a method of the future object. Let me elaborate. There is a global scheduler for cooperative multitasking, which cyclically loops through the list of coroutines and executes them. As one of these coroutines calls future.await(), this method does the following: the promise of future is added to the list of coroutines, the scheduler then called recursively. When the promise finishes, the scheduler call also ends and returns to the callee.

But why will the callee not be resumed prematurely, leading to a paradox situation? Every coroutine called by the scheduler is made active and suspended as long as it is active. A coroutine gets only inactive by yielding, never by awaiting.

You can’t take a function pointer to await.

1 Like

You can’t take a function pointer to an intrinsic either, so it wouldn’t be unprecedented. (This currently generates an ICE; how it will be resolved in Rust remains to be seen, but knowing how major C compilers treat their builtins suggests forbidding such pointers altogether is not an entirely unreasonable option.)

1 Like

But intrinsics are perma-unstable, so I most users wouldn’t encounter it. But await would be used by people on stable, so it seems inconsistent.

2 Likes

Some intrinsics are stable, like mem::transmute (and I remember a proposal to stabilise core::intrinsics::abort as well… or was it unreachable?); however, extern "rust-intrinsic" unsafe fn ... pointers are not. Enabling them on nightly builds and trying to actually use them triggers an ICE. Either way, there is already at least one function in stable Rust that you cannot form a pointer to.

The stable intrinsics are essentially bugs. Now, it’s true we can’t fix the bug for transmute until we can phrase it’s extra check in the type system, but they’re bugs. Everything except that one should now be wrapped in a real function – IIRC the work to add some debug_asserts got the last of the rest of them.

3 Likes

My real point here, though, is that it doesn’t actually matter, because nobody wants to form pointers to mem::transmute anyway – and yet this doesn’t stop it from being callable through regular function-call syntax. The only way it differs from normal functions is by having an unusual calling convention that boils down to inlining some primitive operations into the caller at all times, which happens to render the notion of a function pointer meaningless. An unusual ABI wasn’t enough to make a difference to mem::transmute's calling syntax; I don’t see why the same principle could not be applied to await.

That said, I don’t really have a horse in this fight. The latest syntax, evoking field access, seems somewhat regrettable to me, but ultimately acceptable.

1 Like

Awaiting is much more than a calling convention. It’s easiest to see using JS regenerator transform.

The code like this is executed they way it seems:

function foo() { 
  if (bar()) {
    baz();
  }
}

but if you add async/await:

async function foo() { 
  if (await bar()) {
    await baz();
  }
}

the entire function gets a complete makeover:

function foo() {
  return regeneratorRuntime.async(function foo$(_context) {
    while (1) {
      switch (_context.prev = _context.next) {
        case 0:
          _context.next = 2;
          return regeneratorRuntime.awrap(bar());

        case 2:
          if (!_context.sent) {
            _context.next = 5;
            break;
          }

          _context.next = 5;
          return regeneratorRuntime.awrap(baz());

        case 5:
        case "end":
          return _context.stop();
      }
    }
  });
}

It’s not just a special way to call one method, it’s a complete transformation of the entire calling function into a re-entrant state machine.

Method calls and intrinsics have much more local behavior, e.g if you use a method in a loop, it might add a few instructions and saving and restoring of registers, but the loop and the code around it will still exist more or less the same. If you use await in a loop, the entire loop will be torn into pieces and rebuilt as a state machine.

9 Likes

Indeed, that's correct.

This page goes into detail of how Rust transforms async/await, though it's basically the same thing as JavaScript (the async fn gets transformed into a struct and the body gets transformed into a state machine).

So the important thing to note is that this requires non-local transformations, which is why await cannot be implemented as a regular method.

In other words, the entire async fn is completely transformed into something unrecognizable. Normal methods can't do that, they cannot transform the caller like that.

If Rust had something like Scheme's call-with-current-continuation, then you could implement await as a regular method. But Rust doesn't have that (and adding it would be quite tricky!)

3 Likes

You have said several times in this conversation that you think being aware of this transformation is important. I have said several times in this conversation that I think one of the major virtues of the async/await feature (no matter how its surface syntax looks) is to hide this transformation, because most of the time one wants to think about

async function foo() { 
  if (await bar()) {
    await baz();
  }
}

as if it were "just the same as"

function foo() { 
  if (bar()) {
    baz();
  }
}

except that the points where the operation can block (as in, the same way a synchronous system call can block) are highlighted for your convenience.

I also believe that when one does need to be aware of the transformation into a state machine, one needs to be aware that it affects the entire function, and therefore it's better thought of and taught as an effect of async fn, not await.

Would you please reply specifically to this perspective, so we can hopefully get past both of us repeating each other?

3 Likes

I would agree that most do not have to be aware of the transformation happening here. However, I also think that await is fundamentally different from a function call. By pretending await is a function call, you are confusing two very different operations: calling a function, and awaiting a future. Inside an async fn, there are some similarities between await and a function call, but there is still a fundamental difference between foo(x) and await bar: the former cannot block, and the latter cannot take arguments. Abstracting over arguments and blocking are two entirely orthogonal concepts, realized by functions and futures, respectively. So, even on that abstract level, clearly await is not a function call. (I very specifically did not write await bar(); that is a combination of a function call and an await, but we are only talking about the await part here.)

Admittedly, there is some overlap between function calls and async, namely both "trigger computation elsewhere" (they are both thunks or generalizations thereof). A thunk is basically the same as a function with no arguments, and async also involves executing a thunk, which is why I think some people argue that async is like a call. I disagree, the fact that some functions are just thunks and that async involves a thunk is not enough to conflate these two concepts.

If this discussion would happen in a Haskell context, await would basically be the bind of a monad -- and never would people in Haskell suggest that x <- bar; is the same as let x = foo in. There's a huge difference between a regular function call and the monadic bind action.

This is in fact very similar to ?, which also is a monadic bind operation. Are you saying ? is just a function call, too? We should maybe write it foo.try() then.

(The parallel with monads is also why I think the "parallel" with unsafe fn is inaccurate: unsafe is not an effect, there is no monad here, unsafe fn and fn are two different types for basically "the same" kind of object but with different semantic promises attached to them. They even have the same run-time representation. This is very different from fn vs async fn.)

11 Likes

Yes, I do think it’s important. I want to know and understand what machine code is generated from my Rust code, especially when it’s a major transformation. While it’s true that async fn is the opt-in to this transformation, the major changes in the control flow actually happen at the points where you await.

I don’t know what else I can say without repeating myself. I think we just have different preferences and priorities.

2 Likes

It doesn't need to be so sophisticated. If there is another syntax for await (say keyword prefix). Then you could just add a keyword async_only which can be applied to methods to indicate they can only be called in an async context. (And are inlined).

Then you can have a method:

async fn foo() { 
   let future = readData();
   let data = future.await();
   //...
}

Where await is defined as:

trait Await<T> {
   async_only fn await(&self) -> T;
}

impl Await<T> for F where F : Future<T> {
   async_only fn await(&self) -> T {
      await self
   }
}
1 Like

Yes, that is what some people have argued for. However it runs afoul of the fact that it isn't a real method. So it breaks consistency and causes confusion.

For example, what should this do?

foo.map(Await::await)

And how do you explain to a beginner why the above is not allowed?

So if we're breaking consistency anyways, might as well choose the shorter option (which is .await), since .await is going to be typed out a lot, and it's a lot easier to explain to beginners (it's just magic syntax with a keyword).

1 Like