Trust is earned, not given

A different perspective

2023-12-12 · Projects

Rust, part 8: async fn in traits arrives (1.75), and what four years of workarounds cost

Part 8from the Rust series · 15 parts in all

The most-requested Rust feature of its era was one line of syntax: an async fn inside a trait. It stabilised in Rust 1.75 (December 2023) after roughly four years of design, and part 8 is what it replaced — because the workaround is still in most codebases and its costs are still being paid.

Before: a macro that boxed every future

use async_trait::async_trait;

#[async_trait]
pub trait Fetcher {
    // Expanded to something like:
    //   fn fetch(&self, id: u64) ->
    //       Pin<Box<dyn Future<Output = Result<String, Error> + Send + '_>>;
    async fn fetch(&self, id: u64) -> Result<String, Error>;
}

It worked, and it charged for it: one heap allocation per call for the boxed future, no inlining across the boundary, and a + Send bound baked into the trait that quietly decided your futures had to be Send whether or not your use case needed it.

After: the same code, no box

pub trait Fetcher {
    async fn fetch(&self, id: u64) -> Result<String, Error>;
}

// What the desugaring is, for reference - this is why the bound is needed.
pub trait Fetcher {
    fn fetch(&self, id: u64)
        -> impl Future<Output = Result<String, Error>>;
}

The return-position impl Trait in trait position (RPITIT) that makes this work also landed more general value: an associated type can now be a lifetime-parameterised type (part of the GATs stabilization in 1.65), which is what finally made streaming, borrowing and lending iterators expressible in traits at all.

The catch nobody should be surprised by

An async fn in a trait does not promise the returned future is Send, and Rust will not add that promise for you because it cannot see whether your body holds a non-Send type across an await. So spawning a dyn Fetcher from a multi-threaded executor fails to compile, and the fix is to say it out loud:

// Where Send is required, ask for it explicitly. (Before 1.75 this was the
// only option at all; afterwards it is a deliberate choice.)
trait Fetcher: Send + Sync {
    fn fetch(&self, id: u64) ->
        impl Future<Output = Result<String, Error>> + Send;
}

That is the shape most production traits converged on, and it is why the async_trait macro is still in dependency trees even in new projects: libraries kept it for compatibility, and its Send bound was the behaviour callers had already written against. Next: the error message that follows from all of this, and the one every async Rust developer eventually sees — "future is not Send".