Explicit future construction, implicit await

Edit: This is a revised proposal. New discussion starts here.

Continuing the discussion of Kotlin-style implicit await from rust-lang/rfcs#2394 (comment), here’s a full proposal for that syntax.

Summary

async/await provides two new operations:

  • First you construct a future, either by calling an async fn or closure, evaluating an async { .. } block. You can also (as today) construct a value whose type hand-implements Future.
  • Second you await that future, by (pending finalized syntax) handing it to a built-in await! macro. You can also (as today) move it into a combinator, spawn it on an executor, or otherwise manualy poll it to completion.

In the main async/await RFC, construction is implicit (some_async_fn(..) returns a future) and awaiting is explicit (await!(..)).

This proposal reverses the two: construction is explicit (async { .. }) and awaiting an async fn invocation is implicit (some_async_fn(..)). Just as with non-async code, sequential execution is the norm and async execution stands out.

Motivation

Lazy-vs-eager confusion

Rust’s futures are “lazy”- they do not run until they are polled. This has many benefits, e.g. cross-await borrows, future cancellation via Drop, consistency over where futures run. By contrast, other languages run their async fns to their first await when invoked. This difference can be confusing, because it can lead to less or even more concurrency than the author intended. See more discussion from /u/munificent of the Dart team and @Valloric on the RFC thread.

We can preserve lazy semantics and avoid this confusion, by making async code match sync code in what it does and does not make explicit. Whenever you see some_function(..), it will run to completion regardless of whether some_function is sync or async. When you delay execution of async code by wrapping it in an async block or closure, it becomes clear that it is all delayed by analogy to sync closures. Concurrency is only introduced by APIs designed to do so, e.g. combinators like join or select, functions that start some work and then return a future.

Nicer syntax

Awaiting is a fairly common operation in async fns- it is their raison d’etre, after all. Annotating every await makes for very noisy code. On the other hand, delayed future execution is primarily used for concurrency, which is already explicit through the use of concurrency APIs. It thus seems reasonable to make the common and unsurprising operation of “call this function and wait for its return value” syntactically light-weight, leaving the more complex operation explicit.

This proposal also neatly resolves the question of await syntax, especially its interactions with the ? operator and method chaining. Instead of await(foo(await(bar())?))? or even bar().await?.foo().await?, you write merely bar()?.foo()?, just like in sync code.

By analogy to Rust, this level of annotation is similar to unsafe. The body of an async fn or block can do anything unmarked code can do, and it can additionally call other async fns. Individual async operations are not marked- it is enough that we mark the block or function containing them.

Future-proofing

There are several extensions we might eventually want to make to async fns. For example, we might want to make combinators like unwrap_or_else or Entry::or_insert_with polymorphic over the async effect:

async fn uses_a_result() {
    ...
    some_result.unwrap_or_else(async || {
        some_async_fn() // suspend execution
    }
}

Such async-polymorphic functions would have no use for actual future values themselves, because they need to work with normal sync values too. But they would have to await- and when their argument might be sync or async, they really shouldn’t have to write await!(maybe_async(..)).

Another extension might be to apply a CPS transform to async fns, to improve the performance of suspending and resuming across non-inlined nested calls. This cannot easily be done under the main RFC because its expansion of await! must apply to any future, and thus must depend on the Future trait. This proposal instead decouples the construction of futures from the invocation of async fns, affording us the opportunity to tweak their calling convention independent of the Future trait.

Guide-level explanation

Construction and awaiting

In the main RFC, an async fn(..) -> R actually returns an impl Future<Output = R>, and thus may be invoked from both sync and async code. Under this proposal, async fns do not “secretly” return impl Future, and may thus only be invoked from async code. When they are invoked, they run to completion before their caller continues execution.

To enter an async context where you can call async fns, you write an async block, which evaluates to an impl Future. You can then run this future using the usual APIs:

async fn print() {
    println!("hello from async");
}

fn main() {
    let future = async { print() };
    println!("hello from sync");
    futures::block_on(future);
}

This will print "hello from sync" before printing "hello from async".

Handling impl Futures

Sometimes async code will need to reenter the world of impl Future values- for concurrency, when using hand-written futures, etc. Futures are not automatically run to completion, just like closures are not implicitly called.

The simplest way to get a value from an impl Future is the await function, the async equivalent of block_on. It runs a future to completion, suspending when necessary, and returns its final output:

// Send a request *now* and return a future that represents the response.
fn send_request(item: Item) -> impl Future<Output = Response> { .. }

async fn process_data(item: Item) {
    let response = send_request(item);
    let data = cpu_work();
    let response = futures::await(response);
    process(data, response);
}

This will send a request, then perform some CPU work while the request is processed, then wait for the result, and finally process data with the response.

More complex “combinator” async fns like select or join enable more patterns:

async fn do_work() { .. }

async fn do_work_with_timeout() {
    let work = async { do_work() };

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

    futures::select(work, timeout);
}

This will construct two impl Futures: one that runs do_work, and the other that waits for 100ms. Then select will run both of these futures concurrently, and return to do_work_with_timeout when the first of the two completes.

Reference-level explanation

Lowering async fns

An async fn defines a new anonymous type that implements Future. This is the same type that the main RFC uses for the return type of async fns, but under this proposal it is never exposed directly.

Instead, it is used in the expansion of async fn invocations. This is very similar to the main RFC’s expansion of await!. In an async context, the call some_async_fn(a, b, c) is expanded to this:

let future = /* construct `some_async_fn`'s corresponding type with `a`, `b`, and `c` */;
let mut pin = unsafe { Pin::new_unchecked(&mut future) };
loop {
    match Future::poll(Pin::borrow(&mut pin, &mut ctx)) {
        Poll::Ready(item) => break item,
        Poll::Pending => yield,
    }
}

async combinators

async functions like await, select, and join serve as a “bridge” between normal async code and Future-based code, filling a similar role to threading APIs. Their signatures might look like this:

async fn await<F: Future>(future: F) -> F::Output { .. }

async fn select<F, G, O>(f: F, g: G) -> O where
    F: Future<Output = O>, G: Future<Output = O>
{ .. }

async fn join<F, G>(f: F, g: G) -> (F::Output, G::Output) where
    F: Future, G: Future
{ .. }

All but await can be implemented simply by constructing the corresponding hand-written future and calling await on it. For example:

async fn join<F, G>(f: F, g: G) -> (F::Output, G::Output) where
    F: Future, G: Future
{
    let join = Join { a: MaybeDone::NotYet(f), b: MaybeDone::NotYet(g) };
    futures::await(join)
}

The body of await itself contains the usual polling loop. How this is accomplished is left unresolved for now- it may be a compiler intrinsic; we may provide async code with lower-level access to its task::Context along with the ability to yield; there may be another solution.

Rationale and alternatives

Explicit suspension points

A common objection to this proposal is that it hides async code’s suspension points, making them look like normal function calls. This is important, they argue, because suspension points might allow another task to violate some invariant unexpectedly.

However, we already have an un-annotated operation that can do the same thing: a normal synchronous function call! For example, from @Manishearth’s excellent post The Problem With Single-threaded Shared Mutability:

let x = some_big_thing();
let len = x.some_vec.len();
for i in 0..len {
    x.do_something_complicated(x.some_vec[i]);
}

While there are no awaits or threads in sight, this code can still run into problems. If do_something_complicated calls pop on some_vec, this will panic. This might happen because the codebase is large and somebody changed one of do_something_complicated's callees. Or it might happen because do_something_complicated is supposed to make a matching call push, but has a bug. Or it might happen because this code is just wrong.

Mitigations

Fortunately, Rust is already well-known for being very good at preventing these kinds of problems. Holding a reference to something prevents other code, even on the same thread, from modifying it. RefCell provides RwLock-like dynamic enforcement for more complicated cases.

What about cases where Rust’s type system doesn’t help? For example, @vorner describes a task that saves and restores the per-thread POSIX signal mask around some synchronous work:

async fn f() {
    ...

    let set = sigset_t::empty().add(SIGTERM).add(SIGQUIT);
    sigprocmask(SIG_BLOCK, set)?;
    do_something_with_db()?;
    sigprocmask(SIG_UNBLOCK, set)?;

    ...
}

If do_something_with_db becomes async, and the executor resumes the task on a different OS thread, this function leaves the old thread’s signals blocked, and potentially corrupts the new thread’s signals!

Cases like this are rare, and often better solved by using async-aware APIs. But there is still a solution: synchronous functions! You can temporarily leave an async context by calling a sync function or closure, and know that nothing it does can suspend the current task. The signal example might look like this:

async fn f() {
    ...

    block_signals_for_db()?;

    ...
}

fn block_signals_for_db() -> Result<T, E> {
    let set = sigset_t::empty().add(SIGTERM).add(SIGQUIT);
    sigprocmask(SIG_BLOCK, set)?;
    do_something_with_db()?;
    sigprocmask(SIG_UNBLOCK, set)?;
}

This better expresses the intent that this whole sequence must run on a single OS thread, without suspending. Just like explicit await, it will produce a compiler error if do_something_with_db becomes async.

Benefits

There is another trade-off here, described in the motivation section. What implicit await loses in precise knowledge of suspension points, it gains in clarity around which code runs when. Notably, the latter point is what the designers and users of other languages have warned us about:

For one instance, the Dart designers initially used “lazy” async functions with explicit await, but found them to be so confusing that they are switching to “eager” async functions in Dart 2. See /u/munificent’s comment on /r/rust for more.

Another instance comes from the main RFC thread, where @Valloric’s production use case seemed, at first, impossible to write using “lazy” async functions.

A third instance, from Rust itself, is the design of the ? operator. Users appreciate the ability to see the points that a function may return early. For example, see @kornel’s comment in this thread.

Finally, based on experience with implicit await in Python/gevent, @parasyte described “any argument for-or-against implicit await leaning on the assumption that one improves the situation with shared mutable state” as “moot and misleading.”

Rather than adopting “eager” async functions, implicit await addresses these warnings by completely removing the confusing operation: an expression that looks like a function call but which runs none of its code. Instead, all function calls run the callee’s entire body, and delayed work is made explicit through async blocks.

Also unlike “eager” async functions, suspenions points are not “hidden returns,” because a normal-looking call will also use the normal-feeling execution of the callee’s full body.

“Eager” futures

Some APIs may wish to do more work before returning a constructed future. This enables more patterns around lifetimes, it can be used for concurrency, etc. The main async/await RFC suggests using this pattern:

fn foo<'a>(arg1: &'a str, arg2: &str) -> impl Future<Output = usize> + 'a {
    // intialization that uses arg2

    async move {
        // async code that uses only arg1
    }
}

However, under this proposal, functions written in this style expose a different interface than async fns. While an async fn requires only a call (foo("a", "b")), a function that returns impl Future must also use the await combinator (futures::await(foo("a", "b"))). However, there are several mitigating factors:

When this pattern is used for concurrency, the caller will do something other than immediately call await on the impl Future. Instead, it will store it for later use with a combinator. The names and return types of functions like these should make this clear.

When this pattern is used to adjust lifetimes or perform conversions, the existence of a separate await operation makes it clear that something interesting is going on, e.g. that the arguments may not be borrowed across suspension points. This will become more important in the future if some async fns associated Future types are allowed to implement Unpin.

Mitigations

In cases where the caller is already !Unpin (i.e. always, at first), this distinction only matters if the caller is also using the pattern for concurrency, making the additional await call irrelvant. For the cases when the caller does not need concurrency, we can introduce some minor derive-able boilerplate to make things easier:

async fn foo_async(arg1: &str, arg2: &str) -> usize {
    futures::await(foo(arg1, arg2))
}

This allows a library author to freely switch the implementation around between the pattern above, a hand-written future implementation, and (in some cases) a single async fn. Not quite as much freedom as when async fn and -> impl Future are equivalent, but not as bad as it first appears either.

Notably, other languages often have a similar duplication, for various reasons. The most obvious is that, like Rust, they started without async/await- in that case adding separate async fn adapters is a natural thing to do to preserve backwards compatibility. Another reason is interoperability with other paradigms- there will always be cases where Rust code needs to interact with larger systems that don’t use its Future trait.

Either way, this is still a trade-off: if -> impl Future functions behave identically to async fns, there is no longer any way to tell (without looking at the callee) how much work is delayed, and this is what leads to the dangers others have attributed to “lazy” async functions.

Prior art

The Kotlin language uses a very similar syntax for its async/await implementation. The language is well-received and well-liked, coming in second behind Rust for “Most Loved” in the 2018 StackOverflow survey, and we have had comments here specifically praising its async/await features. The language has not developed a reputation for being confusing or subtle.

More specifically, Kotlin’s API feels very close to the one described above. The equivalent to async fns, known as suspend funs, may only be called from other suspend funs, where they require no additional annotation. The equivalent to async blocks, as the entry point into the async world, is typically a suspend closure, which runs no code on construction.

The equivalent to a task, known as a coroutine, has type Job or Deferred, and is constructed by passing a suspend closure into the equivalent of spawn_with_handle, known as launch or async. The equivalent to an executor, known as a coroutine context, is passed as an argument to launch/async. A Job or Deferred can then be awaited, respectively, with a method called join or await:

// Spawn the first `suspend fun` on the `Unconfined` context:
val one = async(Unconfined) { callAnAsyncFn() }

// Spawn the second `suspend fun` on the default (current) context:
val two = async { callAnotherAsyncFn() }

// Await the results:
println("The answer is ${one.await() + two.await()}")

The primary difference here is that Kotlin separates the interface to a running suspend fun from the interface to a future value. The low-level interface is known as Continuation, and is used to implement both async functions as well as generators. The main RFC decides again this design here, to make way for Streams.

The other difference is that Kotlin async code is callback-based. This does not have a huge impact on suspend funs themselves, but it does make the edges of the system feel closer to “eager” futures. For example, Kotlin code rarely holds on to an un-launched suspend fun, preferring to launch them immediately. This works better in Kotlin, which is garbage collected, than it would in Rust, which aims to collapse each spawned task into a single allocation.

Otherwise, Kotlin makes essentially the same choices as this proposal. suspend funs are inert until launched. Invocation of suspend funs is only possible from a suspend context, and there is no extra annotation to ensure the callee runs to completion. Concurrency is explicit in the syntax, by putting sub-futures that may run concurrently in their own blocks.

For more information:

Unresolved questions

Named types

As they stand, neither the main RFC nor this proposal will be usable as a replacement for much of the async ecosystem. @seanmonstar details this in rust-lang/rfcs#2395 (comment)- the ecosystem needs to store future types in structs, implement additional traits and methods for them. These operations could be enabled by giving a name to each async fn's corresponding Future type.

In an earlier version of this proposal, async fn foo(Args..) was expanded to something like a module definiton:

mod foo {
    type Type = ..;
    fn new(Args..) -> Type { .. }
    impl Future for Type { .. }
}

This solves the problem, though it is somewhat strange. It enables you to construct an instance of foo::Type by calling foo::new(args..), in addition to the async { .. } syntax for constructing a future with a fresh anonymous type.

The ::new syntax could be replaced by a guarantee that the form async { foo() } is an instance of foo::Type. Or, slightly less magically, the form async foo() (replace async with noawait or some bikeshedded equivalent thereof if it makes more sense to you).

There is also some overlap with the abstract type feature of rust-lang/rfcs#2071, intended as a more general extension to impl Trait. However, async fns do not actually use the impl Trait syntax, they have no way to name their corresponding abstract type. More design work would be needed to allow abstract type to name async fns’ corresponding types.

The await method

Another obstacle to general ecosystem use of async functions, also from rust-lang/rfcs#2395 (comment), is the need to poll them more granularly than this proposal’s await method (or the main RFC’s await!) allows.

One potential solution comes from Kotlin. Its await method is implemented on top of a more fundamental compiler intrinsic API, which looks a lot like call-with-current-continuation:

// Remember, `Continuation` is Kotlin's lower-level interface to running `suspend fun`s,
// and it applies both to async code and to generators.
suspend fun <T> suspendCoroutineOrReturn(block: (Continuation<T>) -> Any?): T

Because it only makes sense in a suspend context, it is declared as a suspend fun. It takes a sync closure, and calls it with a reference to the currently-running coroutine. In Rust this might instead be the current &task::Context. The closure’s return value determines whether the calling suspend fun suspends or completes. When the coroutine is resumed, it appears as if suspendCoroutineOrReturn has just returned. For example, Kotlin’s yield method might be implemented like this:

// `yield` is a method that generators have access to.
suspend fun yield(value: T) {
    // Save the yielded value for the caller:
    next = value

    // Stash `cont` so the generator can be resumed, then suspend:
    return suspendCoroutineOrReturn { cont ->
        nextStep = cont
        COROUTINE_SUSPENDED
    }
}

In Rust, you might use an intrinsic like this to implement leaf futures without ever leaving the async fn world. For example, here’s await, using a built-in called with_context! that behaves like Kotlin’s suspendCoroutineOrReturn:

async fn await<F: Future>(mut future: F) -> F::Output {
    let mut pin = unsafe { Pin::new_unchecked(&mut future) };
    loop {
        match Future::poll(Pin::borrow(&mut pin, &mut ctx)) {
            Poll::Ready(item) => with_context!(|_cx| Poll::Ready(item)),
            Poll::Pending => with_context!(|_cx| Poll::Pending),
        }
    }
}

This could also be used to implement many of the ecosystem’s poll_* functions as async fns, as it provides the &task::Context necessary to make individual calls to Future::poll, along with control over suspension and resumption.

Future-compatible version

If we want to implement or even stabilize something, but without deciding for or against this proposal, we can get a forward-compatible version by taking the intersection of the main RFC with this proposal. In that case, both future construction and awaiting are explicit. For example:

async fn f() {
    let future_a = async { await!(some_async_fn("foo")) };
    let future_b = async { await!(some_async_fn("bar")) };
    let result = await!(join_all(vec![future_a, future_b]));
    await!(process(result))
}

Then, if we decide to go with explicit await, we can drop some of the async blocks:

async fn f() {
    let future_a = some_async_fn("foo");
    let future_b = some_async_fn("bar");
    let result = await!(join_all(vec![future_a, future_b]));
    await!(process(result))
}

Or, if we decide to go with this proposal, we can drop the await!s:

async fn f() {
    let future_a = async { some_async_fn("foo") };
    let future_b = async { some_async_fn("bar") };
    let result = join_all(vec![future_a, future_b]);
    process(result)
}
17 Likes

A common objection to implicit await is that people want the ability to see an async fn's suspension points, much like they see a function’s early returns via the ? operator. This is important, they argue, because a suspension allows other code to run, potentially violating invariants they have already checked.

However, we already have an un-annotated operation that can do all the same things: a normal sync function call!

Well, yes, if you're using threads for concurrency. But people who advocate await-based programming over threads tend to see it not just as a higher-performance implementation of the same thing, but as a different model that's easier to work with, precisely because suspension points are annotated.

That said, in Rust, reading state which someone else could have changed requires explicitly acquiring a lock, loading an atomic variable, or (with coroutines if you limit them to a single OS thread) loading a RefCell. I guess you could argue that that already provides an explicit marker for where state can change, which can serve as an alternative to explicitly marking await. But then, those operations can be hidden in sub-functions, whereas the requirement for await bubbles up the entire stack...

Threads don't come into this- a purely single-threaded program can still have its state invalidated by something that happens in a function it calls.

Only if that function itself (or one of its callees) invalidates it; a lot of functions are simple enough that you can be pretty confident that they won’t. In contrast, if you suspend, any random bit of code might run.

Though again, I think Rust’s borrow system changes the practical calculus compared to other languages. If you have a borrow of something, it can’t be changed by anyone, period, no matter what threading model you’re using…

2 Likes

(Personally I’m still not convinced that async/await is a good idea at all, mainly based on the argument from “What Color is Your Function?”. I’d be more interested in lower-level designs, like some way for the compiler to keep track of “what’s the maximum amount of stack that this function and its callees can use”. So instead of async, you could have real stackful coroutines with precisely sized stacks. And bare-metal code could use the same functionality to statically guarantee that it won’t overflow its stack. But it’s not like I have any chance of winning that argument. And anyway, it wouldn’t work with WebAssembly, just like every other novel or unusual control flow technique :disappointed:)

1 Like

That's basically what async/await is. Much like const, we could technically infer async-ness so that most functions are usable in both contexts. But then the same problem comes up: how do you control for backwards-compatibility?

It gets worse in the async case, because the two modes produce different copies in the binary. This is both due to different calling conventions (async needs to thread a &task::Context around) and the cost of a function call (you don't want sync calls to look like loop { match f() { Yield => return; Complete => break } } in the optimized binary just in case).

(You could do stack switching or a CPS transform to avoid that, but those both still place their own limitations on things- such as WebAssembly support.)

I would rather solve the "what color is your function?" problem by a) admitting it's not actually that big a deal, b) eventually supporting a limited form of async-polymorphism, primarily for small functions that would be inlined anyway, and c) reducing the syntactic overhead of the async "color."

That’s not how I see it, the main reason I have for wanting separate construction and execution phases is for dealing with data conversion and temporary borrows, e.g.

fn get(&mut self, url: impl Into<Url>) -> impl Future + '_ {
    let url = url.into();
    async {
        // perform the get
    }
}
3 Likes

@rpjohnst As you know from our discussion in the async/await notation for ergonomic asynchronous IO RFC issue I am very very skeptical about implicit await.

My criticism boils down to these points:

  • It makes calls to sync and async functions look exactly the same. In a low-level language like Rust these kind of execution characteristics are important
  • While we wouldn't need to specify when to await, we would instead need to specify when not to await
  • We loose track where the suspension points are. With explicit await we can easily spot where they are and it enables us too see parallelization potential, e.g. via join_all()

Edit (5 days later): In the following discussion more reasons for explicit await were found! Here is my comment with an updated summary.


I'd like to explain some more what I mean with point 2 and 3. You've given this example:

Now, I want to run this concurrently. How would it look like? With explicit await that's easy:

async fn so_something() {
    let futures = vec![function(), function()];
    await!(join_all(futures));
}

With implicit await we need to mark that we don't want to await quite yet. You've proposed the some_async_fn::new(..) syntax for this:

async fn so_something() {
   let futures = vec![function::new(), function::new()];
   join_all(futures);
}

My concerns:

  • The ::new() is unintuitive in the context of calling an async function. Function calls sync or async should not involve a ::new(). That's unwieldy.
  • Not getting the return value of the function is IMO confusing. It forces us to jump through extra hoops to get the actually returned value, the future.

I've also spotted a problem with this code example:

Corrected:

async fn function() {
    // Construct an `async fn` future and a hand-written future.
    let future_a = print("hello world");::new(); //<-- Added ::new
    let future_b = Timeout::from_secs(5)::new(); // <-- Added ::new
           // Otherwise there would be no implicit await form for these

    // Wait for the first of the two futures to finish.
    select(future_a, future_b); // <-- Semicolon added:
                   // We want to implicitly await the future not return it
}

@comex Let's assume for this discussion that we're on board with futures. A lot of folk in the Rust community are excited about futures for what they can do (and IMO rightfully so). It's unfair to @rpjohnst if his thread about the implicitness of await deteriorated into a discussion about whether futures in general make sense. Rust needs a form of lightweight parallelism to be able to compete with other languages in the webserver space. And futures in their current form are a zero-cost abstraction and thus a perfect fit for Rust.


What next? I would like code examples of realworld-ish code of some networking application. The implicit await examples you've given so far are very simple in nature. I would like to see a nice big example. I suspect that with implicit await we'll quickly loose track about which functions are actually async and have no idea where the suspension points are. I think that that's not so good, but I've also never used Kotlin before. Instead I'm used to JavaScript's explicit await.

3 Likes

I like the general idea. Having experienced the Javascript async/await syntax this quote hit me right on the spot:

Awaiting is a fairly common operation in async fns- it is their raison d’etre, after all. Annotating every await makes for very noisy code. On the other hand, delaying future execution is less common, and is often used with explicit concurrency via combinators, much like sync code using threads. It thus seems reasonable to make the common operation syntactically light-weight, leaving the annotation for the less-common operation.

I admit that making await explicit was useful when first getting used to asynchronous programming but it quickly become a bit noisy in async heavy code. The whole purpose of await (in my use cases at least) is to make async code look like sync code (sequential). Thus we might as well make async code transparent by default. I believe it can ease the usability AND the learnability. Someone who doesn't need delayed execution can use async functions like sync functions (yay) and explicitely opt-in to futures when needed (yay again).

Here's a small suggestion: I'd rather spawn the future with ::async() rather than ::new(). The intent seems clearer and it mirrors the async fn definition: async fn foo() provides the foo::async() constructor.

async fn print(argument: &str) -> usize {
    ...
}

fn main() {
    // Construct a future, but do not run any code from the body of `print`.
    let future: print::Type = print::async("hello world");

    // Run `print` to completion.
    tokio::run(future);
}

@obust The print::async("hello world") in your example should be called inside an async context such as an async block or async function. Otherwise there’s no implicit await to compete with. Edit (3 days later): I shouldn’t have criticized that. It makes sense the way it is.

::async() makes more sense. But, it still looks like a method call. In reality it’s something different, because it tells the async function not to wait. A notation like noawait print!("hello world"); would make more sense to me. In most cases there’s probably no need to specify a keyword at all, because the compiler could infer whether to await or not based on the types. That’d be very similar to the Deref trait.

Hmm I’m currently really torn between whether implicit await makes sense or not. All the asynchornousness would be hidden. That’s not so good, but on the other hand the point of async functions…

If we apply the Stroustrup rule ( https://thefeedbackloop.xyz/stroustrups-rule-and-layering-over-time/ ), I think that we could try and start with an explicit macro even at the await points, and make it forward compatible to leave that out. That would allow, even after stabilizing, a longer time to test out the proposals and get people more used to async programming in general.

The obvious counterargument/downside is that once people get accustomed to some explicit marker, there will be a lot of naysayers when discussing about removing that marker, so that might never happen.

3 Likes

@GolDDranks To make forward compatiblity possible we’d have to forbid unused futures inside async functions:

async fn async_get_num () -> i32 { 42 }
fn print_num (num: i32) {  println!("{}", num); }

async fn my_fn1 () { // With explicit await
    async_get_num(); // Compiler error for forward compatibility
                     // If this worked, it's behaviors would change with the
                     // introduction of implicit await
    
    await async_get_num(); // Explicit await

    print_num("{}", await async_get_num()); // Explicit await

    let future = async_get_num();
    await join_all(vec![future, async_get_num()]); // Explicit await, edit: removed wrong await
}

async fn my_fn2 () { // With implicit await (future Rust)
    some_async_fn(); // Implicit await

    await async_get_num(); // Explicit await still works

    print_num("{}", async_get_num()); // Implicit await (print_num expects i32)

    let future = async_get_num(); // Infers not to await
                                  // (join_all expects futures)
    join_all(vec![future, async_get_num()]); // Implicit await
}

This could be done, though. Forbidding the creation of an unused future isn’t a big deal IMO. It’d be useless anyway. But it keeps all options open for implicit await in the future. Besides, that kind of thing would have needed a warning anyway because it’s likely a bug.

1 Like

Unsolved questions:

  • Future trait and Output type have a method with the same name. Rust could simply prefer the method on the future. However, if new methods on the Future were introduced this would break existing code.
async fn my_fn () { // With implicit await (future Rust)
    let a = async_get_num().is_positive(); // Implicit await
    let b = async_get_num().map(|x| x * 2).is_positive(); // Implicit await after map()
    // What if both types have a map() method?
}
  • Type not clear. Possible solution: When type is not clear default to Future
async fn my_fn () { // With implicit await (future Rust)
    println!("{:?}", async_get_num());
    // What if both the Future and the Output type implement the Debug trait?
}

Optional await might not work… how was the first problem solved with the Deref trait?

After some thinking I have to conclude that a blend of both approaches is not possible. Auto-deref works for smart pointers because they’ve a stable and unchanging API were no methods need to be added. Futures will never have such an unchanging API because people will come up with new combinators and will want to add them. As I see it, it comes down to a decision between:

  • explicit await (like JavaScript, C# and current draft of Rust async functions RFC)
  • implict await and explicit non-await (like Kotlin, see explicit non-await in Kotlin)

Or of course define two kinds of async functions xD

async fn my_fn () { // Implicit await opt-in
    #![implictAwait]

    some_async_fn(); // Implicit await
}

@rpjohnst You won’t like this, I know :smile: I just wanted to throw it out

To take the example from the async/await RFC:

async fn print() {
     println!("Hello from async print")
}

RFC proposal:

  • implicit async
fn main() {
     let future = print();
     println!("Hello from main");
     futures::block_on(future);
}
Hello from main
Hello from async print
  • explicit await
async fn main() {
     await!(print());
     println!("Hello from main");
}
Hello from async print
Hello from main

@rpjohnst proposal:

  • case: explicit async
fn main() {
     let future = print::async(); // will be executed later (aka asynchronously)
     println!("Hello from main");
     futures::block_on(future);
}
Hello from main
Hello from async print
  • case: implicit await
async fn main() {
      print();  // executes at the call site (aka awaits implicitely)
      println!("Hello from main");
}
Hello from async print
Hello from main

In a sense it is really similar to ray a python library that makes parallelism ergonomic and efficient (compared to other solution in the python ecosystem).

import ray 

@ray.remote
def foo():
    print('Hello from parallel foo')

for _ in range(5):
    foo.remote()  # foo is call remotely (aka in parallel)

With ray, sequential execution is the norm and parallel execution stands out explicitely. Similarly, with @rpjohnst proposal, sequential execution is the norm and async execution stands out explicitely.

Disclaimer: I come from the dynamic programming languages world (python/javascript/etc.) which understandably do not have the same requirements as a system language like rust. This critic is rather from a usability standpoint.

2 Likes

Interestingly I realized that by using async fn for any non-trivial future you almost get this. Other than a few temporaries while evaluating the current future all data buffers etc. are allocated in the generated type, so you just have a pointer or two per level of the future that needs to be allocated on the stack (it's a little bit more expensive than that because of using thread locals for the current #[async] macro, but hopefully can be improved when it's part of the compiler).

2 Likes

@obust You didn't criticize at all :grin: Which one do you prefer after having worked with the implicit style in python and the explicit style in JavaScript?

@MajorBreakfast You've gotten quite a few things confused here, so I'm going to try to respond to all of it at once.

No, none of those changes should be made.

  • The syntax for constructing a future from an async fn like print is not print("hello world")::new()- you're right, that would be unintuitive.
    • The syntax is, as I wrote, print::new("hello world"). The function name acts like a module or type name, where the new function inside takes the async fn's arguments and returns its corresponding future type. (It's equivalent to today's version of the async fn itself.)
  • Timeout in this example is a hand-written future, and so doesn't use an extra ::new(). Timeout::from_secs(5) works as it would today- by constructing a Timeout, which implements Future.
  • The final semicolon is unnecessary and does not control whether the future is awaited or returned. As select is an async fn, invoking it is sufficient to await it- remember, the alternative is to invoke select::new(a, b), not to attach ::new() to the result of select(a, b).
    • If a and b's Output/return type is something other than what function returns, then there would be a type error, but in this example it's all just ().

Indeed, this is the entire point. As I described in the motivation section, immediately awaiting an async fn is more common than doing something else with its Future, so I moved the explicit annotation to the other case.

It helps not to think of the "actually returned value" as a future, to go along with the async fn's syntax which does not mention Future anywhere. The "actually returned value" is really the future's Output, and Future is merely a means to get to it.

Well, there's no way we'd lose track of which functions are async, those are still annotated.

We would probably lose track of the suspension points, but I believe I made a fairly thorough case for why that isn't a problem. I would appreciate if we could discuss if/why people disagree with that rather than just re-asserting this point.

No, neither of these things are true. The future constructor, whether it's called ::new or ::await, may be called from any context. It's only the immediate, implicitly-awaiting, print("hello world") syntax that may be used only in an async context.

The reason this looks like a method call is that it is a method call. It is no different from the constructor of a hand-written future. It does not tell the future "not to wait." It tells it to wait. Or if you like, it doesn't tell it anything at all- the direct print("hello world") syntax is what tells it to run.

No, we would not have to do that. We could instead add the ::new syntax, or as I mention in the unresolved questions section, replace its use with async blocks. Your example would look like this:

async fn my_fn1() { // with forward-compatible explicit await
    async_get_num(); // error for forward compatibility

    await!(async_get_num()); // explicit await

    println!("{}", await!(async_get_num())); // explicit await

    let future = async { await!(async_get_num()) }; // or async_get_num::new()
    // your example is wrong here; you `await`ed the second vec element
    await!(join_all(vec![future, async_get_num::new()]); // explicit await
}

async fn my_fn2() { // implicit await (future Rust)
    async_get_num(); // implicit await

    // explicit await still works (though now it's just the identity macro)
    await!(async_get_num());

    println!("{}", async_get_num()); // implicit await (prints i32)

    // construct a future, no inference because it was never ambiguous to begin with
    let future = async { async_get_num() };
    join_all(vec![future, async_get_num::new()]); // implicit await
}

This is never an issue as long as we pick something that's actually forward compatible, which your proposal is not, because it lacks explicit future construction, leading to ambiguity when you do need to obtain an actual future value.

1 Like

Well Python has the same async/await syntax than javascript. So the coroutine world is handled the same.

Ray is designed for making highly distributed execution of current Deep Reinforcement Learning algorithms easy. I can just say the parallelism API that Ray introduced felt very natural from the beginning and made the transition from sequential programming very smooth. This is also the feedback I see from other users (and most users have a science background and are not heavy league developers).

I think Ray’s API for parallelism or rpjohnst’s API for concurrency makes for a very approachable mental model to these rather complex paradigms.

All in all, it would be easier to judge seeing it implemented in a heavy asynchronous program.

2 Likes