Rust / the practical course

04 / Systems practice

Pin and async traits

Learn what pinning protects and how to return futures through a trait object.

65 min + practiceRust 1.98.1 · Edition 2024

By Robert DeVore · Download Markdown

What Pin keeps in place

Some values become address-sensitive, including futures that may contain references into their own suspended state. Pin<P> constrains how the pointee accessed through pointer P may be moved. It does not mean the pointer handle itself cannot move, and it does not automatically allocate anything.

For T: Unpin, moving the value does not violate a pinning invariant, so many pin restrictions become irrelevant. For a !Unpin value, safe APIs prevent operations that would violate the pin contract. The contract also involves destruction and storage validity; it is stronger than “do not call mem::swap right now”.

Use safe constructors first

pin! pins a value in local storage. Box::pin owns a pinned allocation whose handle can move. Most async application code can use these or runtime helpers without implementing unsafe projection. Accessing a pinned struct's fields requires knowing which fields are structurally pinned. Do not use get_unchecked_mut just to bypass a compiler error.

Run the example

cargo run --locked --example 21_pin
use std::{future::Future, pin::Pin};
trait Lookup {
    fn get(&self) -> Pin<Box<dyn Future<Output = usize> + '_>>;
}
struct Fixed(usize);
impl Lookup for Fixed {
    fn get(&self) -> Pin<Box<dyn Future<Output = usize> + '_>> {
        Box::pin(async move { self.0 })
    }
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
    let lookup = Fixed(42);
    let service: &dyn Lookup = &lookup;
    assert_eq!(service.get().await, 42);
}

View the tested source

The interface returns a future in a pinned Box. The trait object hides its concrete type. Its lifetime is tied to &self, so it can borrow the service. The example uses a local executor and does not require Send. An API intended for movable spawned tasks may need Send on the returned future and appropriate bounds on the captured data.

Calling async methods through trait objects

Native async fn in traits works on stable Rust for supported static-dispatch cases. That does not imply those methods can be dispatched directly through dyn Trait. Returning an explicitly erased future is a stable alternative, with allocation and indirection costs. The async-trait crate automates a related transformation; use it when you need this kind of interface.

Intentionally fails on Rust 1.98.1. Run rustc --edition=2024 drills/async_dyn.rs from the repository.

trait Fetch { async fn get(&self) -> usize; }
fn consume(_: &dyn Fetch) {}
fn main() {}
Actual compiler diagnostic · Rust 1.98.1
error[E0038]: the trait `Fetch` is not dyn compatible
 --> drills/async_dyn.rs:2:16
  |
2 | fn consume(_: &dyn Fetch) {}
  |                ^^^^^^^^^ `Fetch` is not dyn compatible
  |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
 --> drills/async_dyn.rs:1:24
  |
1 | trait Fetch { async fn get(&self) -> usize; }
  |       -----            ^^^ ...because method `get` is `async`
  |       |
  |       this trait is not dyn compatible...
  = help: consider moving `get` to another trait

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0038`.

The drill captures the current direct-dyn rejection. This shows what the pinned compiler supports today, not what Rust will support forever. Current async and field-projection initiatives aim to improve these awkward edges.

Exercise

Explain why moving the Box handle is compatible with pinning its allocated future. Then remove the trait object entirely and call a concrete async method. Compare what the two designs require from allocation and the public signature.

Solution and acceptance check

The pointee stays in its allocation when the owning pointer moves. A concrete async method can return its compiler-generated future without the explicit box and vtable used here. Prefer the concrete method unless callers need to work with different implementations through one interface. Do not claim a speedup without measuring the relevant workload.

Sources: std::pin, trait rules, and the async trait RFC.

Find a lesson