Trust is earned, not given

A different perspective

2024-03-19 · Projects

Rust, part 9: Send, Sync, and the future that isn't Send

Part 9from the Rust series · 15 parts in all

There is one compiler error in async Rust that everyone meets and few can explain the first time: future cannot be sent between threads safely, pointing at an .await. It is not a bug in your code and not a limitation to work around β€” it is the borrow checker's rule from part 1, applied across a suspension point. Once you see it, it stops being mysterious.

What the two auto traits mean

Both are auto traits: the compiler derives them structurally and you implement them by hand only behind unsafe, as a promise you have verified. Rc<T> is !Send because its refcount is not atomic; MutexGuard is !Send because it must be unlocked by the same thread that locked it.

The error, and the reason

use std::sync::Mutex;

struct Session { data: Vec<u8> }

async fn handle(state: &Mutex<Session>) {
    let mut guard = state.lock().unwrap();   // std::sync::MutexGuard - !Send

    // The guard is still alive here. A multi-threaded executor may resume this
    // task on a DIFFERENT thread, which would move the guard across threads -
    // exactly what MutexGuard forbids, because it must be unlocked by the
    // thread that locked it.
    some_io().await;

    guard.data.push(1);
}

The compiler is right, and the error text now points at the .await as the offending line, which is the part that used to take an afternoon to find. The generated future is a state machine with a variant per await point, and a !Send local that is live across one makes the whole future !Send.

Worth being precise about which guard, because it changes the fix: std's MutexGuard is !Send, while tokio::sync::Mutex's guard is Send (it has to be, so it can be held across an await). So this error usually means a std lock reached into async code β€” where the deeper problem is not the trait bound at all, but that std::sync::Mutex::lock blocks the thread, and an async task has no right to block one.

The fixes, in order of preference

// 1. Narrow the borrow: take what you need, then drop the guard.
async fn handle(state: &Mutex<Session>) {
    let snapshot = { state.lock().await.data.clone() };
    some_io().await;
    let _ = snapshot;
}

// 2. Use an owned, Send type across the await - and this also avoids holding
//    a lock across I/O, which is the deadlock you were about to write anyway.
async fn handle2(state: &Mutex<Session>) {
    let value = { let g = state.lock().await; g.data.len() };
    some_io().await;
    println!("{value}");
}

// 3. Run it on a single-threaded runtime, where nothing is ever moved.
//    Correct when the work is !Send by nature, and a deliberate choice
//    rather than a workaround.

An important detail about fix 2: never hold a lock across an await is not a Rust rule, it is a rule about locks, and Rust makes you confront it. The same applies to a &mut borrow across an await point in a spawned task β€” the compiler will refuse, and the refusal is usually pointing at a real race.

Where a shared value must genuinely be mutated from several tasks, the tool is Arc<Mutex<T>> β€” and in an async program, tokio's mutex rather than std's, because std::sync::Mutex::lock blocks the thread and an async task has no right to block one. Next: the build system underneath all of this, which is a much bigger part of Rust's story than most people expect.