Thumbnail for {{ page.title }}

Catching up with async Rust

This is a dual feature! It's available as a video too. Watch on YouTube

In December 2023, a minor miracle happened: async fn in traits shipped.

As of Rust 1.39, we already had free-standing async functions:

pub async fn read_hosts() -> eyre::Result<Vec<u8>> { // etc. }

…and async functions in impl blocks:

impl HostReader { pub async fn read_hosts(&self) -> eyre::Result<Vec<u8>> { // etc. } }

But we did not have async functions in traits:

use std::io; trait AsyncRead { async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; }
sansioex on  main via 🦀 v1.82.0 cargo +1.74.0 check --quiet error[E0706]: functions in traits cannot be declared `async` --> src/main.rs:9:5 | 9 | async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; | -----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | | | `async` because of this | = note: `async` trait functions are not currently supported = note: consider using the `async-trait` crate: https://crates.io/crates/async-trait = note: see issue #91611 <https://github.com/rust-lang/rust/issues/91611> for more information For more information about this error, try `rustc --explain E0706`. error: could not compile `sansioex` (bin "sansioex") due to previous error
Cool bear Cool Bear's hot tip

The cargo +channel syntax is valid because cargo here is a shim provided by rustup.

Valid channel names look like x.y.z, stable, beta, nightly, etc. — the same thing you’d encounter in rust-toolchain.toml files or any other toolchain override.

For the longest time, the async-trait crate was recommended to have async fn in traits:

use std::io; #[async_trait::async_trait] trait AsyncRead { async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; }

It worked, but it changed the trait definition (and any implementations) to return pinned, boxed futures.

Cool bear

Boxed futures?

Yeah! Futures that have been allocated on the heap.

Cool bear

Because?

Ah well, because, you see, futures — the value that async functions return — can be of different sizes!

The size of locals

The future returned by this function:

async fn foo() { tokio::time::sleep(std::time::Duration::from_secs(1)).await; println!("done"); }

…is smaller than the future returned by that function:

async fn bar() { let mut a = [0u8; 72]; tokio::time::sleep(std::time::Duration::from_secs(1)).await; for _ in 0..10 { a[0] += 1; } println!("done"); }

Because bar simply has more things going on — more state to keep track of:

sansioex on  main [!] via 🦀 v1.82.0 cargo run --quiet foo: 128 bar: 200

That array there is not deallocated while we sleep asynchronously — it’s all stored in, well, in the future.

And that’s a problem because typically, when we call a function, we want to know how much space we should reserve for the return value: we say that the return value is “sized”.

And by “we”, I really mean the compiler — reserving stack space for locals is one of the first things a function does when it’s called.

Here, the compiler reserves space on the stack for step1, _foo and step2:

fn main() { let step1: u64 = 0; let _foo = foo(); let step2: u64 = 0; // etc. }

As seen in the disassembly:

sansioex on  main [!] via 🦀 v1.83.0 cargo asm sansioex::main --quiet --simplify --color | head -5 sansioex::main: Lfunc_begin45: sub sp, sp, #256 stp x20, x19, [sp, #224] stp x29, x30, [sp, #240]

That “sub” here reserves 256 bytes total.

Cool bear Cool Bear's hot tip

The cargo asm subcommand shown here is from cargo-show-asm, installed via cargo install --locked --all-features cargo-show-asm

The original cargo-asm crate still works but has less functionality and hasn’t been updated since 2018.

Although there’s nothing forcing the compiler to do so, all the locals in our code…

fn main() { let step1: u64 = 0; let _foo = foo(); let step2: u64 = 0; println!( "distance in bytes between before and after: {:?}", (&step2 as *const _ as u64) - (&step1 as *const _ as u64) ); }

…are laid out next to each other — and so we can predict what it’s going to print: the distance between step1 and step2 on the stack.

Cool bear

Uhhh… 128? The size of the future returned by foo?

Almost!

sansioex on  main [!] via 🦀 v1.82.0 cargo run --quiet distance in bytes between before and after: 136

The distance between step1 and step2 includes the size of step1. Our stack looks a little something like this:

Cool bear

Crap, yes, right.

Don’t worry bear, everyone makes fencepost errors.

Just boxing it

Back to this AsyncRead trait:

use std::io; trait AsyncRead { async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; }

If we somehow got ahold of a &dyn AsyncRead, and called read on it:

async fn use_read(r: &mut dyn AsyncRead) { let mut buf = [0; 1024]; // how large is this local? let fut = r.read(&mut buf); fut.await; }

…how would we know how much space to reserve for fut?

Cool bear

Well… I imagine we could make “the size of the future” part of the AsyncRead trait.

That way we could first query that size, then allocate it, then call read, passing it a pointer to the space we just reserved…

Right! I’m pretty sure that’s how unsized locals work — and roughly what the longer-term plan is.

But for the time being, the only way to hold an arbitrary future is to box it!

Even though these are two different futures of different sizes, the locals are the same size, because it’s just a pointer to the actual future, which is… 8 bytes.

fn main() { let _foo: Pin<Box<dyn Future<Output = ()>>> = Box::pin(foo()); let _bar: Pin<Box<dyn Future<Output = ()>>> = Box::pin(bar()); println!("Size of foo: {} bytes", std::mem::size_of_val(&_foo)); println!("Size of bar: {} bytes", std::mem::size_of_val(&_bar)); }
sansioex on  main [!] via 🦀 v1.82.0 cargo run --quiet Size of foo: 16 bytes Size of bar: 16 bytes

Cool bear

But… this prints 16 bytes.

Yup! I did a little lie just now.

Let’s look at those values closer.

Dynamic dispatch

We’ll use LLDB to run through our program, setting a breakpoint right before the end of main:

sansioex on  main [!] via 🦀 v1.83.0 cargo b --quiet && rust-lldb ./target/debug/sansioex -Q Current executable set to '/Users/amos/bearcove/sansioex/target/debug/sansioex' (arm64). (lldb) b main.rs:78 Breakpoint 1: where = sansioex`sansioex::main::h616d18632113fc9e + 496 at main.rs:78:39, address = 0x000000010000478c (lldb) r Process 41462 launched: '/Users/amos/bearcove/sansioex/target/debug/sansioex' (arm64) Size of foo: 16 bytes Process 41462 stopped * thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: 0x000000010000478c sansioex`sansioex::main::h616d18632113fc9e at main.rs:78:39 75 let _bar: Pin<Box<dyn Future<Output = ()>>> = Box::pin(bar()); 76 77 println!("Size of foo: {} bytes", std::mem::size_of_val(&_foo)); -> 78 println!("Size of bar: {} bytes", std::mem::size_of_val(&_bar)); 79 } Target 0: (sansioex) stopped.

LLDB has limited support for Rust, but we can still print the local _foo:

(lldb) p _foo (core::pin::Pin<alloc::boxed::Box<dyn core::future::future::Future<Output=()>, alloc::alloc::Global> >) { __pointer = { pointer = 0x0000600001a24100 vtable = 0x0000000100084068 } }

And there we have our two pointers! 16 bytes total.

Cool bear

What does vtable point to?

Well, pointer points to data, and vtable points to code: if we poke at it, we find 64-bit values that look a lot like addresses…

(lldb) x/8gx .__pointer.vtable 0x100084068: 0x0000000100004ae4 0x0000000000000080 0x100084078: 0x0000000000000008 0x0000000100004c58 0x100084088: 0x0000000100004a60 0x00000000000000c8 0x100084098: 0x0000000000000008 0x0000000100004e50

And if we look up one of them, we find that it is, in fact, the address of our async function:

(lldb) image lookup -a 0x0000000100004c58 Address: sansioex[0x0000000100004c58] (sansioex.__TEXT.__text + 3488) Summary: sansioex`sansioex::foo::_$u7b$$u7b$closure$u7d$$u7d$::h3b04e16f55b57af9 at main.rs:59

Or rather, a closure in our async function. But that’s an implementation detail.

Other fields in the vtable point to other functions: typically, one of those will be the Drop implementation for a given type — it’s important to know how to free a value we hold!

Note that not all “boxes” are two pointers. Only “boxed trait objects” are.

fn main() { let s = String::from("I am on the heap AMA"); let b = Box::new(s); print_type_name_and_size(&b); let b: Box<dyn std::fmt::Display> = b; print_type_name_and_size(&b); } fn print_type_name_and_size<T>(_: &T) { println!( "\x1b[1m{:45}\x1b[0m \x1b[32m{} bytes\x1b[0m", std::any::type_name::<T>(), std::mem::size_of::<T>(), ); }
sansioex on  main [!] via 🦀 v1.83.0 cargo run --quiet alloc::boxed::Box<alloc::string::String> 8 bytes alloc::boxed::Box<dyn core::fmt::Display> 16 bytes

That’s the dyn in Box<dyn Trait> — dynamic dispatch, via the vtable.

And that’s what the async-trait crate does! It transforms this:

#[async_trait::async_trait] trait AsyncRead { async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; }

Into this:

trait AsyncRead { #[must_use] #[allow( elided_named_lifetimes, clippy::type_complexity, clippy::type_repetition_in_bounds )] fn read<'life0, 'life1, 'async_trait>( &'life0 mut self, buf: &'life1 mut [u8], ) -> ::core::pin::Pin< Box< dyn ::core::future::Future<Output = io::Result<usize>> + ::core::marker::Send + 'async_trait, >, > where 'life0: 'async_trait, 'life1: 'async_trait, Self: 'async_trait; }
Cool bear

Well that’s a mouthful.

Yes, the output of procedural macros is seldom readable — the important bit is the return type, Pin<Box<dyn Future>> — this will be 16 bytes, no matter the actual implementation of AsyncRead::read.

dyn-compatibility

But let’s forget the async-trait crate for a moment, because, as of Rust 1.75, there is native support for async functions in traits!

use std::io; trait AsyncRead { async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; }

We can implement that trait on any type, since we’ve defined the trait ourselves (see orphan rules):

impl AsyncRead for () { async fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> { let a = [0u8; 72]; tokio::time::sleep(std::time::Duration::from_secs(1)).await; Ok(a[3] as _) } }

And this time, the futures aren’t boxed:

fn main() { let mut s = (); let mut buf = [0u8; 72]; let fut = s.read(&mut buf); print_type_name_and_size(&fut); }
sansioex on  main [!] via 🦀 v1.83.0 cargo run --quiet <() as sansioex::AsyncRead>::read::{{closure}} 224 bytes

If they were, this would print 16 bytes.

Cool bear

Well that’s great. So everything is solved, yes?

Not everything, no. What do you think this prints?

fn use_async_read(r: Box<dyn AsyncRead>) { let mut buf = [0u8; 72]; let fut = r.read(&mut buf); print_type_name_and_size(&fut); }
Cool bear

Well we’ve just seen that the future is 224 bytes…

…only for the empty tuple type, (), though!

Our parameter could be any type — any type that implements AsyncRead.

Cool bear

Right, so we can’t know. We have the “unsized locals” problem again.

Exactly:

error[E0038]: the trait `AsyncRead` cannot be made into an object --> src/main.rs:51:17 | 51 | let fut = r.read(&mut buf); | ^^^^ `AsyncRead` cannot be made into an object | note: for a trait to be "dyn-compatible" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety> --> src/main.rs:36:14 | 35 | trait AsyncRead { | --------- this trait cannot be made into an object... 36 | async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; | ^^^^ ...because method `read` is `async`

In the future maybe, the size of futures for async methods will be part of the vtable for trait objects, and async fn in traits will be dyn-compatible.

But for now, they’re not.

There are many other things that are dyn-incompatible: taking self by value, for example:

trait EatSelf { fn nomnomnom(self) {} }
sansioex on  main [!] via 🦀 v1.83.0 cargo c Checking sansioex v0.1.0 (/Users/amos/bearcove/sansioex) error[E0277]: the size for values of type `Self` cannot be known at compilation time --> src/main.rs:34:18 | 34 | fn nomnomnom(self) {} | ^^^^ doesn't have a size known at compile-time | help: consider further restricting `Self` | 34 | fn nomnomnom(self) where Self: Sized {} | +++++++++++++++++ help: function arguments must have a statically known size, borrowed types always have a known size | 34 | fn nomnomnom(&self) {} | + For more information about this error, try `rustc --explain E0277`. error: could not compile `sansioex` (bin "sansioex") due to 1 previous error
Cool bear

Could Box be an answer here too?

Yes! Although it’s not suggested by the compiler here, taking Box<Self> is fine, because it’s just a pointer, which has a predictable size. In fact, all smart pointers and references are okay:

// Examples of dyn-compatible methods. trait TraitMethods { fn by_ref(self: &Self) {} fn by_ref_mut(self: &mut Self) {} fn by_box(self: Box<Self>) {} fn by_rc(self: Rc<Self>) {} fn by_arc(self: Arc<Self>) {} fn by_pin(self: Pin<&Self>) {} fn with_lifetime<'a>(self: &'a Self) {} fn nested_pin(self: Pin<Arc<Self>>) {} }

This is to say: dyn-compatibility is an issue with traits in general, not just with async Rust.

Also, just because our trait isn’t dyn-compatible, doesn’t mean it’s impossible to work with. In my HTTP implementation loona, the whole API is designed around those limitations.

We can take an impl AsyncRead for example — that’s fine.

fn use_reader(_reader: impl AsyncRead) {}

Because it’s really just shorthand for this:

fn use_reader<R: AsyncRead>(_reader: R) {}

We can also take an &impl AsyncRead, or a &mut impl AsyncRead.

But we cannot take a &dyn AsyncRead — something in our trait violates the current restrictions of dyn compatibility, and that thing is that it has an implicit associated type.

Associated types

Because, when we have an async fn in trait:

trait AsyncRead { async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; }

We’re really saying that we have an fn that returns “something that implements Future”:

trait AsyncRead { fn read(&mut self, buf: &mut [u8]) -> impl Future<Output = io::Result<usize>>; }

And we’ve seen before that “impl Trait” in argument position translates to a generic type parameter:

fn use_reader(_reader: impl AsyncRead) {} // is equivalent to fn use_reader<R: AsyncRead>(_reader: R) {}

Well, when it’s in return position, it translates to an associated type parameter:

trait AsyncRead { fn read(&mut self, buf: &mut [u8]) -> impl Future<Output = io::Result<usize>>; } // is equivalent to trait AsyncRead { type ReadFuture: Future<Output = io::Result<usize>>; fn read(&mut self, buf: &mut [u8]) -> Self::ReadFuture; }

In fact, on Rust nightly, we can implement that trait pretty easily — this full program compiles and runs:

#![feature(impl_trait_in_assoc_type)] use std::{future::Future, io}; trait AsyncRead { type ReadFuture: Future<Output = io::Result<usize>>; fn read(&mut self, buf: &mut [u8]) -> Self::ReadFuture; } impl AsyncRead for () { // this is the unstable bit: `Self::ReadFuture` is inferred from the // body of `<() as AsyncRead>::read`. type ReadFuture = impl Future<Output = io::Result<usize>>; fn read(&mut self, _buf: &mut [u8]) -> Self::ReadFuture { async move { let a = [0u8; 72]; tokio::time::sleep(std::time::Duration::from_secs(1)).await; Ok(a[3] as _) } } } fn main() { let mut s: () = (); let mut buf = [0u8; 72]; let fut = s.read(&mut buf[..]); print_type_name_and_size(&fut); } fn print_type_name_and_size<T>(_: &T) { println!( "\x1b[1m{:45}\x1b[0m \x1b[32m{} bytes\x1b[0m", std::any::type_name::<T>(), std::mem::size_of::<T>(), ); }
sansioex on  main [!] via 🦀 v1.83.0 cargo +nightly run --quiet <() as sansioex::AsyncRead>::read::{{closure}} 200 bytes

This pattern should be familiar to those of you who have spent quality time with the tower crate, via hyper for example: their Service trait has a Future associated type:

pub trait Service<Request> { type Response; type Error; type Future: Future<Output = Result<Self::Response, Self::Error>>; // Required methods fn poll_ready( &mut self, cx: &mut Context<'_>, ) -> Poll<Result<(), Self::Error>>; fn call(&mut self, req: Request) -> Self::Future; }

And when implementing Service, you pretty much have three choices:

  • Implement Future by hand to avoid heap allocation
  • Set type Future to a boxed future (Pin<Box<dyn Future<...>>>)
  • Require Rust nightly and opt into #![feature(impl_trait_in_assoc_type)]

A refreshed Service trait

Well, as of Rust 1.75, one could imagine a simpler version of this trait:

trait Service<Request> { type Response; type Error; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>; async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>; }

Implementing it would be comparatively trivial — here’s a no-op service:

impl<Request> Service<Request> for () { type Response = (); type Error = (); fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { Poll::Ready(Ok(())) } async fn call(&mut self, _request: Request) -> Result<Self::Response, Self::Error> { Ok(()) } }

And here’s a service that logs any incoming request:

impl<S, Request> Service<Request> for LogRequest<S> where S: Service<Request>, Request: std::fmt::Debug, { type Response = S::Response; type Error = S::Error; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error> { println!("{:?}", request); self.inner.call(request).await } }

None of those futures are boxed, and this all works on Rust 1.75 stable:

#[tokio::main] async fn main() { let mut service = LogRequest { inner: () }; // (note: we assume the service is ready) let fut = service.call(()); print_type_name_and_size(&fut); fut.await.unwrap(); }
sansioex on  main [!] via 🦀 v1.83.0 cargo +1.75 run --quiet <sansioex::LogRequest<()> as sansioex::Service<()>>::call::{{closure}} 32 bytes ()

But this currently comes with several limitations.

Unnameable types

To start with, we can no longer “name” the return type of Service::call.

Some tower services rely on this, for example the built-in Either service:

enum Either<A, B> { Left(A), Right(B), } // Traditional tower Service trait impl<A, B, Request> Service<Request> for Either<A, B> where A: Service<Request>, B: Service<Request, Response = A::Response, Error = A::Error>, { type Response = A::Response; type Error = A::Error; type Future = EitherResponseFuture<A::Future, B::Future>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { match self { Either::Left(service) => service.poll_ready(cx), Either::Right(service) => service.poll_ready(cx), } } fn call(&mut self, request: Request) -> Self::Future { match self { Either::Left(service) => EitherResponseFuture { kind: Kind::Left { inner: service.call(request), }, }, Either::Right(service) => EitherResponseFuture { kind: Kind::Right { inner: service.call(request), }, }, } } }

However, this specific scenario is not an issue — the implementation of our simplified Service trait for Either is simpler and more natural:

// Our simplified Service trait impl<A, B, Request> Service<Request> for Either<A, B> where A: Service<Request>, B: Service<Request, Response = A::Response, Error = A::Error>, { type Response = A::Response; type Error = A::Error; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { match self { Either::Left(service) => service.poll_ready(cx), Either::Right(service) => service.poll_ready(cx), } } async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error> { match self { Either::Left(service) => service.call(request).await, Either::Right(service) => service.call(request).await, } } }

This is a win for “native” async fn in trait.

Things get complicated when it comes to specifying additional bounds, like lifetimes or Send-ness.

Lifetimes: a refresher

Rust famously differs from other languages in that it has you annotate lifetimes.

A function can return something that borrow from the input, but you must say so:

fn substring<'s>(input: &'s str, start: usize, end: usize) -> &'s str { &input[start..end] }
Cool bear Cool Bear's hot tip

Note that in this case, the lifetime annotations could be elided, because there’s only one lifetime to worry about, and thus, it’s clear that the returned value’s lifetime depends on the input.

In this example, t borrows from s, you can tell by looking at the range of addresses they both refer to.

fn main() { let s = String::from("Hello, world!"); let t = substring(&s, 0, 5); println!( "{:?}\n{:?}", s.as_bytes().as_ptr_range(), t.as_bytes().as_ptr_range() ); }
sansioex on  main [!] via 🦀 v1.83.0 cargo run --quiet 0x600003bcc040..0x600003bcc04d 0x600003bcc040..0x600003bcc045

Because we gave the compiler that information, it can prevent us from doing something dangerous, like using t after we’ve freed s:

fn main() { let s = String::from("Hello, world!"); let t = substring(&s, 0, 5); drop(s); println!("{t}"); }
sansioex on  main [!] via 🦀 v1.83.0 cargo check Checking sansioex v0.1.0 (/Users/amos/bearcove/sansioex) error[E0505]: cannot move out of `s` because it is borrowed --> src/main.rs:36:10 | 34 | let s = String::from("Hello, world!"); | - binding `s` declared here 35 | let t = substring(&s, 0, 5); | -- borrow of `s` occurs here 36 | drop(s); | ^ move out of `s` occurs here 37 | println!("{t}"); | --- borrow later used here | help: consider cloning the value if the performance cost is acceptable | 35 | let t = substring(&s.clone(), 0, 5); | ++++++++ For more information about this error, try `rustc --explain E0505`. error: could not compile `sansioex` (bin "sansioex") due to 1 previous error

You can also return something that doesn’t borrow from the input, we just have to say so:

fn substring(input: &str, start: usize, end: usize) -> String { input[start..end].to_string() }

Here, the returned value is “owned”, and our use-after-free is no longer a use-after-free:

fn main() { let s = String::from("Hello, world!"); let t = substring(&s, 0, 5); println!( "{:?}\n{:?}", s.as_bytes().as_ptr_range(), t.as_bytes().as_ptr_range() ); // this is fine now, `t` points to its own memory! drop(s); println!("{t}"); }
sansioex on  main [!] via 🦀 v1.83.0 cargo run Compiling sansioex v0.1.0 (/Users/amos/bearcove/sansioex) Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s Running `target/debug/sansioex` 0x600000a54000..0x600000a5400d 0x600000a54010..0x600000a54015 Hello

Hidden captures

Async functions add another layer of complication because they can be in a “partially executed” state, sort of.

Earlier, we equated this:

// Classic tower Service trait trait Service<Request> { type Response; type Error; type Future: Future<Output = Result<Self::Response, Self::Error>>; fn call(&mut self, request: Request) -> Self::Future; }

With this:

// Our simplified Service trait trait Service<Request> { type Response; type Error; async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>; }

But they’re not equivalent!

See, with the classic tower Service trait, the returned Future cannot borrow from self!

This implementation of it for the type i32, which accepts requests of type i32 and adds itself to it, doesn’t compile:

#![feature(impl_trait_in_assoc_type)] impl Service<i32> for i32 { type Response = i32; type Error = (); type Future = impl Future<Output = Result<Self::Response, Self::Error>>; fn call(&mut self, request: i32) -> Self::Future { async move { Ok(*self + request) } } }
sansioex on  main [✘!+] via 🦀 v1.85.0-nightly cargo +nightly check --quiet error[E0700]: hidden type for `<i32 as Service<i32>>::Future` captures lifetime that does not appear in bounds --> src/main.rs:19:9 | 16 | type Future = impl Future<Output = Result<Self::Response, Self::Error>>; | --------------------------------------------------------- opaque type defined here 17 | 18 | fn call(&mut self, request: i32) -> Self::Future { | --------- hidden type `{async block@src/main.rs:19:9: 19:19}` captures the anonymous lifetime defined here 19 | async move { Ok(*self + request) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ For more information about this error, try `rustc --explain E0700`. error: could not compile `sansioex` (bin "sansioex") due to 1 previous error

There is a way to make this work, and it’s to make the associated type generic (over a lifetime):

pub trait Service<Request> { type Response; type Error; type Future<'a>: Future<Output = Result<Self::Response, Self::Error>> + 'a where Self: 'a; fn call(&mut self, request: Request) -> Self::Future<'_>; }

This feature is called GATs, for “generic associated types”, and was introduced in Rust 1.65 — which explains why it’s not used by tower’s Service type.

If the Service type did use GATs, as shown, then we could borrow from self, like so:

impl Service<i32> for i32 { type Response = i32; type Error = (); type Future<'a> = impl Future<Output = Result<Self::Response, Self::Error>> + 'a; fn call(&mut self, request: i32) -> Self::Future<'_> { async move { Ok(*self + request) } } }

But we can’t, because that’s not how the tower Service trait is defined. It wants a Future that does not borrow from self, and so we have to do… whatever we need to do with self before returning a future.

In this weird contrived case, this means we need to dereference self before the async block:

sansioex on  main [!] via 🦀 v1.85.0-nightly gwd diff --git a/src/main.rs b/src/main.rs index 9fb8ffb..bcdbed2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,8 @@ impl Service<i32> for i32 { type Future = impl Future<Output = Result<Self::Response, Self::Error>>; fn call(&mut self, request: i32) -> Self::Future { - async move { Ok(*self + request) } + let this = *self; + async move { Ok(this + request) } } }

…and again, this isn’t stable — we’re using #![feature(impl_trait_in_assoc_type)].

By comparison, our simplified Service type works on Rust 1.75 stable, and allows borrowing from self:

trait Service<Request> { type Response; type Error; async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>; } impl Service<i32> for i32 { type Response = i32; type Error = (); async fn call(&mut self, request: i32) -> Result<Self::Response, Self::Error> { Ok(*self + request) } } #[tokio::main] async fn main() { let mut service: i32 = 1990; let res = service.call(34).await.unwrap(); println!("Result: \x1b[1;32m{res}\x1b[0m"); }
sansioex on  main [!+] via 🦀 v1.83.0 cargo run --quiet Result: 1990

But this is a breaking change. Being able to do service.call() several times in a row, obtaining several, separate, owned futures, and spawning them on an executor is a feature of the tower Service trait.

But with our simplified Service trait, we cannot have multiple concurrent requests:

#[tokio::main] async fn main() { let mut service: i32 = 2024; let fut1 = service.call(-34); let fut2 = service.call(-25); let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); println!("Got responses: {response1:?}, {response2:?}"); }
sansioex on  main [!] via 🦀 v1.83.0 cargo check --quiet error[E0499]: cannot borrow `service` as mutable more than once at a time --> src/main.rs:22:16 | 21 | let fut1 = service.call(-34); | ------- first mutable borrow occurs here 22 | let fut2 = service.call(-25); | ^^^^^^^ second mutable borrow occurs here 23 | 24 | let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); | ---- first borrow later used here For more information about this error, try `rustc --explain E0499`. error: could not compile `sansioex` (bin "sansioex") due to 1 previous error

Relaxing lifetime bounds

And that’s exactly why, currently, rustc warns you if you have an async fn in a public trait: because with that syntax, you’re not able to specify additional bounds.

Say we wanted to make our simplified Service trait closer to the original tower trait: we could say that the return future ought to be 'static.

sansioex on  main [!] via 🦀 v1.83.0 gwd diff --git a/src/main.rs b/src/main.rs index 6d5223f..7947a70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,13 @@ +use std::future::Future; + pub trait Service<Request> { type Response; type Error; - async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>; + fn call( + &mut self, + request: Request, + ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static; } impl Service<i32> for i32 {

But then, our implementation breaks:

sansioex on  main [+] via 🦀 v1.83.0 cargo c Checking sansioex v0.1.0 (/Users/amos/bearcove/sansioex) error[E0477]: the type `impl Future<Output = Result<<i32 as Service<i32>>::Response, <i32 as Service<i32>>::Error>>` does not fulfill the required lifetime --> src/main.rs:17:5 | 17 | async fn call(&mut self, request: i32) -> Result<Self::Response, Self::Error> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | note: type must satisfy the static lifetime as required by this binding --> src/main.rs:10:70 | 10 | ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static; | ^^^^^^^ For more information about this error, try `rustc --explain E0477`. error: could not compile `sansioex` (bin "sansioex") due to 1 previous error

…and it’s not exactly clear why.

I don’t believe it’s possible to solve that issue while sticking with the async fn syntax: we have to switch to returning an impl Future via an impl block in the implementation as well:

sansioex on  main [!+] via 🦀 v1.83.0 gwd diff --git a/src/main.rs b/src/main.rs index 7947a70..b57b520 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,12 @@ impl Service<i32> for i32 { type Response = i32; type Error = (); - async fn call(&mut self, request: i32) -> Result<Self::Response, Self::Error> { - Ok(*self + request) + fn call( + &mut self, + request: i32, + ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static { + let this = *self; + async move { Ok(this + request) } } }

With this change though, we’re able to have several requests in-flight concurrently:

#[tokio::main] async fn main() { let mut service: i32 = 2024; let fut1 = service.call(-34); let fut2 = service.call(-25); let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); println!("Got responses: {response1:?}, {response2:?}"); }
sansioex on  main [!+] via 🦀 v1.83.0 cargo run --quiet Got responses: 1990, 1999

Sendness

It even looks like.. we’re able to spawn them on the tokio runtime?

sansioex on  main [!] via 🦀 v1.83.0 gwd diff --git a/src/main.rs b/src/main.rs index 16e6549..ae009d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,8 +27,8 @@ impl Service<i32> for i32 { async fn main() { let mut service: i32 = 2024; - let fut1 = service.call(-34); - let fut2 = service.call(-25); + let fut1 = tokio::spawn(service.call(-34)); + let fut2 = tokio::spawn(service.call(-25)); let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); println!("Got responses: {response1:?}, {response2:?}");
sansioex on  main [!] via 🦀 v1.83.0 cargo run --quiet Got responses: Ok(1990), Ok(1999)
Cool bear

Huh. But… but tokio::spawn requires Send!

That’s right, it does!

// from the tokio sources #[track_caller] pub fn spawn<F>(future: F) -> JoinHandle<F::Output> where F: Future + Send + 'static, F::Output: Send + 'static, { // ✂️ }
Cool bear

…and there’s nothing in the definition of Service that requires the returned future to be Send

That’s right too! You know what’s happening? The compiler can see the concrete type of <i32 as Service<i32>>::call.

Cool bear

Oh god. Oh no. So it knows it just happens to be Send?

That’s right! If it weren’t, we’d get an error:

sansioex on  main [!] via 🦀 v1.83.0 gwd diff --git a/src/main.rs b/src/main.rs index ae009d2..df060ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,11 @@ impl Service<i32> for i32 { request: i32, ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static { let this = *self; - async move { Ok(this + request) } + let something_not_send = std::rc::Rc::new(()); + async move { + let _woops = something_not_send; + Ok(this + request) + } } }
sansioex on  main [!] via 🦀 v1.83.0 cargo check --quiet error: future cannot be sent between threads safely --> src/main.rs:34:29 | 34 | let fut1 = tokio::spawn(service.call(-34)); | ^^^^^^^^^^^^^^^^^ future created by async block is not `Send` | = help: within `impl Future<Output = Result<<i32 as Service<i32>>::Response, <i32 as Service<i32>>::Error>> + 'static`, the trait `Send` is not implemented for `Rc<()>`, which is required by `impl Future<Output = Result<<i32 as Service<i32>>::Response, <i32 as Service<i32>>::Error>> + 'static: Send` note: captured value is not `Send` --> src/main.rs:24:26 | 24 | let _woops = something_not_send; | ^^^^^^^^^^^^^^^^^^ has type `Rc<()>` which is not `Send` note: required by a bound in `tokio::spawn` --> /Users/amos/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.42.0/src/task/spawn.rs:168:21 | 166 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output> | ----- required by a bound in this function 167 | where 168 | F: Future + Send + 'static, | ^^^^ required by this bound in `spawn` ✂️

We also could’ve seen this problem with the previous implementation of Service for i32, which happens to be Send, if we did the spawning from a generic function that accepts any Service:

#[tokio::main] async fn main() { let mut service: i32 = 2024; do_the_spawning(&mut service).await; } async fn do_the_spawning<S>(service: &mut S) where S: Service<i32>, { let fut1 = tokio::spawn(service.call(-34)); let fut2 = tokio::spawn(service.call(-25)); let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); println!("Got responses: {response1:?}, {response2:?}"); }

We can add bounds on Response and Error:

@@ -32,6 +32,8 @@ async fn main() { async fn do_the_spawning<S>(service: &mut S) where S: Service<i32>, + S::Response: Send + std::fmt::Debug + 'static, + S::Error: Send + std::fmt::Debug + 'static, { let fut1 = tokio::spawn(service.call(-34)); let fut2 = tokio::spawn(service.call(-25));

Which will reduce the number of errors a bit… but the main issue remains: that future simply isn’t declared to be Send:

error[E0277]: `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static` cannot be sent between threads safely --> src/main.rs:38:29 | 38 | let fut1 = tokio::spawn(service.call(-34)); | ------------ ^^^^^^^^^^^^^^^^^ `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static` cannot be sent between threads safely | | | required by a bound introduced by this call | = help: the trait `Send` is not implemented for `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static` note: required by a bound in `tokio::spawn` --> /Users/amos/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.42.0/src/task/spawn.rs:168:21 | 166 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output> | ----- required by a bound in this function 167 | where 168 | F: Future + Send + 'static, | ^^^^ required by this bound in `spawn`

And there’s nothing we can do about it at the callsite (in the signature of do_the_spawning).

That decision is made in the trait declaration. Luckily, with the impl Trait in return position, we can at least fix it:

sansioex on  main [!⇡] via 🦀 v1.83.0 gwd diff --git a/src/main.rs b/src/main.rs index b32c487..3ff8f6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ pub trait Service<Request> { fn call( &mut self, request: Request, - ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static; + ) -> impl Future<Output = Result<Self::Response, Self::Error>> + Send + 'static; } impl Service<i32> for i32 {

But our trait is strictly less versatile than the original tower Service trait!

Afterword

I’m personally excited about the future of async Rust.

The async WG has been putting out crates that help fill the current gaps:

  • trait-variant lets you declare both a Send and non-Send version of a trait in one go
  • dynosaur lets you use dynamic dispatch on traits with async fn in trait

Ultimately, the big thing I’m waiting for is dyn async traits — you will know the second it lands because I’ll be the first one to excitedly rave about it on Bluesky or Mastodon!

This is (was? you're done reading I guess) a dual feature! It's available as a video too. Watch on YouTube

Comment on /r/fasterthanlime

(JavaScript is required to see this. Or maybe my stuff broke)

Here's another article just for you:

The thumbnail for this page

The bottom emoji breaks rust-analyzer

Some bugs are merely fun. Others are simply delicious!

Today’s pick is the latter.

Reproducing the issue, part 1

(It may be tempting to skip that section, but reproducing an issue is an important part of figuring it out, so.)

I’ve never used Emacs before, so let’s install it. I do most of my computing on an era-appropriate Ubuntu, today it’s Ubuntu 22.10, so I just need to: