Chapter 14 — Async Rust Without Magic
Goal: Understand an async function as a value implementing Future, and see the minimum work an executor performs using only std.
async fn answer() -> u32 {
42
}
fn main() {
let value = answer();
println!("{value}");
}
Prediction
Why does this fail?
A. Async functions require an external crate to compile.
B. value is a future, not a u32, and futures do not implement Display.
C. Async functions can return only ().
D. answer runs on another thread and has not finished.
RevealB
Condensed evidence:
error[E0277]: `impl Future<Output = u32>` doesn't implement `std::fmt::Display`
Calling an async fn constructs a future. It does not run the body to completion and does not create a thread. Conceptually, the compiler converts the function into a state machine storing locals that must survive across .await points.
The core protocol is small:
trait Future {
type Output;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output>;
}
Poll::Ready(output) means done. Poll::Pending means not ready; before returning it, a useful future arranges for the context's Waker to be notified when polling may make progress. An executor owns runnable futures, polls them, sleeps or does other work when they are pending, and polls them again after wakeups.
Here is a minimal demonstration. It is not a production executor; it manually polls a future known to complete immediately:
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
async fn answer() -> u32 {
42
}
fn main() {
let mut future = pin!(answer());
let waker = Waker::noop();
let mut context = Context::from_waker(waker);
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => println!("{value}"),
Poll::Pending => println!("not ready"),
}
}
Output:
42
Pin prevents moving a future after polling begins. Compiler-generated futures may contain state whose correctness depends on a stable location. Pinning does not mean “heap allocate”: pin! pins this value in its stack scope.
Working model: a future is paused work; an executor repeatedly asks whether it can advance.
Precise model: a future is a poll-based state machine. .await polls the awaited future and, on Pending, suspends the enclosing state machine. The waker is a scheduling callback, not the result and not a thread. Async concurrency can interleave many tasks on one thread; parallel CPU execution requires multiple threads.
The simplest practical fix depends on context. Inside another async function, use .await:
async fn answer() -> u32 { 42 }
async fn report() {
let value = answer().await;
println!("{value}");
}
At the synchronous program boundary, a real application normally uses an executor. The standard library intentionally provides the Future protocol but no general block_on, reactor, timers, or async file/network runtime. The tradeoff is either to select a runtime when the application needs those facilities, or keep the operation synchronous. Writing a general executor is systems work, not a shortcut around a dependency.
Cancellation is usually dropping a future. Its stored locals are dropped, but external effects already performed are not rolled back. Also, blocking calls inside async fn still block the executor thread; async does not convert blocking I/O into nonblocking I/O.
Mini challenge
An async function prints before its first .await. When does that print occur?
A. When the future is created by calling the function.
B. When the future is first polled.
C. On a newly created thread.
D. Only when the future is dropped.
Answer: B. Calling an async function creates its state machine. Polling begins executing its body. If it reaches a pending .await, execution pauses there. An eager API could perform work before returning a future, but an ordinary async fn is lazy.