Rust / Advanced / Lesson 15
Async futures and Pin
Separate a future from its executor, reason about suspension and cancellation, and understand pinning without implementing unsafe self-referential machinery.
On this page
Lesson goals
- Explain Future::poll, Pending, Ready, and wakeups.
- Describe Pin and Unpin at the pointee boundary.
- Keep runtime-dependent snippets separate from standard-library examples.
A future is a value describing unfinished work
An async function returns a future rather than automatically spawning a thread. Calling it constructs a value whose body progresses when polled by an executor. await cooperates with that polling model; it is not a universal guarantee that an operation never blocks the underlying thread. The standard library defines Future and task primitives but does not provide a general application runtime with networking and timers. Choose a runtime according to actual I/O and scheduling requirements, and document that dependency. These examples use only immediately ready standard-library futures, so no external runtime is installed or implied.
Poll once when readiness is guaranteed
Future::poll receives a pinned mutable reference and a Context containing a Waker. Poll::Ready returns completion; Poll::Pending means more progress is needed and the wakeup protocol must arrange a later poll when appropriate. A production executor should not spin endlessly on Pending without respecting wakeups. The first example uses ready, which completes on this poll, and a harmless no-op Wake implementation solely to construct the required context. This is a controlled protocol demonstration, not a general executor. Do not poll a completed future again unless its specific API explicitly supports doing so.
use std::future::{Future, ready};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct Noop;
impl Wake for Noop { fn wake(self: Arc<Self>) {} }
fn main() {
let waker = Waker::from(Arc::new(Noop));
let mut context = Context::from_waker(&waker);
let mut future = ready(7u32);
match Pin::new(&mut future).poll(&mut context) {
Poll::Ready(value) => println!("ready {value}"),
Poll::Pending => println!("pending"),
}
}
ready 7
ready guarantees completion here. The no-op waker is suitable only because no later readiness event is required.
Pin constrains moving the pointee
Some futures can hold internal references across suspension and need a stable address once pinned. Pin<Ptr> expresses restrictions on moving or invalidating the pointee; moving the pointer handle itself is a different operation. Unpin means the type does not require those pinning restrictions for its normal use. Many ordinary types are Unpin, but an async-generated future should not be assumed to be. Box::pin provides owned pinned storage through a safe interface. Pin does not make arbitrary unsafe pointer code sound, freeze all fields, or extend borrowed data lifetimes automatically. Its contract must be preserved through projection and destruction.
Compose ready work without a runtime dependency
The second program pins an async block that awaits an immediately ready value and adds five. Its future can be polled once to completion because this particular await never suspends as Pending. The output is independent of a scheduler, timer, or operating-system I/O. In a real async API, callers normally await rather than manually constructing Context. A runtime-specific spawn or timer example would require its crate and runtime setup and must not be labeled independently runnable. Keep the boundary between language syntax, standard futures, and executor services explicit in documentation.
use std::future::{Future, ready};
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct Noop;
impl Wake for Noop { fn wake(self: Arc<Self>) {} }
fn main() {
let waker = Waker::from(Arc::new(Noop));
let mut context = Context::from_waker(&waker);
let mut future = Box::pin(async { ready(4u32).await + 5 });
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => println!("sum={value}"),
Poll::Pending => println!("pending"),
}
}
sum=9
The awaited four is immediately available, so the pinned computation returns nine without suspension.
Suspension preserves ownership obligations
A future retains captured values and borrows across its suspended state. Dropping a future cancels that future value, but does not automatically undo external effects already performed or cancel every independently spawned task. Design cancellation-safe state transitions and define which work is committed before an await. Holding a synchronous mutex guard across suspension can block other tasks or make a future fail Send requirements; shorten the critical section or use a documented async synchronization protocol. A multi-thread executor may require Send, while local execution can allow non-Send futures. These are executor contracts, not universal properties of async syntax.
Common errors and fixes
An async call is assumed to start background work.
- Why it happens
- Future construction was confused with polling or spawning.
- How to fix it
- Await or spawn through the intended documented executor.
- Prevent it next time
- Identify which component drives each future.
A no-op waker is used for real I/O.
- Why it happens
- A ready-only demonstration was mistaken for an executor.
- How to fix it
- Use a runtime with a proper readiness and wakeup protocol.
- Prevent it next time
- Label executor-dependent examples explicitly.
Pin is assumed to make every field immutable.
- Why it happens
- Address stability was confused with general immutability.
- How to fix it
- Review pointee movement, Unpin, and projection rules separately.
- Prevent it next time
- Keep unsafe pinning implementations out of casual refactors.
Check your understanding
1. Replace ready(4u32) in the second example with ready(10u32). Predict the output and explain why the no-op waker still suffices for this exact case.
Show worked solution
The line becomes sum=15. ready returns Ready on the first poll, so the async block completes without needing a later external event or another scheduled poll. The reasoning depends on this concrete computation. Replacing it with a timer or socket future invalidates the no-op-waker assumption and requires a real executor protocol.
2. A task updates a record, awaits a network response, and then completes the update. Describe the cancellation risk and a better contract.
Show worked solution
Dropping the future during await can leave the first mutation committed without the second. Prepare a candidate before externally visible mutation, define a deliberate commit point, or document a recoverable intermediate state with compensation. Cancellation is not automatic transaction rollback. Test dropping at suspension boundaries and specify whether separately spawned work continues.
Key takeaways
- Futures need an executor or a justified polling demonstration.
- Pin protects a movement contract, not arbitrary unsafe code.
- Review cancellation, guards, and captures across await.