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:
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>).
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:
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:
// 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.
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 (Rc
→ Arc, RefCell → Mutex 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.
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:
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.
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:
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
- The async book — official primer, the Pin chapter especially.
- without.boats — Why async Rust — design rationale from a core contributor.
- fasterthanli.me — Pin and suffering — long walkthrough of Pin with examples.
- Rust blog — async fn in trait stabilisation — the constraints that made it take so long.
- Tokio tutorial — the practical chapter on Send/Sync and spawn.