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.
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());