Catching up with async Rust

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.

The rest of this article is exclusive!

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