Channels let one thread own a result collection while other threads send it updates. Sometimes several threads need to access the same long-lived object: a cache, an inventory or a progress counter. That is shared state.
Two separate questions govern the design: who keeps the object alive, and who may access or change it now? Arc answers the first question. Locks and atomic operations answer the second. Rust prevents data races in safe code, but you must still prevent deadlocks, incorrect state transitions and unnecessarily serialized work.
Run the examples
cargo new shared-state-lab --edition 2024
cd shared-state-lab
Each complete Rust block independently replaces src/main.rs; run it with cargo run. All examples use the standard library and join their workers before checking results.
Arc shares ownership across threads
Arc stands for atomically reference counted. Its ownership bookkeeping is safe to update concurrently. Cloning an Arc creates another owner of the same allocation, without cloning the inner data.
use std::sync::Arc;
use std::thread;
fn main() {
let names = Arc::new(vec!["Ada", "Grace", "Linus"]);
let mut handles = Vec::new();
for _ in 0..2 {
let shared = Arc::clone(&names);
handles.push(thread::spawn(move || {
shared.iter().map(|name| name.len()).sum::<usize>()
}));
}
for handle in handles {
assert_eq!(handle.join().expect("reader panicked"), 13);
}
assert_eq!(names.len(), 3);
println!("both readers counted 13 bytes");
}
No lock is needed because every thread only reads immutable data. If the workers only lived inside a scope, borrowed references could avoid reference counting too.
Arc<T> is Send and Sync when T satisfies the relevant Send + Sync bounds. It cannot turn a non-thread-safe type into a thread-safe one. In particular, Arc<RefCell<T>> cannot provide shared mutation across threads because RefCell is not Sync. See Arc's thread-safety rules.
Send and Sync describe different permissions
T: Sendmeans a value of typeTcan safely transfer ownership to another thread.T: Syncmeans shared references toTcan safely cross threads; equivalently,&TisSend.
These are auto traits: the compiler derives them from a type's fields when appropriate. A struct made of ordinary thread-safe owned values usually needs no manual implementation. Rc is neither Send nor Sync; RefCell<T> can be Send when T is Send, but is not Sync. Moving a cell to one new owner is different from sharing it concurrently.
Mutex<T> can be shared when T: Send, because its lock mediates access to that value. Do not add unsafe impl Send or unsafe impl Sync to silence an error. Those implementations promise a safety proof that the compiler cannot check. The Book explains Send and Sync.
Mutex guards exclusive access
A mutex allows one thread at a time to access protected data. lock() waits for access and returns a MutexGuard. The guard gives access to the data; dropping it unlocks the mutex, including during ordinary unwinding.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0_u32));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..250 {
let mut value = counter.lock().expect("counter poisoned");
*value += 1;
}
}));
}
for handle in handles {
handle.join().expect("counter worker panicked");
}
let total = *counter.lock().expect("counter poisoned");
assert_eq!(total, 1_000);
println!("count: {total}");
}
Output: count: 1000.
Arc lets each closure own a handle. Mutex gives one closure exclusive access at a time. The guard is a local value, so the lock is released at the end of each loop iteration. See the Book's shared-state chapter.
Lock the entire invariant
An invariant is a relationship that must remain true whenever other code observes the state. Suppose an inventory begins with 100 tickets. A sale must decrement remaining and increment sold together, keeping their sum equal to 100.
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Debug)]
struct Inventory {
remaining: u32,
sold: u32,
}
impl Inventory {
fn reserve_one(&mut self) -> bool {
if self.remaining == 0 {
return false;
}
self.remaining -= 1;
self.sold += 1;
true
}
}
fn main() {
let inventory = Arc::new(Mutex::new(Inventory {
remaining: 100,
sold: 0,
}));
let mut workers = Vec::new();
for _ in 0..4 {
let shared = Arc::clone(&inventory);
workers.push(thread::spawn(move || {
let mut successes = 0;
for _ in 0..40 {
let reserved = {
let mut state = shared.lock().expect("inventory poisoned");
state.reserve_one()
};
if reserved {
successes += 1;
}
}
successes
}));
}
let successes: u32 = workers
.into_iter()
.map(|worker| worker.join().expect("reservation worker panicked"))
.sum();
let state = inventory.lock().expect("inventory poisoned");
assert_eq!(successes, 100);
assert_eq!(state.remaining, 0);
assert_eq!(state.remaining + state.sold, 100);
println!("sold: {}", state.sold);
}
Output: sold: 100. Which worker sells the last ticket is intentionally unspecified.
Checking availability under one lock and decrementing under a later lock would leave a gap where another worker could consume the last ticket. Keep the check and update in one critical section, the part of the program protected by a lock.
Do expensive parsing, network calls and formatting outside that section. If each worker can compute a local total and send or return it once, prefer that over locking once per record. Shared state is a design choice, not a requirement for parallel work.
Poisoning is an error policy
If a thread panics while holding a standard mutex, later lock attempts will normally report poisoning. This warns that an update may have stopped halfway through. Poisoning is advisory, not a complete proof of whether the inner data is valid.
The previous examples propagate the failure with expect. Recovery is possible only if your code can validate or replace the state. Here replacing an expendable cache has a clear meaning:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let cache = Arc::new(Mutex::new(vec![1, 2]));
let worker_cache = Arc::clone(&cache);
let worker = thread::spawn(move || {
let mut entries = worker_cache.lock().expect("initial lock");
entries.push(99);
panic!("simulated interrupted cache rebuild");
});
assert!(worker.join().is_err());
match cache.lock() {
Ok(_) => panic!("the demonstration should poison this mutex"),
Err(poisoned) => {
let mut entries = poisoned.into_inner();
entries.clear();
cache.clear_poison();
}
}
assert!(cache.lock().expect("cache repaired").is_empty());
println!("cache reset");
}
The panic hook prints an intentional worker panic to standard error; the program catches the join error, repairs the cache, prints cache reset, and exits successfully. Run this with the normal unwinding panic strategy. With panic = "abort", the process would terminate instead.
Do not apply into_inner() blindly to business records whose invariants you cannot reconstruct. See Mutex poisoning.
RwLock allows simultaneous readers
RwLock permits multiple read guards or one write guard. It can suit read-heavy data, but it costs coordination and is not automatically faster than a mutex.
use std::sync::RwLock;
fn main() {
let settings = RwLock::new(String::from("draft"));
{
let first = settings.read().expect("settings poisoned");
let second = settings.read().expect("settings poisoned");
assert_eq!(*first, *second);
}
{
let mut writer = settings.write().expect("settings poisoned");
*writer = String::from("published");
}
assert_eq!(
settings.read().expect("settings poisoned").as_str(),
"published"
);
}
The scopes release both readers before asking for a writer. Trying to acquire a write lock while keeping a read guard in the same thread can deadlock. There is no portable fairness guarantee for which waiting reader or writer acquires this lock next. See RwLock.
Atomics for an independent counter
An atomic operation updates or observes a supported value indivisibly. A metric that only counts completed events may not need a mutex:
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let completed = Arc::new(AtomicUsize::new(0));
let mut workers = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&completed);
workers.push(thread::spawn(move || {
for _ in 0..250 {
counter.fetch_add(1, Ordering::Relaxed);
}
}));
}
for worker in workers {
worker.join().expect("metric worker panicked");
}
assert_eq!(completed.load(Ordering::Relaxed), 1_000);
}
fetch_add is one read-modify-write operation. A separate atomic load followed by store(old + 1) can still lose updates.
Relaxed is sufficient here because the counter is independent; it publishes no other data. Joining the workers establishes completion before the final check. Memory ordering specifies how an atomic operation relates to other memory accesses. A flag saying that a separate data structure is ready needs a different synchronization argument, commonly release/acquire operations or a lock. Even SeqCst does not make updates to several fields one transaction. Start with locks when the invariant spans several values. See atomic Ordering.
Idioms and pitfalls
- Minimize the data inside one lock while keeping each invariant together. One lock per field can be as wrong as one enormous global lock.
- Never hold a lock while joining a worker that needs the same lock.
- With multiple locks, establish one acquisition order and follow it everywhere. Rust's type system does not detect general wait cycles.
- Name guards and use visible scopes. A temporary guard can remain alive longer than an expression makes obvious.
- Never hold a blocking lock guard across
.await; restructure the code or choose an appropriate runtime-aware primitive after understanding its rules. - Check results after synchronization. Repeated successful runs cannot prove that a race or deadlock is impossible.
Exercises
1. Accumulate once per worker
Three workers sum [1, 2, 3], [4, 5] and [6]. Compute each sum outside a shared Mutex, then add it under one lock. Assert that the final total is 21.
2. Explain the oversell
A worker reads remaining > 0, drops its guard, charges a customer, then locks again and subtracts one. Why is this wrong even though every access is protected? What additional design issue does charging introduce?
3. Choose synchronization
Choose between immutable Arc, Arc<Mutex<_>>, and an atomic for: a read-only dictionary used by independent workers; a pair of balances that must change together; a count that does not signal availability of any other data.
Solutions
1. Reduce before locking
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let total = Arc::new(Mutex::new(0_i32));
let mut workers = Vec::new();
for batch in [vec![1, 2, 3], vec![4, 5], vec![6]] {
let total = Arc::clone(&total);
workers.push(thread::spawn(move || {
let local: i32 = batch.into_iter().sum();
*total.lock().expect("total poisoned") += local;
}));
}
for worker in workers {
worker.join().expect("sum worker panicked");
}
assert_eq!(*total.lock().expect("total poisoned"), 21);
}
Returning each local sum through a join handle would remove the shared mutex entirely. The exercise uses it to show how to shorten a necessary critical section.
2. Protect the transition
Another worker can reserve the last ticket between the check and subtraction. Reserve under a single lock before performing external work. A real charge can fail or be repeated, so model reservation, payment confirmation and cancellation explicitly with an idempotent external protocol. Holding an in-process mutex over a network request does not make a distributed transaction.
3. Match the invariant
Use an immutable Arc<Dictionary>, one Arc<Mutex<Balances>> containing both balances, and an AtomicUsize for the independent count. Atomics do not protect arbitrary related data.
Key takeaways
Arckeeps data alive; synchronization controls access. They solve separate problems.- A guard's lifetime determines how long a lock stays held.
- Protect complete state transitions, and design lock order and failure recovery deliberately.
- Use atomics for small states with a clear ordering argument; use local computation and result passing when sharing is unnecessary.
Next lesson
Continue with Async and futures in Rust, where waiting tasks can share an execution thread.

