Skip to content

23. Rust Pin, Unpin and Async Boundaries

Rust Pin restricts access that could move a pinned pointee; it does not freeze an owning pointer or make a future thread-safe. This lesson separates those ideas with a complete dependency-free program and tests, including a wrapper that polls an async future without unsafe field projection.

Learning goals and prerequisites

After async, await and Future, you can identify who owns a future, who only borrows it, and where poll requires Pin. Here you will distinguish movement of a handle from movement of its pointee, choose local or heap pinning, and explain why Unpin, Send and lifetimes are independent constraints.

Use stable Rust, Cargo and edition 2024. The examples work without Raspberry Pi hardware access, external crates or an async runtime. Values are fixtures, not sensor measurements.

Rust Pin and Unpin: which value is restricted?

An ordinary Box owns heap storage, but its API still permits moving its value out. Pin> changes the access contract for a pointee that is not Unpin. Moving that owning handle is allowed; moving the protected value out through safe access is not. Read the Pin type's API alongside these examples.

Unpin is an auto trait: most ordinary types implement it automatically. Its meaning is that pinning imposes no extra movement restriction on this type. It is not an operation that relocates memory, and it does not imply Copy, Send, Sync or 'static. For T: Unpin, safe Pin::new, get_mut and into_inner expose ordinary access. See the Unpin contract.

Our StableLabel contains PhantomPinned, so it does not automatically implement Unpin. This deliberately demonstrates the API restriction; it contains no self-reference and does not intrinsically need a stable address. PhantomPinned alone does not pin a value: an ordinary, not-yet-pinned StableLabel can still move.

Create and run the complete program

1
2
3
4
5
6
7
8
9
cargo new rust-pinning --edition 2024
cd rust-pinning
# Replace src/main.rs with the complete program below.
cargo fmt --check
cargo check
cargo test
cargo test --release
cargo run --quiet
cargo run --quiet --release
use std::future::Future;
use std::marker::PhantomPinned;
use std::pin::{Pin, pin};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll, Wake, Waker};

struct StableLabel {
    text: String,
    _pinned: PhantomPinned,
}

impl StableLabel {
    fn new(text: &str) -> Self {
        Self {
            text: text.to_owned(),
            _pinned: PhantomPinned,
        }
    }

    fn text(self: Pin<&Self>) -> &str {
        &self.get_ref().text
    }
}

// The wrapper owns a movable handle to an independently pinned future.
struct Observed<F> {
    inner: Pin<Box<F>>,
    polls: usize,
}

impl<F> Observed<F> {
    fn new(inner: F) -> Self {
        Self {
            inner: Box::pin(inner),
            polls: 0,
        }
    }
}

impl<F: Future> Future for Observed<F> {
    type Output = F::Output;

    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.get_mut();
        this.polls += 1;
        this.inner.as_mut().poll(context)
    }
}

struct YieldOnce(bool);

impl Future for YieldOnce {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
        if self.0 {
            Poll::Ready(())
        } else {
            self.0 = true;
            context.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

#[derive(Default)]
struct CountWake(AtomicUsize);

impl Wake for CountWake {
    fn wake(self: Arc<Self>) {
        self.0.fetch_add(1, Ordering::Relaxed);
    }
}

// Only for futures known to finish on their first poll; not an executor.
fn ready_value<F: Future>(future: F) -> F::Output {
    let waker = Waker::from(Arc::new(CountWake::default()));
    let mut context = Context::from_waker(&waker);
    let mut pinned = pin!(future);
    match pinned.as_mut().poll(&mut context) {
        Poll::Ready(value) => value,
        Poll::Pending => panic!("fixture unexpectedly suspended"),
    }
}

fn shorten<'long: 'short, 'short>(value: &'long str) -> &'short str {
    value
}

fn main() {
    let label = Box::pin(StableLabel::new("Pi 4B"));
    let address = std::ptr::from_ref(label.as_ref().get_ref());
    // This moves the handle, not the heap allocation's StableLabel.
    let labels = vec![label];
    let unchanged = address == std::ptr::from_ref(labels[0].as_ref().get_ref());
    println!(
        "same pointee={unchanged}, label={}",
        labels[0].as_ref().text()
    );

    let mut text = String::from("Pi");
    Pin::new(&mut text).get_mut().push_str(" 4B");
    println!("Unpin text={text}");

    let mut observed = Observed::new(async {
        YieldOnce(false).await;
        46_700
    });
    let notifications = Arc::new(CountWake::default());
    let waker = Waker::from(Arc::clone(&notifications));
    let mut context = Context::from_waker(&waker);
    assert_eq!(Pin::new(&mut observed).poll(&mut context), Poll::Pending);
    assert_eq!(notifications.0.load(Ordering::Relaxed), 1);
    let value = match Pin::new(&mut observed).poll(&mut context) {
        Poll::Ready(value) => value,
        Poll::Pending => panic!("fixture should finish on second poll"),
    };
    println!("observed={value}, polls={}", observed.polls);

    let futures: Vec<Pin<Box<dyn Future<Output = i32>>>> =
        vec![Box::pin(async { 46_700 }), Box::pin(async { 0 })];
    let values: Vec<_> = futures.into_iter().map(ready_value).collect();
    println!("erased values={values:?}");

    let owner = String::from("Pi 4B");
    let borrowed: Pin<Box<dyn Future<Output = usize> + '_>> =
        Box::pin(async { shorten(&owner).len() });
    println!("borrowed bytes={}", ready_value(borrowed));
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;
    use std::rc::Rc;

    struct DropFlag(Rc<Cell<usize>>);

    impl Drop for DropFlag {
        fn drop(&mut self) {
            self.0.set(self.0.get() + 1);
        }
    }

    #[test]
    fn moving_and_growing_handles_preserves_pointee() {
        let first = Box::pin(StableLabel::new("Pi 4B"));
        let address = std::ptr::from_ref(first.as_ref().get_ref());
        let mut handles = vec![first];
        for _ in 0..32 {
            handles.push(Box::pin(StableLabel::new("fixture")));
        }
        assert_eq!(address, std::ptr::from_ref(handles[0].as_ref().get_ref()));
        assert_eq!(handles[0].as_ref().text(), "Pi 4B");
    }

    #[test]
    fn unpin_allows_ordinary_mutation_and_extraction() {
        let mut text = String::from("Pi");
        let pinned = Pin::new(&mut text);
        Pin::into_inner(pinned).push_str(" 4B");
        assert_eq!(text, "Pi 4B");
    }

    #[test]
    fn wrapper_is_unpin_even_when_inner_future_is_not() {
        fn require_unpin<T: Unpin>(_: &T) {}
        let observed = Observed::new(async { 46_700 });
        require_unpin(&observed);
        assert_eq!(ready_value(observed), 46_700);
    }

    #[test]
    fn pending_wrapper_counts_notification_and_completion() {
        let mut observed = Observed::new(async {
            YieldOnce(false).await;
            0
        });
        let count = Arc::new(CountWake::default());
        let waker = Waker::from(Arc::clone(&count));
        let mut context = Context::from_waker(&waker);
        assert_eq!(Pin::new(&mut observed).poll(&mut context), Poll::Pending);
        assert_eq!(count.0.load(Ordering::Relaxed), 1);
        assert_eq!(Pin::new(&mut observed).poll(&mut context), Poll::Ready(0));
        assert_eq!(observed.polls, 2);
    }

    #[test]
    fn different_async_types_share_an_explicit_interface() {
        let futures: Vec<Pin<Box<dyn Future<Output = i32>>>> =
            vec![Box::pin(async { -500 }), Box::pin(async { 0 })];
        assert_eq!(
            futures.into_iter().map(ready_value).collect::<Vec<_>>(),
            [-500, 0]
        );
    }

    #[test]
    fn boxed_future_can_borrow_a_local_owner() {
        let owner = String::from("Pi 4B");
        let future: Pin<Box<dyn Future<Output = usize> + '_>> = Box::pin(async { owner.len() });
        assert_eq!(ready_value(future), 5);
        assert_eq!(owner, "Pi 4B");
    }

    #[test]
    fn borrowed_pin_is_not_the_cancellation_owner() {
        let drops = Rc::new(Cell::new(0));
        let guard = DropFlag(Rc::clone(&drops));
        let mut owner = Box::pin(async move {
            let _guard = guard;
            std::future::pending::<()>().await;
        });
        {
            let borrowed = owner.as_mut();
            drop(borrowed);
        }
        assert_eq!(drops.get(), 0);
        drop(owner);
        assert_eq!(drops.get(), 1);
    }

    #[test]
    fn pinned_does_not_mean_not_send() {
        fn require_send<T: Send>(_: &T) {}
        let label = Box::pin(StableLabel::new("Pi 4B"));
        require_send(&label);
    }

    #[test]
    fn shortening_reference_does_not_extend_owner_lifetime() {
        let owner = String::from("Pi 4B");
        let shorter = shorten(&owner);
        assert_eq!(shorter, "Pi 4B");
    }
}

Expected output:

1
2
3
4
5
same pointee=true, label=Pi 4B
Unpin text=Pi 4B
observed=46700, polls=2
erased values=[46700, 0]
borrowed bytes=5

The address comparison is only an observation in this fixture, not a proof of an unsafe implementation. No raw pointer is dereferenced. Ordinary Box handles also keep their allocations in place when moved; Pin's additional value is the restricted API, not a new allocator.

Local pinning, owning pinning and reborrowing

Box::pin owns heap storage. The pin! macro instead creates a local pinned value and returns borrowed access to that storage, without requiring heap allocation. Inside an async body the storage can become part of its generated future, so “local” is more precise than always calling it a stack allocation. A locally pinned value cannot escape the lifetime of its backing storage.

as_mut creates another temporary pinned mutable borrow. It lets a poll call consume that borrow while retaining the owning handle for later calls. as_ref exposes shared pinned access. get_ref returns a shared reference; reading through it does not let you move the pointee out.

The drop test captures a guard before the async body starts. Dropping borrowed Pin<&mut _> ends only that borrow; dropping the owning Box destroys the still-unpolled future and its captured guard. This complements the previous lesson's cancellation-after-poll test.

Safe wrapping is not unsafe structural projection

Observed is Unpin because its fields are a movable pinned Box handle and a counter. get_mut therefore gives mutable access to the wrapper. Moving that handle leaves its separately allocated future pinned; as_mut supplies the pinned reference needed by Future::poll. No F: Unpin bound is necessary.

An embedded inner: F is a different design. Projecting Pin<&mut Wrapper> into Pin<&mut F> can promise that a particular field never moves while pinned. Such a structural-pinning design also constrains destruction, replacement and other APIs. Do not imitate our get_mut for an arbitrary embedded !Unpin field. See the structural-pinning and Drop rules before writing a custom unsafe projection. This course defers unsafe implementations to lesson 26.

Pin is not general immutability: interior mutation can remain valid, and Pin::set can replace a pointee by dropping its previous value in place first. The obligation concerns address-sensitive state and valid destruction, not banning every write.

Erased futures, thread bounds and lifetimes

Different async expressions produce different concrete types, even when they return the same i32. Our trait-object collection chooses dynamic dispatch and heap-owned pinning explicitly. ready_value polls each known-ready fixture once; it panics on Pending and is not a scheduler. The two-poll test repolls only its known YieldOnce fixture after observing notification. Do not use either helper to busy-poll arbitrary I/O or poll a completed future again.

The collection's omitted object lifetime defaults to 'static here. The borrowed example instead specifies + '_ and completes while owner is alive. Pin adds neither a longer lifetime nor Send. A future holding Rc is still unsuitable for a Send-requiring spawn API, even if boxed and pinned. Conversely our !Unpin StableLabel is Send: thread transfer of the owning handle need not move its protected allocation.

A first distinction about variance

shorten expresses 'long: 'short: a reference valid for the longer lifetime may be used for the shorter one. This is lifetime subtyping, not an extension of the owner's lifetime. The Reference's variance rules explain how enclosing types affect such conversions.

Shared &T is covariant in T; &mut T is invariant in T, although it remains covariant in its own borrow lifetime. This distinction prevents changing the referent type in ways that could write a too-short reference into longer-lived storage. RefCell is also invariant in T. Do not infer that every container permits a nested reference's lifetime to be shortened merely because a shared str reference does. Pin does not bypass these rules.

Deliberately failing: Pin::new requires Unpin

Use a separate scratch project for this complete failure:

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

struct StableLabel {
    _pinned: PhantomPinned,
}

fn main() {
    let mut label = StableLabel {
        _pinned: PhantomPinned,
    };
    let _borrow = Pin::new(&mut label);
}

cargo check reports E0277 because safe Pin::new requires an Unpin pointee. Repair it with Box::pin(label) or a local pin!(label), not an unsafe constructor added merely to silence the compiler. Neither repair requires implementing Unpin for this demonstration type.

Exercises and troubleshooting

  1. Repair the failure with Box::pin, then move its handle into a vector. Explain why PhantomPinned by itself did not establish the pinning contract.
  2. Call get_mut on a pinned mutable reference to StableLabel. Expect E0277. Use shared get_ref access for inspection instead; do not promise that an arbitrary address-sensitive type is Unpin.
  3. Attempt to move StableLabel out of Pin> with dereference. Expect E0507. Explain why a movable handle does not authorize moving its pointee.
  4. Try a vector of two unboxed async expressions instead of the erased collection. Expect E0308. Choose one concrete future type or an explicitly erased interface depending on the API you want.
  5. Change the borrowed example's object lifetime to 'static while retaining its local String borrow. Expect E0597. Compare the repairs: finish within the borrow or deliberately capture owned data with async move.
  6. Replace Observed's heap-pinned field with F directly. Explain the extra Unpin restriction a safe get_mut-based implementation would need, and why that is not equivalent for generated async futures.
  7. Use local pin! in a nested scope and attempt to return its borrowed pinned reference. Expect a temporary/lifetime error, not heap ownership. Identify the backing storage before choosing a repair.

Verification and next step

On October 10, 2026, this lesson was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, nine debug/release tests, formatting, debug/release output comparisons and five further runs passed. Heap and local pinning repairs passed. Seven expected compile failures covered safe Pin::new, get_mut, extracting an async future, moving a pointee out, distinct async types, a 'static object borrow and escaping local pin storage. These checks demonstrate API boundaries, not hardware performance or the soundness of a custom unsafe pinning implementation.

Continue with declarative and procedural macros: follow syntax expansion and test its typing and name-resolution boundaries.

Previous: async and Future · Course overview

Donate