Rust's Pin and Self-Referential Futures: Why async Needs It At All
Every so often you hit a wall that only exists because of Pin: an error saying a future doesn't implement Unpin, or a signature like self: Pin<&mut Self> in a trait you're implementing by hand. It's one of the least discoverable parts of async Rust. Nothing about "pinning" is intuitive from first principles, and the type itself does something almost nobody writing application code would think to reach for on their own. The reason it exists is specific and mechanical, and once you've seen it, Pin stops looking like ceremony.
Start with an ordinary-looking async function.
async fn example() {
let data = String::from("hello");
let data_ref: &str = &data;
some_other_future().await;
println!("{}", data_ref);
}
Nothing unusual here in synchronous code: a value, a borrow of it, and a later use of that borrow. But an async fn body doesn't execute like ordinary code. The compiler turns it into a state machine: a struct holding every local variable still alive across an .await point, plus an enum tracking which stage of the function it has reached. Each call to poll resumes the state machine from wherever it last left off.
Sketch out what that generated struct roughly has to look like for the function above:
struct ExampleFuture {
state: State,
data: String,
data_ref: *const str,
}
data_ref has to point at data, and data is a field of the very struct data_ref lives inside. That's the whole problem in one sentence: the compiler-generated future is self-referential. It holds a value and a pointer into that value, in the same allocation.
Why moving it is dangerous
Rust's ordinary move semantics are a memcpy: when a value moves, its bytes get copied to a new address and the old location is considered dead. That's fine for almost every type in the language, because nothing outside the value points into its own fields. A Vec<u8> can be moved freely, because the pointer it holds refers to heap data, not to itself.
ExampleFuture is different. If it gets moved, and futures get moved constantly (built on the stack, passed into Box::new, stored in a select! arm, pushed into a FuturesUnordered), the data field lands at a new address, but data_ref still holds the old one. The next line that dereferences data_ref reads freed or unrelated memory. That isn't a hypothetical edge case; it's exactly what would happen the first time an executor moved a paused future between poll calls, which is a completely ordinary thing for an executor to do.
This is the actual problem Pin solves. It has nothing to do with async as a concept and everything to do with the specific shape of value that compiling async fn produces.
What Pin actually guarantees
Pin<P> wraps a pointer type P (typically &mut T or Box<T>) and, for as long as the pointee doesn't implement Unpin, refuses to hand out a plain &mut T through safe code. No safe &mut T means no safe mem::swap, no safe mem::replace, nothing that could relocate the value's bytes. Once something is behind a Pin, safe code can rely on its address staying fixed for the rest of its life, right up until Drop runs.
Unpin is the escape hatch, and it's an auto trait: the compiler derives it automatically for any type that doesn't contain a PhantomPinned marker or another !Unpin type. Almost everything is Unpin. Integers, String, Vec<T>, your average struct: all Unpin, because moving them is always safe regardless of pinning. The types that end up !Unpin are, overwhelmingly, compiler-generated futures that hold a self-reference across an .await, plus anything that deliberately opts out with PhantomPinned to get the same guarantee by hand. For an Unpin type, Pin is a formality: Pin::new works without unsafe and the wrapper doesn't restrict anything a plain reference wouldn't.
This is why Future::poll takes self: Pin<&mut Self> rather than &mut self. The signature is the enforcement mechanism: you cannot call poll on a future at all unless you've first proven, via the type system, that it's pinned in place. Once a future has been polled and may contain pointers into itself, the API makes it impossible to get a movable reference back out.
Where this shows up in practice
Most async code never mentions Pin, because something else is already doing the pinning. tokio::spawn puts the future in a Box and pins it there; a boxed future's address stays stable regardless of how the Box<F> itself gets moved around, because moving the box moves a pointer, not the pointee. That's also why Pin<Box<dyn Future<Output = T>>> is such a common sight in trait objects and return types: boxing sidesteps the whole problem at the cost of an allocation.
The other common case is polling a future by hand on the stack, which is roughly what select! and hand-rolled executors do. You can't just take &mut some_future, because nothing has told the compiler that value won't move again. The tokio::pin! macro handles this by shadowing the binding with a stack-pinned one:
let fut = some_async_fn();
tokio::pin!(fut);
// `fut` is now `Pin<&mut _>`; the original owned value
// can no longer be named or moved.
fut.as_mut().poll(cx);
The macro consumes the original variable and rebinds the name to a pinned reference, so there's no longer any way to reach the value except through something that already promises not to move it. The mechanism is covered in more depth in the async book's chapter on pinning, which is worth reading once this clicks.
Building one by hand
Application code essentially never constructs a self-referential type manually, but seeing the mechanics once makes the rest of the system click. Here's a minimal version, using unsafe code to do by hand roughly what the compiler does automatically for an async fn. Treat it as illustrative rather than a pattern to copy into real code:
use std::marker::PhantomPinned;
use std::pin::Pin;
struct SelfRef {
data: String,
data_ptr: *const String,
_pin: PhantomPinned,
}
impl SelfRef {
fn new(data: String) -> Pin<Box<Self>> {
let res = SelfRef {
data,
data_ptr: std::ptr::null(),
_pin: PhantomPinned,
};
let mut boxed = Box::pin(res);
let self_ptr: *const String = &boxed.data;
unsafe {
// Safety: we write through the pin without moving the
// pointee; `data_ptr` is only ever read after this point.
let mut_ref: Pin<&mut Self> = Pin::as_mut(&mut boxed);
Pin::get_unchecked_mut(mut_ref).data_ptr = self_ptr;
}
boxed
}
fn data(self: Pin<&Self>) -> &str {
&self.get_ref().data
}
}
Allocate, take a pointer into the allocation, store the pointer alongside the data, and never let anything move the struct afterwards. PhantomPinned exists purely to opt the type out of the automatic Unpin derive, so the compiler enforces the "never move it" promise on your behalf rather than trusting you to remember it.
The underlying trade-off is worth naming, because it explains why Rust ended up here at all rather than, say, Go. A goroutine has its own stack; when it suspends at a channel receive, its local variables sit in stack memory that doesn't move relative to each other, so there's nothing to pin. Rust's async functions are stackless: the compiler flattens the whole call into a single state machine object with no separate stack of its own, which is what makes an idle future cheap (often just a fixed-size struct, no thread, no stack allocation) and usable on embedded targets with no heap at all. Pin is the price of that. Without a separate stack to keep locals safely out of reach of each other, the locals end up living inside the future's own memory, and some of those futures end up pointing at themselves.