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 fnor closure, evaluating anasync { .. }block. You can also (as today) construct a value whose type hand-implementsFuture. - 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 manualypollit 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:
- https://github.com/Kotlin/kotlinx.coroutines/blob/master/coroutines-guide.md
- https://github.com/Kotlin/kotlin-coroutines/blob/master/kotlin-coroutines-informal.md
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)
}
)
I just wanted to throw it out