Summary
Make the await operation available directly to generators, independent of the way they are used- whether as Future
s, Iterator
s, or application-specific types. Provide generator items instead of generator expressions. Limit the use of traits like Future
to places they are actually used in the program source.
(The networking WG is currently putting together an RFC for async/await syntax. This is intended as early input to that process.)
Motivation
The current implementation of generators provides closure-like generator expressions as the sole method of constructing a generator value. The futures-await
crate wraps this in #[async]
functions which return impl Future
, and provides the await!
macro as the sole method of awaiting a generator value.
However, the await operation is also useful in many other contexts: iterators, GUI event handlers, game logic, interrupt handlers, etc. Many of these don’t need the Future
trait or the Context
/Waker
system it implies.
We can make generators more useful in non-futures-based contexts by providing these features directly to generators- generator items instead of functions returning impl Future
, and an await operation that applies directly to those generator items.
Guide-level explanation
A generator or async function is a function that can be suspended and resumed part-way through its execution. For example:
async fn counter(limit: u32) yield u32 -> u32 {
let mut i = 0;
while i < limit {
yield i;
i += 1;
}
limit
}
A generator cannot be called from a non-generator function. It can be called from another generator, or be used to construct a Generator
value. Calling a generator causes everything it yields to appear as if it were yielded from the caller:
async fn two_counters() yield u32 {
let x = counter(1);
println!("{}", x);
let y = counter(2);
println!("{}", y);
}
let c = two_counters::new();
assert_eq!(c.resume(), GeneratorState::Yielded(0));
assert_eq!(c.resume(), GeneratorState::Yielded(0)); // also prints 1
assert_eq!(c.resume(), GeneratorState::Yielded(1));
assert_eq!(c.resume(), GeneratorState::Complete(())); // also prints 2
Traits like Future
, Stream
, and Iterator
are implemented for wrapper types, to provide APIs specific to those contexts:
// generator yielding Async<T> implement IntoFuture:
tokio::run(async {
let page = get_page_text("https://example.com/");
// ...
});
// generators yielding T and returning () implement IntoIterator:
for i in two_counters() {
println!("{}", i);
}
Reference-level explanation
Generator types and values
A generator item is syntactically a function prefixed with the async
keyword. It can also specify a type to yield, which like the return type defaults to ()
if unspecified:
async fn generator(t: T, u: U) yield V -> W { ... }
A generator expression is syntactically a closure with no arguments, prefixed with the async
keyword. It can also specify a type to yield the same way, and evaluates to a value with an anonymous type:
async yield V -> W { ... }
A generator item declares a type. That type, and the anonymous types of generator expressions, implement the Generator
trait, which now also provides a single static method for construction. For example, the counter
generator above is equivalent to this:
struct counter(...);
impl Generator for counter {
type Args = (u32,);
type Yield = u32;
type Return = u32;
extern "rust-call" fn new(args: Args) -> Self { ... }
fn resume(&mut self) -> GeneratorState<Yield, Return> { ... }
}
A generator body has two additional capabilities beyond a normal function body:
- It can
yield
a value to its resumer, as in today’s implementation. - It can directly invoke another generator as if it were a function, which awaits it. This constructs a generator value of that type and resumes it repeatedly, yielding what it yields, until it completes, when it evaluates to its return value.
Generator methods
Inherent impls and trait impls can contain generator items. They behave much like associated types, though awaiting them looks like a method call:
trait Trait {
async fn g(&self) yield i32;
}
async fn async_caller<T: Trait>(t: T) yield i32 {
t.g();
}
fn caller<T: Trait>(t: T) {
let g = T::g::new(t);
let _: GeneratorState<i32, ()> = g.resume();
}
This does mean that generic generator trait methods are blocked on generic associated types.
Drawbacks
- Somewhat more work to implement than converting
futures-await
's macros to language features. - More design work to be done around
async for
- see below. - Probably more.
Rationale and Alternatives
Generator composition
The main alternative design today is represented by the futures-await
crate. There, only futures can be awaited, and any other use of generators must resume its callees in a loop manually.
This design lifts that restriction, and also makes it easy to bring futures back into a generator context- Future
might have a method async fn await(&mut self) yield Pending -> Output
that contains such a polling loop.
Debugging and optimization
This design can improve the debugging experience. Awaiting a generator no longer goes through a wrapper type’s poll
function, allowing debuggers to step directly into the callee. Further, stepping into a resume()
can know to break only in the inner-most generator.
This design is also partially forwards-compatible with optimizations like a CPS transform for generators. (The dependence does not go the other way- we do not need anything from the CPS transform design to get the benefits of this design.)
API design
This design also neatly sidesteps two sources of potential confusion:
With futures-await
, you can call an async function from a synchronous function, and get back a future. You might reasonably look at Future
's methods to try to extract the value, or even get an “unused value” warning. But with this design, you must either invoke the async function from an async context, or intentionally construct a generator value from it.
With any futures-based await syntax, there is a question of whether async functions should include impl Future
somewhere in their return type or declaration. C# async functions and C++ coroutines do include this information; Kotlin coroutines do not; Python and Javascript don’t specify types at all. This design decouples generators from futures so this question does not arise at all.
It may not even be necessary to put Future
in the standard library to get async syntax (though it may still be desirable for other reasons).
Unresolved questions
yield
As described above, a generator is suspended by yielding a value. Another option, potentially more flexible, might be something that looks like call-with-current-continuation:
suspend me {
/* me is a reference to the outer-most generator */
/* this block's value is yielded */
}
Another question around yield
is whether it should return a value on resume, and if so how. The first resumption of a generator does not have matching a yield
expression. This might be solved by running the first segment of a generator into its new
method, but this complicates self-referential generators and reduces the benefit of this design’s clear separation between construction and resumption.
async for
Two traits from the futures
crate neatly represent the async world’s versions of values and iterators- Future
is to T
as Stream
is to Iterator<Item = T>
(assuming Future
's Item
/Error
are combined into a single Output
). However, generators introduce another trait. Generator
is somewhere in between and below these two traits, depending on how you look at it.
I see a couple of ways to integrate generators with Future
: a blanket impl for generators that yield ()
, or to better express intent, a blanket impl for generators that yield some marker type- perhaps a ZST struct Pending;
.
This design could be expanded to Stream
as a blanket impl for generators that yield Option<T>
, or again an equivalent marker type. But this starts to feel kind of messy- how do you await a normal Future
-style generator here, since those yield ()
? Some kind of ?
-operator-like conversion? How does that interact with the potential for a CPS transform?
And worse, how do you do iterator combinators here? What’s the equivalent for IntoIterator
when you write async for
? It feels like what we really want is a new trait, equivalent to Iterator
but with an async
next
method:
trait AsyncIterator {
type Item;
async fn next(&mut self) yield ??? -> Item;
}
But then we’re back to hand-writing the iterator-level state machine. Is the answer yet another operation, like futures-await
has with #[async_stream]
and stream_yield!
?
Perhaps, by analogy to a hypothetical const impl
, we could even reuse the same trait Iterator
in both contexts.
Syntax
I kind of like the lack of an extra await
keyword, but really all the syntax here is up for bikeshedding as far as I’m concerned. It’s not really the point of this pre-RFC.