Explicit future construction, implicit await

My reason for pointing out the alternative reason for writing functions that return impl Future via an async block, instead of being an async function, is that I’m not sure they’re going to be as uncommon as you believe. For arguments sake let’s say half the async functions are async fn and half are -> impl Future, then on half your method calls you’re having to put an extra .await() and half nothing. Whereas by the RFC these all behave the same with an await! call on them.

Leading on from the fact that, as specified in the RFC, async fn is an implementation detail that won’t leak into the public API is another point; support for migrating between async fn and manual Future implementations. By keeping async fn as an implementation detail that simply rewrites the function signature and body you can freely switch back and forth between using it or manually implementing your future. Hopefully this would mostly be used for migrating functions to async fn, but it could potentially be useful in cases like performing intricate performance optimisations since you have more control when doing a manual implementation.

Implicit await for only async fn loses out on that compatibility, async fn foo() -> u32 becomes a different signature from fn foo() -> impl Future<Output = u32>. (Implicit await for all impl Future returning functions would retain that, but I’m not sure how implementable that would be, or what over knock on effects it would have to have to the proposal.)

6 Likes

@Nemo157 @vorner Exactly. I'm glad that I'm not the only one who thinks a distinction between functions returning a future and async functions is weird, absolutely unergonomic and a complete 180 on the intent behind @withoutboats's async/await RFC which defines the two as API compatible.

@rpjohnst On the topic why you want a distinction between functions returning a future and async functions you said this:

I can't follow. So, my question is: How would you explain to a beginner why you want two kinds of asynchronous functions?

Coming from Python/gevent, I can share some experience with the thread on implicit await.

At first, implicit await is awesome. Suddenly, I no longer need to expend the cognitive energy to keep track of yield points; everything is just a normal function call. And yet, some of those functions implicitly yield (internally) and it makes developing concurrent microservices very ergonomic, indeed. When you want to explicitly yield, that’s also available via gevent.sleep(0.0)

But after using it for the greater part of a decade, implicit yield does have some caveats, and it is worth pointing them out. The most common pitfall is using blocking functions. Commonly from libraries and packages that either aren’t monkeypatched, or are actually written in C/C++ and simply cannot be monkeypatched to cooperate with the event loop. This problem almost always manifests as terrible performance in production, because once you stop carrying the mental baggage of concurrency, you generally don’t test for it, either.

The second pitfall we often drop into is with shared mutable state. Coroutines (and green threads, etc) are naturally data-race free, just like the Rust guarantee. But coroutines (and Rust itself) will not prevent race conditions with shared mutable state. The shared state can be mutated across yield points, just the same as it can be mutated across synchronous call sites. (I believe this was mentioned above by @rpjohnst) In this sense, the await keyword won’t save you from the e.g. interior mutability of RefCell any more than a gevent-patched implicitly-yielding function call does. So any argument for-or-against implicit await leaning on the assumption that one improves the situation with shared mutable state is, IMHO, moot and misleading.

The third most common pitfall is the learning curve when ramping up newcomers to an existing codebase. Because the concurrency is built-in and hidden, it is really non-obvious (and unintuitive!) how it actually works. It’s just a bunch of blackbox magic that happens whether or not you are aware of its existence at all. This of course often leads back to the first problem with pulling in new dependencies that do not play nicely with the concurrency primitives. (The code functions perfectly, it just silently stops cooperating in random places.) And subsequently, a lot of time is spent debugging these kinds of issues.

For a concrete example of the latter case, we recently introduced the pika client for RabbitMQ into a service written with gevent. The client implements a “blocking” connection, which when monkeypatched by gevent, everything seems to work fine out of the gate. The issue crops up later when an idle connection is silently dropped until the next time a message is published on a topic; You get a connection reset exception! That’s because pika’s blocking API expects to drive its I/O loop so it can respond to server heartbeats. When the server doesn’t receive timely responses, it closes the connection. But the application was written so that the green thread would be mostly blocked on a separate consuming socket, starving the pika blocking API from driving its I/O loop. The solution was to spawn an I/O loop driver in a new green thread.

The illustration here is that just because it looks like it works, in reality that doesn’t always mean that it works as expected. I am under the impression that explicit await would have prevented this kind of condition (or at least been a good indicator of something gone awry) because it is nonsensical to await a synchronous function (like the blocking client API in pika). And if you know you are blocking a single threaded application, then the locality of the bug becomes more apparent.

Ok, so I’ve droned on for long enough about my experience with implicit await. Like I said, it’s very attractive, and I empathize with everyone who agrees with that sentiment. But at the same time, I’m skeptical of implied yields being considerably better than the await keyword. At the same time, function coloring is also a burden, so I understand the other half of the motivation for implicit await. But that issue is distinct in itself, and would be a conversation about removing or greatly improving Futures. Or as @MajorBreakfast put it, whether Futures are a good idea at all.

13 Likes

How would implicit await help in any way against the function coloring? To that matter, how would implicit await help with all the unwrap_or_else being able to accept asynchronous closures? Their type and behaviour (eg. being able to bubble the NotReady up the stack and then being able to return back down) is inherently different, no matter if the syntax looks the same.

2 Likes

Leading on from the fact that, as specified in the RFC, async fn is an implementation detail that won’t leak into the public API is another point; support for migrating between async fn and manual Future implementations. By keeping async fn as an implementation detail that simply rewrites the function signature and body you can freely switch back and forth between using it or manually implementing your future. Hopefully this would mostly be used for migrating functions to async fn, but it could potentially be useful in cases like performing intricate performance optimisations since you have more control when doing a manual implementation.

YES! And since we don't have async functions yet, that means in order for new async functions to really take advantage implicit awaits, most of the existing futures code needs to be migrated to async code as well. And that can't be done in a backwards compatible way unless the names of the functions are changed, because calling an async function has different syntax than calling a function that returns impl Future.

I also think it is kind of weird that async functions act like functions in async context, but like a type in non-async contexts.

I do kind of like treating the async fn as a type definition rather than a function definition. But I think if we do go down that path, then:

  1. It shouldn't have fn in the definition syntax. I would prefer just having something like async foo(...) -> T instead of async fn foo(...) -> T.
  2. It should define a tuple-like struct, where the constructor is the same name as the type. (But probably without pattern matching support)

@vorner What they mean is that calling functions of both "colors" (sync and async) looks the same in async functions with implicit await.


I'm now in favor of explicit await (again) and here's why:

Ordered by importance (in my opinion), most important first

  • Explicit await helps with "the learning curve when ramping up newcomers to an existing codebase" by showing new developers which functions are asynchronous without requiring a look at their definitions. (argument by @parasyte)
  • Async functions have by their nature different execution characteristics and explicit await makes it obvious where we call them. @vorner's comment goes into great detail about this. He points out various problems he sees and says explicit await is valuable "when doing code review". @parasyte says that he was once hunting for a bug in a python project with implicit await and he thinks that it could either "have prevented this kind of condition" or failing that "been a good indicator of something gone awry".
  • Explicit await helps with detecting parallelization potential because it makes the suspension points visible.
  • It's how the system currently works (await!() macro). Sticking with explicit await makes shipping async functions with Rust 2018 edition more likely.
  • Implicit await inherently requires some form of explicit non-await. So, it just changes what we annotate.

Edit (1 day later): Additionally all implementations of implicit await that were proposed so far:

  • Need a different notation for the initialization pattern (What's that?)
  • Are a more complex addition to the language than the solution of the RFC

So far there has been a lot of discussion about the type of async functions. The RFC has now entered final comment period. It defines async functions as functions that return a type with impl Future.


BTW @MajorBreakfast2 is now gone again :smile:

2 Likes

This is not a downside, it's the point- this is what solves the confusion introduced by lazy futures. In fact, the main RFC already has explicit non-await, in the form of async blocks! It just doesn't use it in as many places as this proposal.

Knowing the suspension points doesn't really help, frankly. When parallelizing sync code, you wrap things into closures and pass them to some threading API. When parallelizing async code, you wrap things into async blocks and pass them to a combinator. It's far more about data dependencies than about suspension points.

This is not particularly relevant to this thread, as that can be changed before stabilization. Indeed, that is why this thread was created.

That's why I listed it last :wink: It's IMO an argument, just not very important

From my experience with JavaScript it does. When I see multiple awaits one after another I often decide to use Promise.all() and run them concurrently if the situation permits it.

IMO async functions like the RFC defines them could simply be changed to use implicit await. IMO there is no need to change their API interface. The compiler knows which types are futures and would just introduce an implict await whenever it sees one. Places were we want to store the future in a variable (without awaiting it) could be annoated with noawait. It would be a breaking change, sure, but that's no problem at this stage (before stabilization). The discussion should IMO be more focused around whether we actually want implicit await. That's why I made the list with points why we should stick with explict await - because I'm not sold.

1 Like

This makes it impossible to hold onto a future without awaiting it, as you must when introducing concurrency. You went down that road earlier in this thread and found it to be a rather confusing mess.

Merely adding noawait is insufficient, and makes for an even bigger mess, because you'd need to annotate it every time you handle the future, not merely when you introduce it.

For example, it makes async blocks nonsensical, as I mentioned before:

let x = async { .. }; // OOPS, this is a future so it gets awaited...
let future = noawait async { .. } // ...so you have to do this instead!?
1 Like

@rpjohnst Constant nowait is probably a pain yeah. Your current proposal sidesteps this issue by distinguishing between async fn functions and ordinary functions that return a future. Here is how your proposal works conceptually:

  • async fn functions return* an AwaitMeFuture
  • ordinary functions return a DontAwaitMeFuture
  • DontAwaitMeFutures can be awaited via await**
  • AwaitMeFutures can be not-awaited via the ::new() notation***

*) async functions are defined as a struct, and a call inside an async fn to another async fn translates to another_fn::new().await()

**) DontAwaitMeFuture has an await() method that turns it into an AwaitMeFuture

***) It’s a method on the async fn struct

I mean, okay, sure. But your insistence that async fns return some kind of future is the only thing making it so incredibly convoluted. There really is no distinction between an AwaitMeFuture and a DontAwaitMeFuture, because the call operation itself is what does the awaiting. It may construct a future somewhere, but that’s purely an implementation detail and not exposed to the user at all.

2 Likes

Announcement: I have now completely revised the proposal! I think it’s got something for everyone- clearer motivation, simpler syntax, better examples, more thorough discussion of the alternatives, and links to relevant replies.

I would love for everyone who has questions or concerns to re-read it before charging ahead with further discussion, and also to consider their own responses thoroughly so we don’t re-tread the same ground.

@rpjohnst Oh my bad. You said “I’m going to try to revise the proposal” once. We seldom agree on something, nevertheless I really try to carefully read everything that is said before I post. If you’ve mentioned that you updated the proposal before, I must have missed it and I’m sorry for that!

Edit: @rpjohnst’s has edited his post above to show that this is the announcement and he sent me a PM :slight_smile:

Your updated proposal lists this as a benefit:

According to the Dart 2 docs on asynchronous programming they're still using explict await. They didn't switch to implict await.

1 Like

I think this would be clearer if you used futures::await to bridge between combinator impl Future and async context, rather than introducing async fn Future::await(self) magic without explaining it. You've got the one "magic" fn defined right there already which does what you want, don't introduce two.

Then (Future).await() could be mentioned as a possible future (no pun intended) default fn that could be added to the trait to make awaiting easier.

But overall, this is now more consistent of a proposal, and I think I'm very slightly in favor of going this way, with all other things being equal (which they aren't, of course).

@rpjohnst didn't say they did; they switched to "eager" futures which start executing as soon as you construct them (but that you still have to await to get the value out of):

Note for Dart 2: There is a slight breaking change for async in Dart 2. Instead of immediately suspending, async functions execute synchronously until the first await. In most cases, you won’t notice a change in behavior—as long as you await each time you call an async method, it won’t be an issue.

Whoops, that is precisely what I intended to do. I've updated the post.

@CAD97 It still does not apply as benefit. Rust’s async fns are lazy and this proposal also keeps it that way. It says so in “Motivation > Lazy-vs-eager confusion”.

The point of that section is that this proposal is an alternative to eager futures, which aims to solve the same problems while preserving lazy semantics.

In other words, it’s the combination of explicit await and lazy futures that leads to problems. Remove one or the other and the confusion goes away.

(This reminds me of Rust’s “shared xor mutable”- you only need to remove one, it and it doesn’t have to be the same one every time.)

1 Like

You mean when you forget to call await? Then, either the types don't match or it is caught by the "unused variables" lint introducing a "unused must use" warning for futures (like it exists for Result today) :slight_smile:

You probably wanted to change this one to futures::await as well.

(Gah, Discourse's quote feature is messing up formatting for me on mobile...)