Back to CodePack

Pin Unpinned: Understanding Rust's Most Misunderstood API for Safe Async State Machines

If you've written any nontrivial async Rust, you've likely hit a wall with Pin. The compiler tells you something is !Unpin, you add Box::pin, and move on without fully understanding why. This isn't your fault—the API is genuinely tricky, and most explanations skip the concrete mechanics.

Let's fix that. By the end of this, you'll understand exactly what Pin does, why async state machines require it, and how to work with Pin<&mut T> safely in production code.


The Core Problem: Self-Referential Structs

Async Rust compiles each async fn into a state machine—a struct that holds all local variables across await points. The issue arises when a local variable borrows another local variable.

Consider this async function:

async fn example() {
    let data = vec![1, 2, 3];
    let slice = &data[..]; // borrows data
    tokio::time::sleep(Duration::from_secs(1)).await;
    println!("{:?}", slice);
}

The generated state machine looks roughly like:

struct ExampleStateMachine {
    state: u8,
    data: Vec<i32>,
    slice: &'??? [i32], // lifetime problem
}

When the future is moved after being polled, data shifts in memory. Now slice points to the old location—use-after-free waiting to happen. The compiler must prevent this.


Enter Pin: A Contract, Not a Mechanism

Pin is a wrapper around a pointer that guarantees the pointee will not move. It doesn't relocate data; it restricts how you can access it. The guarantee is:

For Unpin types (most types), Pin does nothing—you can call Pin::new() and get a mutable reference freely. The magic is only for types that opt out via !Unpin.

This is compiler-enforced documentation: "This value must not move."


How Async State Machines Become !Unpin

The Rust compiler generates !Unpin for futures that contain self-references. You can verify this:

use std::pin::Pin;
use std::marker::PhantomPinned;

fn assert_unpin<T: Unpin>() {}

async fn self_referential() {
    let x = 42;
    let rx = &x;
    tokio::time::sleep(Duration::from_secs(1)).await;
    println!("{}", rx);
}

// This won't compile:
// assert_unpin::<typeof(self_referential())>();

Why PhantomPinned? That's the zero-cost marker that implements !Unpin. When the compiler detects self-references, it injects this marker into the future's state.


Common Pitfall: Moving a Future

The most frequent footgun:

let fut = self_referential();
tokio::pin!(fut); // pins to stack - safe

// This would be UB but compiles without pin:
// std::mem::replace(&mut fut, other_fut); // moves fut

tokio::pin! ensures the future stays at one stack address. For heap allocation:

let fut = Box::pin(self_referential());
// Now fut is a Pin<Box<dyn Future>> - safe to move the Box

The Box itself can move (it's on the stack), but the future inside the heap allocation stays put.


Unsafe Proof: When You Need Raw Access

Sometimes you must implement a type that is !Unpin—for example, a lock-free data structure with intrusive linked lists. Here's a minimal self-referential struct:

use std::pin::Pin;
use std::marker::PhantomPinned;

struct SelfReferential {
    data: i32,
    pointer: *const i32, // raw pointer - no borrow checker
    _pin: PhantomPinned,
}

impl SelfReferential {
    fn new(data: i32) -> Self {
        Self {
            data,
            pointer: std::ptr::null(),
            _pin: PhantomPinned,
        }
    }

    fn init(self: Pin<&mut Self>) {
        let this = unsafe { self.get_unchecked_mut() };
        this.pointer = &this.data as *const i32;
    }

    fn get_value(self: Pin<&Self>) -> i32 {
        unsafe { *self.pointer }
    }
}

Key observations: - We use raw pointers (*const i32) because Rust's borrow checker cannot track self-references. - init must take Pin<&mut Self> to guarantee no moves after setup. - get_value takes Pin<&Self> to ensure the pointer remains valid.

This pattern shows why Pin exists: to give safe APIs around inherently unsafe constructs.


The PinBuilder Pattern (Production-Grade)

For complex initialization, use a builder that constructs the pinned value safely:

use std::pin::Pin;
use std::marker::PhantomPinned;

struct RingBuffer {
    buffer: Vec<u8>,
    head: usize,
    tail: usize,
    _pin: PhantomPinned,
}

struct RingBufferBuilder {
    size: usize,
}

impl RingBufferBuilder {
    fn new(size: usize) -> Self {
        Self { size }
    }

    fn build(self) -> Pin<Box<RingBuffer>> {
        let buf = RingBuffer {
            buffer: vec![0; self.size],
            head: 0,
            tail: 0,
            _pin: PhantomPinned,
        };
        Box::pin(buf)
    }
}

The builder ensures the value is pinned before it can be used. This eliminates the possibility of moving after initialization.


Safe Pin Projections: The pin-project Crate

When you have a struct containing multiple pinned fields, you need projections—borrowing individual fields while maintaining the pin guarantee. The pin-project crate generates safe code for this:

use pin_project::pin_project;

#[pin_project]
struct TwoFutures {
    #[pin]
    future_a: Pin<Box<dyn Future<Output = ()>>>,
    #[pin]
    future_b: Pin<Box<dyn Future<Output = ()>>>,
}

impl TwoFutures {
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        // Safe projections via generated methods
        let this = self.project();
        let _ = this.future_a.poll(cx);
        let _ = this.future_b.poll(cx);
        Poll::Ready(())
    }
}

Before pin-project, you'd write unsafe projections:

impl TwoFutures {
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        unsafe {
            let a = self.map_unchecked_mut(|s| &mut s.future_a);
            let b = self.map_unchecked_mut(|s| &mut s.future_b);
            let _ = a.as_mut().poll(cx);
            let _ = b.as_mut().poll(cx);
        }
        Poll::Ready(())
    }
}

The unsafe version is fragile—if you incorrectly map a field, you break the pin guarantee. pin-project eliminates this risk.


The Stack Pin Trap

tokio::pin! is convenient but has a subtle limitation:

async fn fetch_data() -> Vec<u8> {
    // ... some async work
}

async fn process() {
    let fut = fetch_data(); // on stack
    tokio::pin!(fut);       // pins to stack
    // This compiles but if you move 'fut' after pinning, it's UB
    // Safe because 'fut' goes out of scope before moving
}

If you need to pass the future to another function, Box::pin is safer:

fn spawn_and_poll(fut: Pin<Box<dyn Future<Output = ()> + Send>>) {
    tokio::spawn(fut); // moves the Box, not the future
}

Stack-pinned futures cannot be moved—ever. They die when the scope ends. Box-pinned futures can move their heap allocation.


Practical Debugging: When Pin Surprises You

You encounter a !Unpin error. Steps to resolve:

  1. Identify the self-reference. Look for local variables that borrow other locals across await points. Refactor to avoid the borrow if possible.

  2. Use Box::pin. Almost always the right answer for production code. The heap allocation cost is negligible compared to the async runtime overhead.

  3. Check your trait bounds. Some runtime operations require Unpin:

tokio::spawn(async move {
    // If this future is !Unpin, tokio::spawn won't even compile
    // because it requires futures to be Unpin
});

tokio::spawn actually accepts Box::pin'd futures because Pin<Box<T>> implements Unpin. The Box is movable; the contents stay fixed.


The Unsafe Escape Hatch (When You Must)

Rarely, you need to convert Pin<&mut T> to &mut T. This is safe only if you know the value is Unpin:

fn get_mut_unchecked<T>(pinned: Pin<&mut T>) -> &mut T {
    // SAFETY: The caller guarantees T is Unpin
    unsafe { Pin::get_unchecked_mut(pinned) }
}

Never do this unless you're implementing a low-level runtime primitive. For 99% of users, if you need &mut T from Pin, you're designing something wrong.


What Pin Does Not Do


The Real Cost of Pin

Community wisdom says "just Box::pin everything." That's fine for most apps. But if you're building embedded systems or high-throughput services, consider:

For async state machines, Rust's compiler often optimizes away the !Unpin marker when it can prove no self-reference exists. This is why simple async functions compile to Unpin futures.


Summary

Understanding Pin isn't about memorizing the API—it's about internalizing the memory model. Once you see async state machines as self-referential structs, the rest clicks into place.

Now go build something that doesn't segfault.

Support CodePack with crypto — no account needed, no bank. Pay in USDC or SOL via Solana.

Tip $1 USDC Developer Tool Packs

If you found this useful, check out Rust programming books on Amazon or a nice Developer keyboards on Amazon.

Support CodePack with crypto — send BTC, ETH, SOL, LTC, or USDC. No account needed. The monitor checks every 5 minutes.

Pay with Crypto Developer Tool Packs

If you found this useful, check out Rust programming books on Amazon or a nice Developer keyboards on Amazon.