Rust Patterns That Matter #22: Send / Sync Bounds in Async
Post 22 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.
Previous: #21: Pin and Boxing Futures
You call tokio::spawn(my_async_fn()) and the compiler says: "future
returned by my_async_fn is not Send." You have no idea
what's not Send or why it matters. This is the final boss of Rust's
async story, and the fix is almost always structural.
The motivation
use std::rc::Rc;
async fn do_work() {
let data = Rc::new("hello");
some_async_call().await;
println!("{data}");
}
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
tokio::spawn(do_work()); // ERROR
});
}
error: future cannot be sent between threads safely
-- within `impl Future<Output = ()>`, the trait `Send`
is not implemented for `Rc<&str>`
Why tokio::spawn needs Send
Tokio uses a work-stealing scheduler. A task might start on thread 1, suspend at
an .await, and resume on thread 3. The runtime moves the future between
threads. If the future contains non-Send data, that move is unsound.
Send means "safe to transfer to another thread." Types like
Rc (non-atomic reference count), RefCell (non-atomic borrow
flag), raw pointers, and some mutex guards are !Send. If any of these
are alive across an .await point, the future's state machine captures
them, and the future becomes !Send.
Finding the culprit
The compiler error usually tells you exactly what's wrong:
note: future is not `Send` as this value is used across an await
--> src/main.rs:4:5
|
3 | let data = Rc::new("hello");
| ---- has type `Rc<&str>` which is not `Send`
4 | some_async_call().await;
| ^^^^^^^^^^^^^^^^^^^^^^^ await occurs here, with `data` maybe used afterwards
Read this carefully: it points to the variable (data), its type
(Rc<&str>), and the .await point it's held
across. The fix is to make data not alive at the .await.
Pattern 1: Scope the non-Send value
Drop the non-Send value before the .await:
async fn do_work() {
{
let data = Rc::new("hello");
println!("{data}");
} // data dropped here
some_async_call().await; // no non-Send values alive — future is Send
}
By scoping data into a block that ends before the .await,
it's not part of the future's state machine at the suspension point. The future
becomes Send.
Pattern 2: Extract synchronous work
Move the non-Send work into a synchronous function:
fn process_locally() -> String {
let data = Rc::new("hello");
// ... complex work with Rc ...
data.to_string()
}
async fn do_work() {
let result = process_locally(); // Rc lives and dies here
some_async_call().await; // only String (Send) crosses the await
println!("{result}");
}
The non-Send type lives entirely within the synchronous function.
Only the Send-safe result crosses the .await.
Pattern 3: Use Send-safe alternatives
Often the simplest fix is replacing the non-Send type with its
thread-safe counterpart:
Rc->ArcRefCell->tokio::sync::Mutex(orstd::sync::Mutex)Cell->AtomicU32/AtomicBool/ etc.
use std::sync::Arc;
async fn do_work() {
let data = Arc::new("hello"); // Arc is Send
some_async_call().await;
println!("{data}"); // works — future is Send
}
Pattern 4: spawn_local
If the task genuinely can't be Send (it deeply uses Rc
and restructuring is impractical), use tokio::task::spawn_local:
let local = tokio::task::LocalSet::new();
local.run_until(async {
tokio::task::spawn_local(do_work());
}).await;
spawn_local runs the task on the current thread only - no
work stealing, no thread migration. The future doesn't need to be Send
because it never moves between threads. The trade-off is that local tasks don't
benefit from multi-threaded scheduling.
Sync briefly
Send means "safe to move to another thread." Sync
means "safe to reference from another thread." Formally:
T is Sync if and only if &T is
Send.
Sync comes up less often in async code, but the principle is the same.
If a shared reference to your type crosses an .await and ends up on
another thread, the type needs to be Sync.
The key insight
"Future is not Send" errors feel like type-system puzzles, but the fix is almost always structural:
- Find the non-
Sendvalue (the compiler tells you) - Find the
.awaitit's held across (the compiler tells you this too) - Make the value not alive at the
.await- scope it, extract it into a sync function, or replace it with aSendalternative
The fix is restructuring, not fighting the type system.
Series wrap-up
This is the twenty-second and final pattern. The series started with the first wall
(interior mutability) and
ends with the last one (Send/Sync in async). Each pattern
exists because Rust's ownership model forces you to express things that other
languages leave implicit. The patterns aren't workarounds - they're the
vocabulary of productive Rust.
See it in practice: Building a Chat Server #6: Going Async uses this pattern for tokio::spawn compatibility.
Telex