As 2022 draws to a close, I want to take a moment to look at where we are with supporting async functions in dyn traits (AFIDT) and suggest some ideas for how to make progress in 2023.

The set the stage, we'd like to make the following snippet of code work:

trait AsyncCounter {
    async fn increment_by(&mut self, amount: usize);
    async fn get_value(&self) -> usize;
}

async fn use_counter(counter: &mut dyn AsyncCounter) {
    counter.increment_by(42).await;
    counter.get_value().await
}

Through the rest of this post we'll see how close we are to getting that working and what's left to get it across the finish line.

Current Status

Static Async Functions in Traits

Thanks to some awesome work primarily by Michael Goulet, we have support in the nightly compiler for async functions in traits in static dispatch contexts. This means we can make our starting example compile with just one change, replacing dyn with impl [playground].

trait AsyncCounter {
    async fn increment_by(&mut self, amount: usize);
    async fn get_value(&self) -> usize;
}

async fn use_counter(counter: &mut impl AsyncCounter) -> usize {
    counter.increment_by(42).await;
    counter.get_value().await
}

This functionality is powerful, and given that generics in Rust seem much more common that trait objects, I suspect this provides most of the value people are looking for from async functions in traits.

dyn*

The next piece that's currently working on nightly is a new kind of trait object called dyn* Trait. This gives us part of the solution for what the return type for async functions needs to be when using them as objects. Recall that async fn foo() -> usize is sugar for fn foo() -> impl Future<Output = usize>, and in traits the impl Future<Output = usize> part becomes an associated type in the trait. In the static case, each impl can have its own type for the return value but in the dynamic case we need to have a type that will work for all possible impls. In other words, the return type needs to be dynamic.

We could do this by boxing the return value. This is what the async-trait crate does, by saying any async trait method returns Box<dyn Future>. This approach has some downsides that we'd like to avoid baking into the language. Doing so would mean calling async methods always incurs a heap allocation. This is potentially a performance problem, but more importantly it means that the feature would be unusable in situations where you don't have a memory allocator, which would itself be breaking precedent from existing Rust features.

What we want is a way to return a value that says "this implements Future, but that's all you know about it." Trait objects get us close to that, but you always use them behind a pointer. If we used Box<dyn Future>, we'd be in the same case we were before. We could try to return &mut dyn Future, but then we don't know where the return value is borrowed from or who is responsible for solving it.

dyn* works around these issues by also hiding what kind of pointer it is (or even if it is a pointer). All you know is that the object implements the trait, and you have some drop function you need to call when you're done with it that does any cleanup. Thus, using dyn* Future as the return type for async methods gives us something that meets all our requirements and can make the trait object-safe.

Anyway, enough with the motivation. The important part here is that we have an experimental implementation of dyn* also working in the nightly compiler.

What's Left

Type System Foundations

Before we can get all the way to async functions in trait objects, we need a few more extensions to the type system. To illustrate this a little better, I want to show conceptually how async methods get lowered by the compiler.

We start with the async fn methods, like we had at the beginning of the post.

trait AsyncCounter {
    async fn increment_by(&mut self, amount: usize);
    async fn get_value(&self) -> usize;
}

The next step is to get rid of the async keyword and change the return values to impl Future.

trait AsyncCounter {
    fn increment_by(&mut self, amount: usize) -> impl Future<Output = ()>;
    fn get_value(&self) -> impl Future<Output = usize>;
}

The compiler treads -> impl Trait methods like an associated type for the return value for each method.

trait AsyncCounter {
    type IncrementBy<'a>: Future<Output = ()> + 'a
    where
        Self: 'a;
    fn increment_by<'a>(&'a mut self, amount: usize) -> Self::IncrementBy<'a>;

    type GetValue<'a>: Future<Output = usize> + 'a
    where
        Self: 'a;
    fn get_value<'a>(&'a self) -> Self::GetValue<'a>;
}

Once we got to this point, we could theoretically make a dyn-safe version of the trait by explicitly substituting all the associated types with a dyn* type. The result would look like something like this:

async fn use_counter(
    counter: &mut dyn AsyncCounter<
        IncrementBy = dyn* Future<Output = ()>,
        GetValue = dyn* Future<Output = usize>,
    >,
) -> usize {
    counter.increment_by(42).await;
    counter.get_value().await
}

This is getting rather verbose, but it's also wrong. According to the compiler, we've forgotten our lifetime parameters.

error[E0107]: missing generics for associated type `AsyncCounter::IncrementBy`
  --> src/lib.rs:20:9
   |
20 |         IncrementBy = dyn* Future<Output = ()>,
   |         ^^^^^^^^^^^ expected 1 lifetime argument
   |
note: associated type defined here, with 1 lifetime parameter: `'a`
  --> src/lib.rs:7:10
   |
7  |     type IncrementBy<'a>: Future<Output = ()> + 'a
   |          ^^^^^^^^^^^ --
help: add missing lifetime argument
   |
20 |         IncrementBy<'_> = dyn* Future<Output = ()>,
   |         ~~~~~~~~~~~~~~~

(and similarly for AsyncCounter::GetValue)

So what do you we use as our lifetime parameter?

Well, it turns out we can't say what we want to in Rust just yet. What we'd like is something like this:

async fn use_counter(
    counter: &mut dyn AsyncCounter<
        for<'a> IncrementBy<'a> = dyn* Future<Output = ()> + 'a,
        for<'a> GetValue<'a> = dyn* Future<Output = usize> + 'a,
    >,
) -> usize {
    counter.increment_by(42).await;
    counter.get_value().await
}

But that doesn't even parse, let alone have support for it in the type checker.

The closest I was able to get was be enabling the generic_associated_types_extended feature, and then I could write the following:

async fn use_counter(
    counter: &mut impl for<'a> AsyncCounter<
        IncrementBy<'a> = dyn* Future<Output = ()> + 'a,
        GetValue<'a> = dyn* Future<Output = usize> + 'a,
    >,
) -> usize {
    counter.increment_by(42).await;
    counter.get_value().await
}

But here for for<'a> isn't example where we want it, and also I've switched one of the dyns to an impl.

As I understand it, the generic_associated_types_extended feature is basically a placeholder for things we might add to GATs in the future. I'm not familiar with what these extensions might be, so this is definitely an area I'm going to dive into soon. From what I can tell, the feature currently disables a few errors, but that means that it is almost certainly wildly unsound. There should be lots of room for fun here!

Ergonomics

In theory, if we had the type support, we could manually do async functions in trait object, but it'd be painful. It would essentially amount to writing one of the lowered examples from the previous section all the time.

So there's a lot of polish work to make this a pleasant feature to use. I think there is some open design space here, so I'll lay out a few possibilities that I see.

Auto-dyn Traits

Hmm, auto-dyn sounds like some nefarious corporation from the Terminator universe. Anyway, we saw above that traits with async methods are not dyn-safe. But, they become dyn-safe if you give values for all the associated types. This is what we did in this sample of code:

async fn use_counter(
    counter: &mut dyn AsyncCounter<
        IncrementBy = dyn* Future<Output = ()>,
        GetValue = dyn* Future<Output = usize>,
    >,
) -> usize {
    counter.increment_by(42).await;
    counter.get_value().await
}

Unfortunately, if we had async fn increment_by(...) and such, there's not currently a way to name the return type for that method. So, we could add syntax to do that, which might look something like this:1

1
2
3
4
5
6
7
8
9
async fn use_counter(
    counter: &mut dyn AsyncCounter<
        increment_by(..) = dyn* Future<Output = ()>,
        get_value(..) = dyn* Future<Output = usize>,
    >,
) -> usize {
    counter.increment_by(42).await;
    counter.get_value().await
}

This functionality could be useful in other scenarios, so I hope we get some version of this, but it's going to get verbose doing this on every async trait. But we could also have the compiler do this automatically for us. For any async methods, the compiler can set the hidden associated type for the return value to be the right dyn* type.2 Then the user would hopefully rarely have to be aware that transformation had happened.

If the compiler did this transformation, then things should just work and we can write code like this:

1
2
3
4
async fn use_counter(counter: &mut dyn AsyncCounter) -> usize {
    counter.increment_by(42).await;
    counter.get_value().await
}

And that's exactly where we said we wanted to be at the beginning of this post.

Dyn-safe impls

So far we've only focused on how to use a dyn AsyncCounter once someone has given you one. But we also need to be able to create such objects, or all the other code we've looked at in this post will be useless.

Ideally we'd write our impl just like any other:

impl AsyncCounter for MyCounter {
    async fn increment_by(&mut self, amount: usize) {
        ...
    }

    async fn get_value(&self) -> usize {
        ...
    }
}

Then we'd be able to call use_counter like this:

async fn call_dyn_use_counter(counter: &mut MyCounter) -> usize {
    use_counter(counter).await
}

In this code, the call to use_counter(counter) would take the &mut MyCounter that is counter and coerce it into a &mut dyn AsyncCounter that we can pass into use_counter. But doing so is likely to give us an error. The reason is that use_counter is expecting dyn* types for all of AsyncCounter's associated types, but the impl AsyncCounter for MyCounter has opaque future types instead.

There is a workaround in reach. The currently implementation of Return Position Impl Trait In Traits (RPITIT) allows you to use a more specific return type when you implement a trait, so you could make your impl dyn-safe by specifying dyn* as the return type (or any other concrete future type that works for you). This would give us:

impl AsyncCounter for MyCounter {
    fn increment_by(&mut self, amount: usize) -> dyn* Future<Output = ()> + '_ {
        async { ... }
    }

    fn get_value(&self) -> dyn* Future<Output = usize> + '_ {
        async { ... }
    }
}

And if you try to compile this, at least right now get a lovely type checking cycle.

Full type checking cycle
error[E0391]: cycle detected when type-checking `::increment_by`
  --> src/main.rs:19:5
   |
19 |     fn increment_by(&mut self, amount: usize) -> dyn* Future + '_ {
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: ...which requires computing layout of `[async block@src/main.rs:20:9: 20:39]`...
note: ...which requires optimizing MIR for `::increment_by::{closure#0}`...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
note: ...which requires elaborating drops for `::increment_by::{closure#0}`...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
note: ...which requires borrow-checking `::increment_by::{closure#0}`...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
note: ...which requires processing MIR for `::increment_by::{closure#0}`...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
note: ...which requires preparing `::increment_by::{closure#0}` for borrow checking...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
note: ...which requires unsafety-checking `::increment_by::{closure#0}`...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
note: ...which requires building MIR for `::increment_by::{closure#0}`...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
note: ...which requires building THIR for `::increment_by::{closure#0}`...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
note: ...which requires type-checking `::increment_by::{closure#0}`...
  --> src/main.rs:20:9
   |
20 |         async move { ... }
   |         ^^^^^^^^^^^^^^^^^^
   = note: ...which again requires type-checking `::increment_by`, completing the cycle
   = note: cycle used when type-checking all item bodies

For more information about this error, try rustc --explain E0391.

The problem is that to return a future as a dyn*, the future must be PointerSized, but the compiler can't figure that out. It might be that we could be more precise and break the cycle here3, but even if we could most interesting futures are not going to be PointerSized.

So instead we can solve this by wrapping the body in something that is PointerSized, like a Box.

impl AsyncCounter for MyCounter {
    fn increment_by(&mut self, amount: usize) -> dyn* Future<Output = ()> + '_ {
        Box::pin(async { ... })
    }

    fn get_value(&self) -> dyn* Future<Output = usize> + '_ {
        Box::pin(async { ... })
    }
}

This would compile (and indeed something close to it compiles today if you add enough feature flags) but it's not great. It's more verbose than I'd like. It also means that you have to decide when you do the implementation if it's going to be dyn-safe or not. And finally, a dyn-safe impl will pay that overhead even when used in a static dispatch context, which violates Rust's zero-cost abstraction principle.

Dyn-safe Wrappers

One way to get the best of both worlds while writing even more code is to add dyn-safe wrappers. The idea is to write a struct that wraps anything that implements your trait but adapts the return value into a dyn* Future. Such a wrapper would look like this:

struct DynSafeAsyncCounter<T>(T)

impl<T: AsyncCounter> AsyncCounter for DynSafeAsyncCounter<T> {
    fn increment_by(&mut self, amount: usize) -> dyn* Future<Output = ()> + '_ {
        Box::pin(self.0.increment_by(usize))
    }

    fn get_value(&self) -> dyn* Future<Output = usize> + '_ {
        Box::pin(self.get_value())
    }
}

And then to coerce anything that impls AsyncCounter into a dyn-safe version, we just have to wrap it in DynSafeAsyncCounter, like this:

async fn call_dyn_use_counter(counter: &mut MyCounter) -> usize {
    use_counter(DynSafeAsyncCounter(counter)).await
}

While being a little verbose, especially on the wrapper impl side, this approach also has some extra functionality. For example, we could combine this with the allocators API or storage API to enable things like arena-allocated or stack-allocated futures in environments where heap allocation is undesirable or unavailable.

Auto-dyn Wrappers

The manual wrappers in the last section are good for looking at what goes on behind the scenes, users would likely prefer not to have to write all that boilerplate most of the time. Fortunately, this is also the kind of thing that can be automated in many cases.

One way we could do this is what proposed with the Boxing adapter. Using Boxing looks pretty similar to the DynSafeAsyncCounter wrapper above:

async fn call_dyn_use_counter(counter: &mut MyCounter) -> usize {
    use_counter(Boxing::new(counter)).await
}

The important difference though is that we don't have a different wrapper for each trait we might want to convert. Instead, Boxing works for all traits. How, you ask? Compiler magic, of course!

In theory, this approach also allows for things like InlineAdapter to store the returned futures on the heap rather than the stack. Unfortunately, because this approach relies on compiler magic, in practice we'd be limited to only a handful of adapter types because each one would need to be built into the compiler.

There might be other approaches that could work, but the really powerful ones are likely to require new language features.

Higher Order Impls

When I see things that the compiler or standard library can do that the programmer can't do in this language, I see this as often a sign of missing features from the language. Let's return to the compiler magic needed to make the Boxing wrapper work. We'll imagine Boxing::new is a function that takes a T and gives you a Boxing<T> (hopefully not an unreasonable assumption). The reason in our example of using Boxing::new(counter) that we didn't have to mention AsyncCounter anywhere like we did with DynSafeAsyncCounter is that with Boxing, for any trait that T implements, the compiler generates an impl of that trait for Boxing<T>.

We don't have a way to express that in Rust today, but if we did, it might look like this:

impl<trait Trait, T: Trait> Trait for Boxing<T> {
    // What goes here???
}

What this example does is creates a new kind of generic parameter. We can already do type parameters (T), lifetime parameters ('a), and const parameters (const X), so this would add a new kind for trait parameters (trait Trait). We then constrain the type parameter to have to implement the Trait we are abstracting over, and then say that we are showing how to implement Trait for Boxing<T>.

It's not really clear what goes in the impl body though. In a normal impl we'd list out all the methods and such of the trait and provide implementations of those. The problem is, we know nothing about Trait other than that it's a trait, so we can't list any methods. Instead, we need to tell the compiler how to generate an implementation from any trait given and implementation for another type. We can get a long way with conversion methods that show you how to adapt the Self type into an impl Trait. For our use case of making dyn-safe async traits, we'd also want some kind of wrapper to run over the return values that does the boxing and coercion into a dyn*. We might be able to forward any associated types and constants to the underlying trait (e.g. type AssociatedType = T::AssociatedType), but that may not be what we want in all cases.

Following this to its conclusion, we'd probably want ways to hook any methods, types, and constants that meet certain criteria and apply some kind of transformations to them. If we call these transformations "advice" then this feature starts to sound a lot like aspect-oriented programming.

But maybe we could take inspiration from elsewhere as well. For example, Haskell has generalised derived instances for newtypes and deriving via which both sound like they might be relevant.

I suspect this would turn out to be a very gnarly feature and hard to prove soundness for. But if we could do it, it gives us a powerful solution to the dyn-safe wrapper problem that's also applicable in other areas. For example, right now smart pointers like Box, Rc, and Arc do not implement the traits of the thing they point to. There are bespoke forwarding impls for a lot of traits, like Debug, Display, Fn*, Read, Write, Future, etc., but these all that to be manually added. In many cases this isn't really noticeable, because the method call syntax can unwrap the pointer using Deref and DerefMut, but it does cause problems sometime. With higher order impls, we could add an impl<trait Trait, T: Trait> Trait for Box<T> and friends.

Conclusion

I'm really excited about the progress we've made in 2022 towards async support in Rust generally and specifically around async functions in traits! In writing this post, I've realized how much fun work there still is left to do.

I know I've personally had some trouble keeping the big picture in mind. I've though "well, we have experimental dyn* support and AFIT support, so all that's left are details." Writing this post has been helpful to me to clarify some of the things I'm aware of that are missing, and I hope it's helpful to others in the same way. I'm sure I've missed some things, and there are definitely several design options available, so I'd love feedback about what's missing and which approaches would best meet the needs of users. If you'd like to get involved, please reach out on Zulip!

As far as priorities for now go, I think there are two main things.

The most important is to figure out the type system implications and enable any missing functionality. This would let us write async functions in trait objects by hand or maybe with some macros. This means the language has the power we want, even if it isn't quite as ergonomic.

Secondly, I think the remaining design questions would benefit from a lot of simplification. Hopefully there is some minimally viable subset that would meet real user needs with nice ergonomics without having to wait on speculative features like higher order imples. Finding just the right subset is going to take some time, but I think the care the Rust project has put into finding the right set of features in previous instances is a huge part of what makes Rust the great language it is today!

I'm excited that there are so many fun problems to work on in this area, and I'm looking forward to a great 2023 for async Rust!


1

I'm not really a fan of this proposed syntax, but I also haven't seen any that I like better.

2

We'd probably want to do a similar transformation for any function that returns impl Trait as well.

3

I doubt it...