If you are writing async Rust using Tokio, you have likely encountered this compiler error chain:
error: future cannot be sent between threads safely
--> src/main.rs:15:5
|
15 | tokio::spawn(async move {
| ^^^^^^^^^^^^ future created by async block is not `Send`
|
= help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, Data>`
= note: required because it's used within this `async` block
This error usually occurs when you hold a standard library std::sync::MutexGuard across an .await point.
The Root Cause: Async State Machines and Send
To understand why this breaks, you must understand how Rust compiles async blocks and how the Tokio runtime schedules tasks.
- The State Machine: When you write an
asyncblock, the compiler transforms it into a state machine generated as an anonymousstruct. Any variable that must exist across an.awaitpoint (a yield point) is stored as a field in that struct so its state is preserved when execution resumes. - Work Stealing: Tokio's multi-threaded runtime uses a work-stealing scheduler. A task might start executing on Thread A, yield at an
.await, and later be picked up and resumed by Thread B. - The
SendTrait: Because a task can move between threads, the generatedFuturestruct must implementSend. If any field within that struct is!Send(not Send), the entire Future becomes!Send. - The Culprit:
std::sync::MutexGuardis!Send. On many operating systems (like those using pthreads), unlocking a mutex from a thread different than the one that locked it is Undefined Behavior. Since Tokio might move your task to a different thread after the.await, holding a standard mutex guard across that yield point is unsafe.
The Broken Code
Here is a typical example of code that triggers this error. We acquire a lock, perform an async operation (simulating I/O), and then attempt to use the data.
use std::sync::{Arc, Mutex};
use tokio::time::{sleep, Duration};
struct AppState {
counter: u32,
}
#[tokio::main]
async fn main() {
let state = Arc::new(Mutex::new(AppState { counter: 0 }));
let state_clone = state.clone();
// ERROR: This spawn fails to compile
tokio::spawn(async move {
// 1. Lock acquired here
let mut guard = state_clone.lock().unwrap();
// 2. State modification
guard.counter += 1;
// 3. Await point (Yield)
// The compiler sees 'guard' is alive before this await
// and used after it. It forces 'guard' into the Future struct.
sleep(Duration::from_millis(100)).await;
// 4. Guard dropped here
println!("Counter: {}", guard.counter);
});
}
Solution 1: Limit the Scope (The Performance Fix)
In 90% of cases, you do not actually need to hold the lock during the async operation. Holding a lock during I/O is generally an anti-pattern as it blocks other threads from accessing the data while the task is just waiting on network/disk.
The fix is to ensure the MutexGuard is dropped before the .await call. We can enforce this using a standard block scope.
use std::sync::{Arc, Mutex};
use tokio::time::{sleep, Duration};
struct AppState {
counter: u32,
}
#[tokio::main]
async fn main() {
let state = Arc::new(Mutex::new(AppState { counter: 0 }));
let state_clone = state.clone();
tokio::spawn(async move {
// Step 1: Scope the lock
{
// Lock is acquired
let mut guard = state_clone.lock().unwrap();
guard.counter += 1;
println!("Updated counter to: {}", guard.counter);
// guard is dropped HERE, automatically unlocking the mutex
}
// Step 2: Perform the async operation
// The guard does not exist in the Future's state machine here.
sleep(Duration::from_millis(100)).await;
// If you need the lock again, re-acquire it later.
});
}
Why this works: The compiler analyzes the lifetime of the guard. Since it is dropped before the .await, it does not need to be stored in the generated Future struct. The Future remains Send.
Solution 2: tokio::sync::Mutex (The Async Fix)
If you absolutely must hold the lock across an await point (e.g., you are ensuring transactional consistency across multiple I/O calls), you cannot use std::sync::Mutex. You must switch to tokio::sync::Mutex.
Tokio's Mutex is designed specifically for this scenario. Its guard implements Send, allowing it to travel between threads safely.
use std::sync::Arc;
use tokio::sync::Mutex; // Note the import change
use tokio::time::{sleep, Duration};
struct AppState {
counter: u32,
}
#[tokio::main]
async fn main() {
// Wrap in Tokio's Mutex
let state = Arc::new(Mutex::new(AppState { counter: 0 }));
let state_clone = state.clone();
tokio::spawn(async move {
// 1. Acquire the async lock
// Note: .lock() is now an async function that must be awaited
let mut guard = state_clone.lock().await;
guard.counter += 1;
// 2. Perform async I/O while holding the lock
// This is now safe because Tokio's MutexGuard is Send.
sleep(Duration::from_millis(100)).await;
println!("Counter after sleep: {}", guard.counter);
// Guard drops here
});
}
Caveat: Tokio's Mutex has higher overhead than std::sync::Mutex. It essentially queues tasks within the runtime. Only use this if you strictly require holding the lock over the await.
Summary and Best Practices
- Analyze the Scope: Check if you truly need the lock during the async wait. Usually, you calculate something, drop the lock, do I/O, then re-lock if necessary to save results.
- Use
std::sync::Mutexby default: If you can scope the lock to drop before awaiting, stick to the standard library mutex. It is faster and lighter. - Use
tokio::sync::Mutexfor transactions: If the critical section spans an await point (e.g.,lock -> read_db -> write_file -> unlock), use the Tokio variant.
By managing the lifetime of your guards relative to your await points, you ensure your Futures remain Send safe for the runtime.