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 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 manualypoll
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 poll
ed. 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 fn
s 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 fn
s- 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 fn
s. 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 fn
s. 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 fn
s, 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 fn
s, 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 fn
s 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 fn
s, 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 Future
s
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 fn
s 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 Future
s: 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 fn
s
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 fn
s, 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 fn
s. 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 fn
s 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 fn
s, 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 fn
s, known as suspend fun
s, may only be called from other suspend fun
s, 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 Stream
s.
The other difference is that Kotlin async code is callback-based. This does not have a huge impact on suspend fun
s 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 fun
s are inert until launched. Invocation of suspend fun
s 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 fn
s 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 fn
s’ 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 fn
s, 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)
}