Into the Future with IntoFuture - Improving Rust Async Ergonomics
NOTE: This feature has now been stabilized and is available in Rust 1.64. (September 22, 2022)
Imagine you could sleep for a duration without using tokio::time::sleep()
and just write:
// tokio::time::sleep(Duration::from_secs(10)).await;
Duration::from_secs(10).await;
Or collect a tuple or vector of futures without using futures::future::join_all
:
// futures::future::join_all((fut1, fut2)).await;
(fut1, fut2).await;
// futures::future::join_all(vec![fut1, fut2]).await;
vec![fut1, fut2].await
A more complicated but common pattern in async
Rust is constructing a
complex operation with a builder struct, finalizing the builder into a Future
and .await
ing the result.
HTTP requests are a prime example:
let response = reqwest::Client::new()
.post("https://domain/api/create")
.header("key", "value")
.json(...)
.send()
// ^^^^^^^
.await?;
send()
acts as the finalizer and turns the builder into a future.
But wouldn’t it be nice to skip that extra method call and just .await
on the
builder directly?
The good news: a feature enabling these patterns has been available on nightly Rust since December 2021 and is finally heading to stable.
Time to take a closer look!
Hacky Workarounds
Even without the promised new feature there already was a way to make this work
in a limited set of circumstances.
We can just implement Future
on a builder directly.
Some libraries actually do this, like the surf HTTP client.
Let’s check out their RequestBuilder implementation:
pub struct RequestBuilder {
/// Holds the state of the request.
req: Option<Request>,
/// Holds the state of the `impl Future`.
fut: Option<BoxFuture<'static, Result<Response>>>,
...
}
impl Future for RequestBuilder {
type Output = Result<Response>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.fut.is_none() {
let req = self.req.take().unwrap();
let client = ...;
self.fut = Some(Box::pin(async move { client.send(req).await }))
}
self.fut.as_mut().unwrap().as_mut().poll(cx)
}
}
On the first poll()
the prepared request is consumed and a future is stored.
Subsequent polls then utilize the future.
This works, but you might already notice how awkward this is to implement. Some additional code confirms it:
pub fn header(mut self, key: impl Into<HeaderName>, value: impl ToHeaderValues) -> Self {
self.req.as_mut().unwrap().insert_header(key, value);
// ^^^^^^^
self
}
pub fn build(self) -> Request {
self.req.unwrap()
// ^^^^^^
}
Everything is wrapped in Option<T>
, and the code is littered with .unwrap()
.
Not exactly idiomatic, and a heavy price to pay for a bit of convenience…
Luckily there is a better way!
History Lesson
async-await was accepted in RFC 2394, which is over four years old by now.
It specified the expansion
of await
as follows:
let mut future = IntoFuture::into_future($expression);
// ^^^^^^^^^^^^^^^^^^^^^^^
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,
}
}
The important part is the IntoFuture::into_future(x)
call above: the .await
ed
expression is resolved through a call to the into_future()
method on an IntoFuture
trait.
This allows all types to convert into a Future
on demand.
This was apparently forgotten in the initial implementation, then implemented by seanmonster in 2019 (!), reverted again due to major performance regressions, and eventually reimplemented partially in 2020 and completed in 2021.
There is now a stabilization report, which means we will hopefully see this riding the release train fairly soon.
Quite the saga!
There is an interesting takeaway here: even a seemingly small feature can be troublesome to implement in a complex environment, and can get stuck in limbo for a long time if no one is actively pushing it through.
Anyway, enough history.
Under the Hood
The current std::future::IntoFuture
trait looks like this:
pub trait IntoFuture {
/// The output that the future will produce on completion.
type Output;
/// Which kind of future are we turning this into?
type IntoFuture: Future<Output = Self::Output>;
/// Creates a future from a value.
fn into_future(self) -> Self::IntoFuture;
}
This should be mostly self-explanatory: the trait defines the final Output
type returned
by the future, the concrete Future
type, and a method to convert Self
into the future.
Every .await
first expands the expression with a call to IntoFuture::into_future()
.
Why don’t all Future
s need to implement IntoFuture
then?
They do! This is achieved by a default impl,
which is a no-op and just returns the type itself.
impl<F: Future> IntoFuture for F {
type Output = F::Output;
type IntoFuture = F;
fn into_future(self) -> Self::IntoFuture {
self
}
}
Examples Please!
Now we can finally look at some code.
I’ll go through the three motivating examples one by one.
Sleepy
A nice use case would be sleeping for a Duration
without manually calling tokio::time::sleep
.
This implementation would have to live in std
, which isn’t possible without
a generic executor infrastructure. But we can define a custom Duration
.
// Enable the feature.
// This is required until into_future hits stable.
#![feature(into_future)]
struct Duration(std::time::Duration);
impl Duration {
fn from_secs(secs: u64) -> Self {
Self(std::time::Duration::from_secs(secs))
}
}
// The juicy bit:
// Implement `IntoFuture` to convert our Duration into a `Future`.
impl std::future::IntoFuture for Duration {
type Output = ();
type IntoFuture = tokio::time::Sleep;
fn into_future(self) -> Self::IntoFuture {
tokio::time::sleep(self.0)
}
}
Et voilà, we can now just write:
#[tokio::main]
async fn main() {
Duration::from_secs(100).await;
}
This code already works on the current nightly Rust. You can try it out on the playground.
Pretty straight-forward, and it should be easy to adapt for different scenarios.
Joinery
Implementing .await
on a Vec<_>
of futures or on tuples like (Fut1, Fut2, ...)
again would have to be done in std
. This would also require std
gaining some
functionality from the futures
crate, namely futures::future::JoinAll
.
But we can emulate it again with a custom wrapper.
// Enable the feature.
// This is required until into_future hits stable.
#![feature(into_future)]
struct MyVec<T>(Vec<T>);
impl<F: std::future::Future> std::future::IntoFuture for MyVec<F> {
type Output = Vec<F::Output>;
type IntoFuture = futures::future::JoinAll<F>;
fn into_future(self) -> Self::IntoFuture {
futures::future::join_all(self.0)
}
}
And here we go:
use std::future::ready;
#[tokio::main]
async fn main() {
let out = MyVec(vec![ready(1), ready(2), ready(3)]).await;
assert_eq!(vec![1, 2, 3], out);
}
For completeness, another working playground example.
Builder
The builder pattern should be obvious to implement now.
Below is a skeleton implementation of an HTTP request builder.
// Enable the feature.
// This is required until into_future hits stable.
#![feature(into_future)]
pub struct Builder {
uri: http::Uri,
...
}
// The future that executes a request.
pub struct RequestFuture { ... }
impl std::future::Future for ResponseFuture { ... }
impl Builder {
...
fn send(self) -> ResponseFuture { ... }
}
impl std::future::IntoFuture for Builder {
type Output = http::Response<Vec<u8>>;
type IntoFuture = ResponseFuture;
fn into_future(self) -> Self::IntoFuture {
self.send()
}
}
The following will now work:
Builder::new()
...
.await
A silly but working example on the playground.
Caveats
Basic rule of life: every good thing also comes with downsides.
IntoFuture
comes with some notable caveats.
What’s Going On?
Is Duration::from_secs(1).await
really a good idea? Or is it confusing?
The Rust community (includig myself!) often favors explicitness over magic.
IntoFuture
adds some compiler magic and extends the available functionality.
It increases the expressivity of the language.
Like every such feature, it also has a potential to make code more cryptic.
Here is why I still like it:
The conversion doesn’t happen randomly, but only at .await
points.
We can re-conceptualize <EXPR>.await
as an alias for <EXPR>.into_future().await
.
123.await
or some_user.await
would indeed be very confusing. But take the
request_builder.send()
example: is there another reasonable mapping from
a request builder to a future? I don’t see any.
There are many types where there is a clear, logical mapping from the type to a corresponding future.
It might seem odd to .await
on a regular type at first, but I believe this just
requires a small mindset shift.
Implementing IntoFuture
on types will be a case by case decision, and should
be avoided when it wouldn’t make sense. But otherwise it can meaningfully increase the
expressiveness of Rust code.
(Compiler) Performance
Resolving the IntoFuture
trait implementations and inserting a method call for
each .await
increases the workload for the compiler.
The implementation has some overhead, but it was deemed expected and acceptable.
Runtime performance will not be affected in release builds, since the overhead will be optimized away.
The same is not necessarily true for debug builds though, which might suffer a bit.
But this should not be something you have to worry about, unless you have hundreds of thousands
of await
calls. It’s just another function call in the end.
What’s my Type?
The second caveat is a general problem with async
Rust today.
Remember the send()
definition from reqwest
?
pub fn send(self) -> impl Future<Output = Result<Response, Error>>;
It uses -> impl Future<_>
to avoid manually implementing Future
and makes
the compiler do the annoying work instead.
This won’t fly for implementing IntoFuture
, since the definition requires a
concrete type.
The easy workaround is to use a
BoxFuture
,
which needs an additional allocation, but that will rarely be relevant outside of
a no-std
environment.
Tangent: there is another language feature on the horizon that will solve this problem: type_alias_impl_trait.
It allows us to define a type alias and constrain it by a trait - without specifying the actual type definition. The compiler will resolve the alias to a concrete type based on later usage.
This is not just useful for async! It helps where naming the concrete type is hard, like long iterator chains, or impossible like for closures.
With type_alias_impl_trait
we can easily solve the above problem:
#![feature(type_alias_impl_trait)]
// ^^^^^^^^^^^^^^^^^^^^^
// Enable the type_alias_impl_trait feature.
#![feature(into_future)]
struct Response;
struct Builder;
// Define a type alias that must implement Future<Output = Response>,
// but let the compiler infer what the type actually is.
type ResponseFuture = impl std::future::Future<Output = Response>;
impl Builder {
fn send(self) -> ResponseFuture {
// ^^^^^^^^^^^^^^
// Use the previously declared type type alias.
// `ResonseFuture` will now be "bound" to the concrete type
// returned by this function.
async move {
// Complex code here...
...
Response
}
}
}
impl std::future::IntoFuture for Builder {
type Output = Response;
type IntoFuture = ResponseFuture;
// ^^^^^^^^^^^^^^
// Use the alias, which now is resolved a concrete type.
fn into_future(self) -> Self::IntoFuture {
self.send()
}
}
This code compiles fine on nightly, as verified by the playground.
The feature is reportedly also moving towards stabilization, which is exciting.
The Gist
This post covered the history of IntoFuture
, explained how it works and presented
some example implementations.
IntoFuture
is a relatively minor but very welcome improvement to the Rust
async story, especially if some implementations can make it into std
.
There are many other missing features that async
Rust still needs, like …
async in traits, type alias impl trait, async closures, async drop,
merging most of the futures
crate into std
, common APIs abstracting over executors …
just to name a few.
Let’s hope this sets a trend and I can write a lot of similar posts in the not too distant future.
Discuss on Reddit