Shared-State Concurrency
How to safely share mutable data across multiple threads in Rust using Mutex, Arc, and the Send and Sync traits
Message passing, covered in the previous section, treats data as something you hand off from one thread to another. Once sent, the sender no longer touches it. That model maps neatly to Rust's ownership rules: you transfer ownership through a channel and the original thread can't use the value anymore.
Shared-state concurrency works the other way. Multiple threads keep access to the same piece of data and read or modify it in place. This is like multiple ownership across threads: several parts of the program own a reference to the same memory at the same time. The challenge is making sure two threads don't write simultaneously or one read while another writes, because that would produce garbage or crashes.
In many languages this requires careful manual locking, and mistakes show up as intermittent runtime bugs that are hard to reproduce. Rust uses the type system to prevent data races at compile time, but it still gives you the tools to share state when you need to. The two main building blocks are Mutex<T> for mutual exclusion and Arc<T> for thread-safe reference counting, supported by the Send and Sync marker traits that enforce what can cross thread boundaries.
How a Mutex Works
A mutex (mutual exclusion) guarantees that only one thread at a time can access the data it guards. To read or write that data, a thread must first acquire the mutex's lock. If another thread already holds it, the requesting thread blocks until the lock is released. The mutex does not store the data itself; it owns the data and hands out a temporary guard that allows access while the lock is held.
A good mental model is a panel discussion with a single microphone. Only the person holding the microphone can speak. Others who want to speak must wait until the microphone is handed over. If someone forgets to pass the microphone, the whole discussion stalls. Mutexes have the same two rules: always acquire the lock before touching the data, and always release it when you're done.
In most languages, forgetting to unlock a mutex is a common and dangerous bug. Rust solves this by tying the lock release to a value's lifetime.
The Mutex<T> API in a Single Thread
Before involving threads, it helps to see how Mutex<T> behaves when only one thread exists. The standard library provides std::sync::Mutex.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
} // lock released here automatically
println!("m = {:?}", m);
}
Mutex::new(5) creates a mutex holding the integer 5. You cannot access the inner value directly: m is a Mutex<i32>, not an i32. To reach the integer you call m.lock(), which returns a LockResult<MutexGuard<i32>>. The unwrap here will panic only if another thread panicked while holding the lock—a situation called "poisoning," which we'll come back to.
The MutexGuard implements Deref and DerefMut, so you can treat num like a regular mutable reference. When num goes out of scope at the end of the inner block, its Drop implementation releases the lock automatically. This is the critical detail: you cannot forget to unlock, because the unlock is tied to the guard's lifetime. After the guard is dropped, the lock is available again and printing m shows the updated value 6.
Automatic Unlock:
The lock is released the moment the MutexGuard goes out of scope, even if an early return, a panic, or an ? error propagation causes the scope to exit unexpectedly. This eliminates a whole class of deadlock bugs that come from forgotten manual unlocks.
Sharing the Mutex Across Threads
A single-threaded mutex is not very useful. The goal is to let multiple threads access the same Mutex<T>. A first attempt might look like this:
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
This does not compile. The error message states that counter was moved into the closure in the first iteration and cannot be used again in the second. Each thread::spawn takes a move closure that takes ownership of the variables it captures. The loop tries to give ownership of counter to ten different threads, which is impossible.
The compiler error is telling you something fundamental: a value in Rust can only have one owner. To share the mutex among threads, you need multiple ownership.
Arc: Thread-Safe Reference Counting
In single-threaded code, you can give a value multiple owners with Rc<T>, the reference-counted pointer from Chapter 15. But wrapping the mutex in Rc fails with a different error:
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
The trait Send is not implemented for Rc<T>. Rc maintains its reference count with non-atomic operations. If two threads cloned and dropped an Rc at the same time, the counter could become corrupted—one thread might read a stale count while another is writing it, leading to a memory leak or a double-free. Rust refuses to let this happen.
The thread-safe alternative is std::sync::Arc<T>, which stands for Atomic Reference Counting. It uses atomic operations to update the count, which are safe to perform from multiple threads simultaneously. The trade-off is a small runtime cost compared to Rc, but the safety guarantee is worth it.
The Arc<Mutex<T>> Pattern
Combining Arc and Mutex gives you a value that multiple threads can own and that only one thread at a time can mutate. This is the standard building block for shared-state concurrency in Rust.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Every iteration of the loop calls Arc::clone(&counter) to produce a new owning handle to the same Arc. The cloned Arc is moved into the thread's closure. Each thread acquires the lock, increments the integer, and releases the lock when the MutexGuard goes out of scope. After all threads finish and the handles are joined, the main thread locks the mutex one last time to read the final count.
The output is always Result: 10. There is no way for two threads to increment the counter simultaneously and lose an update, because the mutex serializes access. And because the type system enforces that you cannot access the inner value without locking, you cannot accidentally read or write the data outside the critical section.
Using Arc for Read-Only Data:
If multiple threads only need to read the data, not mutate it, you can use Arc<T> without a mutex. Arc provides shared read access directly. The Mutex is only necessary when at least one thread needs to write.
How the Send and Sync Traits Guarantee Safety
Rust's concurrency guarantees are not built into the compiler as special cases for threads and mutexes. They come from two marker traits in the standard library: Send and Sync. These are the mechanism that makes "fearless concurrency" possible, and they also make the safety rules extensible to any type you define.
A type is Send if ownership of values of that type can be transferred between threads. Most types are Send automatically. A type is not Send if it wraps something tied to a specific thread, like Rc<T> (non-atomic reference count) or a raw pointer without synchronization. The compiler refuses to send a non-Send type to another thread via thread::spawn or through a channel.
A type is Sync if it is safe to share a reference to it between threads. In other words, &T must implement Send — if a reference can be sent to another thread, then multiple threads can read the data through that reference concurrently. Primitives like i32 and bool are Sync, as are most immutable types. A type fails to be Sync if interior mutability without synchronization is exposed, like RefCell<T> or Cell<T>, which allow mutation through a shared reference without atomic operations.
Mutex<T> is Sync if T is Send. The mutex enforces mutual exclusion on its own, so sharing a reference to a Mutex across threads is safe. Arc<T> is Send if T is Send + Sync, and Sync if T is Send + Sync. These rules compose to guarantee that Arc<Mutex<i32>> is both Send and Sync, which is why the pattern works.
These traits are auto-derived by the compiler for most types. For unsafe code or FFI wrappers, you may need to implement them manually with unsafe impl Send for MyType {}. That's an advanced topic, but the key insight is that the entire thread-safety framework rests on two simple, composable traits that the compiler checks automatically.
Not All Shared-State Types Are Sync:
RefCell<T> and Cell<T> are useful in single-threaded scenarios, but they are not Sync. If you try to wrap them in an Arc and share across threads, the compiler will stop you. For thread-safe interior mutability with read/write access, use RwLock<T> instead of RefCell, or stick with Mutex<T>.
When Locking Goes Wrong: Poisoned Mutexes
If a thread panics while holding a Mutex lock, the mutex becomes poisoned. This means the data it guards may be in an inconsistent state — the thread was halfway through a modification when it panicked. To protect the rest of the program from reading corrupted data, the mutex marks itself as poisoned.
When you call lock() on a poisoned mutex, it still returns a LockResult, but the unwrap() will propagate the panic. The idea is that you should explicitly decide how to handle the situation: you might choose to unwind the entire task, or you might inspect the error and decide the data is still usable. You can call lock().unwrap_or_else(|poisoned| poisoned.into_inner()) to recover the guard and proceed, but this should only be done when you are confident the partially modified state is safe.
let counter = Arc::new(Mutex::new(0));
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num = 42;
panic!("oops");
});
let _ = handle.join();
// The mutex is now poisoned.
match counter.lock() {
Ok(mut num) => println!("Unlocked and valid: {}", *num),
Err(poisoned) => {
let mut num = poisoned.into_inner();
println!("Recovered poisoned mutex. Current value: {}", *num);
*num = 0; // reset to a safe state
}
}
Poisoning Is Not a Theoretical Concern:
Any unwrap() on a lock acquisition in production code will crash the entire thread if the mutex was poisoned by another thread's panic. In long-running server applications, consider handling LockResult explicitly or using a supervision strategy where poisoned state triggers a controlled restart of a worker rather than a full process abort.
Common Mistakes and How Rust Prevents Them
Even though Rust eliminates data races at compile time, shared-state concurrency still carries a few pitfalls that the compiler cannot catch. Recognizing them upfront prevents painful debugging sessions later.
Deadlocks. A deadlock happens when two threads each hold a lock the other needs. For example, Thread A locks mutex a and then tries to lock mutex b, while Thread B locks b and then tries to lock a. Both threads block forever. Rust cannot detect this at compile time; it's a runtime logic error. The best defense is to avoid holding multiple locks simultaneously. If you must, always acquire them in a consistent global order across all threads.
Holding a Mutex Guard Across an .await Point. The standard Mutex is not designed for async code. If you hold a MutexGuard across an .await in an async function, the lock remains held while the task yields control, potentially blocking other tasks for long periods. The tokio runtime provides an async-aware Mutex for this scenario, but the standard library's Mutex should not be used in async contexts for long critical sections.
Forgetting to Release the Lock. In Rust this is essentially impossible because the MutexGuard drops the lock when it goes out of scope. The one subtle case is when you explicitly leak the guard with std::mem::forget or store it in a static variable. You should never do this intentionally, and clippy will warn about it.
Using Non-Sync Types Across Threads. The compiler catches attempts to share Rc, RefCell, or raw pointers. The fix is to replace Rc with Arc and RefCell with Mutex, RwLock, or atomic types.
Prefer Atomics for Simple Counters:
For simple shared counters where the only operation is increment, decrement, or load/store, use std::sync::atomic types like AtomicI32 instead of Mutex<i32>. Atomics are lock-free and significantly faster. The Arc<Mutex<T>> pattern is for when the data is more complex than a single integer or the operations must be done transactionally.
Choosing Between Message Passing and Shared State
Both models have strengths, and Rust supports both without imposing a single philosophy. Message passing tends to produce code that is easier to reason about: each piece of data has a clear owner at any point, and threads communicate by sending values rather than mutating a shared location. This maps directly to Rust's ownership model and often requires less synchronization machinery.
Shared state is a better fit when multiple threads genuinely need to read and write the same data structure with low latency, or when the state is large and copying it to send over a channel would be expensive. An in-memory cache shared across worker threads, a connection pool, or a concurrent hash map all fall into this category.
The real advantage of Rust's approach is that you can mix both styles in the same program without fear. The type system ensures that any shared state is correctly synchronized, and channels guarantee that transferred values cannot be accessed afterward. You are not forced into one paradigm.
Summary
Shared-state concurrency in Rust is built on a small set of primitives that compose with the ownership system. Mutex<T> guarantees exclusive access to its inner data, and Arc<T> allows multiple threads to co-own the mutex safely. The Send and Sync traits ensure that only thread-safe types can cross thread boundaries, and they apply to both standard library types and your own.
The result is that data races — the most feared concurrency bug — are detected at compile time rather than manifesting as sporadic runtime failures. The compiler does not prevent deadlocks or semantic logic errors, but it removes the class of errors that make concurrency terrifying in other languages. With this foundation, you can explore more advanced patterns: read-write locks for frequent reads and rare writes, lock-free atomics for high-performance counters, and async-aware synchronization primitives for non-blocking workloads.