Tokio Mutex Versus Std Mutex and When Each Is Correct
Use std::sync::Mutex when the lock is never held across an await and tokio::sync::Mutex when it must be. The std one is faster and is the default choice.

Async Rust has two mutexes with the same name and different jobs. std::sync::Mutex is a normal operating-system lock: acquiring it can block the thread. tokio::sync::Mutex is an async lock: acquiring it suspends the task and lets the thread run something else. The Tokio documentation itself says to prefer the std one in most cases, which surprises people who assume "async code needs an async mutex". The deciding question is not "is my code async" but "do I need to keep the lock held across an .await". This post shows the cases on each side, the measured price of the async lock, fairness differences, and the third option (parking_lot) for completeness. Everything compiled on Rust 1.99, edition 2024, with tokio 1.
The rule
Use std::sync::Mutex when the critical section is short and contains no .await. Use tokio::sync::Mutex when you must hold the lock while awaiting something. If you find yourself wanting the second, check first whether you can restructure so you do not need to (copy the data out, release the lock, then await), or whether the state belongs to an actor task, as in sharing state between tasks. The reasons come from what happens at contention.
What blocking means for each
When task A holds a std mutex and task B on the same worker thread calls lock(), B blocks the thread. If A can only make progress when that thread runs (because A is waiting for a timer or an I/O event handled by the same worker), neither can proceed: a deadlock, with no panic and no message. The compiler stops you in the common case, because a std guard is not Send and tokio::spawn requires its future to be:
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::sleep;
static STATE: Mutex<Vec<u32>> = Mutex::new(Vec::new());
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let holder = tokio::spawn(async {
let mut guard = STATE.lock().unwrap();
guard.push(1);
sleep(Duration::from_millis(50)).await;
guard.push(2);
});
holder.await.unwrap();
}error: future cannot be sent between threads safely
--> src/main.rs:9:18
|
9 | let holder = tokio::spawn(async {
| __________________^
10 | | let mut guard = STATE.lock().unwrap();
11 | | guard.push(1);
12 | | sleep(Duration::from_millis(50)).await;
13 | | guard.push(2);
14 | | });
| |______^ future created by async block is not `Send`
|
= help: within `{async block@src/main.rs:9:31: 9:36}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, Vec<u32>>`
note: future is not `Send` as this value is used across an await
--> src/main.rs:12:42
|
10 | let mut guard = STATE.lock().unwrap();
| --------- has type `std::sync::MutexGuard<'_, Vec<u32>>` which is not `Send`
11 | guard.push(1);
12 | sleep(Duration::from_millis(50)).await;
| ^^^^^ await occurs here, with `mut guard` maybe used later
note: required by a bound in `tokio::spawn`
--> tokio-1.54.1/src/task/spawn.rs:176:21
|
174 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
| ----- required by a bound in this function
175 | where
176 | F: Future + Send + 'static,
| ^^^^ required by this bound in `spawn`The error is the same on a current-thread runtime, because spawn does not know which runtime flavor will run the task. The message points at the guard, at the await where it is still alive, and at the Send bound on spawn. To see what the lock looks like from outside while a task sleeps with the guard held, run the same code on a LocalSet, which accepts futures that are not Send, and probe it with try_lock so that the program cannot hang:
use std::sync::Mutex;
use std::time::Duration;
use tokio::task::LocalSet;
use tokio::time::sleep;
static STATE: Mutex<Vec<u32>> = Mutex::new(Vec::new());
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let local = LocalSet::new();
local
.run_until(async {
let holder = tokio::task::spawn_local(async {
let mut guard = STATE.lock().unwrap();
guard.push(1);
sleep(Duration::from_millis(50)).await;
guard.push(2);
});
sleep(Duration::from_millis(10)).await;
match STATE.try_lock() {
Ok(_) => println!("second task: got the lock"),
Err(e) => println!("second task: lock is held by a sleeping task ({e})"),
}
holder.await.unwrap();
println!("final: {:?}", STATE.lock().unwrap());
})
.await;
}second task: lock is held by a sleeping task (try_lock failed because the operation would block)
final: [1, 2]The first task kept the guard across a sleep, so for 50 ms the lock was unavailable even though nobody was using it. A plain lock() in the second task, instead of try_lock, would have frozen the whole program: the first task could never wake up to release it. On a multi-thread runtime a second worker blocks instead, and you lose throughput rather than liveness, which is why the bug survives light testing. The error and its fixes are developed in mutex guards across await.
When you do need the async mutex
The legitimate case is a resource that is used through async calls and must not be interleaved: a single connection that cannot run two requests at once, a file writer, a protocol state machine. The lock guards the sequence, and the sequence includes awaits:
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration, Instant};
struct Connection {
log: Vec<String>,
}
impl Connection {
async fn request(&mut self, who: &str) {
self.log.push(format!("{who}: sent"));
sleep(Duration::from_millis(100)).await;
self.log.push(format!("{who}: got reply"));
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let conn = Arc::new(Mutex::new(Connection { log: Vec::new() }));
let t = Instant::now();
let mut handles = Vec::new();
for who in ["a", "b", "c"] {
let conn = conn.clone();
handles.push(tokio::spawn(async move {
conn.lock().await.request(who).await;
}));
}
for h in handles {
h.await.unwrap();
}
for line in &conn.lock().await.log {
println!("{line}");
}
println!("total {:?}", t.elapsed());
}a: sent
a: got reply
b: sent
b: got reply
c: sent
c: got reply
total 300msThe three requests ran one at a time, each completing before the next began (300 ms in all), and the log shows no interleaving. A std mutex could not do this, because holding its guard across the sleep either would not compile on a multi-thread runtime or would block a worker. Note that the guard lives for the whole request call: that is the point of the lock, and also the cost, since it serialises everything behind it. If the connection supports pipelining, a pool of connections or a task that owns the connection and queues requests gives more throughput.
The price of the async lock
The async mutex does real work to be awaitable: it keeps a wait queue of tasks and wakes them in order. On an uncontended lock the extra cost is small; under contention it adds a task switch per hand-off. Measured with 8 tasks making 250,000 increments each to a shared counter on an Apple M1 Max in a release build (best of three), the std mutex took 40 ms and the Tokio mutex 222 ms: 20 ns versus 111 ns per locked increment, a factor of 5.5. That is a microbenchmark of the worst case (a lock with nothing in it, hammered), and in a real handler where the critical section does anything meaningful the difference vanishes. It is a reason not to reach for the async mutex by habit, not a reason to avoid it where you need it.
Fairness and ordering
The Tokio mutex is fair: waiting tasks acquire it in the order they asked (first in, first out), so a busy task cannot starve the rest. The std mutex makes no such promise. On Linux it is a futex-based lock that can let the thread that just released it take it again before a waiter wakes ("barging"), which is faster overall and can starve a waiter under heavy load. If you need ordering guarantees, such as requests being served in arrival order, that is a reason for the async mutex; if you need raw throughput on short sections, it is a reason against it.
Making the std mutex work with async code
The standard technique for state with short updates is to confine the guard to a block so it is dropped before any await:
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::time::{sleep, Duration};
struct Cache {
entries: Mutex<HashMap<u32, String>>,
}
impl Cache {
async fn get_or_load(&self, id: u32) -> String {
if let Some(hit) = self.entries.lock().unwrap().get(&id).cloned() {
return hit;
}
sleep(Duration::from_millis(20)).await;
let loaded = format!("record {id}");
self.entries.lock().unwrap().entry(id).or_insert_with(|| loaded.clone());
loaded
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let cache = Cache { entries: Mutex::new(HashMap::new()) };
println!("{}", cache.get_or_load(7).await);
println!("{}", cache.get_or_load(7).await);
println!("cached entries: {}", cache.entries.lock().unwrap().len());
}record 7
record 7
cached entries: 1Each lock is taken and released within one statement, so no guard exists at the await. The cost of this pattern is a small race: two tasks missing the same key both load it, and the second or_insert_with keeps the first result. For a cache, loading twice is usually acceptable; if it is not, coalesce the loads, as in cache stampede coalescing.
parking_lot and the lock-free options
parking_lot::Mutex is a drop-in replacement for the std mutex with a smaller footprint (one byte), no poisoning and, historically, better performance under contention. Modern std mutexes narrowed most of the gap, so choose it for the no-poisoning API or the extras (try_lock_for, fair unlock), not for speed. For read-mostly data, RwLock lets many readers in at once; under write contention it is no faster than a mutex, as the measurement above shows (218 ms for the write path). For one number, an atomic is simpler than either.
Decision table
| Situation | Use |
|---|---|
| Short update, no await inside | std::sync::Mutex |
Must hold across .await, state is a resource used through async calls | tokio::sync::Mutex (or an owning task) |
| Long-lived guard shared in a struct | lock_owned() on an Arc<tokio::sync::Mutex<T>> returns a guard with no lifetime |
| You want FIFO ordering of waiters | tokio::sync::Mutex |
| A single counter or flag | An atomic |
| Called from sync code and async code | std mutex; the async one has blocking_lock but that panics inside the runtime |


