Rust 81 🦀 Async/Await Syntax and Execution Flow
Rust’s async/await syntax looks familiar to anyone who has written asynchronous code in JavaScript or C#, but the execution model underneath is fundamentally different. In most languages, async is built into the runtime: the language ships with an executor, and await hands control to that executor. Rust does not bundle a runtime at all. The async keyword transforms a function or block into a state machine that implements the Future trait, and .await is the syntactic sugar that polls that state machine—but something else must drive the polling loop. That “something else” is a runtime crate like Tokio or futures, and understanding the boundary between the language and the runtime is the key to understanding Rust’s async model.
The execution flow has three distinct layers. At the syntax layer, async fn and async blocks return values that implement Future, and .await suspends execution at await points. At the trait layer, Future::poll is called repeatedly by an executor, returning either Poll::Ready with the final value or Poll::Pending with a registered Waker that will notify the executor when progress is possible . At the runtime layer, an executor manages tasks, polls futures when woken, and coordinates concurrency across threads if configured to do so.
This chapter covers the transformation from async fn to Future, the poll contract and the role of Waker, the critical distinction between .await as a yield point and everything between await points as synchronous blocking code, and the reasons main cannot be marked async. It assumes familiarity with Rust traits and basic concurrency concepts.
Key point: async creates a lazy state machine; .await polls it. Neither starts execution. Only a runtime—an executor calling poll—can drive a future to completion, and the only yield points in the entire system are .await expressions.
Why Rust separates async from the runtime
Rust does not bundle a runtime. Most languages that support async—C#, JavaScript, Go, Kotlin, Swift—include an executor as part of the standard library or runtime environment . Rust deliberately does not. The Future trait lives in core, but the machinery that polls futures, schedules tasks, and integrates with operating system I/O does not. This means a Rust program using async must explicitly choose and initialize a runtime crate, and the choice has consequences: Tokio targets high-throughput servers, smol targets minimal overhead, and embedded runtimes target microcontrollers with no heap .
Futures are lazy. Calling an async fn executes none of its body. It returns a Future that represents the computation but has not started it . The body runs only when poll is called on that future. This laziness is why the compiler warns about unused futures: hello_async() alone produces a warning that the future “does nothing unless you .await or poll them” . The warning is not pedantic; it reflects a real semantic: a future that is never polled will never complete, and its body will never run.
The compiler transforms async into state machines. When Rust sees an async fn or async block, it compiles the body into an anonymous data type implementing Future . Each .await inside the body becomes a suspension point in the generated state machine. Local variables that live across an .await become fields of the generated struct. This transformation is why .await can only appear inside async contexts: the compiler needs to generate the state machine, and it can only do that for functions or blocks marked async.
The trade-off. The separation of language from runtime gives Rust flexibility: you can run async code on a single-threaded executor with no allocation, or on a multi-threaded work-stealing runtime with native I/O integration. The cost is that async Rust has more setup than async JavaScript: you must add a runtime dependency, initialize it, and understand that main cannot be async because main is not a future .
a. Async functions, async blocks, and the Future trait
The Future trait has one required method and one associated type :
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Output is the type the future produces when it completes. poll attempts to advance the future toward completion. It returns Poll::Ready(value) if the future finished, or Poll::Pending if it is not yet ready and has registered a waker to be notified when progress becomes possible .
An async fn is roughly equivalent to a function returning impl Future<Output = T>, where the body is wrapped in an async move block . The async block captures variables from its environment, and async move takes ownership of captured variables, which is necessary when the future must outlive the current scope .
use std::future::Future;
// An async function.
async fn fetch_value() -> u32 {
42
}
// The equivalent non-async function returning a Future.
fn fetch_value_equivalent() -> impl Future<Output = u32> {
async move {
42
}
}
// An async block.
fn make_future() -> impl Future<Output = u32> {
let x = 10;
async move {
// x is moved into the future.
x * 2
}
}
The distinction between async and async move matters for lifetimes. An async block borrows variables from its environment; an async move block takes ownership. When a future must be sent to another thread or stored beyond the current scope, async move is required .
b. The poll contract and the Waker mechanism
poll is not called once. It is called repeatedly by an executor until the future returns Poll::Ready . Between calls, the executor may do other work or sleep. The future signals when it is ready to make progress by calling Waker::wake on the waker stored from the Context passed to poll.
The contract is precise: a future that returns Poll::Pending must arrange for the waker to be called when progress is possible . If it does not, the executor will never poll it again, and the task hangs silently. This is the canonical async bug: forgetting to register the waker.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
// A future that returns Pending once, then Ready.
struct PendingOnce {
polled: bool,
}
impl Future for PendingOnce {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.polled {
Poll::Ready("done")
} else {
self.polled = true;
// Register the waker and signal readiness.
// In a real future, wake would be called when I/O completes.
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
The Context carries the Waker, which is a handle for waking the task . On each call to poll, the future should store a clone of the current waker (not a stale one from a previous poll) because the executor may pass a different waker on subsequent calls . When the future is ready to make progress, it calls wake, the executor pushes the task back onto the run queue, and poll is called again.
An executor is conceptually a loop: poll a future, and if it returns Pending, park the thread until a waker fires . A minimal block_on implementation is roughly ten lines: pin the future, create a waker that unparks the current thread, poll in a loop, and park on Pending .
c. Await points as the only yield points
.await is a postfix keyword: future.await, not await future . When the compiler encounters .await, it expands to a loop that polls the inner future. If the inner future returns Poll::Ready, the loop breaks with the value. If it returns Poll::Pending, the outer future returns Poll::Pending, yielding control to the runtime .
The critical consequence: everything between await points runs synchronously. There is no preemption inside an async block. If you write an async function that does expensive computation without any .await, that future will monopolize the executor thread, starving all other futures on that thread . The runtime cannot interrupt it because the only yield points are .await expressions.
async fn starving() {
// This loop has no await points.
// It blocks the executor thread for its entire duration.
for i in 0..1_000_000 {
// Expensive synchronous work.
let _ = i * i;
}
// Only here does control return to the runtime.
}
async fn cooperative() {
for i in 0..1_000_000 {
let _ = i * i;
// yield_now creates an await point.
// Other futures can make progress here.
tokio::task::yield_now().await;
}
}
The Rust Book explicitly documents this starvation problem: if an async block contains no await points, it blocks any other futures from progressing . The fix is either to insert .await points at appropriate boundaries (using yield_now if the goal is purely to yield) or to move blocking work to a separate thread via spawn_blocking or a dedicated thread pool.
This model is called cooperative multitasking: each future voluntarily yields at await points. A future that does not yield holds the thread. There is no timer to preempt it, no signal to interrupt it. The async runtime trusts the future to be cooperative.
Complete Example Session
// ============================================
// PART 1: AN ASYNC FUNCTION RETURNS A FUTURE
// ============================================
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
// Calling this function does NOT execute the body.
// It returns a Future that must be polled.
async fn compute() -> u32 {
println!("compute: started");
42
}
// ============================================
// PART 2: THE EQUIVALENT FUTURE RETURN
// ============================================
// The compiler transforms compute() into roughly this shape.
fn compute_equivalent() -> impl Future<Output = u32> {
async move {
println!("compute: started");
42
}
}
// ============================================
// PART 3: A HAND-WRITTEN FUTURE
// ============================================
struct DelayedValue {
value: Option<u32>,
}
impl Future for DelayedValue {
type Output = u32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
if let Some(v) = self.value.take() {
Poll::Ready(v)
} else {
// Wake immediately to simulate readiness.
// Real futures wake when I/O or a timer completes.
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
// ============================================
// PART 4: POLLING IS REPEATED
// ============================================
// An executor calls poll until Ready.
// DelayedValue returns Pending once, then Ready.
// The waker triggers the second poll.
// ============================================
// PART 5: AWAIT EXPANDS TO POLL
// ============================================
async fn use_await() -> u32 {
// .await polls the inner future to completion.
// If inner returns Pending, use_await yields.
let v = compute().await;
v + 1
}
// ============================================
// PART 6: MAIN CANNOT BE ASYNC
// ============================================
// fn main() is not a future. It cannot be polled.
// A runtime must be initialized to drive futures.
//
// #[tokio::main]
// async fn main() {
// let result = use_await().await;
// println!("{result}");
// }
//
// #[tokio::main] expands to a synchronous main
// that builds a runtime and block_on's the async body.
// ============================================
// PART 7: BETWEEN AWAITS IS SYNCHRONOUS
// ============================================
async fn blocks_executor() {
// No await points here. The executor thread is blocked.
for _ in 0..1_000_000 {
std::hint::black_box(0);
}
// Only now does control return.
tokio::task::yield_now().await;
}
// ============================================
// PART 8: YIELDING COOPERATIVELY
// ============================================
async fn yields_cooperatively() {
for _ in 0..1_000_000 {
std::hint::black_box(0);
// Each await is a yield point.
tokio::task::yield_now().await;
}
}
// ============================================
// PART 9: THE RUNTIME IS THE DRIVER
// ============================================
// The runtime:
// 1. Calls poll on the top-level future.
// 2. Receives Pending with a registered waker.
// 3. Parks the thread or runs other tasks.
// 4. Wakes when the waker fires.
// 5. Calls poll again.
// 6. Repeats until Ready.
// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// main (sync) → runtime::block_on(async_body)
// → poll(async_body) → Pending (await point)
// → runtime parks
// → waker fires
// → poll(async_body) → Ready(value)
// → block_on returns value
// → main continues
This session moves from the syntax transformation (async fn to Future), through a hand-written future showing the poll contract and waker, through the .await expansion and the main limitation, to the starvation problem and the runtime’s driving loop. The code demonstrates that async in Rust is not magic: it is a state machine, a poll method, a waker, and an executor loop.
Quick Reference
Syntax Transformations
| Source | Compiled To |
|---|---|
async fn f() -> T | fn f() -> impl Future<Output = T> |
async { ... } | Anonymous struct implementing Future |
future.await | Loop polling future until Ready |
async move { ... } | Future that owns captured variables |
The Future Trait
| Component | Purpose |
|---|---|
type Output | Final value type |
poll(Pin<&mut Self>, &mut Context) | Attempt progress |
Poll::Ready(T) | Future completed |
Poll::Pending | Not ready; waker registered |
Runtime Responsibilities
| Task | Description |
|---|---|
| Poll futures | Call poll when woken |
| Manage wakers | Deliver wake notifications to tasks |
| Schedule tasks | Run multiple futures concurrently |
| Provide I/O | Async network/file primitives (Tokio) |
Starvation Prevention
| Pattern | Effect |
|---|---|
.await on I/O | Yields until I/O ready |
yield_now().await | Yields immediately to runtime |
spawn_blocking | Moves blocking work to thread pool |
| No await points | Blocks executor thread |
Best Practices
✅ Do This:
// Use async move when the future outlives the scope. // ✅
fn make() -> impl Future<Output = u32> { let x = 1; async move { x } }
// Store the waker on every Pending return. // ✅
cx.waker().wake_by_ref(); Poll::Pending
// Insert yield points in long-running async loops. // ✅
tokio::task::yield_now().await;
// Use #[tokio::main] for the runtime entry point. // ✅
#[tokio::main] async fn main() { ... }
// Move blocking work out of async contexts. // ✅
tokio::task::spawn_blocking(|| heavy_sync_work()).await
❌ Don’t Do This:
// Call an async fn without awaiting or polling. // ❌
hello_async(); // Warning: unused future.
// Forget to register the waker on Pending. // ❌
Poll::Pending // without cx.waker() -> task hangs forever.
// Run CPU-heavy loops without await points. // ❌
for _ in 0..1_000_000 { work(); } // Starves executor.
// Mark main as async without a runtime. // ❌
async fn main() { ... } // Compiler error.
// Block the executor with std::thread::sleep. // ❌
std::thread::sleep(dur); // Use tokio::time::sleep instead.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Future never runs | async fn called but not awaited | .await or spawn it |
| Task hangs silently | Waker not registered on Pending | Store and call waker |
| Executor starvation | Long sync work between awaits | Add yield_now().await |
main cannot be async | No runtime to poll it | Use #[tokio::main] |
| Future outlives borrow | async block borrowed locals | Use async move |
| Blocking the runtime | std::thread::sleep in async | Use tokio::time::sleep |
Real-World Examples
1. Basic Async Function
async fn fetch() -> String { String::from("data") }
let data = fetch().await; // Runs when awaited.
2. Async Block as Future
let fut = async { 1 + 2 };
let result = fut.await; // 3
3. Async Move for Ownership
let s = String::from("owned");
let fut = async move { s.len() }; // s moved into future.
4. Hand-Written Future Skeleton
impl Future for MyFut {
type Output = u32;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<u32> { ... }
}
5. Waker Registration
if !self.ready {
self.waker = Some(cx.waker().clone());
return Poll::Pending;
}
6. Yield in a Loop
loop {
do_work();
tokio::task::yield_now().await;
}
7. Runtime Entry Point
#[tokio::main]
async fn main() {
let v = compute().await;
}
8. Spawning an Independent Task
tokio::spawn(async {
println!("runs independently");
});
9. Blocking Work Offloaded
tokio::task::spawn_blocking(|| {
std::fs::read("large_file")
}).await.unwrap();
10. Chained Awaits
let text = fetch(url).await.text().await;
Visual
The Async Transformation
┌──────────────────────────────────────────────┐
│ COMPILER TRANSFORMATION │
│ │
│ async fn compute() -> u32 { │
│ let a = step1().await; │
│ let b = step2(a).await; │
│ b + 1 │
│ } │
│ │
│ ─────────── compiles to ─────────── │
│ │
│ fn compute() -> impl Future<Output = u32> │
│ │
│ State machine: │
│ State 0: start, call step1().poll() │
│ State 1: store a, call step2(a).poll() │
│ State 2: return b + 1 │
│ │
│ Each .await = one state transition. │
│ │
└──────────────────────────────────────────────┘
The Poll Loop
┌──────────────────────────────────────────────┐
│ EXECUTOR LOOP │
│ │
│ loop { │
│ match future.poll(cx) { │
│ Ready(v) => return v, │
│ Pending => park thread, │
│ } │
│ } │
│ │
│ Waker fires → unpark → poll again. │
│ │
│ Future must register waker before │
│ returning Pending, or nobody wakes │
│ the executor and the task hangs. │
│ │
└──────────────────────────────────────────────┘
Await as Yield Point
┌──────────────────────────────────────────────┐
│ COOPERATIVE MULTITASKING │
│ │
│ async fn a() { │
│ work_a1(); ──┐ │
│ inner.await; │ yield point │
│ work_a2(); ──┘ │
│ } │ │
│ ▼ │
│ runtime switches │
│ to another task │
│ │
│ Between await points: synchronous, │
│ no preemption, no switching. │
│ │
│ No await = no yield = starvation. │
│ │
└──────────────────────────────────────────────┘
Runtime Architecture
┌──────────────────────────────────────────────┐
│ LANGUAGE vs RUNTIME │
│ │
│ ┌────────────────────────────────────┐ │
│ │ LANGUAGE (std/core) │ │
│ │ async keyword → Future │ │
│ │ .await → poll loop │ │
│ │ Future trait, Poll, Context, Waker│ │
│ └────────────────────────────────────┘ │
│ │ │
│ │ must be driven by │
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ RUNTIME (tokio, async-std, smol) │ │
│ │ Executor: polls futures │ │
│ │ Reactor: OS I/O integration │ │
│ │ Timer, spawn, block_on │ │
│ └────────────────────────────────────┘ │
│ │
│ Rust ships no runtime. You choose one. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
async fn | Compiles to function returning impl Future |
async block | Anonymous struct implementing Future |
.await | Polls inner future; yields on Pending |
Future::poll | Attempts progress; returns Ready or Pending |
Waker | Signals executor to poll again |
| Waker contract | Must be called on Pending or task hangs |
| Yield points | Only .await expressions |
| Between awaits | Synchronous, non-preemptible |
main | Cannot be async; needs runtime entry |
| Runtime | External crate; drives polling loop |
Key takeaways:
asynccreates a lazy future; it does not run anything. The body executes only whenpollis called. Calling anasync fnwithout awaiting it produces a warning and no execution ..awaitexpands to a poll loop. It polls the inner future, and if the result isPending, it returnsPendingto the outer future, yielding to the runtime .- The waker is mandatory on
Pending. A future that returnsPendingwithout registering the waker will never be polled again. The task hangs silently, with no error . - The only yield points are
.awaitexpressions. Everything between await points runs to completion synchronously. Long-running synchronous work starves the executor unless it yields explicitly . maincannot beasyncbecausemainis not a future. A runtime must be initialized to poll the top-level future.#[tokio::main]generates the synchronous wrapper .- Rust does not bundle a runtime. The
Futuretrait is incore, but the executor is an external choice. This is a feature, not an oversight: it allows async on embedded systems and high-throughput servers with the same language syntax . async moveis required when the future outlives the scope. Anasyncblock borrows;async movetakes ownership. Returning a future from a function that captures locals requiresasync move.
Remember: Rust’s async/await is a syntax for building and polling state machines, not a runtime. The async keyword transforms code into a Future; .await polls that future until it completes or yields. An executor—an external crate—drives the polling loop, waking tasks via the Waker when they are ready to make progress. The contract is cooperative: futures must yield at .await points, and they must register the waker before returning Pending. Everything between await points is synchronous and non-preemptible, which means long-running work must either yield explicitly or be moved to a separate thread. main cannot be async because main is not a future; the runtime’s entry point macro bridges the synchronous and asynchronous worlds.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!