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 .awaiting 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 .awaited 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 Futures 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