TL;DR I go on a mad sleep-deprived rant musing about async
If it helps comprehension, it might be worth it to replace async { ... }
with async || { ... }
to make clear the deferred execution, which then makes the main goal of the RFC (clearing up the lazy/eager semantics of when the async fn gets run) really clear. There could then be an impl<T, F> Future<T> for F where F: AsyncFn() -> T
or whatever to make it actually useful.
This is not necessarily a suggested direction for Rust to take; this is more just an exploration of what implicit await could look like in a rust-y world.
Clean slate, here’s the trait taxonomy that I think would clearly lend itself to this RFC’s intent. (But completely ignores the pinning problem.) @rpjohnst, correct me if I missed anything egregious.
// The Fn traits, but with "rust-async-call" calling conventions
// "rust-async-call" handles CPS or task::Context polling or however async is implemented
#[fundamental]
#[lang = "async-fn"]
#[rustc_paren_sugar]
trait AsyncFn<Args>: AsyncFnMut<Args> {
type Output;
#[unstable(feature = "async_fn_traits", issue = "0")]
extern "rust-async-call" fn call(&self, args: Args) -> Self::Output;
}
#[fundamental]
#[lang = "async-fn"]
#[rustc_paren_sugar]
trait AsyncFnMut<Args>: AsyncFnOnce<Args> {
type Output;
#[unstable(feature = "async_fn_traits", issue = "0")]
extern "rust-async-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}
#[fundamental]
#[lang = "async-fn"]
#[rustc_paren_sugar]
trait AsyncFnOnce<Args> {
type Output;
#[unstable(feature = "async_fn_traits", issue = "0")]
extern "rust-async-call" fn call_once(self, args: Args) -> Self::Output;
}
That’s it. This is enough for async fn
with implicit await
by itself:
async fn run() {
let left = async || { get_left() };
let right = async || { get_right() };
let [left, right] = async::all([left, right]);
let output = process(left, right);
println!("{}", output);
}
And even enough for moving from a sync context to an async context:
fn main() {
let task = async || { run() };
async::block_on(task);
}
The operations required here are:
- Calling an
extern "rust-async-call" fn
in a sync context is not allowed
- Calling an
extern "rust-call" fn
in an async context runs it as normal for sync context
- Calling an
extern "rust-async-call" fn
in an async context runs it to completion
- This means that a
NotReady
response from the called fn bubbles up through the caller
-
async || { ... }
creates a closure which runs its internal code in an async context
- This automagically implements the correct
AsyncFn
trait
- So it can be called from within an async context
- It is clear that no execution of code has happened yet
- The standard library provides a
block_on
sync fn for driving an AsyncFnOnce()
The initialization pattern still works:
// only arg1's lifetime is captured
fn foo<'a>(arg1: &'a str, arg2: &str) -> impl AsyncFn() -> usize + 'a {
// do some initialization using arg2
move async || {
// asynchronous portion of the function
}
}
async fn main() {
let a1 = &*"a1";
let f;
{
let a2 = &*"a2";
f = foo(a1, a2);
}
f();
foo("bar", "baz")();
}
I don’t know enough about how async/await/futures are implemented to go the next step into Future
or Async
or whatever you want to call the platform on which async fn
are made to work. I think the important part of this formulation is actually that it is independent from whatever machinery powers an extern "rust-async-call"
. In an actual implementation it would be built on top of CPS or Future::poll
.
If a task really needs to be executed sequentially, with a guarantee that none of the functions it is calling are async
and will cause a suspension, then it should be a sync function and not an async
one, I do think.
Being completely honest here, I do not really understand much, if any of how async/futures are implemented. I feel like I have a handle on using futures, but only just. I’ve probably overlooked multiple things in the draft above, and maybe breezed over important details. It doesn’t help that I’m finishing this up around 3:30 in the morning.
The biggest pitfall in the main RFC is that let x = (async || { ... })();
doesn’t run ...
, it creates a delayed execution blob and gives that to x
. It does not help that in many (most?) other languages that use async
/await
, calling an async fn
, more importantly than the fact that they eagerly run to the first await
, also get scheduled. In JavaScript, a Future<T>
means that the computation to get your T
is going on. In Rust, a Future<Output=T>
is some deffered computation, the same way a closure is, which needs to be polled/ran/awaited to get at its output.
I don’t think the “lazy”/“eager” futures confusion is about whether an async function runs to the first await (though it can be). I really think that the main confusion is whether an async function is started (and thus the executor could continue it when it has a free moment) before you’ve explicitly decided to wait on it having a result.
The main benefit of this proposal is that this isn’t a pain point. Deferred processing is explicit, and a function call is a function call. If you’re in an async
context, you can call into some asynchronous subroutine. And an asynchronous
routine can tell the executor that it’s waiting on some other routine or outside resource, and to do some other work in the meantime.
Overall, though I think this is an interesting design space, I don’t think it’s right for Rust anymore. I don’t know how exactly to express it, but the core teams’ RFCs seem to fit better. The way of processing async discussed here feels higher level than the zero-cost that Rust is built on top of, now.