Thanks to my sponsors: Mason Ginter, Josiah Bull, C J Silverio, playest, Max von Forell, Jörn Huxhorn, David Cornu, Mike Cripps, Andy Gocke, Chris Biscardi, Nicolas Riebesel, Blake Johnson, SeniorMars, Chris Walker, Evan Relf, Paul Horn, Berkus Decker, Chris Sims, Sylvie Nightshade, Dennis Henderson and 237 more
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'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.
Boxed futures?
Yeah! Futures that have been allocated on the heap.
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'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.