Was this a typo? Every other sentence in your post appears to be arguing for prefix syntax.
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.
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
await
ed resource never becomes available (infinite blocking call), - The
Future
isDrop
ped (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.
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.
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.
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.
I give this, and that Drop
ping a Future
between poll
s is different than panic=unwind
ing 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.
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.
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:
- we shouldn't consider it seriously as it's not common and even not recommended functionality
- recovered from panic thread cannot be seen as completely terminated
- 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
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 Drop
ped 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.
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.
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.
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.
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).
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; }
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
).