← Debugging Guides

阅读中文版本

Rust — tokio::spawn Requires Future + Send: async Blocks with Rc

Resolve future cannot be sent between threads safely in tokio::spawn, find the Rc or non-Send captured across await points, and restructure or localize the borrowing.

advanceddebuggingrusttokioasync

Symptoms

  • error: future cannot be sent between threads safely with a note about Rc<...> captured in an async block being held across an await
  • The same async logic works in a single-threaded runtime or when called directly without spawn
  • The compiler lists the specific await point where the non-Send value is alive

Possible causes

  • An async block spawned onto the multithreaded runtime captures an Rc or thread-local handle
  • A non-Send guard (MutexGuard from std, RefCell borrow) is held across an await point
  • A synchronous library handle that is !Send is stored inside the task state
  • Current-thread runtime masked the requirement, and moving to #[tokio::main] revealed it

Diagnosis steps

Read the await-point note

cargo check 2>&1 | grep -B5 -A15 "cannot be sent"

Interpretation

The diagnostic names the captured type and the exact await where it is held — that await and that capture define the fix scope.

Distinguish the two variants

cargo check 2>&1 | grep -E "Rc<|RefCell|MutexGuard|not Send"

Interpretation

Wrong type (Rc) means swapping to Arc; right type held too long (guard across await) means scoping the borrow before the await.

Test under the multithreaded runtime

cargo test -- --test-threads=4 with #[tokio::test(flavor = "multi_thread")]

Interpretation

Tests on the current-thread flavor pass while production spawns fail; testing multi-threaded closes the gap.

Root cause

tokio::spawn requires the future to be Send because the multithreaded scheduler may resume it on a different worker thread. If an async block holds a non-Send value (Rc, std MutexGuard, thread-local state) across an await, the future as a generated state machine is !Send and the spawn is rejected at compile time.

Fix

Swap shared types to Arc and tokio::sync::Mutex when needed across awaits, scope std::sync guards so they drop before any await, drop or restructure Rc usage into a local computation before the async boundary, and use the current-thread runtime deliberately when glue code cannot be made Send.

Risk note

Replacing every std::sync::Mutex with tokio::sync::Mutex adds async overhead to paths that never await. Scope guards instead where possible.

Verify the fix

The spawned task compiles and runs on the multi-thread runtime; tests exercising the task under --test-threads=4 stay green.

Symptom walkthrough

The error is a guided tour of your future:

error: future cannot be sent between threads safely
   |
note: future is not `Send` as this value is used across an await
   --> src/service.rs:58:9
   |
54 |     let counter = Rc::new(RefCell::new(0));
   |         ------- has type `Rc<RefCell<i32>>` which is not `Send`
...
58 |     do_thing().await;   // counter alive across this await

Two distinct diseases share this symptom. Wrong type: Rc captured where the runtime may move the task — swap to Arc. Right type, wrong scope: a std::sync::MutexGuard held across an await makes an otherwise fine future !Send — the guard must drop before the await. The note names the value and the await, so the fix scope is exact.

Fix in practice

By disease. Shared state across awaits becomes async-safe:

let counter = Arc::new(tokio::sync::Mutex::new(0));
// or when no await happens inside: scope the std guard

// guard scoped, dropped before await:
let snapshot = {
    let g = std_counter.lock().unwrap();
    g.clone()
};              // g drops here
do_thing(&snapshot).await;

Rc-heavy synchronous code gets localized to one side of the boundary:

let prepared = prepare_with_rc(data);  // sync, Rc inside, returns owned
spawn(async move {
    use_owned(prepared).await;          // no Rc in the state machine
});

And when glue code cannot be made Send, choose the runtime deliberately:

tokio::runtime::Builder::new_current_thread()
    .enable_all().build()
    .block_on(single_threaded_main());