The thread killer in your async code
You are running a web server handling thousands of concurrent requests. A new request arrives, needs to update a shared counter, and grabs a std::sync::Mutex. The lock is currently held by another request. Your thread blocks. It sits there, doing nothing, waiting for the OS to wake it up. Meanwhile, the async runtime is trying to schedule other tasks on that same thread, but it cannot. The thread is stuck in the kernel. The whole server grinds to a halt because one lock decided to take a nap.
This is the most common performance trap in Rust async programming. You imported the wrong mutex. You used a blocking primitive inside an async context. The code compiles. The code runs. The code kills your throughput.
std::sync provides blocking primitives for multi-threaded programs. tokio::sync provides non-blocking primitives for async runtimes. The difference is not just syntax. The difference is whether your thread stops working or yields control so other work can happen.
Blocking versus yielding
Rust's std::sync module contains types like Mutex, RwLock, and Condvar. These types are designed for synchronous code. When you call lock() on a std::sync::Mutex, the function does not return until the lock is acquired. If the lock is busy, the function calls into the operating system. The OS puts the current thread to sleep. The thread stops executing instructions. It consumes no CPU, but it also does no work. It is gone until the lock is released.
In a synchronous application, this is fine. You have a thread pool. If one thread sleeps, another thread picks up the work. The system scales by adding threads.
In an async application, this is catastrophic. Async runtimes like Tokio use a small number of threads, usually one per CPU core. They multiplex thousands of tasks onto those threads. If a task blocks a thread, the runtime cannot run any other task on that core. You have effectively reduced your server to a single-threaded program for the duration of the block. If many tasks block, you run out of threads. The runtime panics or deadlocks.
tokio::sync primitives solve this by yielding. When you call lock().await on a tokio::sync::Mutex, the function returns a Future. You await that Future. If the lock is busy, the Future returns Poll::Pending. The runtime sees Pending and switches to another task. Your task is parked. It waits without holding the thread. When the lock becomes available, the runtime wakes your task up and polls it again. The thread never sleeps. It stays busy running other tasks while yours waits.
Blocking a thread in async is like stopping a highway because one car needs a map. Yielding is like pulling over to the shoulder while the rest of traffic flows.
The mechanism: OS sleep versus waker notification
Understanding the difference requires looking at what happens under the hood.
std::sync::Mutex relies on the OS futex mechanism. When you call lock(), the implementation attempts an atomic compare-and-swap. If it fails, it calls a futex syscall. The kernel puts the thread to sleep and removes it from the scheduler. The thread is dead weight. When the lock is released, the kernel wakes the thread. The thread returns to user space and continues. This involves a context switch and a syscall. It is expensive, but acceptable in sync code where threads are cheap.
tokio::sync::Mutex relies on the Future and Waker system. When you await the lock, the Future checks the lock state. If busy, it registers a Waker. The Waker is a callback that tells the runtime how to wake this task. The Future returns Pending. The runtime drops the Future and moves to the next task. No syscall. No thread sleep. Just a pointer update and a loop iteration. When the lock is released, the implementation calls the Waker. The Waker pushes the task back onto the runtime's queue. The runtime polls the task later. This is lock-free and syscall-free for the waiting period. It is extremely fast.
The trade-off is API complexity. tokio::sync types require .await. You cannot use them in synchronous code. You cannot pass a tokio::sync::Mutex to a library that expects a std::sync::Mutex. The types are incompatible. You must choose the right tool for the execution context.
Minimal example
Here is the difference in code. The imports tell the whole story.
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex as TokioMutex;
/// Demonstrates the difference between blocking and async locks.
async fn compare_locks() {
// std::sync blocks the thread.
// If this lock is contended, the OS thread sleeps.
// The runtime cannot run other tasks on this core.
let std_lock = StdMutex::new(42);
let guard = std_lock.lock().unwrap();
println!("std value: {}", *guard);
// tokio::sync yields to the runtime.
// If this lock is contended, the task parks.
// The runtime switches to other tasks immediately.
let tokio_lock = TokioMutex::new(42);
let guard = tokio_lock.lock().await;
println!("tokio value: {}", *guard);
}
The std version uses lock().unwrap(). The tokio version uses lock().await. The await is the signal. It tells the compiler to generate a state machine that can pause and resume. It tells the runtime to yield if the work is not done.
Check your imports. use std::sync::Mutex in an async handler is a silent performance killer.
Realistic example: Shared state in a web handler
Real async code often shares state across handlers. A database connection pool is a classic example. Multiple handlers need to borrow connections from the pool. The pool must be protected by a lock.
use std::sync::Arc;
use tokio::sync::Mutex;
/// A simple connection pool wrapper.
struct Pool {
connections: Vec<String>,
}
impl Pool {
fn new() -> Self {
Self {
connections: vec!["conn1".to_string(), "conn2".to_string()],
}
}
/// Acquires a connection.
/// In real code, this would be async and involve network I/O.
fn get_connection(&mut self) -> Option<String> {
self.connections.pop()
}
}
/// Web handler that uses the pool.
async fn handle_request(pool: Arc<Mutex<Pool>>) {
// Lock the pool.
// We use tokio::sync::Mutex so we can yield while waiting.
// If we used std::sync::Mutex here, we would block the worker thread.
let mut pool_guard = pool.lock().await;
// Get a connection.
if let Some(conn) = pool_guard.get_connection() {
println!("Got connection: {}", conn);
// Simulate work with the connection.
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
} else {
println!("Pool exhausted");
}
// The guard is dropped here, releasing the lock.
// Other tasks waiting on the lock can proceed.
}
Notice the type of pool. It is Arc<Mutex<Pool>>. The Mutex here is tokio::sync::Mutex. The Arc is std::sync::Arc. This combination is standard. Arc provides thread-safe shared ownership with atomic reference counting. Atomic operations do not block. They are lock-free. Arc is safe to use in both sync and async code. The danger is only in the inner type. Arc<std::sync::Mutex<T>> is the trap. Arc<tokio::sync::Mutex<T>> is the fix.
Convention aside: The community always uses std::sync::Arc for shared ownership, even in async code. There is no tokio::sync::Arc. The atomic ref counting is fast enough and does not block. Do not try to wrap Arc in a mutex unless you need to mutate the Arc itself, which is rare. Wrap the data, not the pointer.
Pitfalls and compiler errors
Mixing sync and async primitives leads to subtle bugs and compiler errors.
The most common error is trying to share a tokio::sync::MutexGuard across threads. The guard is tied to the task, not the thread. It does not implement Sync. If you try to put the guard in a struct that is shared, the compiler rejects you with E0277 (trait bound not satisfied).
use tokio::sync::Mutex;
struct SharedState {
data: Mutex<Vec<u32>>,
}
impl SharedState {
/// This fails to compile.
/// The guard is not Sync, so it cannot be shared across threads.
fn get_guard(&self) -> tokio::sync::MutexGuard<'_, Vec<u32>> {
// This would require blocking or returning a Future.
// You cannot return a guard from a sync method easily.
unimplemented!()
}
}
The fix is to keep the guard scoped to the async task. Clone the data you need, or perform the work while holding the guard. Drop the guard before returning.
Another pitfall is std::sync::RwLock. The same rules apply. std::sync::RwLock::read() blocks the thread. tokio::sync::RwLock::read().await yields. If you use std in async, you block. If you use tokio in sync, you cannot compile because there is no .await.
The borrow checker will not save you from blocking the runtime. You have to read the docs.
When std::sync is actually safe
std::sync is not banned in async code. It is banned in async tasks that run on the main executor threads. If you isolate blocking work, std::sync is perfectly fine.
Use tokio::task::spawn_blocking to run blocking code on a separate thread pool. Inside spawn_blocking, you can use std::sync::Mutex freely. The thread pool is designed for blocking work. It has enough threads to handle sleeps.
use std::sync::Mutex;
/// Heavy computation that uses a blocking mutex.
fn heavy_work(shared_data: &Mutex<Vec<u32>>) {
let guard = shared_data.lock().unwrap();
// Do CPU-heavy work here.
// Blocking is fine because we are in spawn_blocking.
println!("Processing {}", guard.len());
}
async fn run_heavy_work(shared_data: std::sync::Arc<Mutex<Vec<u32>>>) {
// Spawn a blocking task.
// This moves the work to the blocking thread pool.
// The async task yields immediately.
tokio::task::spawn_blocking(move || {
heavy_work(&shared_data);
})
.await
.unwrap();
}
This pattern is essential for CPU-bound work. Async runtimes are for I/O-bound work. If you have a loop that crunches numbers for seconds, do not run it in an async task. It will starve other tasks. Spawn it in spawn_blocking. Inside that closure, std::sync is the right choice.
Decision matrix
Use std::sync primitives when you are writing a synchronous, multi-threaded application with no async runtime. Use std::sync::Mutex inside a tokio::task::spawn_blocking closure where blocking is expected and isolated from the async executor. Use std::sync::Arc for shared ownership in both sync and async code; the atomic reference counting does not block and is the community standard for pointers. Use tokio::sync primitives when you are inside an async function and need to coordinate access to shared state without blocking the executor. Use tokio::sync::Mutex when multiple tasks need exclusive access to data and you must yield while waiting. Use tokio::sync::Semaphore when you need to limit concurrency in an async flow, such as a connection pool or rate limiter. Use tokio::sync::mpsc when you need to send messages between tasks without blocking the sender or receiver.
Counter-intuitive but true: the more you use std::sync in async, the more you risk deadlocking your runtime. Trust the async primitives. They are built for the job.