async/await Internals

On this page

Calling an async fn constructs a Future, which is advanced when polled. A Future describes a computation that has not yet completed; it does not come with its own independent thread. The executor decides when to poll, and asynchronous I/O or other wait sources notify the executor when they can make progress.

An .await does not necessarily imply a single thread switch. If a sub-Future is immediately Ready, the current poll can continue directly, potentially completing multiple awaits within a single poll.

First pollAdvance until ready or waiting is necessary1Return PendingSave state needed for later, and establish the corresponding wake path2Receive wakeRequest another poll; wakeups can be coalesced3Re-poll4Advancing a Future: only re-poll when necessaryMay continue Pending, or return Ready(output)An await does not necessarily suspend; a wake carries no result and does not guarantee the next poll is complete.

Constructing a Future and Executing the Function Body

use std::{cell::Cell, future::Future, task::{Context, Poll, Waker}};
async fn calculate(started: &Cell<u32>) -> u32 {
    started.set(started.get() + 1);
    let left = std::future::ready(20).await;
    let right = std::future::ready(22).await;
    left + right
}
fn main() {
    let started = Cell::new(0);
    let future = calculate(&started);
    assert_eq!(started.get(), 0);
    let mut future = std::pin::pin!(future);
    let mut context = Context::from_waker(Waker::noop());
    assert_eq!(future.as_mut().poll(&mut context), Poll::Ready(42));
    assert_eq!(started.get(), 1);
}

The function body runs only on the first poll. Since both ready Futures complete immediately, the entire function returns 42 within a single poll. A noop Waker is used here because the example does not require waiting; it does not schedule tasks to run and cannot drive real asynchronous I/O. Waker::noop

Arguments to a call expression are still evaluated when the Future is constructed. For example, in calculate(make_counter()), the call to make_counter is not automatically deferred; only the execution of the async function body is driven by polling. async move also takes ownership of captured values at the creation site, even if the Future is never polled.

What State the Compiler Saves

The compiler represents an async function as an anonymous state machine capable of suspending and resuming. It saves the currently awaited sub-Futures and local values needed for later. When waiting for read_file, it typically saves the state of the read operation, not the file content that has been received but not yet yielded.

The specific layout of the state machine is an implementation detail; you should not treat "each await must correspond to an enum variant with the exact same name" as an ABI guarantee. Large values crossing suspension points can increase the size of the Future; ending their lifetime early can sometimes reduce state size.

The Contract Between poll and Waker

Future::poll takes Pin<&mut Self> and Context, returning Poll<Output>. Pending means there is no result yet; the implementation must establish a wake path for when the task can make progress. This work can be delegated to the polled sub-Futures, avoiding the need to save the Waker at every layer.

wake is a scheduling hint; it can be coalesced or occur before poll returns Pending. When implementing your own waiting primitives, you must correctly handle the race between checking state and registering notifications. You cannot simply "check for no result, then register a callback," as this might miss a completion event that happens in between.

Once Ready is returned, the caller should not arbitrarily poll the completed Future again. Specific types may panic or exhibit other behavior defined by their interface; do not treat it as a protocol like Iterator where next can be called again. Future

Ready Means Complete, Not Necessarily Successful

A Future's Output can be Result<T, E>. Thus, Poll::Ready(Err(error)) means the computation has completed and returned an error; Pending means the final result has not yet been obtained. The executor should not treat Err as "not ready yet" and continue polling.

poll ResultScheduler MeaningCaller Handling
PendingCannot complete currentlyWait for notification, then advance again
Ready(Ok(value))CompletedUse the business result
Ready(Err(error))CompletedPropagate, report, or recreate the operation based on business logic
use std::{future::Future, task::{Context, Poll, Waker}};
fn main() {
    let mut future = std::pin::pin!(async {
        "bad".parse::<u32>()
    });
    let mut context = Context::from_waker(Waker::noop());
    assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_))));
}

Why Saving Only the First Waker Is Insufficient

The same Future may receive different task Wakers on subsequent polls, for example, when driven by different outer combinators. Waiting primitives must update the current notification target according to the protocol; you cannot assume the first registration remains valid forever. wake itself carries no result; results must be stored in correctly synchronized shared state and read by poll. Future::poll

A waiting primitive that is easy to reason about uses a single lock to protect both "whether the result exists" and "the current Waker": the consumer checks the result inside the lock, and if incomplete, registers the Waker; the producer writes the result and retrieves the Waker inside the lock, then calls wake after unlocking. This avoids the scenario where a completion event falls exactly between the check and the registration, leaving no one to notify. Real-world libraries also handle shutdown, duplicate completions, cancellation, and fairness; do not treat this sequence as a complete channel implementation.

Waking is not executing

Separate external readiness, waking and polling to avoid imagining await as an independently running function. Poll to Pending, then trigger I/O alone: the task becomes schedulable, but the Future has not returned a result. Repeated wakes do not require an equal number of polls.

Preparing the visual
Waking is not executing

Only polling advances a Future; wake requests later polling, and Pending is not autonomous background execution.

A Manual Polling Example Without a Runtime

The following Future requests rescheduling on the first poll and returns a result on the second. The example uses the safe Wake interface and does not require writing a RawWaker:

use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
use std::task::{Context, Poll, Wake, Waker};

struct YieldOnce(bool);
impl Future for YieldOnce {
    type Output = u32;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
        if self.0 {
            Poll::Ready(42)
        } else {
            self.0 = true;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

struct WakeCount(AtomicUsize);
impl Wake for WakeCount {
    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); }
}

fn main() {
    let count = Arc::new(WakeCount(AtomicUsize::new(0)));
    let waker = Waker::from(count.clone());
    let mut context = Context::from_waker(&waker);
    let mut future = std::pin::pin!(YieldOnce(false));
    assert!(future.as_mut().poll(&mut context).is_pending());
    assert_eq!(count.0.load(Ordering::Relaxed), 1);
    assert_eq!(future.as_mut().poll(&mut context), Poll::Ready(42));
}

This is a deterministic two-poll demonstration, not a general executor. A real executor must maintain task queues and handle notification races; you cannot simply map every Pending to an unconditional park.

Pin Stabilizes the Pointed-to Value

The state machine may contain references to its own internal data. Therefore, once address stability is established via the appropriate pinning contract, you cannot arbitrarily move a pinned !Unpin value until it is properly destructed.

Pin is not a magical address lock applied by the compiler to all values; it is a contract maintained by restricted operations in safe APIs and obligations in unsafe implementations. The pointer wrapper Pin<Box<F>> itself can be moved, as long as the F it points to remains in place; when F: Unpin, Pin does not impose the same movement restrictions. Pin

Before the first pinning, a Future can usually be moved. Not every async Future is actually self-referential, nor does having a String local variable necessarily mean moving the String object moves its heap buffer; you must distinguish between the object and the storage it points to.

Moving the wrapper is not moving the pinned value

First move only the owning handle: address-sensitive F stays at heap location A. Then inspect the counterfactual move of F itself and why its internal address does not update automatically. Pin does not establish those relationships; initialization, projection and destruction must uphold the contract. Unpin types can opt out of this movement restriction.

Preparing the visual
Moving the wrapper is not moving the pinned value

Pin pins the pointee; the wrapper can move. Safe APIs do not move out a pinned !Unpin value.

What await, spawn, and join Do

await advances a sub-Future within the current task; spawn registers a Future as an independent task; join-like combinators can advance multiple Futures within the same task without creating OS threads. Concurrency is about overlapping lifetimes; whether it is parallel depends on the execution environment.

If a poll executes a long CPU loop or a blocking call, it will occupy the current executor thread. Merely marking a function as async does not automatically yield execution for std::thread::sleep, blocking I/O, or heavy computation.

Drop Is Part of the Cancellation Mechanism

Dropping an incomplete Future is a common cancellation method in Rust async; the language allows this, and you should not say a Future "cannot be dropped arbitrarily." It destructs the state it holds and stops the object from being polled further; however, independent tasks, requests handed off to devices, and remote transactions may continue.

Cancellation safety discusses whether progress is lost or business protocols are broken upon dropping. For example, if read_exact has already read some bytes and is cancelled, recreating the operation may not resume from the original position. Whether resources are released and whether business effects are rolled back are two separate issues.

Constructed, not yet polledOwns captures; body unrunPolled, returned PendingStores locals and FuturesReturned ReadyResult handed to callerDrop captured inputsBody stays unexecutedDestruct saved stateNo continuation after awaitCaller owns the resultDo not poll againWhich stage cancellation occurs at determines what state needs to be destructedThese are object lifetime rules; whether external requests or independent tasks stop requires their own protocols.

Verifying Cancellation Destructs Local Values, But Does Not Execute Post-Suspension Cleanup

use std::{cell::Cell, future::Future, rc::Rc, task::{Context, Waker}};
struct Resource(Rc<Cell<u32>>);
impl Drop for Resource {
    fn drop(&mut self) { self.0.set(self.0.get() + 1); }
}
async fn waiting(drops: Rc<Cell<u32>>, after: Rc<Cell<bool>>) {
    let resource = Resource(drops);
    std::future::pending::<()>().await;
    after.set(true);
    drop(resource);
}
fn main() {
    let drops = Rc::new(Cell::new(0));
    let after = Rc::new(Cell::new(false));
    let mut future = Box::pin(waiting(Rc::clone(&drops), Rc::clone(&after)));
    let mut context = Context::from_waker(Waker::noop());
    assert!(future.as_mut().poll(&mut context).is_pending());
    assert_eq!(drops.get(), 0);
    drop(future);
    assert_eq!(drops.get(), 1);
    assert!(!after.get());
}

pending never becomes ready on its own; here we intentionally poll only once before cancelling, so no wake is needed. Resource is constructed and survives across the suspension point, so Drop is called upon cancellation; after.set(true) is after the await and is never executed. If critical cleanup is written only at the end of the function, you cannot guarantee the cancellation path will reach it.

Standard Drop is synchronous and does not automatically await remote release confirmation. When protocol-level shutdown is needed, you should explicitly provide asynchronous steps like close/shutdown, and decide on compensation for cancellation, timeouts, or crashes. Even if a local guard is released, you cannot infer that a remote transaction has been rolled back.

Dropping a Pin Reference vs. Dropping an Owned Future

The example above uses Box::pin so that drop(future) explicitly destroys the heap-allocated Future. If pin! yields a Pin<&mut F>, dropping this reference wrapper does not immediately destroy the F it points to; the actual object is still released according to its scope's lifetime. When reasoning about cancellation, first find the container that owns the Future to avoid mistaking "stopping holding a borrow" for "having executed all destructors."

Cancellation Safety Requires Checking Who Holds the Progress

If the Future itself saves "how many bytes have been sent," cancellation will lose this progress, while the underlying stream may have already received part of it; re-sending from the beginning might duplicate data. If progress is saved in an outer persistent object, the next operation has a chance to continue, but message boundaries, idempotency, and fault recovery must still be defined.

Therefore, you should simultaneously ask: which object is being dropped, what progress does it own, where have external actions progressed, and where would recreating the operation start? Cancellation safety is a contract for specific operations, not a property automatically possessed by all async fns.

For specific boundaries regarding timeouts, select, and background tasks, continue reading the Tokio Runtime and Tower.