Skip to content

The Rust Engineering Handbook / Chapter 59

async/.await Borrowing, Lifetimes, Send, and Pinning

Read async lifetime and Send diagnostics as stored-state evidence, then repair suspension boundaries without reflexive cloning or boxing.

Suppose relay-service accepts a decoded batch and hands its processing to a task API with this shape:

fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static;

Depending on the stored state, the call can fail because a borrowed request “does not live long enough,” an Rc<Scratch> “cannot be sent between threads safely,” or a MutexGuard remains live across an await. Adding async move, Arc, Box::pin, or a 'static annotation at random may move the error, but it does not answer the design question: what state did this future retain at a suspension point, and what boundary is asking to own or move that state?

Chapter 58 established that an async function returns stored control flow. The compiler-generated future contains the locals and child futures needed after each .await. That model turns these diagnostics from async folklore into ordinary field and ownership analysis. If a value is live across suspension, reason as though it were a field in a struct. Then apply three independent questions:

  1. How long are its references valid?
  2. May the whole stored value cross a thread boundary (Send)?
  3. After polling begins, must its address remain stable (Pin)?

Those axes interact, but none substitutes for another.

Trace the bound backward, not the error forward

Start at the API that rejected the future. A work-stealing executor commonly permits a suspended task to resume on a different worker. Its spawn operation therefore needs the future and often its output to be Send. A detached task can outlive the stack frame that created it, so the operation commonly requires 'static. Those are API design choices enabled by language traits, not requirements imposed on every future by async syntax.

Now trace inward. Consider a borrowed helper:

pub async fn borrowed_sum(values: &[u64]) -> u64 {
    yield_once().await;
    values.iter().sum()
}

The returned future is tied to the lifetime of values. Before completion it stores a reference that will be used after yield_once. Running it to completion inside that borrow is valid. Handing it to a detached API requiring 'static is not, because the task could be polled after the source slice is gone.

'static on a type bound does not mean “allocated forever.” It means that the value contains no borrowed data whose lifetime is shorter than 'static. An owned String, Vec<T>, or Arc<T> can satisfy a 'static bound and still be dropped as soon as the task completes. Conversely, &'static T really is a reference whose referent has the static lifetime. Keep the bound and the reference lifetime distinct.

async move moves captured bindings into the future. It can turn a future that borrows a local String into one that owns that String:

let request_id = String::from("request-17");
let task = async move {
    send_audit(request_id).await
};

It does not make borrowed referents immortal. If request_id were an &str borrowed from a local buffer, async move would move the reference, not the buffer. The future would retain the same lifetime relationship.

The lab’s rejected ui/borrowed_spawn.rs isolates this fact. A future closes over a local String by reference and is passed to a simple require_static function. The error is not about any runtime. It is proof that detached ownership and stack borrowing conflict.

Send is computed from the state that can cross suspension

Send is an auto trait: a composite type implements it when its stored components permit safe transfer across threads. The generated future follows the same propagation. A non-Send local that exists briefly between two suspension points need not make the future non-Send; a non-Send local retained across an .await usually does.

The rejected fixture makes that storage visible:

let future = async {
    let local = Rc::new(17);
    suspend().await;
    println!("{local}");
};
require_send(future);

local is needed after suspend().await, so it belongs to the suspended state. Rc<T> is not Send: its reference count is not synchronized for cross-thread ownership. Therefore the future containing it is not Send.

The first question is not “Can we replace Rc with Arc?” It is “Why does scratch state cross this await?” If it is used only to compute an owned subtotal, end its lifetime first:

pub fn owned_sum_job(
    values: Vec<u64>,
) -> impl Future<Output = u64> + Send + 'static {
    async move {
        let subtotal = {
            let local_only = Rc::new(values);
            local_only.iter().sum::<u64>()
        }; // Rc and its allocation are gone here

        yield_once().await;
        subtotal
    }
}

The braces are an ownership boundary, not a compiler appeasement ritual. They state that the later asynchronous phase needs only subtotal, not the scratch representation. The explicit return bounds make the example a compile-time witness: if later maintenance accidentally retains non-Send or borrowed state, this function stops compiling at its own boundary.

Two generated future-state comparisons show Rc scratch state crossing await and making the future non-Send versus scratch being dropped before await while only a u64 subtotal crosses. Three separate bottom panels distinguish borrow lifetime, Send thread transfer, and Pin address stability.

Read the upper half as a liveness map. Source-level scope is useful evidence, but what matters is whether a value is needed in a later state. Temporaries can have unintuitive drop scopes, especially inside a larger expression. When the stored-state boundary matters, prefer a named block that produces the small owned result the next phase actually needs. Confirm behavior on the book’s pinned toolchains; do not turn a current compiler’s precise future layout into a stable ABI claim.

A lock guard across await is two defects hiding in one field

This code retains a standard-library mutex guard across suspension:

let guard = values.lock().unwrap();
remote_checkpoint().await;
println!("{}", guard.len());

On supported standard-library platforms, std::sync::MutexGuard is not Send, in part because portable mutex implementations may require unlocking on the acquiring thread. A cross-thread spawn boundary rejects the generated future because it stores the guard.

Even on a local executor, where Send may not be required, holding the guard is usually the more serious design problem. The task retains mutual exclusion for the entire, externally controlled duration of remote_checkpoint. The awaited child can wait on the network, a timer, backpressure, or another task that needs the same lock. No OS thread must be blocked for the system to deadlock at the task level.

Take the smallest coherent state you need, release exclusion, then suspend:

pub fn snapshot_len_job(
    shared: Arc<Mutex<Vec<u64>>>,
) -> impl Future<Output = usize> + Send + 'static {
    async move {
        let len = {
            let guard = shared.lock().expect("snapshot mutex poisoned");
            guard.len()
        };
        yield_once().await;
        len
    }
}

For a real relay batch, the snapshot might be an immutable version number plus an owned batch, not merely a length. If copying the snapshot is too expensive, reconsider ownership: move the batch out with mem::take, send a command to an owning task, split the lock domain, or redesign the transaction so the awaited operation occurs before or after the protected commit.

An async-aware mutex changes how a waiter yields while acquiring the lock and may supply a guard designed for async use. It does not make a long critical section harmless. Holding such a guard across .await can be intentional when the protected protocol truly spans that await, but it increases contention and creates dependency cycles that ordinary thread tools may not expose. Record the invariant and bound the wait.

Borrowing across await can be exactly the right design

“Never borrow across .await” would discard one of Rust’s useful capabilities. This helper is valid:

async fn write_frame(stream: &mut Stream, frame: &[u8]) -> io::Result<()> {
    stream.write_all(frame).await?;
    stream.flush().await
}

Its returned future borrows both arguments until completion. That is appropriate when the caller awaits it within the enclosing operation and retains ownership. The signature prevents concurrent use of the same mutable stream while the write is pending.

Borrowing becomes incompatible when the caller wants to detach the future from that lexical owner. There are three honest responses:

  • keep structured local ownership and await before the borrow ends;
  • transfer an owned capability into a child task whose lifetime is explicitly managed;
  • use a scoped task facility whose contract proves children finish before borrowed data expires.

The least honest response is to spread Arc<Mutex<_>> until the compiler accepts detachment. That converts a clear lifetime relationship into runtime sharing, contention, and a shutdown problem. Shared ownership may be correct, but it should be selected because multiple tasks genuinely co-own a capability.

Spawn bounds are policies, not the definition of async

A multithreaded spawn API usually needs Future + Send + 'static. A current-thread executor can run !Send futures because it never transfers them to another thread. A scoped executor can admit non-'static futures because it joins them before the scope ends. Directly awaiting a future requires neither detachment nor a task allocation.

The lab’s CurrentThreadExecutor intentionally stores Pin<Box<dyn Future<Output = ()> + 'static>> without Send. One test moves an Rc<RefCell<_>> into a task and runs it on one thread. This is safe under that executor’s contract; it would not be safe to migrate the task. Runtime-specific local-task facilities encode the same distinction.

This makes !Send a design signal, not automatically a defect. GUI state, interpreter handles, thread-affine libraries, and Rc-based local graphs may belong on one thread. The application must then make that affinity observable in architecture: isolate the local executor, communicate through owned messages, keep blocking work elsewhere, and define how shutdown reaches it.

Do not infer a scheduling guarantee merely from a type bound. A Send future may move across threads; the trait does not promise that it will. A !Send future cannot be handed to an API that reserves the right to do so.

Pinning answers a different question

Async futures can be self-referential in the broad state-machine sense: after polling begins, one stored component may depend on the stable address of another component or a nested future. Future::poll therefore receives Pin<&mut Self>. Pinning preserves the pointee’s address when the pointee is not Unpin.

Pinning does not:

  • extend a reference’s lifetime;
  • make a value Send or Sync;
  • move borrowed input into owned storage;
  • imply heap allocation;
  • prevent cancellation by drop;
  • make fields generally immutable.

Box::pin(future) combines heap allocation with pinning because ownership and dynamic storage require a box. std::pin::pin!(future) can pin in the future’s current local storage without a new heap allocation. Either can make a future pollable; neither repairs a captured Rc or a dangling borrow.

Application code normally relies on .await and executor APIs to pin correctly. Manual projection becomes relevant when implementing a future or combinator with structurally pinned child fields. That work belongs behind a reviewed abstraction, because projecting Pin<&mut Parent> to fields while preserving which fields may move is a soundness obligation.

The diagnostic order should therefore be:

  1. Identify the bound the caller requires.
  2. Identify the value live across the reported .await.
  3. Decide whether that value should be retained at all.
  4. Repair ownership, scope, or the execution boundary.
  5. Address pinning only if the polling/storage API actually requires it.

Production review follows the suspended state

At every .await, review the future as a durable record that may remain resident, be cancelled, or resume elsewhere:

  • Borrow validity: Which owner must remain alive until this state completes?
  • Transfer: Which retained field prevents or enables Send? Is thread migration required or merely convenient?
  • Exclusion: Does a guard, permit, transaction, or mutable borrow cross suspension? What is the maximum wait?
  • Memory: Are large buffers, parse trees, or error contexts retained for thousands of pending tasks?
  • Cancellation: If dropped here, which local destructors run and which external side effects remain?
  • Affinity: Is a local executor deliberate, documented, and isolated behind owned messages?
  • Address stability: Who pins before polling, and is any manual projection justified?
  • Diagnostics: Does the fix reduce stored state, or merely wrap it in another pointer?

Observability should reflect these questions. Record time waiting for a lock separately from time holding it. Track in-flight task count and retained bytes by phase. If local and migratable executors coexist, label task placement and queue delay by executor. A Send compile error is caught before production; a future retaining a 4 MiB buffer across a slow await is not.

Exercise: repair the boundary, not just the type

Start with a relay-service function that accepts &Request, locks a shared deduplication map, builds an Rc<Scratch>, awaits a remote write, then attempts to spawn the future. Produce four artifacts:

  1. A state table for each suspension point listing captured references, guards, owned values, size-sensitive buffers, and whether every field is Send.
  2. A rejected compile witness for the original Send + 'static spawn boundary. Explain the primary diagnostic and at least one causal note beneath it.
  3. Two valid designs: one structured function that borrows and is awaited locally; one detached job that owns a minimal request model, drops scratch and guards before suspension, and exposes impl Future + Send + 'static at its boundary.
  4. A decision explaining why async move, Arc, Box::pin, or a local executor would or would not be appropriate. Include cancellation and shutdown consequences, not only compilation.

Then change one requirement: the parser library is permanently thread-affine. Re-evaluate the design. A strong answer does not force it into a multithreaded pool; it isolates parsing on a local executor or owning thread, sends owned results onward, bounds the handoff queue, and attaches that component to service shutdown.

A compact async-state test

When a future fails a boundary, say this sentence precisely:

The caller requires [lifetime/Send/pinning property], but the suspended state retains [specific field or reference] across [specific await].

Then choose among three moves: stop retaining it, own it correctly, or choose an execution boundary that permits it. This framing prevents four common false fixes: cloning without an ownership reason, boxing without a storage reason, adding move without inspecting the captured value, and treating 'static as a request to leak memory.

The next chapter widens the boundary. Once a future’s stored state is valid and spawnable under the chosen policy, the system still needs separate owners for ready queues, I/O readiness, timers, blocking work, runtime entry, and application task lifetime.

Sources and version note

The rules for async blocks and functions follow the official async keyword documentation and The Rust Programming Language’s future/state-machine explanation. Send propagation and the non-Send status of Rc and MutexGuard follow the official Send documentation. Address-stability, Unpin, structural projection, and local versus heap pinning follow the standard library’s std::pin documentation. The fixture uses safe, dependency-free Rust 2024 and treats future layout and diagnostic wording as compiler-version-specific observations, verified with Rust 1.97.0 and the declared Rust 1.85.0 MSRV.