Skip to main content

Rust Async Deep Dive: Fixing `Unpin` Errors and Understanding Pinning

 

The Hook

You have written an asynchronous Rust service. You are trying to store a collection of Futures in a Vec to run them concurrently, or perhaps you are attempting to race two async tasks using tokio::select! within a loop. Suddenly, the compiler halts with a distinct error:

the trait bound 'impl Future: Unpin' is not satisfied or the trait 'Unpin' is not implemented for 'dyn Future<Output = ...>'

This error is a rite of passage for Rust systems engineers. It usually leads to a frantic application of Box::pin until the red squiggles disappear, often without a clear understanding of why it was necessary or what performance cost was just incurred.

The Why: Self-Referential Structs

To understand Unpin, you must understand how Rust models async/await under the hood.

When you write an async fn, the compiler transforms that function into a generated state machine (an enum). If your async function has local variables that are borrowed across an .await point, the resulting struct becomes self-referential.

Consider this conceptual lowering of an async function:

async fn process_data() {
    let buffer = [0u8; 1024];
    let reader = MyAsyncReader::new(&buffer); // borrowing 'buffer'
    reader.read().await;
}

In the generated state machine, one field (representing reader) holds a pointer to another field (representing bufferwithin the same struct.

If you were to move this struct in memory (e.g., passing it to a function by value, resizing a Vec containing it), the struct's memory address changes. However, the pointer inside reader would still point to the old memory address of buffer. This results in a dangling pointer.

This is why Unpin exists.

  1. Unpin is an auto-trait that means "It is safe to move this type in memory." primitives (u8bool) and standard structs are Unpin.
  2. !Unpin (Not Unpin) means "This type may rely on its memory address not changing." Generated Futures are almost always !Unpin.

The error occurs because methods like Future::poll requires Pin<&mut Self>. You cannot create a Pin<&mut T> from a T that is !Unpin unless you guarantee that T will never move again.

The Fix

We will look at two common scenarios: pinning on the Heap (for collections/dynamic dispatch) and pinning on the Stack (for loops/selects).

Scenario 1: Heterogeneous Collections (Heap Pinning)

You want a list of different tasks to run. You cannot use Vec<Box<dyn Future...>> directly if you intend to poll them, because Box<T> allows moving the content, violating the safety requirements of !Unpin futures.

The Broken Code

use std::future::Future;

// This fails to compile
fn run_tasks() {
    let mut tasks: Vec<Box<dyn Future<Output = ()>>> = Vec::new();

    tasks.push(Box::new(async { println!("Task 1"); }));
    tasks.push(Box::new(async { println!("Task 2"); }));

    // Error: `dyn Future` cannot be polled because it is not `Unpin`
    // The poll method requires Pin<&mut Self>
}

The Solution: Pin<Box<T>>

We must use Box::pin. This places the Future on the heap and wraps the pointer in a Pin type. The Pin wrapper prevents safe Rust from giving you mutable access to the pointer in a way that would allow you to swap the data out (move it).

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

// A type alias for a pinned, boxed future
type BoxedFuture = Pin<Box<dyn Future<Output = ()>>>;

struct TaskManager {
    tasks: Vec<BoxedFuture>,
}

impl TaskManager {
    fn new() -> Self {
        Self { tasks: Vec::new() }
    }

    fn add_task<F>(&mut self, future: F)
    where
        F: Future<Output = ()> + 'static + Send,
    {
        // FIX: Use Box::pin instead of Box::new
        self.tasks.push(Box::pin(future));
    }

    // A simple executor stub to demonstrate polling
    fn run_one_step(&mut self, cx: &mut Context<'_>) {
        let mut completed_indices = Vec::new();

        for (i, task) in self.tasks.iter_mut().enumerate() {
            // Because it is a Pin<Box<_>>, as_mut() works validly here
            if let Poll::Ready(_) = task.as_mut().poll(cx) {
                completed_indices.push(i);
            }
        }
        
        // Remove completed tasks (in reverse to preserve indices)
        for i in completed_indices.iter().rev() {
            self.tasks.remove(*i);
        }
    }
}

#[tokio::main]
async fn main() {
    let mut manager = TaskManager::new();
    
    manager.add_task(async {
        println!("Async Operation 1");
    });
    
    println!("Tasks added successfully.");
}

Scenario 2: Iteration and Racing (Stack Pinning)

Often you are working within a single scope—like a loop using tokio::select!. You don't want the allocation overhead of Box::pin.

Historically, developers used the tokio::pin! macro. In modern Rust (1.68+), we use std::pin::pin!.

The Broken Code

async fn worker() {
    // A future that is !Unpin
    let work = async { 
        tokio::time::sleep(std::time::Duration::from_millis(100)).await; 
    };

    // Error: `select!` usually requires the future to be Pinned because
    // it polls the future multiple times. If `work` moved between polls,
    // it would be UB.
    /* 
    tokio::select! {
        _ = &mut work => {} // Compile error regarding Unpin
    }
    */
}

The Solution: std::pin::pin!

This macro pins the value to the stack. It shadows the variable, wraps it in Pin, and relies on unsafe scoping rules to ensure the memory is not invalidated before the scope ends.

use std::time::Duration;
use tokio::time::sleep;
use std::pin::pin;

#[tokio::main]
async fn main() {
    let slow_task = async {
        sleep(Duration::from_millis(500)).await;
        "Slow"
    };

    let fast_task = async {
        sleep(Duration::from_millis(100)).await;
        "Fast"
    };

    // 1. Create the futures
    // 2. PIN them to the stack. 
    // `std::pin::pin!` consumes the original variable and returns a Pin<&mut T>
    let mut pinned_slow = pin!(slow_task);
    let mut pinned_fast = pin!(fast_task);

    // Now valid to use in select! or manual polling
    tokio::select! {
        res = &mut pinned_slow => println!("Winner: {}", res),
        res = &mut pinned_fast => println!("Winner: {}", res),
    }
}

The Explanation

Why did these fixes work?

Pin<P> is a Type-System Lock

Pin is not a container that holds data directly; it is a wrapper around a pointer type (like Box or &mut T).

  • Box<T>: I own the data, and I can move it out.
  • Pin<Box<T>>: I own the data, but I promise never to move it out of the heap address it currently occupies.

When we used Box::pin(future), we allocated the Future on the heap. The address of the data is now fixed (e.g., 0x1234). Even if we move the Pin<Box<...>> itself around stack frames (pushing it into a vector), the actual heavy Future struct stays at 0x1234. The self-referential pointers inside the Future remain valid.

The Stack Pin Trick

std::pin::pin!(f) essentially does this:

  1. Takes ownership of f.
  2. Creates a shadowed variable that holds f locally.
  3. Returns a Pin<&mut T> pointing to that local variable.
  4. Because the original f is consumed/shadowed, you cannot access it directly anymore to move it. The compiler guarantees the data stays at that stack address until it is dropped.

Conclusion

The Unpin error is Rust protecting you from invalidating memory in self-referential async state machines.

  1. If you are building executors, generic streams, or dynamic task lists, use Box::pin. You need the heap allocation to keep the address stable while you pass ownership around.
  2. If you are handling control flow (loops, selects, joins) within a function scope, use std::pin::pin!. It is zero-cost and creates a safe pinned reference on the stack.

Treat Pin not as an annoyance, but as the mechanism that allows Rust to have zero-cost async abstractions without a garbage collector.