The Future That Ran Nothing: What Calling an async fn Actually Does
A teammate hands you a function marked async and says "try it and print the value." You call it from main, run the program, and nothing happens. No file is opened, no request goes out, exit code 0. Nothing is broken: calling an async fn never runs its body. It builds a value that describes the work and returns that value.
async fn load_config(path: &str) -> String {
println!("opening {path}"); // not printed yet
let raw = read_toml(path).await; // not executed yet
raw.trim().to_string()
}
fn main() {
let pending = load_config("app.toml");
println!("program continues"); // the only line that runs
}
pending is not a String. It is an opaque compiler-generated type implementing Future<Output = String> — a state machine with one variant per suspension point, holding your arguments and locals as fields.
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}
The executor's whole job fits in a loop you could write yourself:
let mut fut = Box::pin(load_config("app.toml"));
let result = loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => break v,
Poll::Pending => { /* park this task; someone will call us back */ }
}
};
Everything async in this roadmap terminates in that loop — block_on, tokio::spawn, select!, timeout are all ways of deciding when to call poll and what to do with Pending. Notice what the call site did not do: no thread was created, no lock was taken, no syscall was issued. Constructing a future is pure bookkeeping.
Advancing the machine is what poll does: Pending means "not yet, ask again later," Ready(v) means the output exists and the machine is finished. An executor — for us, the Tokio runtime — owns the loop: call poll, park the task on Pending, re-poll when a waker fires. A future is a recipe; poll is the stove. Until someone polls it, load_config has done nothing beyond moving its arguments into a struct. That also means async fn costs nothing at call time — no thread, no allocation you can see, no syscall.
This laziness produces the most common "my code silently did nothing" bug:
let config = load_config("app.toml"); // no error: binding counts as a use
println!("{config}"); // E0308: expected String, found opaque type
rustc diagnoses both shapes. A bare statement — load_config("app.toml"); — gets warning: unused Future that must be used, because the Future trait is annotated #[must_use] with the note futures do nothing unless you .await or poll them. Formatting the value gets error[E0308]: mismatched types with a help line pointing at .await. The nastier variant is let _ = load_config("app.toml");: the underscore explicitly discards the value, the warning disappears, and the work never runs — no warning, no error, no config.
The design reason matters in interviews. Futures must be cheap to create and inert before execution, because you assemble them inside select! branches and join! arguments where some other code decides what actually runs. If constructing a future already fired the request, every cancelled race would have hit the network. Laziness is also what makes futures freely movable and storable before they start: a Vec of futures is just a list of plans.
Two things drive a future: .await inside an async context, and tokio::spawn onto a runtime. The Tokio Runtime covers the second option; the next chapter takes apart what .await does at the suspension point. Recall from Send and Sync Marker Traits that whatever the state machine carries decides whether it can move between threads — that inventory starts here.
Interview setup question: "What does an async fn return, and when does its body first execute?" Answer: an opaque impl Future<Output = T>; the body first executes when an executor polls it — at an .await, or after the runtime polls a task you spawned.
What .await Does at the Suspension Point: Poll, Park, Wake, Resume
Ask ten engineers what .await means and you hear "it waits." Waiting implies a blocked thread; .await blocks nothing. It means: poll the inner future on this task until it returns Ready; if it returns Pending, suspend this function right here and hand Pending upward.
async fn fetch_page(url: &str) -> Page {
let html = get(url).await; // suspension point 1
parse(&html).await // suspension point 2
}
The sequence:
- The runtime polls
fetch_page. It runs toget(url), which registers interest in the socket with the reactor and returnsPending. - Control returns to the executor with
Pending. The thread is free for other tasks —url, the inner future, and every local still live are parked inside the state machine, not on a stack frame. - When the socket becomes readable, the reactor invokes the waker stored in the
Context, and the scheduler re-queues this task. - The runtime polls
fetch_pageagain. Execution resumes immediately after the first.await, withhtmlbound to the response. The second.awaitrepeats the cycle. parsereturnsReady(page); the outer future returnsReady, and the.awaitexpression evaluates topage.
The Context/Waker pair is how a future says "call me back" without polling in a loop and without the reactor knowing anything about your type. They meet at exactly one point: the waker.
Two consequences interviews probe constantly:
Sequential .await is sequential. fetch_page costs get_time + parse_time. If you want both at once you need two futures polled together — that is join! and select! for Concurrent Futures, not more .awaits.
.await only exists in async context. Writing fut.await in a plain fn or inside std::thread::spawn gives error[E0728]: cannot await outside of an async function. The compiler's suggestion — make the enclosing function async — pushes the suspension point up the call chain, which is exactly how async spreads through a codebase. That pressure is deliberate: each async fn marks a place where the runtime may move execution elsewhere, so every caller must opt in.
| You write | Who polls | Wall time for two 200 ms ops |
|---|---|---|
a().await; b().await; |
one task, in order | ~400 ms |
tokio::join!(a(), b()) |
one task, interleaved | ~200 ms |
tokio::spawn(a()) + .await handle |
a separate task on a worker | ~200 ms |
There is also a compile-time consequence you will meet again: a &str held across an await lives inside the future, so the future now borrows from its own state. That self-borrow is what makes some futures un-Send — see Send and Sync Marker Traits — and what makes them address-sensitive once polling starts, which is the whole story of Pin and Unpin Explained.
To keep the model sharp, here is what .await does not do: it does not create a thread, it does not poll in a busy loop, and it does not automatically overlap with anything. Awaiting inside an async fn simply nests futures — fetch_page's state machine holds get's state machine, and one poll call drives both, so an awaited operation's latency is added to your own. Getting parallelism means a second task (spawn) or a second branch (join!/select!), never .await alone. The Waker itself is Clone + Send + Sync: futures hand a fresh copy to the reactor on every poll (stored in the Context), so a task can be moved between workers between suspensions and still be wakeable — which is why the runtime is free to schedule your task on any thread.
Finally, .await is a cancellation point in disguise: if the future containing it is dropped while suspended, execution never resumes after that .await. Nothing is interrupted mid-poll; the state machine is simply dropped. Timeouts, Cancellation, and Async I/O turns that fact into a design tool.
Three Missing-.await Bugs and the Futures That Won't Send
The bugs below all compile (or nearly compile) and all end in work that never ran or a task that never started. Recognizing them on sight is worth interview points.
1. Futures collected but never drained. You batch a request:
let mut futures: Vec<_> = urls.iter().map(|u| fetch(u)).collect();
// 20 futures exist; zero requests have been made
for f in futures {
f.await; // works, but runs sequentially: sum of all latencies
}
The collect built 20 plans and executed nothing — fetch(u) only captured u. .await inside the loop is correct but serial; futures::future::join_all(futures).await polls them concurrently and collapses the wall time. Forgetting the drain (or awaiting only futures[0]) compiles with a must_use warning at best and silently does nothing at worst. A future in a variable is work not done.
2. async blocks capture by reference unless you say move.
let name = String::from("orders");
let report = async { println!("{name}"); }; // borrows `name`
drop(name); // error: `name` does not live long enough
report.await;
The block holds &name in its state machine, so it cannot outlive the local. Wrap it as async move { ... } and ownership transfers into the future — required whenever the future escapes the scope (returning it, storing it, spawning it). The mirror-image mistake: async move steals a variable you still need, and the next line gets "use of moved value." Decide the capture policy explicitly; do not let it default.
3. A non-Send value held across an await.
tokio::spawn(async {
let guard = pool.lock().unwrap(); // std::sync::MutexGuard
let rows = query(&guard).await; // guard lives across the await
println!("{rows}");
});
The compiler answers error[E0277]: future cannot be sent between threads safely, with the note future is not Send as this value is used across an await. The state machine stores the guard as a field, so the future's type carries a !Send part. Fixes, in order of preference: extract what you need and drop the guard before the await, switch to tokio::sync::Mutex whose guard is Send, or run the blocking work with spawn_blocking on this thread. The full decision tree lives in Send and Sync Marker Traits.
| Smell | Symptom | Cause |
|---|---|---|
let _ = some_async_fn(); |
zero output, zero warnings | explicit discard of a lazy future |
futures built in a loop, only some awaited |
partial results | collection is not a driver |
E0728 |
cannot await outside async | suspension point in sync code |
future cannot be sent |
spawn fails to compile | !Send state stored across an await |
One more silent case: tokio::spawn(fetch(u)) without keeping the JoinHandle does not cancel the task — it detaches, and the result (or panic) becomes unobservable. Dropping the handle is a deliberate act, not an error, but you should know you made it. The Tokio Runtime covers handle semantics, task lifetimes, and why a forgotten spawn in main can vanish before it runs.
A review checklist that catches all four failure modes in about a minute:
- Find every
async fncall that is not followed by.awaitand not consumed byjoin!/select!/spawn— each one is work that never runs. - Find every
async/async moveblock and ask who owns the captured data, and whether the block can outlive it. - Find every
.awaitinside alock()scope or a&of a local owned by the caller ofspawn. - Find every
JoinHandleyou did not name — decide whether detach was intended.
Run that checklist on any diff that adds an async function and the silent classes of bug in this chapter do not reach review, let alone production.
Quiz
1. You write `let cfg = load_config("app.toml");` and then `println!("{cfg}");`. What happens?
2. What is the output of `async fn trace() { println!("a"); }` called as `fn main() { trace(); println!("b"); }`?
3. During an `.await`, the inner future's poll returns `Poll::Pending`. What does the current task do next?
4. A spawned task keeps a `std::sync::MutexGuard` alive across an `.await`. Which diagnostic do you get?
Flashcards
Question
What does calling an async fn return, and when does its body first execute?
Click to reveal answer
Answer
It returns an opaque type implementing Future<Output = T>: a state machine holding arguments and locals. The body first executes when an executor polls it — at an .await inside another future, or when a spawned task is scheduled — never at the call itself.
Question
What exactly does .await do when the inner future returns Poll::Pending?
Click to reveal answer
Answer
It saves every local live at that point into the future's state machine and returns Pending to the executor, so the thread is released for other tasks. Registration with the reactor happens through the Context's Waker; when the event fires, the task is re-polled and resumes immediately after that .await with the value already bound.
Question
Why can forgetting .await compile, and which spell silences the warning entirely?
Click to reveal answer
Answer
Futures are #[must_use], so a bare call warns `unused Future that must be used` (note: futures do nothing unless you .await or poll them). Binding the future to a variable or writing `let _ = f();` counts as a use, silences the warning, and still runs nothing — the classic silent no-op.
Question
What is the difference between `async { }` and `async move { }`?
Click to reveal answer
Answer
A plain async block captures referenced locals by reference, so the future cannot outlive them (the borrow checker enforces it at the await). `async move` takes ownership into the future's state — required when the future is returned, stored, or spawned as 'static — and the surrounding scope loses access to those values.
Question
Name the two drivers of a future and the bound each one imposes.
Click to reveal answer
Answer
.await inside an async context polls it inline as part of the enclosing future (no extra bounds beyond what the caller already needs), while tokio::spawn hands it to the runtime as an independent task requiring Future + Send + 'static. Manual poll additionally requires Pin<&mut Self>, which is why hand-driven futures are usually Box::pin'd.
Revision Notes
Key Takeaways
- 1. Calling an async fn builds a state machine and runs nothing; the body starts only when an executor polls the future
- 2. .await on Pending saves locals, returns control to the executor, and resumes after a waker fires — the thread is never blocked
- 3. Futures are #[must_use] and lazy, so `let _ = f();` and undrained Vec<futures> compile cleanly while doing zero work
- 4. Sequential .await costs the sum of latencies; concurrency requires join!, select!, or spawn polling futures together
- 5. A value held across an await lives inside the future's state, deciding both whether it is Send and whether it must stay pinned
Interview Tips
- • "What does async fn return?" — an opaque Future; state machine; body runs on first poll. Follow-up to expect: name the executor's loop (poll, Pending, park, wake, re-poll)
- • "Why are futures lazy?" — composition: select!/join!/timeouts decide what runs, so constructing a future must not fire I/O; a cancelled race would otherwise hit the network
- • "Explain await in one sentence" — poll the inner future on this task until Ready; on Pending, suspend here and return Pending upward with locals saved. Mention Context/Waker to show the wakeup path
- • "Your spawned task won't compile" — read the note on E0277: a !Send value is held across an await; then say which fix you would pick and why (drop the guard before the await, tokio Mutex, or spawn_blocking)
Cheat Sheet
async fn and await — Cheat Sheet
The mental model:
| Question | Answer |
|---|---|
What does async fn f() return? |
An opaque type implementing Future<Output = T> — a state machine |
| When does the body run? | First time an executor polls it (.await or a spawned task) |
What does .await do on Pending? |
Saves locals into the state machine, returns Pending upward, thread moves on |
| How does it resume? | Reactor calls the Waker → task re-queued → poll again → resumes after the .await |
| Who drives futures? | A runtime: .await (inline) or tokio::spawn (task) |
Compiler signals:
warning: unused `Future` that must be used
= note: futures do nothing unless you `.await` or poll them
error[E0308]: mismatched types // future where Output was expected → add .await
error[E0728]: cannot await outside of an async function
error[E0277]: future cannot be sent between threads safely
= note: future is not `Send` as this value is used across an await
Capture rules:
async { /* borrows locals — must not outlive them */ }
async move { /* takes ownership — required for 'static/spawn */ }
Time model:
a().await; b().await; // sequential: t_a + t_b
tokio::join!(a(), b()) // one task, interleaved: max(t_a, t_b)
tokio::spawn(a()) // separate task, needs Send + 'static