Rust Shared-State Concurrency: Mutex and Arc Tutorial
Shared-state concurrency allows multiple threads to directly access and modify the same data. Unlike message passing, which sends data ownership between threads, shared-state concurrency requires synchronization primitives to prevent data races and race conditions. Rust provides Mutex<T> for mutual exclusion and Arc<T> for thread-safe shared ownership, allowing you to safely coordinate access to mutable data across threads without garbage collection or lock corruption.
Understanding Shared-State Concurrency
In shared-state concurrency, multiple threads hold references to the same data and coordinate their access. Two concepts are essential:
Mutual Exclusion (Mutex): A Mutex<T> acts as a lock that ensures only one thread can access the protected data at any given moment. Other threads block until the lock is released, preventing simultaneous mutations that would cause data corruption.
Atomic Reference Counting (Arc): An Arc<T> is a thread-safe variant of Rc<T> that uses atomic operations for reference counting. It allows multiple threads to own the same value without moving or copying it.
When to use shared-state vs. message passing
| Scenario | Use Shared-State | Reason |
|---|---|---|
| Multiple threads need frequent read/write access to shared data | Mutex<T> + Arc<T> | Direct access is simpler than channel coordination. |
| Threads do independent work, synchronize rarely | Message passing | Cleaner separation of concerns; fewer lock contentions. |
| Implementing high-performance caches or counters | Mutex<T> | Avoids channel overhead. |
| Producer-consumer pipelines | Message passing | Natural fit; ownership transfer via channels. |
Using Mutex<T> to Protect Shared Data
A Mutex<T> guards data by allowing only one thread to access it at a time. The lock is acquired via the lock() method, which blocks until the lock is available.
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
// Acquire the lock and get a mutable reference to the data
let mut num = counter.lock().unwrap();
*num += 1;
println!("Counter: {}", *num); // Counter: 1
} // Lock is released when `num` goes out of scope
The .lock() method returns a Result<MutexGuard<T>, PoisonError>. MutexGuard is a smart pointer that:
- Dereferences to the data via
*operator. - Automatically releases the lock when dropped (goes out of scope).
- Prevents dangling references to locked data—the guard must exist for the data to be accessed.
use std::sync::Mutex;
let value = Mutex::new(5);
{
let mut guard = value.lock().unwrap();
*guard += 10;
} // Lock released here when guard is dropped
// Safe to acquire lock again
let final_value = value.lock().unwrap();
println!("{}", *final_value); // 15
Combining Arc<T> and Mutex<T> for Multi-Threaded Sharing
To share mutable data across threads, combine Arc<T> (for shared ownership) and Mutex<T> (for synchronized access). Rc<T> is single-threaded and will not compile with threads; Arc<T> is the thread-safe alternative.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Wrap the counter in Mutex for synchronized access,
// then wrap in Arc for shared thread-safe ownership
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
// Spawn 10 threads, each incrementing the counter
for _ in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
// Wait for all threads to finish
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap()); // Final count: 10
}
Step-by-step breakdown
Arc::new(Mutex::new(0))— Create an atomic reference-counted mutex wrapping the counter.Arc::clone(&counter)— Clone the arc (not the data) for each thread. Arc clones are cheap; they only increment a reference count.counter_clone.lock().unwrap()— Acquire the lock, blocking if another thread holds it.unwrap()panics if the lock is poisoned (a thread panicked while holding the lock).*num += 1— Safely modify the protected data.- Auto-drop — When
numleaves scope, the lock is released, allowing other threads to acquire it.
Common Patterns and Gotchas
Deadlocks
A deadlock occurs when a thread tries to acquire a lock it already holds, or when two threads wait for each other's locks. Rust cannot prevent logical deadlocks at compile time.
// DEADLOCK: Thread holds lock, tries to acquire it again
let mutex = Arc::new(Mutex::new(0));
let m = mutex.lock().unwrap();
let m2 = mutex.lock().unwrap(); // Blocks forever—current thread holds the lock
Prevention: Keep critical sections short; never try to acquire the same lock twice.
Poisoned locks
If a thread panics while holding a mutex lock, Rust marks the mutex as "poisoned" to prevent other threads from continuing with possibly corrupted data. Future lock attempts return Err.
let mutex = Arc::new(Mutex::new(vec![]));
{
let mut guard = mutex.lock().unwrap();
panic!("oops!");
// Lock is now poisoned
}
// Later, this returns an error
match mutex.lock() {
Ok(guard) => println!("Acquired lock"),
Err(poisoned) => println!("Lock was poisoned—data may be corrupted"),
}
Practice: Use unwrap() on lock results only in main; in libraries, use match or .unwrap_or_else() for graceful handling.
Performance considerations
Holding locks too long blocks other threads, causing contention and poor performance.
// BAD: Lock is held during expensive I/O
let mutex = Arc::new(Mutex::new(data));
let mut guard = mutex.lock().unwrap();
expensive_io_operation(); // Other threads blocked!
*guard = new_value;
// GOOD: Lock only around the shared data mutation
expensive_io_operation();
let mut guard = mutex.lock().unwrap();
*guard = new_value; // Lock released immediately after
Mutex vs. RefCell
| Feature | Mutex<T> | RefCell<T> |
|---|---|---|
| Thread-safe | Yes (atomics) | No |
| Borrow checking | Runtime (panics if locked) | Runtime (panics on double-borrow) |
| Lock acquisition | Blocking | Immediate |
| Use case | Multi-threaded shared state | Single-threaded interior mutability |
Key Takeaways
Mutex<T>provides mutual exclusion: only one thread accesses the data at a time; the lock is automatically released when the guard drops.Arc<T>provides thread-safe shared ownership; each clone increments an atomic reference count.Arc<Mutex<T>>is the standard pattern for shared mutable state across threads.- Keep critical sections short to avoid contention and potential deadlocks.
- Handle poisoned locks gracefully; a poisoned lock indicates a panic occurred while holding it.
- Atomic operations guarantee that reference counting itself is thread-safe; the data inside the mutex is protected by mutual exclusion.
Frequently Asked Questions
Why do I need to call .unwrap() on lock()?
lock() returns a Result because the mutex might be poisoned (a thread panicked while holding the lock). In most cases, you .unwrap() to panic if poisoned; in production code, handle the error gracefully.
What is the performance cost of Arc and Mutex?
Arc cloning is O(1) and uses atomic operations, which are extremely fast. Mutex::lock() involves atomic operations and may cause OS-level thread blocking, which is slower than unguarded access. Keep critical sections small.
Can I use Mutex instead of channels?
For simple shared-state coordination, yes. But channels are better for producer-consumer patterns and separating concerns. Shared-state is better for frequent read/write access to a small shared value.
What happens if I drop a MutexGuard early?
The lock is released immediately—useful for reducing critical section length:
{
let mut guard = mutex.lock().unwrap();
*guard = new_value;
} // Lock released here
// Safe code here; no lock held
Can multiple threads call lock() simultaneously?
Yes. All threads call lock() and block until the lock is available. The OS scheduler wakes them up one at a time. Only one thread executes the critical section at any moment.