Skip to content

← Async From First Principles step 16 of 25

Hard Research

Structural pinning and safe pin projection by hand

Every combinator you have written so far cheated: the children lived behind Box::pin, which made the parent trivially Unpin. Real libraries do not cheat, because that box is a heap allocation per child on a hot path. This is how they avoid it.

pub struct Timed<F> { inner: F, polls: u32 }   // inner stored INLINE
pub fn count_polls(pends: u32) -> (u32, u32)

Timed counts its polls and resolves to (count, inner_output). count_polls wraps an async block — a genuinely !Unpin future — so a Box-based shortcut would not be answering the question.

The starter fails with E0596 on &mut *self, for the reason you now know: with inner stored inline, Timed<F> is Unpin only when F is, and F is not known to be.

Structural pinning, decided per field

When a struct is pinned, each of its fields is either structurally pinned (the field is treated as pinned too, so you may hand out Pin<&mut Field>) or not (the field is ordinary, so you may hand out &mut Field). You decide, per field, and the decision comes with obligations.

Here:

  • inner: F is structurally pinned. It is a future; once polled it may be self-referential; it must not move.
  • polls: u32 is not. It is a number. Nothing points into it.

To produce the pinned reference to inner you need unsafe, because you are asserting something the compiler cannot check:

let this = unsafe { self.get_unchecked_mut() };
this.polls += 1;
let inner = unsafe { Pin::new_unchecked(&mut this.inner) };

The std docs list four obligations for a structurally pinned field, and this type satisfies all four:

  1. The Unpin impl must account for the pinned field. Leave Unpin auto-derived. Timed<F> is then Unpin exactly when F is — which is the truth.
  2. Drop must not move it. Timed has no Drop impl. If it had one it would receive &mut self, from which a field can be moved out, silently breaking the guarantee. Adding a Drop impl to a pin-projecting type is a real hazard; do not do it casually.
  3. The type must not be #[repr(packed)], which is allowed to move fields around to satisfy alignment.
  4. No safe API may move the field out. There is no fn into_inner(self) -> F here, and there must not be.

The trap, and it is the entire point

There is a way to make E0596 disappear in one line:

impl<F> Unpin for Timed<F> {}   // DO NOT DO THIS

It compiles. It needs no unsafe. It is unsound, and the compiler will never tell you.

Why: that impl claims Timed<F> may be moved freely for every F. But Timed stores F inline, so moving a Timed moves the F inside it — and if that F is a polled async block holding a reference into its own storage, the reference is now dangling. Safe code can then trigger it: Pin::new(&mut timed) becomes available, Pin::get_mut becomes available, mem::swap on two Timeds becomes available, and you have memory corruption reachable from entirely safe code.

The correct answer is to write no Unpin impl and let the auto trait do its job, so that Timed<F>: Unpin if and only if F: Unpin. The test cases feed you an async block precisely so that the honest version is the one that has to work.

Compare item 16.11: there the blanket impl was sound, because the child was boxed and genuinely movable. Same line of code, opposite verdict, and the difference is one word in the struct definition. That is what “unsafe means you owe the compiler a proof” looks like in practice.

What pin-project generates

The pin-project crate exists to write this for you: you annotate fields as #[pin] or not, and the macro emits the projection functions, the correct conditional Unpin impl, and a compile-time check that you have no manual Drop. It is not available in this harness, and having written the projection once you can read what it generates.