Or: how a compiler trick leads to a surprisingly deep problem
The Setup: What Actually Happens When You Write async fn
When you write an async fn in Rust, you might picture something like this:
async fn do_something() {
let result = fetch_data().await;
process(result).await;
}
What you’re actually handing to the compiler is more interesting than it looks. The compiler takes that function and transforms it into a state machine — a struct that implements the Future trait and tracks where in the function you currently are.
Think of it as the compiler creating something like three states:
- State 0: hasn’t started yet
- State 1: waiting at the first
.await(fetch_data) - State 2: waiting at the second
.await(process) - Done: finished
Each state holds whatever data the function needs at that point. This is the whole point of async — it lets you write code that looks sequential but can pause and resume without blocking the thread.
Mental model check:
.awaitis not magic execution — it’s a pause point where the state machine can suspend and hand control back to the runtime. The runtime can then poll it again later when the thing it’s waiting on is ready.
The Sneaky Part: All Your Data Lives Inside the Future
Here’s the thing that trips people up. All the local variables in your async function — the ones that need to survive across .await points — are stored inside the state machine (the Future struct).
This makes sense if you think about it. The function pauses and gets suspended. When it resumes, it needs all that data back. So it has to live somewhere. That somewhere is the Future itself.
So a Future is not just “a promise of a value.” It’s an entire bundle of state — all the intermediate variables that the function needs across its lifetime.
The Problem: Futures Can Move
Here is where things get interesting.
A Future is just a normal Rust value. And in Rust, values can be moved — meaning their memory location can change. You can put a Future in a Box, pass it somewhere, push it into a Vec, etc. The runtime itself may move it around before it starts running.
That’s usually fine. The problem shows up in a specific situation:
What if a variable inside your Future references another variable inside the same Future?
For example:
async fn example() {
let data = String::from("hello");
let reference = &data; // reference to data
some_async_thing().await; // pause here
use_it(reference).await; // use the reference after resuming
}
At the first .await, the state machine pauses. Both data and reference are now stored inside the Future. But reference points to the memory location of data.
Now here’s the problem: if the Future gets moved in memory, data moves to a new address. But reference still points to the old address. That reference is now dangling — pointing at garbage memory.
This is what people call a self-referential struct: a struct that contains a pointer to itself (or to something inside itself). They’re notoriously tricky, and Rust’s normal ownership rules don’t protect against this.
One subtle detail: the compiler represents that internal pointer as a raw pointer (
*const T), not a safe&reference, precisely because you can’t safely store a self-referential borrow in a normal struct.Pinis what makes using that raw pointer sound.
The Solution: Pin
Rust’s answer to this is Pin.
Pin<P> is a wrapper around a pointer (like Box<T> or &mut T) that makes a guarantee: once something is pinned, it will not move in memory.
When the async runtime polls a Future, it does so through a Pin<&mut Future>. This tells the Future: “I promise you are not going to be moved. You’re safe to hold internal references.”
That’s it. That’s the core of what Pin does. It’s a contract that says: this value is stuck where it is.
With that guarantee in place, self-referential structures become safe. The references inside the Future can be trusted to remain valid because the memory they point to won’t change.
Pin in Practice
Here is what Pin actually looks like when you use it:
use std::pin::Pin;
let mut data = String::from("hello");
// Pin it — now it can't be moved through this reference
let pinned: Pin<&mut String> = Pin::new(&mut data);
// Can still use it:
println!("{}", pinned.as_ref().get_ref()); // "hello"
// For Unpin types like String, you can get &mut back:
let mutable: &mut String = Pin::into_inner(pinned); // works because String: Unpin
// But for self-referential state machines (!Unpin), this is blocked by the type system.
In real async code, you encounter Pin in three main places:
// 1. The poll() signature — every Future is polled through Pin
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
// 2. Box::pin() — heap-allocate and pin a future
let future: Pin<Box<dyn Future<Output = i32>>> = Box::pin(async { 42 });
// 3. pin!() macros — pin a future on the stack
std::pin::pin!(my_future); // stable since Rust 1.68
tokio::pin!(my_future); // tokio's equivalent
// Now my_future: Pin<&mut impl Future>
Box::pin() is the safe, easy default — it heap-allocates the future so it has a stable address. The stack pin!() macros are more efficient but only work for local use.
What .await Actually Does (and Doesn’t Do)
One thing worth clearing up: .await itself doesn’t cause the movement problem.
.await is just the syntax for saying “pause here and let the runtime drive things.” It defines where the state machine can suspend and resume. It’s what makes async functions composable and cooperative.
The movement problem is separate — it’s about the Future as a whole being moved before or during polling. The .await points just happen to be where the internal references need to stay valid.
So when you see Pin in Rust async code, it’s not protecting .await from doing something dangerous. It’s protecting the entire Future object from being relocated after it’s been set up.
The Unpin Escape Hatch
Most types in Rust automatically implement Unpin. This trait is a signal that says: “this type is always safe to move, so Pin has no special meaning for me.”
For Unpin types, Pin<&mut T> behaves exactly like &mut T — there are no extra restrictions. Pinning them is a no-op.
// These are all Unpin — pinning them adds no restrictions:
// i32, String, Vec<T>, HashMap<K, V>, Box<T>, &T, &mut T
// These are !Unpin — they must be pinned before polling:
// The state machines generated by `async fn` and `async {}`
The compiler automatically marks generated async state machines as !Unpin whenever they might contain self-referential data. You never have to do this yourself for normal async code.
If you’re writing a Future by hand and it has no self-references, you can signal that explicitly:
// "I'm safe to move — trust me"
impl Unpin for MySimpleFuture {}
This makes the future easier to work with in contexts that require F: Future + Unpin.
Quick Reference
| What | When | How |
|---|---|---|
| Pin a future on the heap | Storing in a collection, returning from a function | Box::pin(future) |
| Pin a future on the stack | Local use in select! or manual polling | std::pin::pin!(future) or tokio::pin!(future) |
| Accept a pinned future | Function parameter | future: Pin<&mut F> |
| Require movability | When you need to move a future after creation | F: Future + Unpin |
The Mental Model, Summarized
Here’s the whole thing in plain language:
async fnbecomes a state machine (a Future) that can pause and resume- All variables that survive across
.awaitpoints live inside that state machine - If a variable references another variable inside the same state machine, you have a self-referential structure
- Futures can be moved in memory, which would invalidate those internal references
Pinprevents the Future from being moved after it starts being polled, keeping those references valid
One important disclaimer: the compiler doesn’t literally create labeled enums named
State0,State1, etc. That’s just a mental model. The actual generated code is more complex and doesn’t necessarily look like what you’d write by hand. The mental model is useful for building intuition, not for reading compiler output.
Why This Matters
Most of the time, you don’t need to think about any of this. You write async fn, use .await, and everything just works. The runtime wraps your Futures in Pin correctly, and the compiler generates safe code.
But when you start writing manual Future implementations, working with async in embedded or no-std contexts, or using libraries that expose pinned types directly — that’s when understanding this model pays off.
And honestly, even just knowing why Pin exists makes async Rust feel a lot less like arbitrary machinery. It’s solving a real problem: internal references inside a value that can’t be allowed to move.
Once that clicks, Pin stops feeling like a strange restriction and starts feeling like exactly the right tool for the job.
Key Takeaways
Pin<P>is a wrapper that prevents the pointee from being moved — essential for self-referential state machinesBox::pin()is the safe, easy default for pinning futures on the heappin!()macros pin on the stack — you can move thePin<&mut>wrapper, but the underlying future stays putUnpinis an auto-trait that means “this type is always safe to move, pinning me changes nothing” — most types areUnpin; async state machines are not