Explicit future construction, implicit await


#62

Since the RFC was posted I tried to follow it all, but especially this thread always seems to move faster than I can keep up with; what a great, engaging community this is!

There has been a lot of talking about the Kotlin language in here. However, a comparison with Go could also shed some light on it. Disclaimer: I never used Go and all this is hearsay, but as far as I recall, in Go every statement is a possible suspension point, because everything can always yield to the Go green threading executor thingy. In practice this is indistinguishable from a preempted world in which an interrupt can move control over the thread from you after every CPU instruction, i.e. OS scheduling. This may be fine for Go (and Kotlin which comes with a default CPU pool?) which was designed to do one thing and do it well and solved the function colouring problem with a sledgehammer. Rust on the other hand has different design goals and ditched green threading a long time ago (which made me very happy). People are building kernels and other very low-level stuff in Rust. In a world where there may be no executor, no tokio reactor, no runtime support at all; maybe the futures will be polled, maybe they will be spawned on some hand-rolled state machine, who knows? Thus I am all for the “make interesting things explicit” way. My only wish was that async fns could anounce their impl Future in their visible signature; I understand from the RFC discussion that this is problematic together with lifetime elision, but I think it would have made the case clearer here, because then an async fn can just be read as a function which you can call to get exactly what it says on the tin, an impl Future, which you can then await at your leisure.

When the proposal for an @-sigil was first made, my guts hated it, but as I absorb more of the discussion, it looks nicer to me each day. So a +1 to that as well from me.


#63

How, then, did anyone ever get confused using Dart 1?


#64

I didn’t quite understand this point. IIUC, you are saying that if await was explicit, you would have tried to await!(pika_client()) and gotten an error that pika_client is not async. Is that correct?


Sorry, I didn’t quite understand the distinction between block_on and await.

To be clear, the actual work of do_work does not start until the select, right? So if I wanted to start the work earlier and concurrently do some other computation I would do something like this:

async fn do_work() { .. } // assume the work happens on thread B

async fn do_work_with_timeout() {
    let work = async { do_work() };
    let work = async { futures::await(work) }; // concurrently start `work` on B

    my_sync_function(); // do some computation synchronously on this thread

    let when = Instant::now() + Duration::from_millis(100);
    let timeout = Delay::new(when);

    futures::select(work, timeout);
}

Is that correct?

Is this supposed to say in a sync context (i.e. not in an async block/fn/closure)? Or does it intentionally mean that in an async context we will block until the Future is resolved?

I still don’t completely buy this argument. I realize that it’s possible for a function to do arbitrary crazy stuff in theory, but in practice I don’t usually expect a function to do arbitrary stuff in the middle without that being documented. On the other hand, in concurrent code I very much expect random stuff to happen in the middle of a function call (say, on another thread).


#65

Because Dart futures were lazy°, i.e. there was no way to synchronously run code when called. Now they’re eager like in JavaScript, were everything until the first await is executed when you call the async function. Implicit/explicit await is orthogonal to this.

° Edit: (1 day later): What the Dart people call “lazy” is significantly different to Rust’s definition of “lazy”. More on that in subsequent comments


#66

Yes, I believe that is exactly what @parasyte meant.

block_on is synchronous. It actually blocks the calling thread until the future has completed. await its “async equivalent” because it does not block the calling thread, but instead suspends the calling task.

You are correct that do_work does not start until the call to select. But your example does does not do what you want. You would need to do something like this instead:

fn start_do_work() -> impl Future {
    signal_thread_b_to_start_working();
    async { .. }
}

async fn do_work_with_timeout() {
    let work = start_do_work();

    my_sync_function();

    let when = Instant::now() + Duration::from_millis(100);
    let timeout = Delay::new(when);

    futures::select(work, timeout);
}

The latter- some_async_fn(a, b, c) has no meaning in a sync context, and results in a compiler error. This is what gives this proposal the name “implicit await.”

Why does this not also apply to explicit await in Rust? This also accurately describes the main RFC, and this is precisely why the Dart designers reached out to warn us! (Yes, the main RFC does provide the -> impl Future { .. async { .. } } trick, but this is also available to Dart 1.)

The problem is not lazy futures on their own. It is lazy futures combined with the mistaken expectation that the expression some_async_fn() will do something more than construct the future. Dart 2 addresses the first aspect by making futures eager; implicit await addresses the second aspect by making that expectation correct.

Again, please think through the point you’re trying to make, because you’re re-treading ground that has already been covered here, on GitHub, and on Reddit.


#67

It’s only a problem if we forget the await. This can be detected by adding an “unused_must_use” warning to futures like it exists today for Result.

async fn get_num() () { 42 }

async fn my_fn() {
  get_num(); // Warning: "unused_must_use"
  await get_num(); // Correct

  get_num().is_positive(); // Error: Method does not exist
  (await get_num()).is_positive(); // Correct
}

With this, lazy futures and explicit await is just as easy.


#68

I don’t really understand what implicit await has to do with making it more clear that async functions are lazy futures. I get that you want the call to look more like a constructor, to make it clear that is only constructing the future and not executing it eagerly, but that seems orthogonal to whether await is implicit or explicit. An alternative could be to have syntax like:

async do_work(...) {
    let x = await!(async_fn::new());
    let y = await!(future_fn());
   ....

where async “functions” are really just definitions for structs and are always constructed with the new method, and awaits are explicit.

I’d also like to point out that there is now garantee that there is any eager evaluation for non-async functions that return impl Futurure either. And even if the future is eagerly evaluated, you don’t know how much it is evaluated, and if you want it to continue executing, you need to make sure it gets polled by awaiting, adding it to some execution context, or manually polling it.

I think that marking Futures as a must_use type, and having good documentation would be a better solution.


#69

For literally the third time, Dart 1 already has all of that, and was still confusing enough that they changed things.

That’s not an accurate description of what I want. In fact, if you take a look at the revised proposal, you’ll see I don’t use constructor-like syntax at all. (FWIW, your alternative syntax has a lot in common with the revised proposal’s “future-compatible” section.)

The point of implicit await is to make the foo() syntax, which looks like a function call, also act like a function call. The follow-on effect of this is that all the remaining ways to expose lazy behavior don’t look like function calls. Some may look like constructor calls, but others look like closures, or whatever else we come up with.


#70

I’ve posted this now in the futures RFC: Link to comment


#71

But they changed a different aspect! They went from “lazy” ° to “eager” and kept explict await!

They wern’t happy with the “lazy” ° aspect (for reasons explained in the post on reddit), so they changed it to “eager”. Implict vs explict await had nothing to do with it! A forgotten await is BTW just as bad with “eager” as with “lazy” ° futures. It just fails in a different way (future runs concurrently instead of not at all (Rust) or asynchronously (Dart 1), edited: Added distinction, see my next comment why).

° Edit: (1 day later): What the Dart people call “lazy” is significantly different to Rust’s definition of “lazy”. More on that in subsequent comments


#72

@rpjohnst Thanks for the clarifications and the thought provoking pre-RFC :slight_smile:

So I’m coming at this from the perspective of an async I/O novice. I have never really used async I/O for anything serious, and my first introduction to async I/O was through Alex’s Back to the Futures talk and Rust Futures as they existed.

That said, starting from my “blank slate” perspective, I find the current RFC proposal more easy to understand than this proposal. Introspecting a bit, I think this might be because

  • There seems to be “less stuff” in some sense. For example, in the original RFC, async fns are really just another way to construct a Future. There doesn’t need to be any bridging between async fn and the rest of the Futures ecosystem. This significantly reduces the cognitive overhead for me as a beginner.
  • There are a few counterintuitive things:
    • async fn are not async by default, rather they are awaited by default. I realize that this is the whole point, but it still takes a bit of getting used to.
    • futures::await is async. As you saw, the difference between await and block_on is a bit non-obvious until explained.
  • I actually don’t find it more clear to know when things run under this model. If anything, I’m having a bit more trouble because I’ve already trained myself to the lazy model. Under the lazy model, I know that stuff only happens when I poll. Under this model they happen when I make a function call, unless it’s a call to an async fn in an async block.

I don’t know how common my perspective is, but hopefully it is a useful data point.


#73

I just noticed that there’s a difference between what’s called “lazy” in Rust and in Dart 1.

  • Rust futures do nothing if you don’t await them
  • Dart 1 futures run even if you don’t await them, they just start asynchronously: https://dartpad.dartlang.org/33706e19df021e52d98c (DartPad is “Based on Dart SDK 1.25.0.”, click on “DartPad” in the top left corner to see this)

@rpjohnst You should remove the Dart thing under “benefits”. Dart 1’s futures weren’t lazy by Rust’s definition and Dart 2’s futures still have explicit await. There really isn’t any basis for comparison. (If anything, the fact that they chose explicit await and are sticking with it serves more like a counter example)


#74

Maybe this phrasing will make things clearer: under this model, stuff always happens when you make a function call. An async block is like a closure, in that its body does not run (i.e., you in fact do not make said function call) until it is polled.

I’m actually somewhat confused by your description. You make it sound like you expect async { some_sync_fn() } to run some_sync_fn immediately, when it is actually deferred along with the rest of the async block’s body!


#75

I think you have the gist of it, yes.


#76

all the remaining ways to expose lazy behavior don’t look like function calls

that isn’t the case though. Regardless of what happens with async/await syntax a call to a normal function that returns a Future is lazy, and looks like a normal function call. As a concrete example:

// in a non-async context
async_stream.read(buffer); // looks like a normal function call, doesn't do anything until the return value is scheduled.

you’ll see I don’t use constructor-like syntax at all

ok, what I should have said is you want a different syntax to somehow indicate that the future is lazy. But my point that it is orthogonal to implicit await still stands.

For literally the third time, Dart 1 already has all of that, and was still confusing enough that they changed things.

Dart has a very different execution model though. Dart (and javascript) has an implicit event loop, and futures are automatically scheduled onto the event loop, in rust however, the future must be added to something to poll it.

Python’s async/await is actually pretty similar to the RFC. An async function returns a coroutine object, which must be added to an event loop or awaited in another async function. In python the coroutine is also lazy, not eager, and await is explicit. Has there been a lot of confusion about coroutines being lazy in python?


#77

I think for some reason my mental model was that if I put something in an async block, it immediately starts running concurrently somehow and I get a future to the final result. Looking back, that doesn’t really make sense, though…


#78

As for the suggestion to wrap things that must not do async into a sync function (or maybe a local closure) ‒ I’ve already thought of that. And I would probably structure my code that way, but it’s an ugly hack at best, because:

  • I think it’ll be less rare in Rust than you think. You still seem to consider Rust to be one more Kotlin or Go, designed to just run your server code, but fast… while Rust can do that, what makes it unique is it’s very low-level nature, allowing you to do all kinds of crazy things while preserving at least some sanity. Degrading this support for the sake of a code that pretends not to be async on the surface doesn’t sound correct in that context.
  • If the author decides to write it that way, sure it becomes more readable in the code review or when hunting for bugs. But if the author forgets or doesn’t know about the trick, the review is still tedious ‒ in other words, I’d either have to mandate that every place with a mutex held is wrapped into a sync function, or give up on that and still suspect every single thing.
  • This doesn’t go well with the „pit of success“ principle. It should be easier to do the right thing (writing a code that is clear about the intentions, here), while here it looks like the right thing is much more convoluted.

You state that now you want you async fn to return future too, but that it runs it right away… how does it fit into any mental model? How does the async fn's call expand into something that does the NotReady loop and pinning? How can the function (if it’s a function) influence what happens on the caller side?

How much is that async fn just fn → Future? If I have another function, like this:

async fn measure_time<R, F: FnOnce() -> R>(f: F) -> R {
    let before = ntp_get_time(); // This one is async
    let result = f(); // ????
    let after = ntp_get_time(); // Async
    print("It took: {}", after - before);
}

Now, if I pass a sync function, it does something I expect. How should it act when I pass an async fn? Should it not compile, because async fn isn’t just fn → Future, at least sometimes (inconsistent), should it create and await that future (probably consistent but very surprising to the author of the measure_time or should it just return the Future, because some kind of other exception (inconsistent)?

I think implicit await fits well with languages where the async is really a core feature of the language, like Go, where everything is just async and implicit await in a sense. But I feel Rust is built around different models and async is just an extension, one of many and in much of the code isn’t async. It feels like the IO monad in Haskell to me a bit ‒ you could write all your program in the IO monad, but the usual style is to do it only on the top level ‒ to read/write the input, but the the computation in pure code. In Rust I would imagine the usual pattern would be to do the async somewhere on the top level (maybe just one async fn, with few suspension points) and all the computations to be done in sync code. Sure, not everything fits there.


#79

Instead of this implementation, why not have async fns implement a compiler magic AsyncFn* trait that’s similar to the closure traits. Then later the AsyncFn* traits can be changed to be aliases for an Async + Fn* where Async is some trait that exposes the machinery of async fns enough to implement functions like Future::await, and possibly even opt into implicit await for handwritten types. This also would nicely interact with the abstract Types feature.


#80

I believe the mental model is that if it looks like a normal function call, it is (i.e. it actually does stuff). If it is in an async block, it does not do anything immediately and the whole block evaluates to a Future (you can only call async fns in an async block). So


#81

That said, there might need to be some compiler magic to track the async-ness of f, and I’m not really sure if that’s possible in general…