| |

Rust 81 🦀 Async/Await Syntax and Execution Flow

⏱️ Estimated read: 15 min | |

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

SourceCompiled To
async fn f() -> Tfn f() -> impl Future<Output = T>
async { ... }Anonymous struct implementing Future
future.awaitLoop polling future until Ready
async move { ... }Future that owns captured variables

The Future Trait

ComponentPurpose
type OutputFinal value type
poll(Pin<&mut Self>, &mut Context)Attempt progress
Poll::Ready(T)Future completed
Poll::PendingNot ready; waker registered

Runtime Responsibilities

TaskDescription
Poll futuresCall poll when woken
Manage wakersDeliver wake notifications to tasks
Schedule tasksRun multiple futures concurrently
Provide I/OAsync network/file primitives (Tokio)

Starvation Prevention

PatternEffect
.await on I/OYields until I/O ready
yield_now().awaitYields immediately to runtime
spawn_blockingMoves blocking work to thread pool
No await pointsBlocks 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

PitfallWhy It HappensFix
Future never runsasync fn called but not awaited.await or spawn it
Task hangs silentlyWaker not registered on PendingStore and call waker
Executor starvationLong sync work between awaitsAdd yield_now().await
main cannot be asyncNo runtime to poll itUse #[tokio::main]
Future outlives borrowasync block borrowed localsUse async move
Blocking the runtimestd::thread::sleep in asyncUse 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

ItemValue
async fnCompiles to function returning impl Future
async blockAnonymous struct implementing Future
.awaitPolls inner future; yields on Pending
Future::pollAttempts progress; returns Ready or Pending
WakerSignals executor to poll again
Waker contractMust be called on Pending or task hangs
Yield pointsOnly .await expressions
Between awaitsSynchronous, non-preemptible
mainCannot be async; needs runtime entry
RuntimeExternal crate; drives polling loop

Key takeaways:

  • async creates a lazy future; it does not run anything. The body executes only when poll is called. Calling an async fn without awaiting it produces a warning and no execution .
  • .await expands to a poll loop. It polls the inner future, and if the result is Pending, it returns Pending to the outer future, yielding to the runtime .
  • The waker is mandatory on Pending. A future that returns Pending without registering the waker will never be polled again. The task hangs silently, with no error .
  • The only yield points are .await expressions. Everything between await points runs to completion synchronously. Long-running synchronous work starves the executor unless it yields explicitly .
  • main cannot be async because main is 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 Future trait is in core, 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 move is required when the future outlives the scope. An async block borrows; async move takes ownership. Returning a future from a function that captures locals requires async 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.

Was this helpful?


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!