Choosing Between Arc<Mutex<T>> and Arc<RwLock<T>> for Shared State
Learn when to use Arc<Mutex<T>> versus Arc<RwLock<T>> in Rust. This guide compares exclusive and shared access patterns, explores performance trade-offs, and provides a concrete implementation for shared state.
07 Aug 2026, 17:44 UTC

The Concurrency Decision: Exclusive vs. Shared Access
When managing shared state across multiple threads in Rust, you must wrap your data in an Arc (Atomic Reference Counted) pointer to ensure the data lives long enough, and a synchronization primitive to ensure thread safety. The primary decision is whether to use a Mutex (Mutual Exclusion) or an RwLock (Read-Write Lock).
Choosing the wrong primitive often leads to "lock contention," where threads spend more time waiting for access than performing actual work. While it is tempting to assume RwLock is always faster because it allows multiple readers, the internal bookkeeping required to track those readers introduces overhead that can degrade performance in write-heavy or low-contention scenarios.
Comparison of Synchronization Primitives
| Feature | Arc<Mutex<T>> | Arc<RwLock<T>> |
|---|---|---|
| Access Pattern | Exclusive (One thread at a time) | Shared Reads / Exclusive Writes |
| Overhead | Low (Simple boolean/atomic flag) | Higher (Reader count tracking) |
| Throughput | Consistent, lower peak | High for read-heavy workloads |
| Risk | Simple deadlocks | Writer starvation |
Trade-offs and Decision Logic
Use Arc<Mutex<T>> when:
- The data is updated frequently (write-heavy).
- The critical section (the code holding the lock) is very short, such as updating a counter or pushing to a vector.
- You need the simplest possible synchronization with the lowest CPU overhead per lock acquisition.
Use Arc<RwLock<T>> when:
- Reads occur significantly more often than writes (e.g., a configuration cache).
- The read operations are computationally expensive or take a long time to complete, making exclusive access a major bottleneck.
- You have a large number of concurrent reader threads that would otherwise be serialized by a Mutex.
Implementation Example: Shared Configuration
The following example demonstrates a shared configuration object where multiple threads read settings, but an occasional update is required. This is a classic use case for RwLock.
use std::sync::{Arc, RwLock};
use std::thread;
struct Config {
api_key: String,
timeout: u32,
}
fn main() {
// Wrap the config in Arc for shared ownership and RwLock for shared access
let config = Arc::new(RwLock::new(Config {
api_key: "initial_key".to_string(),
timeout: 30,
}));
let mut handles = vec![];
// Spawn 5 reader threads
for i in 0..5 {
let conf_clone = Arc::clone(&config);
handles.push(thread::spawn(move || {
// .read() allows multiple threads to enter this block simultaneously
let read_guard = conf_clone.read().expect("Lock poisoned");
println!("Thread {} reading timeout: {}", i, read_guard.timeout);
// Lock is dropped here automatically
}));
}
// Spawn 1 writer thread
let conf_clone = Arc::clone(&config);
handles.push(thread::spawn(move || {
// .write() ensures exclusive access; no readers can be active
let mut write_guard = conf_clone.write().expect("Lock poisoned");
write_guard.timeout = 60;
println!("Writer updated timeout to 60");
}));
for handle in handles {
handle.join().unwrap();
}
}
Operational Constraints and Risks
Lock Poisoning: In Rust, if a thread panics while holding a lock, the lock becomes "poisoned." Subsequent attempts to lock it will return an Err. In the example above, .expect("Lock poisoned") is used to crash the program if this occurs, as the data may be in an inconsistent state.
Writer Starvation: std::sync::RwLock does not guarantee a specific priority. If there is a constant stream of readers, a writer may be blocked indefinitely. If your application requires guaranteed write updates, consider using a Mutex or a fairness-oriented lock from a crate like parking_lot.
Async Contexts: Never use std::sync::Mutex or RwLock across an .await point. Doing so can block the entire async executor thread, leading to a deadlock or severe performance collapse. For async code, use tokio::sync::Mutex or tokio::sync::RwLock.
Verification and Testing
To verify that your choice of primitive is appropriate, use the following checks:
- Trait Check: Ensure your shared type implements
SendandSync. The compiler will enforce this, but explicitly checking viafn assert_sync<T: Sync>(_: &T) {}can be useful in generic code. - Contention Profiling: Use a tool like
perfortracyto identify if threads are spending excessive time in__lll_lock_wait(Linux). - Benchmarking: Use the
criterioncrate to measure the latency of a read-heavy loop with aMutexversus anRwLock. If theMutexversion is faster, theRwLockoverhead is outweighing the concurrency benefits.
Rollback: To switch from RwLock to Mutex, replace .read().unwrap() and .write().unwrap() calls with a single .lock().unwrap() call. Since both return a guard that implements Deref, the internal logic accessing the data remains unchanged.