Shared-State Concurrency
Rust offers two complementary approaches to concurrency. The first — message passing with channels — moves data between threads without sharing it. The second — shared state — lets multiple threads access the same piece of memory, but controls that access with synchronisation primitives so only one thread mutates it at a time.
Both are safe in Rust. The type system and ownership rules prevent data races in either model, at compile time.
Approach | Mechanism | Best for |
|---|---|---|
Message passing | Channels (mpsc) | Pipelines, producer/consumer, decoupled threads |
Shared state | Mutex, RwLock, Atomics | Shared configuration, caches, counters |
Mutex<T>: Mutual Exclusion
A Mutex (mutual exclusion lock) ensures that only one thread can access the inner data at a time. Before reading or writing, a thread must acquire the lock. When it is done, it releases the lock so another thread can proceed.
In Rust, Mutex is part of the standard library at std::sync::Mutex. The lock
is acquired by calling .lock(), which blocks until the lock is available and
returns a MutexGuard<T>. The guard gives you mutable access to the inner value
and automatically releases the lock when it goes out of scope — this is the RAII
pattern and it means you can never forget to unlock.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut val = m.lock().unwrap(); // acquire the lock
*val += 1;
println!("val = {}", *val); // prints 6
} // MutexGuard dropped here — lock is released automatically
println!("m = {:?}", m);
}val = 6
m = Mutex { data: 6, poisoned: false, .. }.lock() returns a Result because a mutex can be "poisoned" — if a thread panics while holding the lock, the mutex is marked poisoned so other threads know the data may be in an inconsistent state..unwrap() is fine in examples; in production, handle the error.Arc<Mutex<T>>: Sharing Across Threads
A plain Mutex cannot be sent to multiple threads because ownership can only exist
in one place. The solution is to wrap it in an Arc (Atomically Reference Counted)
so that multiple threads can hold a reference-counted handle to the same mutex.
Arc<T> is the thread-safe equivalent of Rc<T>. Cloning an Arc increments
the reference count atomically; when the last clone is dropped, the inner value
is freed.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Wrap the counter so it can be shared across threads
let counter = Arc::new(Mutex::new(0usize));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter); // cheap reference-count bump
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()); // 10
}Result: 10
Arc::clone(&counter) before the spawn is idiomatic. It makes clear you are cloning the Arc (bumping the reference count), not deep-copying the data inside.RwLock<T>: Multiple Readers or One Writer
A Mutex gives exclusive access to every caller — even threads that only want to
read must wait for each other. An RwLock (read-write lock) is more nuanced:
- Many threads can hold a read lock simultaneously — they share immutable access.
- Only one thread can hold a write lock — no readers are allowed at the same time.
This makes RwLock a better choice for read-heavy workloads where reads are
frequent but writes are rare, such as a shared configuration object.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let config = Arc::new(RwLock::new(vec!["timeout=30", "retries=3"]));
// Spawn several reader threads — they all run concurrently
let mut handles = vec![];
for i in 0..4 {
let cfg = Arc::clone(&config);
handles.push(thread::spawn(move || {
let data = cfg.read().unwrap(); // multiple readers allowed
println!("reader {} sees {} entries", i, data.len());
}));
}
// Spawn one writer thread — waits until all readers finish
let cfg = Arc::clone(&config);
handles.push(thread::spawn(move || {
let mut data = cfg.write().unwrap(); // exclusive access
data.push("loglevel=info");
println!("writer added entry, total: {}", data.len());
}));
for h in handles {
h.join().unwrap();
}
}reader 0 sees 2 entries reader 1 sees 2 entries reader 2 sees 2 entries reader 3 sees 2 entries writer added entry, total: 3
Lock type | Read guard | Write guard | Concurrency |
|---|---|---|---|
Mutex<T> | MutexGuard<T> | MutexGuard<T> | One thread at a time |
RwLock<T> | RwLockReadGuard<T> | RwLockWriteGuard<T> | Many readers OR one writer |
Mutex if fairness matters more than read concurrency.Atomic Types: Lock-Free Primitives
For simple values — counters, flags, single integers — reaching for a Mutex is
overkill. The std::sync::atomic module provides atomic types that can be
read and modified across threads without any lock:
AtomicUsize,AtomicIsize,AtomicI32,AtomicU64AtomicBoolAtomicPtr<T>
Atomic operations are implemented with CPU-level instructions (e.g., x86 LOCK XADD)
that guarantee the operation completes without interruption.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let counter = Arc::new(AtomicUsize::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
c.fetch_add(1, Ordering::SeqCst); // atomic increment
}));
}
for h in handles {
h.join().unwrap();
}
println!("Counter: {}", counter.load(Ordering::SeqCst)); // 10
}Counter: 10
Common Atomic Operations
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
fn main() {
let n = AtomicUsize::new(10);
// load / store
println!("load: {}", n.load(Ordering::Relaxed));
n.store(20, Ordering::Relaxed);
// fetch_add / fetch_sub — returns the OLD value
let old = n.fetch_add(5, Ordering::SeqCst);
println!("old was {}, now {}", old, n.load(Ordering::SeqCst)); // 20, 25
// compare_exchange(expected, new, success_ordering, failure_ordering)
// Only swaps if the current value equals expected
let result = n.compare_exchange(25, 100, Ordering::SeqCst, Ordering::Relaxed);
println!("CAS result: {:?}", result); // Ok(25) — swap succeeded
println!("n is now: {}", n.load(Ordering::SeqCst)); // 100
// AtomicBool for flags
let flag = AtomicBool::new(false);
flag.store(true, Ordering::Release);
println!("flag: {}", flag.load(Ordering::Acquire));
}load: 10 old was 20, now 25 CAS result: Ok(25) n is now: 100 flag: true
Memory Ordering
Every atomic operation requires a memory ordering argument that tells the CPU and compiler how much synchronisation is needed around the operation. Choosing the wrong ordering can lead to subtle bugs on multi-core systems.
Ordering | Guarantee | Use when |
|---|---|---|
Relaxed | Only the atomic op itself is atomic — no ordering relative to other memory ops | Simple counters where only the final value matters |
Acquire | All reads/writes after this load see memory written before the paired Release store | Reading a flag that guards other data |
Release | All reads/writes before this store are visible to threads that do an Acquire load | Writing data then setting a ready flag |
AcqRel | Combines Acquire + Release — for read-modify-write ops like fetch_add | fetch_add, compare_exchange |
SeqCst | Total sequential consistency across all atomic ops in all threads | When in doubt — strongest guarantee, slight overhead |
Ordering::SeqCst. It is the strongest ordering and is always correct, at the cost of a small performance penalty. Optimise to weaker orderings only when you understand the memory model well.Shared Configuration with Arc<RwLock<T>>
A frequently read, occasionally updated configuration object is a classic use case
for Arc<RwLock<Config>>. Readers take a read lock (concurrent), the updater
takes the write lock (exclusive).
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
type Config = HashMap<String, String>;
fn read_config(cfg: &Arc<RwLock<Config>>, key: &str) -> Option<String> {
let guard = cfg.read().unwrap();
guard.get(key).cloned()
}
fn update_config(cfg: &Arc<RwLock<Config>>, key: &str, value: &str) {
let mut guard = cfg.write().unwrap();
guard.insert(key.to_string(), value.to_string());
}
fn main() {
let config: Arc<RwLock<Config>> = Arc::new(RwLock::new(HashMap::new()));
update_config(&config, "host", "localhost");
update_config(&config, "port", "8080");
println!("host = {:?}", read_config(&config, "host"));
println!("port = {:?}", read_config(&config, "port"));
}host = Some("localhost")
port = Some("8080")Thread-Safe Cache with Arc<Mutex<HashMap>>
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let cache: Arc<Mutex<HashMap<u32, String>>> =
Arc::new(Mutex::new(HashMap::new()));
let mut handles = vec![];
for i in 0..5u32 {
let cache = Arc::clone(&cache);
handles.push(thread::spawn(move || {
let value = format!("result_{}", i * i);
cache.lock().unwrap().insert(i, value);
}));
}
for h in handles {
h.join().unwrap();
}
let cache = cache.lock().unwrap();
let mut keys: Vec<u32> = cache.keys().copied().collect();
keys.sort();
for k in keys {
println!("{} => {}", k, cache[&k]);
}
}0 => result_0 1 => result_1 2 => result_4 3 => result_9 4 => result_16
Deadlock Avoidance
A deadlock occurs when two or more threads each hold a lock the other needs, and all are waiting indefinitely. Rust does not prevent deadlocks at compile time — they are a logical error, not a memory-safety issue. Follow these strategies to avoid them.
Always acquire locks in the same order. If every thread locks A then B (never B then A), no cycle can form.
Minimize lock scope. Hold locks for the shortest time possible. Drop the guard before doing I/O or calling external code.
Prefer fine-grained locks. Lock individual fields rather than a giant struct — reduces contention and shrinks the window for deadlocks.
Use
try_lock()when possible.Mutex::try_lock()returns immediately withErrif the lock is taken — retry or bail out rather than block forever.Prefer message passing when the design allows it. Channels eliminate shared mutable state entirely, making deadlocks impossible for those code paths.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let lock_a = Arc::new(Mutex::new(0u32));
let lock_b = Arc::new(Mutex::new(0u32));
// SAFE: both threads acquire lock_a then lock_b — same order
let (la, lb) = (Arc::clone(&lock_a), Arc::clone(&lock_b));
let t1 = thread::spawn(move || {
let _a = la.lock().unwrap();
let _b = lb.lock().unwrap();
println!("thread 1 holds A then B");
});
let (la, lb) = (Arc::clone(&lock_a), Arc::clone(&lock_b));
let t2 = thread::spawn(move || {
let _a = la.lock().unwrap(); // same order — no deadlock
let _b = lb.lock().unwrap();
println!("thread 2 holds A then B");
});
t1.join().unwrap();
t2.join().unwrap();
}thread 1 holds A then B thread 2 holds A then B
Choosing the Right Primitive
Situation | Reach for |
|---|---|
Simple counter or flag shared across threads | AtomicUsize / AtomicBool |
One writer, many readers, structured data | Arc<RwLock<T>> |
General mutable shared data | Arc<Mutex<T>> |
Decoupled producer/consumer threads | std::sync::mpsc channel |
High-throughput queues, work stealing | crossbeam crate |
Arc<Mutex<T>> without locking, and the RAII guard guarantees the lock is always released. Follow the patterns here — consistent lock ordering, narrow lock scopes, and atomics for simple values — and you will write concurrent Rust that is both correct and fast.