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
- You choose the runtime, and it becomes a real dependency with real behaviour — tokio's work-stealing scheduler versus a single-threaded current-thread runtime is a design decision, not a flag.
- Traits leak runtime details (for a while,
#[async_trait]macro-expanded everything to boxed futures — see part 8). - You get one for free where there is no OS: an embedded executor can be a few hundred bytes of interrupt-driven state machine, which is the payoff for the inconvenience.
Next: the stabilization people waited four years for, and what the workaround had been costing.