Skip to content

22. Rust async, await and Future

An async function returns a future; it does not create a thread or run its body immediately. Awaiting a pending future suspends the enclosing future, while an executor decides when to poll it again. This lesson separates language syntax, standard-library contracts and runtime responsibilities.

Prerequisites and outcome

Complete channels, atomics and shutdown. You will observe lazy execution, Pending/Ready transitions, wake notification, borrowed versus owned captures, and destruction of a cancelled future. The fixtures need no async dependency, network connection or sensor.

Build the complete Rust async example

cargo new pi_futures
cd pi_futures

Keep edition = "2024" in Cargo.toml. Replace src/main.rs with:

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

// One future, one waiting thread. This is not an I/O runtime or task scheduler.
struct Notification {
    notified: Mutex<bool>,
    changed: Condvar,
}

impl Wake for Notification {
    fn wake(self: Arc<Self>) {
        self.wake_by_ref();
    }

    fn wake_by_ref(self: &Arc<Self>) {
        *self.notified.lock().expect("notification poisoned") = true;
        self.changed.notify_one();
    }
}

fn block_on<F: Future>(future: F) -> F::Output {
    let notification = Arc::new(Notification {
        notified: Mutex::new(false),
        changed: Condvar::new(),
    });
    let waker = Waker::from(Arc::clone(&notification));
    let mut context = Context::from_waker(&waker);
    let mut future = pin!(future);
    loop {
        // Reset before polling, so a wake during poll is not overwritten.
        *notification.notified.lock().unwrap() = false;
        match future.as_mut().poll(&mut context) {
            Poll::Ready(value) => return value,
            Poll::Pending => {
                let notified = notification.notified.lock().unwrap();
                let _guard = notification
                    .changed
                    .wait_while(notified, |notified| !*notified)
                    .unwrap();
            }
        }
    }
}

struct YieldOnce {
    yielded: bool,
}

impl Future for YieldOnce {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
        if self.yielded {
            Poll::Ready(())
        } else {
            self.yielded = true;
            context.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

async fn reading(text: &str) -> Result<Option<i32>, ParseIntError> {
    YieldOnce { yielded: false }.await;
    let text = text.trim();
    if text.is_empty() {
        Ok(None)
    } else {
        text.parse().map(Some)
    }
}

#[derive(Debug, PartialEq, Eq)]
struct Report {
    measured: usize,
    missing: usize,
}

async fn report(inputs: &[&str]) -> Result<Report, ParseIntError> {
    let mut report = Report {
        measured: 0,
        missing: 0,
    };
    for input in inputs {
        match reading(input).await? {
            Some(_) => report.measured += 1,
            None => report.missing += 1,
        }
    }
    Ok(report)
}

fn main() {
    let inputs = ["46700", "", "0"];
    let future = report(&inputs);
    println!("future created; input length={}", inputs.len());
    let result = block_on(future).expect("valid fixtures");
    println!("measured={}, missing={}", result.measured, result.missing);
    println!("zero={:?}", block_on(reading("0")).unwrap());
    println!("invalid rejected={}", block_on(reading("broken")).is_err());

    let label = String::from("Pi 4B");
    let owned = async move { label.len() };
    println!("owned label bytes={}", block_on(owned));
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;
    use std::rc::Rc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    struct CountWake(AtomicUsize);

    impl Wake for CountWake {
        fn wake(self: Arc<Self>) {
            self.0.fetch_add(1, Ordering::Relaxed);
        }

        fn wake_by_ref(self: &Arc<Self>) {
            self.0.fetch_add(1, Ordering::Relaxed);
        }
    }

    struct DropFlag(Rc<Cell<bool>>);

    impl Drop for DropFlag {
        fn drop(&mut self) {
            self.0.set(true);
        }
    }

    #[test]
    fn zero_missing_negative_and_invalid_are_distinct() {
        assert_eq!(block_on(reading(" 0 ")).unwrap(), Some(0));
        assert_eq!(block_on(reading(" \t")).unwrap(), None);
        assert_eq!(block_on(reading("-500")).unwrap(), Some(-500));
        assert!(block_on(reading("broken")).is_err());
    }

    #[test]
    fn sequential_await_accounts_for_each_valid_record() {
        assert_eq!(
            block_on(report(&["46700", "", "0"])).unwrap(),
            Report {
                measured: 2,
                missing: 1,
            }
        );
        assert!(block_on(report(&["0", "broken", "7"])).is_err());
    }

    #[test]
    fn empty_input_completes_without_a_pending_read() {
        assert_eq!(
            block_on(report(&[])).unwrap(),
            Report {
                measured: 0,
                missing: 0,
            }
        );
    }

    #[test]
    fn unpolled_body_does_not_run() {
        let calls = Cell::new(0);
        let future = async {
            calls.set(calls.get() + 1);
            7
        };
        assert_eq!(calls.get(), 0);
        assert_eq!(block_on(future), 7);
        assert_eq!(calls.get(), 1);
    }

    #[test]
    fn dropping_an_unpolled_future_skips_its_body() {
        let calls = Cell::new(0);
        let future = async { calls.set(1) };
        drop(future);
        assert_eq!(calls.get(), 0);
    }

    #[test]
    fn pending_requests_a_wake_then_completes() {
        let count = Arc::new(CountWake(AtomicUsize::new(0)));
        let waker = Waker::from(Arc::clone(&count));
        let mut context = Context::from_waker(&waker);
        let mut future = pin!(YieldOnce { yielded: false });
        assert_eq!(future.as_mut().poll(&mut context), Poll::Pending);
        assert_eq!(count.0.load(Ordering::Relaxed), 1);
        assert_eq!(future.as_mut().poll(&mut context), Poll::Ready(()));
    }

    #[test]
    fn cancelling_pending_future_drops_locals_without_undoing_effects() {
        let dropped = Rc::new(Cell::new(false));
        let observed = Rc::clone(&dropped);
        let effects = Rc::new(Cell::new(0));
        let changed = Rc::clone(&effects);
        let mut future = Box::pin(async move {
            let guard = DropFlag(observed);
            changed.set(1);
            YieldOnce { yielded: false }.await;
            changed.set(2);
            drop(guard);
        });
        let count = Arc::new(CountWake(AtomicUsize::new(0)));
        let waker = Waker::from(count);
        let mut context = Context::from_waker(&waker);
        assert_eq!(future.as_mut().poll(&mut context), Poll::Pending);
        assert_eq!(effects.get(), 1);
        assert!(!dropped.get());
        drop(future); // Drop the owning pinned box, not merely a borrowed Pin.
        assert!(dropped.get());
        assert_eq!(effects.get(), 1);
    }

    #[test]
    fn local_borrow_and_non_send_state_work_on_this_executor() {
        let label = String::from("Pi 4B");
        let count = Rc::new(Cell::new(0));
        let future = async {
            count.set(1);
            YieldOnce { yielded: false }.await;
            label.len()
        };
        assert_eq!(block_on(future), 5);
        assert_eq!(label, "Pi 4B");
        assert_eq!(count.get(), 1);
    }

    #[test]
    fn async_closure_lends_mutable_capture_to_sequential_calls() {
        async fn call_twice(mut callback: impl std::ops::AsyncFnMut() -> usize) -> [usize; 2] {
            [callback().await, callback().await]
        }
        let mut calls = 0;
        let callback = async || {
            YieldOnce { yielded: false }.await;
            calls += 1;
            calls
        };
        assert_eq!(block_on(call_twice(callback)), [1, 2]);
        assert_eq!(calls, 2);
    }
}
1
2
3
4
cargo check
cargo test
cargo fmt --check
cargo run --quiet

Expected binary output:

1
2
3
4
5
future created; input length=3
measured=2, missing=1
zero=Some(0)
invalid rejected=true
owned label bytes=5

Nine tests exercise lazy execution, empty/invalid fixtures, wake-before-wait, pending cancellation, non-Send local state and async closures. The executor is deliberately limited to one root future on the calling thread. It has no I/O driver, timer service, task queue, fairness policy or cancellation handle.

async creates a stateful future; await does not spawn

Calling report constructs a future holding its input borrow. The body starts when polled, and execution can suspend at an await whose child is Pending. An await whose child is immediately Ready need not suspend. The compiler manages the state needed across suspension; this is not a separate stackful operating-system thread. See async blocks.

The loop awaits one reading at a time, so this report is sequential. Writing async alone does not make three reads concurrent. A runtime or combinator can schedule multiple futures, but syntax does not select that policy. async move takes ownership of captures; moving a borrowed reference still does not extend its referent's lifetime.

? after await operates on the child's Result output. It returns an error from the enclosing async computation; the caller obtains that Result when its future completes. return inside an async block returns from that block's future, not an outer synchronous function. See await expressions.

Future, Poll and Waker form the execution contract

Future has an associated Output and a poll method receiving Pin<&mut Self> and Context. Ready provides the result; Pending means not yet complete. Wake requests another poll, not a guaranteed successful result. A pending operation must arrange notification when progress becomes possible, using the current task's waker. See Future.

YieldOnce stores one Boolean. Its first poll changes state, requests a wake and returns Pending; its next poll returns Ready. It demonstrates protocol transitions, not a real timer or a fair scheduler yield. Some real futures observe work started elsewhere, such as spawned tasks; the lazy async-body rule does not imply that all underlying external operations wait for this future to be polled.

Do not poll a completed future again. The trait does not promise a useful repeated result; a particular implementation may panic or otherwise reject it. That restriction is not permission for safe polling to cause undefined behaviour.

Async closures provide async callback interfaces

The final test uses async || to capture a mutable counter and an AsyncFnMut bound to call it twice. Each call creates a future that can borrow from the closure; the first call is awaited to completion before requesting the next mutable call. Calling an async closure still does not run its body immediately. See async closures.

AsyncFn, AsyncFnMut and AsyncFnOnce describe async-aware call capabilities. Stable callable-bound syntax, such as the test's impl AsyncFnMut() -> usize, does not imply that directly naming every associated future type or calling the trait's low-level methods is stable. The AsyncFnMut documentation marks those individual APIs separately. An ordinary closure returning an async block, || async { ... }, is not interchangeable in every borrowing design with an async closure that lends its captures.

The teaching executor avoids a lost notification

Wake is a safe way to construct a Waker from Arc-backed state. Our implementation sets a Boolean under a mutex and signals a condition variable. block_on clears the flag before polling, then checks it under the same mutex before sleeping. A wake during poll is therefore retained; a wake between the check and sleep is coordinated by the condition-variable wait. See Wake.

Condvar::wait_while checks its predicate after wakeups, including spurious ones. The executor releases its notification mutex before polling user code. It does not run a tight polling loop or reuse an unrelated thread-park token. These choices explain this fixture's wake-before-wait behaviour; they are not a production-runtime endorsement. See Condvar.

If a future never becomes ready and never notifies, this executor waits indefinitely. It cannot make arbitrary futures correct, enforce timeouts or drive runtime-specific I/O. Nested independent calls also do not create a shared scheduler. Use an established runtime for application-level async services rather than expanding this teaching example into one by accident.

Pin is an API boundary, not a Send requirement

poll needs a pinned mutable reference. We use pin! for a local owned future and Box::pin when a test must explicitly destroy the owning allocation at a chosen point. No unsafe projection is needed here. YieldOnce contains only movable fields and can be mutated through its Pin because it is Unpin; do not generalise that permission to every generated async future.

Pin and Unpin concern movement under a pinning contract, not thread safety. Our block_on has no Send or 'static bound: it stays on the calling thread and accepts local borrows and Rc state. A multi-threaded runtime's spawn API often imposes Send + 'static, but those bounds are not required by Future itself. The next lesson develops pinning and projection carefully.

Cancellation drops state, not history

The cancellation test polls once. A local guard is constructed and a visible effect changes to 1, then execution suspends. Dropping the owning future destroys that guard; code after await never runs, and the previous effect is not rolled back. Dropping an unpolled async body skips its body, although captured owned values still have their ordinary destruction rules.

An async operation may already have sent a message, written bytes or started external work. Dropping its future is not a universal rollback or a guarantee that separately spawned work stops. Some runtime handles detach tasks when dropped. Define cancellation safety for each operation and protocol; do not equate a future's local Drop with successful external cleanup. Destructors cannot simply await asynchronous finalization.

Blocking code still blocks an executor thread

std:🧵:sleep, blocking recv, file reads and long CPU loops remain blocking work when placed inside an async body or poll. await does not transform them into nonblocking operations. Production runtimes provide their own I/O, timer and blocking-work facilities; those are library APIs, not Rust language features.

Holding a standard mutex guard across suspension can also obstruct progress and affect whether a future is Send. End unnecessary guards before awaiting, and choose synchronization according to the runtime's requirements. Async is useful for suitable concurrency, not an automatic performance improvement for this tiny parser.

Deliberately failing: await outside an async context

In a separate scratch project, replace src/main.rs with:

1
2
3
4
5
6
7
8
async fn reading() -> i32 {
    46_700
}

fn main() {
    let value = reading().await;
    println!("{value}");
}

cargo check reports E0728. await belongs in an async context. Making main async without a runtime entry point is not an ordinary executable repair; put the await inside an async computation and drive it with an executor, as in the working program.

Exercises and troubleshooting

  1. Wrap the scratch await in an async block and execute it with this lesson's block_on. Expect 46700. Explain which code creates the future and which code polls it.
  2. Add another invalid input after a valid one: report returns Err rather than a partial successful report. State whether retaining partial progress is a desired alternate API before changing error policy.
  3. Remove the wake request from YieldOnce in a scratch test using CountWake. The first poll is still Pending but the observed notification count becomes zero. Do not execute the broken future with block_on: it has no reason to be repolled and can hang.
  4. Explain the cancellation test's retained effect. Move the effect after await and predict that cancellation before resumption leaves it untouched; compare with successful completion.
  5. Attempt to send an async move future owning Rc<Cell<_>> to a thread by requiring its type to be Send. Expect a diagnostic that the future cannot be sent between threads safely. async move changes ownership, not the captured type's thread capability.
  6. Construct a future borrowing a local String inside a block, then try to drive it outside that block. Expect E0597. Either drive it while the owner is alive or deliberately move owned data into the future.
  7. Contrast dropping Box::pin(future) with dropping a temporary Pin<&mut Future>. Only the former owns and destroys that future; a borrowed pin wrapper is not a cancellation owner.

Verification and next step

On October 10, 2026, this lesson was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, nine debug/release tests, formatting, debug/release outputs and five further output comparisons passed, including the lending async closure and AsyncFnMut bound. The async-block repair and effect-after-await variant passed, including an additional successful-completion test. Await outside async and an escaping borrow produced E0728 and E0597; requiring Send rejected the Rc-capturing future. The notification-omission variant compiled but failed its single-poll notification assertion without running a hanging executor. These checks cover the fixtures, not production I/O, scheduler fairness, every wakeup interleaving or performance.

Continue with Pin, Unpin and async boundaries: distinguish a movable owning handle from its pinned pointee and design a safe future wrapper.

Previous: channels and shutdown · Course overview

Donate