Trust is earned, not given

A different perspective

2023-09-26 · Projects

Rust, part 7: async Rust — futures, executors, and why there is no runtime in std

Part 7from the Rust series · 15 parts in all

Rust's async is the part that most surprises people coming from JavaScript or C#: there is no runtime in the standard library. async fn is in the language; the thing that actually runs it is not. That is not an oversight — it is what lets the same language be used for a firmware loop and a million-connection server. Part 7 is the model.

A future is a value that does nothing until polled

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    // Writing this does NOT start it. Nothing happens until it is awaited.
    let lazy = fetch_report();

    // The attribute above expanded to: build a runtime, then block_on(this).
    println!("{}", lazy.await);
}

async fn fetch_report() -> usize {
    sleep(Duration::from_millis(50)).await;
    42
}

A Future has one method, poll, which returns either Ready(value) or Pending. An executor — tokio, async-std, smol, embassy — is the thing that loops over pending futures and polls them again when their waker fires. Awaiting is not "wait here"; it is "yield to the executor, which may run something else meanwhile".

This is why an async function that never awaits anything is just a synchronous function in an async coat, and why blocking inside one stalls the whole thread's worth of tasks — std::thread::sleep in an async handler is a classic outage.

Concurrency needs the executor's permission

use tokio::task;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // NOT concurrent: each await completes before the next starts.
    let a = fetch("a").await?;
    let b = fetch("b").await?;

    // Concurrent: both are polled by the runtime as they become ready.
    let (a, b) = tokio::try_join!(fetch("a"), fetch("b"))?;

    // Or hand a future to the runtime so it can make progress in parallel,
    // which requires a 'static + Send future (part 9 explains why).
    let handle = task::spawn(fetch_owned("c".to_string()));
    let c = handle.await?;

    println!("{a} {b} {c}");
    Ok(())
}

That first pair is the single most common async bug in Rust and it is not a compiler error, because sequential code is valid code. Nothing about .await implies concurrency; only a combinator (join!, try_join!, select!) or a spawn does.

The cost of no runtime in core

Next: the stabilization people waited four years for, and what the workaround had been costing.