Async Interview #2: cramertj, part 3
11 December 2019
This blog post is continuing my conversation with cramertj. This will be the last post.
In the first post, I covered what we said about Fuchsia, interoperability, and the organization of the futures crate.
In the second post, I covered cramertj’s take on the Stream
,
AsyncRead
, and AsyncWrite
traits. We also discused the idea of
attached streams and the imporance of GATs for modeling those.
In this post, we’ll talk about async closures.
You can watch the video on YouTube.
Async closures
Next we discussed async closures. You may have noticed that while you
can write an async fn
:
async fn foo() {
...
}
you cannot write the analogous syntax with closures:
let foo = async || ...;
Such a thing would often be useful, especially when writing the
combinators on futures and streams that one might expect (like map
and so forth). Unfortunately, async closures turn out to be somewhat
more complex than their synchronous counterparts – to get the
behavior we probably want, it turns out that they too would require
some support for generic associated types (GAT), because they sort of
want to be “attached closures”.
An example using iterator
To see the problem, let’s start with a synchronous example using
Iterator
. Here is some code that uses for_each
to process each
datum in the iterator and – along the way – it increments a counter
found on the stack:
fn process_count(iterator: impl Iterator<Item = Datum>) {
let mut counter = 0;
iterator.for_each(|data| {
counter += 1
process_datum(datum);
});
use(counter);
}
So what is actually happening when we compile this? The closure expression
actually compiles to a struct that implements the FnMut
trait. This struct
will hold a reference to the counter
variable. So in practice the desugared
form might look like:
fn process_count(iterator: impl Iterator<Item = Datum>) {
let mut counter = 0;
iterator.for_each(ClosureStruct { counter: &mut counter |})
use(counter);
}
The line counter += 1
is compiled then to the equivalent of *self.counter += 1
:
impl FnMut<Datum> for ClosureStruct {
type Output = ();
fn call(&mut self, datum: Datum) {
*self.counter += 1;
process_datum(datum);
}
}
Converting the example to use stream
So what would happen if we were using an async closure? The
ClosureStruct
would still be constructed, presumably, in the same
way. But the closure trait no longer directly performs the
action. Instead, when you call the closure, you get back a future
the performs the action; that future is going to need to have a
reference to counter
too, and that comes from self
. So that means
that the type of this future is going to have to hold a reference to
self
, which means that the impl would have to look something like
this:
impl AsyncFnMut<Datum> for ClosureStruct {
type Future<'s> = ClosureFuture<'s>;
fn call<'s>(&'s mut self, datum: Datum) -> ClosureFuture<'s> {
ClosureFuture::new(&mut self.counter, datum)
}
}
As you can see, modeling this properly requires GATs. In fact, async
closures are basically “attached” closures which return a value that
borrows from self
. (And, just as attached iterators might sometimes
be useful, I’ve found that sometimes I have need of an attached
closure in synchronous code as well.)
What you can write today
The only thing you can write today is a closure that returns an async block:
let foo = || async move { ... };
But this has rather different semantics. In this case, for example, we
would be copying the current value of counter
into the future, and
not holding a reference to the counter
(and if you tried to hold a
reference, you’ll get an error).
Conclusion
This wraps up my 3-part summary of my conversation with cramertj. Looking back, I think the main take-aways are:
- We could stabilize
AsyncRead
andAsyncWrite
and resolve the questions of uninitialized memory (and presumably vectorized writes, which we didn’t discuss explicitly) in some analogous way with the sync version of the traits. Stream
and async closures would benefit from being “attached”, which requires us to make progress on GATs.- In particular, we would not want to add generator syntax until we have a convincing and complete story.
- Similarly, until the async closures story is more complete, we
probably want to hold off on adding too many utility functions in
the stdlib. Auxiliary libraries like
futures
allow us to introduce such functions and later make changes. - The
select!
macro is cool and everybody should read the async book chapter to learn why. =)
Comments?
There is a thread on the Rust users forum for this series.