← All weekly notes

11 min read

Why Rust futures need Pin

A step-by-step mental model for moves, async state machines, Pin, Unpin, stack and heap pinning, and static versus dynamic futures.

Rust · Async · Futures · Memory safety

While learning how Tokio polls tasks, I kept meeting this type:

Pin<&mut Self>

At first, Pin looked like an async-specific smart pointer. It is neither. Pin is a general memory-safety contract for values whose correctness can depend on staying at one address. Async Rust is simply where I encounter that contract most often.

The easiest path for me is to build the idea in order:

  1. Rust values normally move.
  2. An async function becomes a future state machine.
  3. That state machine may need to refer to its own stored values.
  4. Such a future must not move while it is being polled.
  5. Pin expresses that promise; Unpin identifies values that do not need it.
  6. Stack versus heap decides where the value is pinned.
  7. Static versus dynamic dispatch decides how its concrete type is called.

Those last two decisions are independent. Keeping them separate removes most of the confusion.

01 · Moving values

A Rust move may change a value's address

Rust moves values during assignment, function calls, returns and collection operations.

let first = String::from("hello");let second = first;

Ownership moved from first to second, so first can no longer be used. At the language level, Rust does not promise that the String value itself remains at the old address.

The compiler may optimize away physical copies. A String also owns a separate heap buffer, so moving the three-word String handle does not normally move that buffer. Neither detail gives arbitrary values an address guarantee. Safe Rust assumes ordinary values are movable unless an API establishes a pinning contract.

For most values, moving is harmless:

Before move                    After move 0x1000: Counter { value: 7 }   0x1000: old location unusable                               0x2000: Counter { value: 7 }

Counter means the same thing at either address. It does not contain anything that depends on 0x1000.

The problem begins when a value stores a pointer to a part of itself. Then its address becomes part of its correctness.

02 · Async state machine

An async function becomes a state machine

Calling an async fn creates a future. The function body progresses when an executor polls that future.

async fn download() -> usize {	let response = open_connection().await;	let body = read_body(response).await;	body.len()}

The compiler creates a hidden concrete type that remembers what must survive each suspension point. Conceptually—not as exact generated Rust—it resembles an enum:

enum DownloadFuture {	Start,	WaitingForConnection,	WaitingForBody {		response: Response,	},	Done,}

Each .await is a possible pause:

create future    ↓poll Start    ↓connection not ready ──→ Pending ──→ wake later                                      ↓                                 poll again                                      ↓body not ready ────────→ Pending ──→ wake later                                      ↓                                 Ready(length)

The real generated type can store local variables, awaited child futures and a state discriminant. Its exact layout is a compiler implementation detail. The important fact is that values used after an .await must be kept inside the future so they are still available when execution resumes.

03 · Address sensitivity

A future may become address-sensitive

Consider a local reference that remains live across an .await:

async fn print_after_wait() {	let message = String::from("hello");	let borrowed = &message; 	wait_once().await; 	println!("{borrowed}");}

When this function is suspended, the future must retain both message and the information represented by borrowed. Conceptually, the resulting state can point from one stored field into another:

Future at address 0x1000 ┌──────────────────────────────────┐│ message: "hello"                 ││ borrowed ───────────────────┐    ││ state: waiting             │    │└────────────────────────────┼────┘                             └──→ message inside this future

If that address-sensitive state were copied to 0x2000 without repairing the internal relationship, the saved pointer could still refer to the old location.

move bytes to 0x2000        ↓new future contains an old-address pointer        ↓resume and dereference it        ↓invalid memory access

Not every future actually contains a self-reference. Rust nevertheless needs one Future interface that is safe for futures that do. Compiler-generated async futures therefore commonly do not implement Unpin.

The timing is also important:

  • A future may be moved freely before it is pinned.
  • It must be pinned before it is polled through the Future interface.
  • Once a non-Unpin value is pinned, it must remain at that address and valid there until its destructor runs.

It is not the first .await that magically pours concrete around the value. The pinning pointer establishes the contract before polling begins.

04 · Pin

What Pin actually guarantees

The core Future method is:

trait Future {	type Output; 	fn poll(		self: Pin<&mut Self>,		cx: &mut Context<'_>,	) -> Poll<Self::Output>;}

poll does not receive an ordinary &mut Self. It receives a pinned mutable reference. For a future that does not implement Unpin, safe code cannot turn that reference into an unrestricted &mut Self and then use operations such as mem::replace to move the future out.

Pin<Ptr> pins the pointee, not the pointer object:

Pin<Box<MyFuture>> │   │ │   └── Box handle may move │ └────── MyFuture allocation must stay put

Moving a Pin<Box<MyFuture>> variable is therefore safe. The small box handle moves, while the future in the heap allocation stays at the same address.

Pin does not:

  • allocate memory;
  • freeze all fields or make the value immutable;
  • lock a thread or operating-system page;
  • make a type Send or thread-safe;
  • prevent unsafe code from breaking the promise.

It is a library contract enforced at safe API boundaries. Correct unsafe implementations uphold that contract; safe callers then cannot accidentally move a pinned, address-sensitive value.

05 · Unpin

Unpin means moving is harmless

Unpin is an auto trait. Most ordinary Rust types implement it automatically because their validity does not depend on their address.

Examples normally include numbers, references, String, Vec<T> when appropriate, and regular application structs composed of Unpin fields.

For T: Unpin, pinning adds no movement restriction:

let mut name = String::from("Ada");let pinned = Pin::new(&mut name); let ordinary: &mut String = Pin::get_mut(pinned);ordinary.push_str(" Lovelace");

This is safe because moving a String value does not invalidate the String itself.

A type can opt out of automatic Unpin, commonly with PhantomPinned, when its implementation relies on a stable address:

use std::marker::PhantomPinned; struct AddressSensitive {	data: String,	pointer_into_data: *const str,	_pin: PhantomPinned,}

That snippet only shows the shape of such a type. Correctly initializing its internal pointer, projecting pinned fields and implementing Drop require careful unsafe reasoning. In normal application code I avoid hand-writing self-referential structs and use a well-reviewed abstraction when one is really necessary.

The memory rule is:

T: Unpin      → moving T is always safe; Pin is mechanically removableT: !Unpin     → once pinned, safe code must not move T until drop

I should not “fix” an Unpin compiler error by manually implementing Unpin unless I can prove the type has no address-sensitive invariant.

06 · Stack vs heap

Stack pinning versus heap pinning

Pinning does not require the heap. It requires a pointer whose pointee can remain at a stable address for the required lifetime.

Stack pinning with pin!

The standard pin! macro can pin a local value for the rest of its scope:

async fn run_locally() {	let future = do_work();	let pinned = std::pin::pin!(future); 	let result = pinned.await;	use_result(result);}

The value stays in this stack frame. The pinned reference cannot escape beyond the lifetime of that local storage.

current stack frame┌─────────────────────────┐│ future at stable address│←── Pin<&mut Future>└─────────────────────────┘scope ends → future drops

Stack pinning is useful when the future is local and short-lived. It avoids a heap allocation.

Heap pinning with Box::pin

Box::pin allocates a value and returns a pinned owning pointer:

let pinned: Pin<Box<_>> = Box::pin(do_work());send_to_executor(pinned);
stack                         heap┌──────────────────┐        ┌──────────────────────┐│ Pin<Box<_>> handle├───────→│ future: stable address│└──────────────────┘        └──────────────────────┘       handle may move             pointee may not move

Heap pinning is useful when ownership must escape the current scope, be stored in a collection, or be type-erased behind dyn Future.

The short comparison is:

ChoiceOwns the future?Heap allocation?Typical lifetime
Pin<&mut F> from pin!NoNoCurrent scope
Pin<Box<F>> from Box::pinYesYesCan be moved and stored as an owning handle

07 · Static vs dynamic

Static versus dynamic futures

Static and dynamic here describe dispatch, not memory location and not lifetimes.

Static dispatch: one concrete future type

Every async function or async block has a concrete compiler-generated future type. Its name is hidden, but the compiler knows its size and implementation.

async fn load_user(id: u64) -> User {	query_user(id).await}

Conceptually, async fn returns an unnamed concrete type implementing Future:

fn load_user(id: u64) -> impl Future<Output = User> {	async move {		query_user(id).await	}}

impl Future means “one specific type chosen here, even though its name is hidden.” The compiler can monomorphize generic callers and often inline through the future's poll implementation.

When runtime branching fits inside one outer async block, I can keep static dispatch:

fn load_user(id: u64, use_cache: bool) -> impl Future<Output = User> {	async move {		if use_cache {			read_cache(id).await		} else {			query_user(id).await		}	}}

There is one outer async block, so there is one concrete returned future type. Its state machine contains the branch.

Dynamic dispatch: erase different future types

Two separate async blocks have two different concrete types, even when both return the same output. If an API must return either type directly, store mixed futures in one collection, or hide implementations behind a trait boundary, I can erase the concrete type:

use std::future::Future;use std::pin::Pin; type UserFuture =	Pin<Box<dyn Future<Output = User> + Send + 'static>>; fn load_user(id: u64, use_cache: bool) -> UserFuture {	if use_cache {		Box::pin(async move {			read_cache(id).await		})	} else {		Box::pin(async move {			query_user(id).await		})	}}

Each branch creates a different future type. Both are placed behind the same dyn Future interface.

There are three separate pieces in that return type:

Pin< Box< dyn Future<Output = User> + Send + 'static > >│    │    │                              │      ││    │    │                              │      └─ no short-lived borrows│    │    │                              └──────── can move between threads│    │    └───────────────────────────────────── runtime-dispatched interface│    └────────────────────────────────────────── owns unsized value on heap└─────────────────────────────────────────────── pointee cannot be moved

A trait object such as dyn Future is dynamically sized, so it must be used behind a pointer. The pointer carries a data address and vtable information. Polling performs an indirect call through that vtable.

Box solves ownership and unknown size. Pin solves address stability. dyn Future solves type erasure. They are often used together, but they solve three different problems.

PropertyStatic: impl FutureDynamic: dyn Future
Concrete type known to compilerYesErased at boundary
DispatchDirect/monomorphizedIndirect through vtable
Heap allocation required by dispatchNoUsually Box or another owning pointer
Different future types in one collectionNot directlyYes, behind one trait-object type
Optimization opportunityUsually greaterUsually less across the erased boundary
API flexibilityOne concrete returned typeRuntime-selected implementations

Dynamic dispatch is not automatically slow, and static dispatch is not automatically best. The choice depends on whether allocation, binary size, compile time, API stability and heterogeneous storage matter on that path.

'static is not static dispatch

The 'static bound in dyn Future + 'static is about borrowed data. It says the future does not contain a borrow that can expire before the future is dropped. An async block that owns its captured values can satisfy this bound and still complete immediately.

It does not mean:

  • the future runs forever;
  • the future lives in static memory;
  • dispatch is static;
  • the future is pinned.

Similarly, Pin<Box<MyConcreteFuture>> uses static dispatch even though it is heap allocated, while Pin<Box<dyn Future>> uses dynamic dispatch. Pinning and dispatch remain independent axes.

08 · Practical rules

Practical rules I can apply

Normal .await usually needs no explicit Pin

In ordinary async code, the compiler handles the local pinning needed by .await:

async fn handle() -> Result<Response, Error> {	let user = load_user(42).await?;	build_response(user)}

I normally see explicit pinning when manually polling, storing heterogeneous futures, implementing lower-level future abstractions, or passing futures through type-erased APIs.

Read common compiler errors as design clues

“Cannot be unpinned” usually means an API wants to move or access a future in a way that is not valid after pinning. I first try pin! for a local future or Box::pin for an owned future. I do not blindly add an Unpin implementation.

“Async blocks have different types” means separate generated state machines reached a place that requires one type. I can move the branch inside one outer async block, redesign the generic boundary, use an enum, or intentionally erase the types with Pin<Box<dyn Future<...>>>.

“Borrowed value does not live long enough” or a missing 'static bound is a lifetime/ownership problem. It is not fixed by Pin. For spawned work, moving owned data into the async block is often the right design.

What Pin does not replace

  • Ownership still decides who drops the value.
  • Lifetimes still decide how long references remain valid.
  • Send and Sync still decide thread-safety properties.
  • Future still describes asynchronous progress.
  • The executor still decides when to poll.

My final memory map

async fn   ↓ createsconcrete future state machine   ↓ may becomeaddress-sensitive while suspended   ↓ therefore poll receivesPin<&mut Self> Where is it pinned?      How is it called?--------------------     -------------------------stack: pin!              static: F / impl Futureheap: Box::pin            dynamic: dyn Future Unpin means: moving remains harmless.Pin means: if moving is not harmless, safe code cannot do it after pinning.

The sentence I want to remember is:

Box answers where and who owns it. Pin answers whether the pointee may move. dyn Future answers whether its concrete type is erased.

That is enough to decode most async Rust signatures without treating them as one mysterious type.

References