Internals · long form
Internals

Ownership meets async

The borrow-checker page and the async-runtime page each explain half the story. This is the other half — where the two rules collide. Why Pin had to be invented. Why an async block can hold an & across an .await in some cases and not others. What Send and Sync actually constrain about a future. And why the worst error message in Rust — "future cannot be sent between threads safely" — has an honest, deterministic cause that ownership explains in one sentence.

Long read · synthesis chapter linking the borrow checker, async desugaring, and the trait bounds spawn needs


1 · The two rules, and where they meet

Rust's ownership system enforces one rule at a time: at any point in the program, every value has exactly one owner; a value can be borrowed either as one &mut or as any number of &, never both. The borrow checker proves this statically for every function body by tracking which references are live where.

Async in Rust adds a second rule about lifetimes: when an async fn is compiled, the local variables it holds across .await points get embedded as fields of a generated state-machine struct. The struct lives wherever the executor put it — heap, stack, inline in a task pool. When the executor polls it again, those fields are what the resumed code reads. If any of those fields is a reference, the borrow checker has to prove the referent is still alive.

Most of the strange things about Rust async — Pin, 'static bounds on tokio::spawn, the dance with Arc<Mutex<T>> — follow directly from these two rules trying to coexist. They are not arbitrary. They are the only way the language can keep its promise of compile-time memory safety once a future can be suspended and resumed.

2 · Why Pin had to be invented

Consider a tiny async fn that holds a reference into a local across an await:

rust src/main.rs · the self-reference an async fn can create
async fn join_words() -> String {
    let s = String::from("hello world");
    let first = &s[..5];          // reference into s
    sleep(Duration::from_millis(10)).await;
    format!("{} ({})", s, first)  // reads first after the await
}

The compiler generates a struct holding both s and first as fields. first is a reference into s. Both fields live inside the same struct — a self-referential struct. If you moved that struct after creating it, first would now point at where s used to be, not where it is now. Undefined behaviour. Memory corruption. A safety hole.

The compiler cannot reject self-references entirely — they are unavoidable once you let people write code like this. So it solves the problem at the type system level. After a future has been polled even once, it must not be moved. The marker for "this value lives somewhere that cannot move" is Pin<P>, where P is a pointer (typically &mut T or Box<T>).

rust src/main.rs · the Future trait signature, the one place Pin appears
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
    //         ^^^^^^^^^^^^^^^^
    //         the receiver is pinned: the executor promises
    //         this future will not move until it completes or is dropped
}

Pin is contagious by design. Once you have a pinned future, any helper that polls it must accept the receiver as Pin<&mut T>. To get a pinned pointer in the first place you either pin to the stack with pin! (core::pin::pin!, stable since 1.68) or heap-allocate with Box::pin. The friction is the point: pinning is rare, and the API forces you to notice when you are doing it.

There is an opt-out. Types that do not contain self-references can implement Unpin, which says "I can be moved freely even when pinned". Almost every user-written type is Unpin by default; the compiler auto-implements it. Only compiler-generated futures from async blocks containing self-references are !Unpin. That is why most code never has to think about Pin at all.

3 · The 'static bound on spawn

The single most-asked Rust async question on Stack Overflow:

rust src/main.rs · the rejection that confuses everyone
async fn run(req: &Request) {
    tokio::spawn(async move {
        process(req).await;   // error: `req` does not live long enough
    });
}

tokio::spawn's signature is approximately fn spawn<F>(future: F) -> JoinHandle<F::Output> where F: Future + Send + 'static. The 'static bound says the future must own everything it captures — no borrowed references to data on the caller's stack.

This is not arbitrary. spawn hands the future to the runtime's task queue. The runtime might decide to run it on a different thread, at any future point in time — next millisecond, ten seconds from now, never. It cannot wait for the caller's frame to stick around. The only way the borrow checker can prove "this future is safe to keep running for an unknown time" is if the future owns everything it needs.

The standard workarounds, in order of cost:

rust src/main.rs · three workarounds, increasing in cost
// 1. Clone what you need into the future (cheap for Arc/String/small data).
async fn run(req: &Request) {
    let req = req.clone();
    tokio::spawn(async move { process(&req).await });
}

// 2. Wrap shared mutable state in Arc<Mutex<T>> (or Arc<RwLock<T>>).
let state = Arc::new(Mutex::new(Foo::new()));
let state2 = state.clone();
tokio::spawn(async move {
    let mut s = state2.lock().await;
    s.do_thing();
});

// 3. Use a scoped runtime (rare). tokio::task::spawn_local on a LocalSet
//    keeps the task on the current thread, so 'static can be relaxed for
//    !Send futures but still cannot escape the current scope safely.

The error message is honest. It is also famously hostile. cargo build --message-format=human renders it across forty lines. Knowing the rule — "spawn means owned" — turns it from a puzzle into a one-line diagnosis: which captured value is a reference, and how should you turn it into ownership.

4 · Send and Sync — what they actually constrain

Two of the most important marker traits in the language. Both are auto-implemented based on what a type contains.

Send means "this type can be moved to another thread". A Rc<T> is not Send because its reference count is not atomic — moving the same Rc to two threads and dropping it on both would race. Arc<T> is Send because its count is atomic. Most types are Send automatically; the exceptions are types that contain non-thread-safe primitives (raw pointers, Rc, Cell in some configurations).

Sync means "this type can be referenced from multiple threads simultaneously" — equivalent to "&T is Send". Mutex<T> is Sync because access goes through the lock. Cell<T> is not, because Cell permits interior mutation through a shared reference.

Now combine that with the spawn signature: F: Future + Send + 'static. The future itself must be Send. Whether the compiler-generated future is Send depends on whether every local variable held across an .await is Send. Hold an Rc across an .await in an async block that you intend to spawn, and the compile fails with the famous "future cannot be sent between threads safely" diagnostic, pointing at where the non-Send value crosses the await boundary.

rust src/main.rs · the non-Send-across-await mistake
async fn handle() {
    let counter = Rc::new(0);            // Rc is NOT Send
    do_io().await;                       // counter held across await
    println!("{}", counter);             // generated future is NOT Send
}

tokio::spawn(handle());                  // compile error: future cannot
                                         // be sent between threads safely
// fix: use Arc, or drop the Rc before the .await.

The fix is mechanical once you understand it. Move the non-Send work to a scope that ends before the await, or replace the non-Send type with its thread-safe sibling (RcArc, RefCellMutex or RwLock). Knowing this rule turns the diagnostic from "huh?" to "ah, line 27, of course".

5 · Holding references across await — when it works

Despite all the talk of 'static, you absolutely can hold a borrowed reference across an .await — as long as the borrow checker can prove the referent outlives the await. The classic case is borrowing from a value that the async fn itself owns.

rust src/main.rs · borrow across await, accepted
async fn parse_then_send(mut buf: Vec<u8>) {
    let header = parse_header(&buf[..16]);  // &buf is a borrow
    send_over_socket(&header).await;        // borrow held across await
}
// Accepted: buf is owned by this async fn, lives until the fn returns,
// so the borrow into it is valid the whole time.

The generated future struct holds buf as a field and header as a reference into it — a self-reference. That is exactly the case Pin was invented for. Because the borrow is into this future's own fields, not into the caller's stack, it is sound, and the borrow checker accepts it.

What fails is the analogous code with a borrow from outside:

rust src/main.rs · borrow into caller, rejected
async fn parse_then_send(buf: &[u8]) {
    let header = parse_header(&buf[..16]);
    send_over_socket(&header).await;
}
// In this signature buf is a borrow from the caller — fine within the
// async fn body itself. The trouble appears when you spawn the future:
// the 'static bound rejects buf's lifetime.

This is why API design for async-heavy crates often pushes you toward owned arguments (Bytes, Arc<[u8]>, Vec<u8>) at the spawn boundary, even if internal helpers take &[u8].

6 · The Mutex you actually want is not std::sync::Mutex

std::sync::Mutex<T> is a blocking mutex: lock() blocks the current OS thread until the lock is available. In an async context that is usually wrong — blocking the thread starves every other task the executor was running on that thread.

tokio::sync::Mutex<T> is async-aware. lock().await yields control while waiting; the executor runs other tasks on the same thread; the wait wakes when the lock is free. Holding the guard across .await is legal and works the way you'd expect — but the guard is not Send for some configurations, so the same Send-across-await trap applies. Read the docs of the specific guard type.

A useful heuristic: use std::sync::Mutex for short, CPU-bound critical sections that never await (incrementing a counter, swapping a small struct). Use tokio::sync::Mutex when the critical section has to await — usually because it does IO. The async version is more expensive per operation; do not reach for it just because the rest of your code is async.

7 · The borrow-async interaction in select!

tokio::select! polls multiple futures concurrently and runs the arm of the first to complete. It is a common source of borrow-checker pain because select! has to hold references to all branches at once.

rust src/main.rs · select! and a borrow that survives the await
async fn process(state: &mut State) {
    loop {
        tokio::select! {
            msg = state.in_chan.recv() => {     // first mut borrow of state
                state.handle(msg).await;         // second mut borrow ← rejected
            }
            _ = state.shutdown.notified() => break,
        }
    }
}
// Both arms borrow state; the compiler cannot prove they don't overlap
// in a way that lets one await across the other's borrow.

The standard fix is to limit the scope of the first borrow:

rust src/main.rs · the fix
async fn process(state: &mut State) {
    loop {
        // separate the recv from the handle so the borrow ends inside the arm
        let msg = tokio::select! {
            msg = state.in_chan.recv() => msg,
            _ = state.shutdown.notified() => return,
        };
        state.handle(msg).await;
    }
}

This pattern repeats across async code: split a borrow into the part that needs to live across the await and the part that does not. The async rewrite of synchronous code is often less about async fn and more about restructuring borrows to fit suspension points.

8 · Cancellation and Drop are how async cleanup works

Async Rust has no try/finally. When a future is dropped — because it was cancelled, the executor shut down, a select! arm completed first — its Drop runs, and that is the only chance for cleanup. The async state-machine struct holds all the local variables; dropping the struct drops them in declaration order; their Drop impls run and release resources.

This is why RAII matters more in async, not less. A Vec dropped on cancellation frees its memory. A mutex guard dropped on cancellation releases the lock. A network connection dropped on cancellation sends FIN. As long as resources are owned by the future's locals, cancellation does the right thing for free.

Where this gets subtle is async work that has already crossed an .await into external state. If your async fn sent a request to a downstream service and then was cancelled while waiting for the response, the downstream service still got the request and will still respond. Cancellation does not unsend. Code that needs cancellation to be meaningful has to be designed for it — typically with abort signals, retry-with-idempotency, or compensating actions. The Rust language and Tokio give you the cancellation primitive; making it semantically clean is your problem.

9 · Why async traits took six years

From Rust 1.39 (when async/await stabilised) to 1.75 (when async fn in traits stabilised, December 2023), you could not write async fn in a trait. The reason was not laziness; it was that the natural desugaring conflicts with how Rust handles traits.

A trait method fn foo() -> impl Future<Output = T> returns "some type that implements Future, decided per-impl". To call that through a dyn Trait object, Rust needs to know the concrete return type at the call site so it can lay out the v-table — but with impl Future there is no single concrete type.

The 1.75 stabilisation allowed async fn in traits but only for static dispatch (regular generic T: Trait bounds). dyn Trait for async-fn-in-trait still requires async_trait-macro-style boxing — every method returns Pin<Box<dyn Future>>, paying an allocation per call. There is ongoing work on a more efficient solution (return-position impl trait in traits, GATs); the 2026 ergonomic state is "static dispatch is good, dynamic dispatch still costs you a box".

This is the rare case where ownership and async created a problem the type system has not yet fully resolved.

10 · The mental model that makes the errors readable

Three pictures, in order:

An async fn is a compiler-generated state machine struct that holds its locals. Every .await is a suspension point that becomes a state in an enum. Locals that survive past an await become fields of the struct.

The same ownership rules apply to those fields as to any struct. Borrowed fields require the borrow checker to prove the referent outlives the struct. Non-Send fields make the whole struct non-Send. Self-referential fields require Pin.

An executor owns the struct, polls it, and decides when to move it (before the first poll only). spawn says "I might move it to another thread, so it must be Send and own its data". block_on says "I keep it on this thread and will not move it after the first poll".

Carry those three pictures into every async compile error, and the messages stop being cryptic. They are saying: this state machine cannot be built because one of the rules above is violated; here is which one.

References

Found this useful?