Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Async and Lifetimes Collide

Holding a reference across a function boundary is one problem. The borrow checker handles it. You’ve been doing it forever and it works.

Holding a reference across an await point is a different problem. The reference now needs to be alive not just until the function returns, but until the entire future completes — which might be milliseconds, or seconds, or minutes, on a different thread, after being woken by an OS event you don’t directly control. That is a longer-lived constraint, applied to a more complicated graph, by a less expressive part of the type system, with worse error messages.

Welcome.

The basic shape

Consider:

#![allow(unused)]
fn main() {
async fn process(data: &[u8]) -> usize {
    let len = data.len();
    tokio::time::sleep(Duration::from_millis(10)).await;
    len + data.iter().filter(|&&b| b == 0).count()
}
}

This works. data is borrowed across the await, and the future returned by process borrows from data for its entire lifetime. The compiler infers, via elision, that the returned future’s type is impl Future<Output = usize> + 'a, where 'a is the lifetime of data. The caller is required to keep data alive until they await the future to completion.

Now try storing the future:

#![allow(unused)]
fn main() {
async fn caller() {
    let bytes = vec![1, 2, 3, 0, 4, 0];
    let fut = process(&bytes);
    drop(bytes);  // can we?
    let result = fut.await;
}
}

We cannot. The borrow checker rejects:

error[E0505]: cannot move out of `bytes` because it is borrowed
   --> src/main.rs:4:10
    |
3   |     let fut = process(&bytes);
    |                       ------ borrow of `bytes` occurs here
4   |     drop(bytes);
    |          ^^^^^ move out of `bytes` occurs here
5   |     let result = fut.await;
    |                  --- borrow later used here

This is fine. The error is well-formed, the message is comprehensible, and the fix is obvious — don’t drop bytes before awaiting.

The pain starts when the await happens inside something that demands Send.

The Send bound, propagated

Most async runtimes are multi-threaded. tokio::spawn requires its future to be Send, because the executor may move the task between threads at any await point. Send for a future means: every variable held across an await is Send. Not “the future as a whole is Send if it doesn’t currently hold non-Send data.” Every variable that could be alive at any await point must be Send. The auto-trait inference is structural and pessimistic.

Now consider:

use std::cell::RefCell;

async fn updater(state: &RefCell<i64>) {
    let mut guard = state.borrow_mut();
    let new_val = fetch_update().await;
    *guard += new_val;
}

#[tokio::main]
async fn main() {
    let state = RefCell::new(0);
    tokio::spawn(updater(&state));
}

This will fail with an error message that goes on for half a screen. The summary:

error: future cannot be sent between threads safely
   --> src/main.rs:14:18
    |
14  |     tokio::spawn(updater(&state));
    |                  ^^^^^^^^^^^^^^^ future returned by `updater` is not `Send`
    |
note: future is not `Send` as this value is used across an await
   --> src/main.rs:6:33
    |
5   |     let mut guard = state.borrow_mut();
    |         --------- has type `RefMut<'_, i64>` which is not `Send`
6   |     let new_val = fetch_update().await;
    |                                 ^^^^^^ await occurs here, with `mut guard` maybe used later

Two things just happened.

First, updater(&state) also failed because &state is &RefCell<i64>, and RefCell is not Sync, so &RefCell is not Send. Even if the future itself were Send, you can’t send the borrow that the future holds.

Second, the future is not Send because RefMut<'_, i64> is not Send, and the future stores guard across the await. The state machine has a field of type RefMut. The struct is therefore not Send. The future is not Send. tokio::spawn rejects.

Both issues are real. The fix is structural: replace RefCell with something Send + Sync (e.g., Mutex from tokio::sync, or parking_lot::Mutex if you don’t need async-aware locking; if you do need to await while holding the lock, you need tokio::sync::Mutex). And then you have to hold the guard across the await intentionally, knowing the lock is now held across a yield point, which means every other future trying to take that lock will block.

This is the genre. The Send bound makes you confront, structurally, every type that lives across an await. Most of the time, the answer is “use the async-aware version of the synchronization primitive.” Sometimes the answer is “rewrite the code so the lock is not held across the await.” Occasionally the answer is “don’t use tokio::spawn; use a LocalSet or a single-threaded runtime where Send is not required.” All three are valid. The choice depends on whether your fundamental constraint is throughput, correctness, or the structure of what you’re locking.

Auto-traits are structural and unforgiving

Send and Sync are auto-traits. They are inferred for every type, automatically, based on whether all the type’s components are themselves Send/Sync. For futures, this means: the inferred Send-ness of an async fn’s return type depends on every variable held across every await point.

This has two consequences that bite people.

Consequence 1: One non-Send variable, anywhere, kills Send-ness for the whole future. You can have a function with twenty await points, and on await number 13 you happen to hold a Rc<T> because you needed to share something cheaply between two helper closures, and now your future is not Send and tokio::spawn rejects it. The fix is Arc<T> everywhere, or restructuring so the Rc doesn’t cross the await. This is one of the places async Rust feels punitive — a small, local choice has a global type-level consequence.

Consequence 2: Lifetime parameters in your future infect Send-ness when they’re tied to non-Send references. If your future borrows a &T where T: !Sync, the future is not Send. Even if you don’t access T across an await — the borrow itself sits in the state machine. Auto-traits don’t know that you’re not going to look at it.

The combination is brutal. You can spend a long afternoon adding Send bounds to trait methods, only to find that the actual cause of the !Send future is a Cell<u8> field on a struct that’s borrowed three frames up. The trait method was fine; the structural definition of Send-ness propagated the failure all the way to the call site.

The borrow-across-await error, in full

Here is a real one. Take this:

#![allow(unused)]
fn main() {
async fn handler(server: &Server, req: Request) -> Response {
    let cache = server.cache.lock().await;
    let cached = cache.get(&req.key).cloned();
    drop(cache);

    if let Some(c) = cached { return c; }

    let result = server.compute(&req).await;
    server.cache.lock().await.insert(req.key, result.clone());
    result
}
}

Looks fine. Probably is fine. But suppose server.compute(&req) takes &self and &Request, and is async fn. Then the future returned by compute borrows from server and req. We await that future. While we’re awaiting it, we are holding the borrow.

Now try to tokio::spawn(handler(&srv, req)) from a multi-threaded context. The compiler will reject:

error: future cannot be sent between threads safely
   --> src/main.rs:20:18
    |
20  |     tokio::spawn(handler(&srv, req));
    |                  ^^^^^^^^^^^^^^^^^^ future returned by `handler` is not `Send`
    |
note: captured value is not `Send`
   --> src/main.rs:11:27
    |
11  |     let result = server.compute(&req).await;
    |                                 ^^^^ has type `&Request` which is not `Send`

This is unintuitive. &Request should be Send if Request: Sync. The error is misleading. What’s actually happening is that the future returned by compute has a type with a borrow of req in it, and that future is held across an internal await inside compute, and the inferred future type is not Send because the chain of inference broke somewhere two layers down.

The fix is usually one of:

  • Make sure all the types involved are Send + Sync. (Request, Server, every internal type.)
  • Add explicit + Send bounds to the futures returned by trait methods (more on this in chapter 7).
  • Restructure so the borrow doesn’t cross the await — pass owned values instead.

The general rule: if you spawn it, the entire dependency tree of futures must be Send end to end. A single broken link anywhere in the call graph brings down the spawn. The error message will point you to the proximate cause but not necessarily the underlying one.

Why this is a separate problem from synchronous borrows

In synchronous code, the borrow checker has perfect information. It knows the exact span of every reference. It knows, for each scope, which references are live and which have been dropped. The graph is a tree, and the analysis is local.

In async code, the borrow checker is reasoning about what the future will do when polled. The future is an object; it can be moved, awaited at a different point in the program, dropped without ever completing. The analysis has to be conservative across all of that. Specifically, every variable that exists at the moment of an await must be assumed to still exist at every subsequent point in the function, because the await could in principle suspend forever.

This means async code has an extra principle the borrow checker enforces: variables held across an await must satisfy both the synchronous borrow rules and the auto-trait constraints of the future’s eventual use site. The first constraint is the same one you’ve always lived with. The second is the one that produces the cryptic errors, because it depends on a use site that may be far away in the code.

A working pattern

Here is a pattern that handles most of the real cases. When you have a function that:

  1. Takes some references.
  2. Awaits something (possibly with a different lifetime).
  3. Returns a value.

The default-correct version is:

#![allow(unused)]
fn main() {
async fn handler(server: Arc<Server>, req: Request) -> Response {
    let cached = {
        let cache = server.cache.lock().await;
        cache.get(&req.key).cloned()
    };

    if let Some(c) = cached { return c; }

    let result = server.compute(&req).await;
    server.cache.lock().await.insert(req.key.clone(), result.clone());
    result
}
}

Differences from the broken version:

  • Arc<Server> instead of &Server. The future owns its own reference-counted handle to the server, no lifetime parameter.
  • Request (owned) instead of &Request. The future owns the request.
  • The MutexGuard is dropped at the end of the inner block (the cached scope), so it is not held across the second await.

This is the “give up and clone” pattern, and chapter 9 will cover it more thoroughly. For now: if you find yourself fighting Send bounds and lifetime errors on a function you’re going to spawn, the answer is almost always to make the function take owned values (with Arc for shared ones), not borrowed values. The async runtime takes ownership of futures; trying to keep borrows alive across that ownership transfer is fighting the runtime’s design.

Why you keep losing

The async lifetime errors feel disproportionately bad for three combined reasons.

The error messages reference the inferred future type, which is unspellable and arbitrarily large. “future cannot be sent between threads safely” is true but unhelpful when the future has thirty fields and the offending one is buried in a state machine three async functions deep.

The fix is often non-local. The thing that needs to change is sometimes the type of a struct field, or the bound on a trait method, or the Cell that someone used in a helper module two crates down. The error points at the spawn site, but the spawn site is not where the bug is.

The auto-trait propagation is unforgiving. You can write code that is correct, idiomatic, and would work fine in any sane non-Send context, and have it rejected because one internal type is !Send. The negation propagates all the way to the call site. The compiler is not wrong to do this — multi-threaded execution requires it — but it is a kind of cost-of-doing-business that synchronous Rust does not pay.

The path through is to lean on Arc, lean on owned data, lean on tokio::sync primitives, and to keep your async functions structurally simple — short, well-typed, with Send bounds explicit at the boundaries. The next two chapters (Pin and async traits) explain why the design has to be this way, but the pragmatic survival kit is mostly: own your data, share via Arc, don’t borrow across awaits unless you’ve thought about it.

Sources