When shared state meets multiple threads
You are building a web server. Ten threads are processing incoming requests. Every time a request arrives, you need to bump a global counter to track traffic. You try to share a plain integer across the threads. The compiler screams. You cannot mutate shared data from multiple threads without coordination. The borrow checker blocks you because data races are undefined behavior, and Rust refuses to let them happen.
You need a way to let multiple threads touch the same data without corrupting it. That is what Mutex<T> is for. The name stands for "mutual exclusion." It ensures that only one thread can access the data at a time. Other threads wait until the lock is released.
Think of Mutex<T> as a conference room with a single key. The data lives inside the room. To change the data, you need the key. If someone else has the key, you wait outside until they return it. You cannot enter the room without the key, and you cannot have two people in the room at once. The Mutex holds the key and the data. Calling .lock() grabs the key. Dropping the lock guard puts the key back.
The guard pattern
Mutex does not give you a reference directly. It gives you a guard object. The guard holds the lock. As long as the guard exists, you have exclusive access. When the guard goes out of scope, the lock releases automatically. This is the RAII pattern: Resource Acquisition Is Initialization. The resource is the lock. The initialization is the guard. The cleanup happens when the guard drops.
use std::sync::Mutex;
/// Demonstrates basic Mutex usage for shared mutable access.
fn main() {
// Create a Mutex wrapping an integer.
let counter = Mutex::new(0);
// lock() returns a Result. unwrap() panics on poison.
// The returned guard holds the lock until it drops.
let mut num = counter.lock().unwrap();
// Modify the value while holding the lock.
*num += 1;
// num goes out of scope here, releasing the lock.
}
The guard ties the lock to the stack. Scope ends, lock releases. No manual unlock needed.
How Mutex satisfies the compiler
Rust's type system enforces thread safety through two traits: Send and Sync. A type is Send if it can be moved to another thread. A type is Sync if it can be shared via a reference across threads. Most types are Send. Fewer types are Sync.
A plain i32 is Send but not Sync for mutation. You can move an integer to a thread, but you cannot share a mutable reference to it. If you try to share a Vec across threads without protection, the compiler rejects you with E0277 (trait bound not satisfied). Vec<T> is not Sync. The compiler knows that concurrent mutation of a Vec would corrupt its internal pointers.
Mutex<T> changes the rules. If T is Send, then Mutex<T> is Sync. The compiler sees the Mutex and knows access is serialized. You can share a &Mutex<T> across threads safely. The Mutex acts as a bridge. It takes a type that is only Send and makes it Sync by enforcing exclusive access at runtime.
use std::sync::Mutex;
/// Shows that Mutex<T> is Sync when T is Send.
fn share_counter(counter: &Mutex<i32>) {
// This compiles because Mutex<i32> is Sync.
// The reference can be sent to another thread.
let _ = std::thread::spawn(move || {
// This would fail without the move, but the Mutex allows sharing.
let _guard = counter.lock().unwrap();
});
}
Mutex bridges the gap between Send and Sync. Trust the compiler here. If it accepts the Mutex, the data race is impossible.
Real-world usage with Arc
In practice, you rarely use Mutex alone. You need to share ownership of the data across threads. A Mutex has a single owner by default. To share it, you wrap it in an Arc. Arc stands for Atomic Reference Counted. It allows multiple owners to point to the same heap allocation.
The standard pattern is Arc<Mutex<T>>. The Arc handles ownership. The Mutex handles mutation. You clone the Arc to pass it to threads. The Arc counter keeps the data alive as long as any thread holds a clone.
use std::sync::{Arc, Mutex};
use std::thread;
/// Shared state for a worker pool.
struct SharedState {
count: i32,
}
fn main() {
// Arc allows multiple owners. Mutex allows mutation.
let state = Arc::new(Mutex::new(SharedState { count: 0 }));
let mut handles = vec![];
// Spawn 10 threads, each cloning the Arc.
for _ in 0..10 {
// Convention: use Arc::clone(&state) to signal shallow clone.
// state.clone() looks like a deep copy but is not.
let state = Arc::clone(&state);
let handle = thread::spawn(move || {
// Lock the mutex to mutate the count.
// This blocks if another thread holds the lock.
let mut guard = state.lock().unwrap();
guard.count += 1;
// guard drops here, releasing the lock.
});
handles.push(handle);
}
// Wait for all threads to finish.
for handle in handles {
handle.join().unwrap();
}
// Read the final value.
let final_count = state.lock().unwrap().count;
println!("Final count: {}", final_count);
}
Pair Arc and Mutex like peanut butter and jelly. One shares ownership, the other serializes access.
Pitfalls: Poisoning, Deadlocks, and Async
Mutex is safe, but it introduces runtime risks. The most common issue is poisoning. If a thread panics while holding a lock, the mutex becomes poisoned. Subsequent calls to .lock() return an Err. If you use .unwrap() on the lock, your program panics again. You get a cascade of panics from a single failure.
Poisoning is a design choice. Rust assumes that if a thread panics while holding a lock, the data might be in an inconsistent state. The mutex protects you from using corrupted data. In many applications, panicking on poison is the right behavior. The data is suspect. Continuing could cause subtle bugs.
If you need to recover, handle the error explicitly. You can call .into_inner() on the PoisonError to extract the guard and continue. This signals that you have verified the data is safe despite the panic.
use std::sync::Mutex;
/// Demonstrates handling a poisoned mutex.
fn handle_poison(counter: &Mutex<i32>) {
// lock() returns Result<MutexGuard, PoisonError>.
let guard = match counter.lock() {
Ok(g) => g,
Err(e) => {
// The mutex is poisoned. A thread panicked while holding it.
// We recover the guard to inspect the data.
// This is only safe if you know the panic didn't corrupt the value.
e.into_inner()
}
};
// Use the guard safely.
println!("Value: {}", *guard);
}
Deadlocks are another risk. A deadlock happens when two threads wait for each other's locks forever. Thread A holds Lock 1 and waits for Lock 2. Thread B holds Lock 2 and waits for Lock 1. Neither can proceed. The program freezes.
Deadlocks are silent killers. They do not produce compiler errors. They do not always panic. The program just stops. Avoid deadlocks by establishing a global lock order. Always acquire locks in the same order across all threads. If you need multiple locks, document the order and stick to it.
Async code introduces a third pitfall. std::sync::Mutex blocks the OS thread when waiting for a lock. In an async runtime, blocking the thread can starve other tasks. If you are using tokio or async-std, use their async mutex types instead. They yield the executor while waiting. Using std::sync::Mutex in async code works, but it hurts performance and can cause deadlocks in single-threaded runtimes.
Deadlocks are silent killers. Profile your locks. If a thread stops moving, check your lock order.
Choosing the right synchronization
Rust offers several tools for shared state. Pick the one that matches your access pattern.
Use Mutex<T> when you need shared mutable access across threads and the critical section involves more than a single atomic operation. Use Mutex<T> when you are protecting complex data structures like HashMap or Vec that require exclusive access for mutation. Use RwLock<T> when reads vastly outnumber writes and you want multiple threads to read concurrently. Use AtomicUsize or AtomicBool when you only need to update a single primitive value and want lock-free performance. Use RefCell<T> when you need interior mutability but only within a single thread.
Don't reach for Mutex if an atomic type does the job. Atomics are faster and deadlock-proof.