Skip to content
academia.sh

Lesson 07 / 10

`async`/`await`

Suspension points are written visibly in the source: a body that pauses once completes in two polls, a body that is ready immediately completes in one poll, and an outer body waiting on an inner future advances when something else finishes.

Published Updated

In this lesson

  • Shows that an "await" expression does nothing but poll a future and marks the resumption point in the source
  • Measures a suspension point's effect on a run by comparing the poll counts of a body that pauses once against one that is ready immediately
  • Explains that an outer body awaiting an inner future falls into the when-something-else-finishes class, distinguishing it from the completion question measured by Go's Concurrency course
Contents

The previous lesson built the ImmediatelyReady type: a future that finishes the instant poll is called and never returns Pending. That was the plainest possible body — one that never pauses. This lesson extends the same contract to a body that pauses once, and introduces the syntax that lets Rust generate such bodies instead of hand-writing them: async and await. The question asked stays the same — what does this unit need in order to advance — but now the answer comes with a visible mark in the source.

The previous lesson’s ImmediatelyReady, and this lesson’s PausesOnce, were types that implemented the Future trait by hand: they wrote the poll method with their own hands. In real programs, most of an asynchronous body’s lines are not hand-written — they are written as an ordinary function body marked with the async keyword, and the compiler automatically turns that body into a Future implementation. async and await are the source-level shorthand for hand-written poll logic; this lesson shows that underneath that shorthand, the same contract — the Ready/Pending pair — is still running.

The Suspension Point Is Written in the Source

The previous lesson’s ImmediatelyReady implemented the Future trait by hand. PausesOnce implements the same trait so that it returns Pending exactly once: on the first call it changes its state and wakes itself for the next poll, on the second call it completes.

// advance.rs — a slice of the core: the executor, a future that pauses once, a future
// that is ready immediately, and an asynchronous function awaiting an inner future.

use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Context, Poll, Wake, Waker};

pub struct Notifier {
    pub ready: Mutex<bool>,
    pub condvar: Condvar,
}

impl Wake for Notifier {
    fn wake(self: Arc<Self>) {
        *self.ready.lock().unwrap() = true;
        self.condvar.notify_one();
    }
}

pub fn run<F: Future>(task: F) -> (F::Output, u32) {
    let mut task = Box::pin(task);
    let n = Arc::new(Notifier {
        ready: Mutex::new(false),
        condvar: Condvar::new(),
    });
    let waker: Waker = n.clone().into();
    let mut context = Context::from_waker(&waker);
    let mut poll_count = 0;
    loop {
        poll_count += 1;
        match task.as_mut().poll(&mut context) {
            Poll::Ready(x) => return (x, poll_count),
            Poll::Pending => {
                let mut ready = n.ready.lock().unwrap();
                while !*ready {
                    ready = n.condvar.wait(ready).unwrap();
                }
                *ready = false;
            }
        }
    }
}

pub struct PausesOnce {
    pub paused: bool,
    pub name: &'static str,
    pub channel: Sender<String>,
}

impl Future for PausesOnce {
    type Output = &'static str;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<&'static str> {
        if self.paused {
            self.channel.send(self.name.to_string()).unwrap();
            Poll::Ready(self.name)
        } else {
            self.paused = true;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

pub struct ImmediatelyReady {
    pub name: &'static str,
    pub channel: Sender<String>,
}

impl Future for ImmediatelyReady {
    type Output = &'static str;
    fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<&'static str> {
        self.channel.send(self.name.to_string()).unwrap();
        Poll::Ready(self.name)
    }
}

pub async fn await_inner(inner: PausesOnce, channel: Sender<String>) -> &'static str {
    let _ = inner.await;
    channel.send(String::from("task-awaiter")).unwrap();
    "awaiter"
}

await_inner is an async fn. This syntax saves it from having to hand-implement its body as a Future: the compiler transforms the body, on its own, into a state machine, and that machine implements the Future trait itself. The inner.await line inside the body is the one visible trace of this transformation — the suspension point. When the compiler sees this line, it adds a branch to the state machine saying “I can pause here”: if inner’s poll returns Pending, the outer body also finishes its own poll with Pending and picks up where it left off on the next call. .await is not a macro call, it is an operator; its name can appear freely outside code blocks too.

The state machine produced is conceptually close to the enum built in the Rust Fundamentals course: one of a finite number of distinct states is active each time, and which state is active depends on where the previous poll call left off. The difference is that this enum is not hand-written — the compiler produces one suspension state for every .await in the body and builds the transitions between them itself. await_inner has exactly one .await; so the state machine the compiler produces switches between only two states: the state waiting on inner and the completed state.

The function’s signature — async fn await_inner(...) -> &'static str — looks as though the written return type is &'static str, but the actual return value is this compiler-produced state machine: a type never named in the source, carrying the Future<Output = &'static str> contract. The associated type F::Output in the run<F: Future> signature covers exactly this — run works purely by trusting the Future contract, without knowing which concrete state machine it was given. The call await_inner(inner, sender.clone()) itself runs nothing either; the laziness rule established in the previous lesson holds here too — the call only constructs the state machine’s first state, and no field runs until it is handed to run.

The Number of Pauses Is Reflected in the Run

The following program runs three bodies on the same executor: PausesOnce, which pauses once, ImmediatelyReady, which never pauses, and await_inner, which awaits a PausesOnce inside it.

// poll-counts.rs — the poll counts of a body that pauses once, one that is ready
// immediately, and one that awaits an inner future.
mod advance;
use advance::{await_inner, run, PausesOnce, ImmediatelyReady};
use std::sync::mpsc::channel;

fn main() {
    let (sender, receiver) = channel::<String>();

    let (value_a, poll_count_a) = run(PausesOnce {
        paused: false,
        name: "task-polled-a",
        channel: sender.clone(),
    });
    let (value_b, poll_count_b) = run(ImmediatelyReady {
        name: "task-ready-b",
        channel: sender.clone(),
    });

    let inner = PausesOnce {
        paused: false,
        name: "task-inner",
        channel: sender.clone(),
    };
    let (value_c, poll_count_c) = run(await_inner(inner, sender.clone()));

    drop(sender);
    let mut records: Vec<String> = receiver.try_iter().collect();
    records.sort();

    println!("values: a={} b={} c={}", value_a, value_b, value_c);
    println!("poll counts: a={} b={} c={}", poll_count_a, poll_count_b, poll_count_c);
    println!();
    println!("records ({}):", records.len());
    for record in &records {
        println!("  {}", record);
    }
}
values: a=task-polled-a b=task-ready-b c=awaiter
poll counts: a=2 b=1 c=2

records (4):
  task-awaiter
  task-inner
  task-polled-a
  task-ready-b

AP2. task-polled-a finished in two polls: the first call set paused to true and returned Pending, the second call called channel.send and returned Ready. task-ready-b finished in a single poll because it never paused. These two numbers show that how many times each of two different bodies run on the same executor gets polled is directly derivable from their behavior in the source — the poll count here is not an environment-dependent side fact, it is a deterministic result coming from the body’s own logic. All three numbers stayed identical across five separate runs.

await_inner’s poll count was also two, but the reason is different. When this body reaches inner.await, its own poll internally calls inner‘s poll. On the first outer call, inner returns Pending, and the outer body carries that straight up and returns Pending too. On the second outer call, inner finishes (the task-inner record is written here), the .await line ends, and the rest of the body (writing the task-awaiter record) runs within that same call and returns Ready. Two polls either way, but one comes from its own state, the other from a different future’s state inside it.

The Number of Suspension Points Determines the Poll Count

A body is not limited to a single .await; it can carry more than one suspension point in a row. The following excerpt replaces await_inner with a body that awaits two separate PausesOnce values in sequence. ImmediatelyReady and await_inner are not needed for this measurement, so they are left out of the excerpt.

// advance.rs — the core slice changes: a body with two consecutive suspension points is
// added; ImmediatelyReady and await_inner are not needed for this measurement.

use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Context, Poll, Wake, Waker};

pub struct Notifier {
    pub ready: Mutex<bool>,
    pub condvar: Condvar,
}

impl Wake for Notifier {
    fn wake(self: Arc<Self>) {
        *self.ready.lock().unwrap() = true;
        self.condvar.notify_one();
    }
}

pub fn run<F: Future>(task: F) -> (F::Output, u32) {
    let mut task = Box::pin(task);
    let n = Arc::new(Notifier {
        ready: Mutex::new(false),
        condvar: Condvar::new(),
    });
    let waker: Waker = n.clone().into();
    let mut context = Context::from_waker(&waker);
    let mut poll_count = 0;
    loop {
        poll_count += 1;
        match task.as_mut().poll(&mut context) {
            Poll::Ready(x) => return (x, poll_count),
            Poll::Pending => {
                let mut ready = n.ready.lock().unwrap();
                while !*ready {
                    ready = n.condvar.wait(ready).unwrap();
                }
                *ready = false;
            }
        }
    }
}

pub struct PausesOnce {
    pub paused: bool,
    pub name: &'static str,
    pub channel: Sender<String>,
}

impl Future for PausesOnce {
    type Output = &'static str;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<&'static str> {
        if self.paused {
            self.channel.send(self.name.to_string()).unwrap();
            Poll::Ready(self.name)
        } else {
            self.paused = true;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

pub async fn await_twice(inner1: PausesOnce, inner2: PausesOnce) -> &'static str {
    let _ = inner1.await;
    let _ = inner2.await;
    "awaited-twice"
}
// two-pauses.rs — the poll count of a body carrying two consecutive suspension points.
mod advance;
use advance::{await_twice, run, PausesOnce};
use std::sync::mpsc::channel;

fn main() {
    let (sender, receiver) = channel::<String>();

    let inner1 = PausesOnce {
        paused: false,
        name: "task-inner-first",
        channel: sender.clone(),
    };
    let inner2 = PausesOnce {
        paused: false,
        name: "task-inner-second",
        channel: sender.clone(),
    };
    let (value, poll_count) = run(await_twice(inner1, inner2));

    drop(sender);
    let mut records: Vec<String> = receiver.try_iter().collect();
    records.sort();

    println!("value: {}", value);
    println!("poll count: {}", poll_count);
    println!("records ({}):", records.len());
    for record in &records {
        println!("  {}", record);
    }
}
value: awaited-twice
poll count: 3
records (2):
  task-inner-first
  task-inner-second

AP3. Two suspension points correspond to three polls: the first call sets inner1 to Pending, the second call finishes inner1 and triggers inner2‘s first poll (which also returns Pending, within that same outer call), the third call finishes inner2 and runs the rest of the body. The general rule follows from this: nn suspension points in a row require n+1n+1 polls in a row — the last call is always the one that completes the advancement, the one that does not return Pending. This number, too, stayed identical across five separate runs; the number of .awaits in the source determines the poll count in the run in advance. The task-inner-second record can never be written, in any run, before the task-inner-first record exists — because the second suspension point’s inner2 only has its first poll called once inner1 has completed, so it is never evaluated before the first one finishes. This order is guaranteed not because the ledger sorts alphabetically, but because the state machine can only cross to the next branch after finishing the previous one.

Being Awaited Is Not the Same as Being Polled

task-inner — the PausesOnce instance inside await_inner — falls into the when-polled class: the same type, the same behavior as task-polled-a; being used inside .await does not change its class. task-awaiter, on the other hand — await_inner’s own completion line — falls into the when-something-else-finishes class. The difference is this: task-inner is polled directly, and needs nothing but being called repeatedly in order to advance; await_inner, by contrast, never reaches its own final line until inner completes — its advancement depends not on its own polling, but on another task finishing.

Go’s Concurrency course measured how a goroutine stops — on its own, because what it was waiting for arrived, because it was canceled, or never stopping at all. This lesson does not ask that question; what is measured here is not completion, but where it pauses and from where it resumes. await_inner never stops, it only returns Pending once and resumes from the same point — and this resumption point matches, line for line, where .await is written. A single line in the source announces the suspension point in the run ahead of time.

This distinction becomes clearer when set against the channel wait in the Threads topic. There, a thread was suspended by the operating system while waiting for news from a channel; the thread itself consumed no processor time while it waited, but this suspension was outside the programmer’s control. await_inner, by contrast, describes its own suspension with an .await line it wrote itself; the executor does not even see this line, it only sees the Pending that poll returns. The suspension decision here lives in the source itself; in the thread case, it lives in a decision made by the runtime.

The Limiting Measurement

The suspension point is written in the source, but how long it pauses is not written. The inner.await line says await_inner can pause there; how many poll calls pass before it resumes depends on inner’s own logic, and this lesson prints only the count, not the duration. PausesOnce pauses once because it was written that way; another implementation could pause ten times, and the .await visible in the source would not change. The visibility of the suspension point lets you read from the source that a suspension exists — not how many times it will repeat.

This is the shape the course’s ban on environment-dependent data will take again in the next lesson’s input/output example: a modeled wait makes only how many times something was polled measurable, not the duration of a real delay.

The same limit holds for the two-consecutive-suspension-point measurement: the n+1n+1 rule only counts the number of suspension points, not how long each pause lasted. Real seconds could pass between inner1 and inner2 in a real program, or none at all — the rule does not change, because the rule counts how many times poll calls are made, not how much time passes between them.

Summary

  • async fn turns a body into a compiler-generated state machine instead of a hand-written Future implementation; .await marks the suspension point in that machine.
  • A body that pauses once finishes in two polls, a body that never pauses finishes in one; the poll count is deterministic and does not change across five separate runs.
  • A body awaiting an inner future also falls into the when-polled class, but that same body’s own completion point falls into the when-something-else-finishes class — the two can coexist within a single task.
  • Go’s Concurrency course measured how a unit stops (completion); this lesson measures where it pauses and how it resumes (suspension and resumption).
  • The suspension point is written visibly in the source, but how many times it will fire is not read from the source — it is only measured in the run. nn suspension points in a row require n+1n+1 polls in a row: the last call is always the one that completes the advancement, the one that does not return Pending.
  • An async fn’s body does not run the moment it is called — it only constructs the first state of the compiler-produced state machine; the laziness rule from the previous lesson holds here too.

Next Step

This lesson measured how many times a body pauses and resumes; the question not yet asked is who schedules these pause-and-resume calls. run has, up to now, been used as a given tool — the code called the same function every time and assumed it waited until woken. The next lesson questions this assumption and takes run itself as its subject: it runs the same body with two different executor implementations and measures that the executor adds nothing to the task’s body, deciding only when to poll it.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close