Rust’s ownership system prevents data races at compile time — the compiler refuses to compile code where two threads could access the same data unsafely. But ownership alone isn’t enough for real systems: web servers need shared caches, worker pools need shared queues, and services need shared metrics. This guide covers everything you need to share state correctly and efficiently across Rust threads.
Why Rust Concurrency Is Different
In most languages, data races are runtime bugs — often intermittent, hard to reproduce. In Rust, they’re compile errors. The type system enforces these rules:
- A value has one owner at a time
- You can have many immutable references (
&T) OR one mutable reference (&mut T) — never both simultaneously - Values sent across thread boundaries must be
Send - Values shared by reference across thread boundaries must be
Sync
The concurrency primitives in std::sync exist precisely to satisfy these constraints while enabling safe shared mutable state.
Threads and Ownership
std::thread::spawn requires the closure to be 'static and Send — it must own all data it uses, because the thread might outlive the spawning scope:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
// `move` takes ownership of `data`
let handle = thread::spawn(move || {
println!("{:?}", data);
});
handle.join().unwrap();
}
To share data across multiple threads, you need both shared ownership (Arc) and interior mutability (Mutex or RwLock).
Arc<T> — Shared Ownership Across Threads
Arc (Atomically Reference Counted) allows multiple owners. When the last Arc clone is dropped, the value is freed. It uses atomic operations for thread-safe reference counting:
use std::sync::Arc;
use std::thread;
fn main() {
let shared = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = Vec::new();
for i in 0..3 {
let data = Arc::clone(&shared); // Cheap clone — just increments ref count
handles.push(thread::spawn(move || {
println!("Thread {}: sum = {}", i, data.iter().sum::<i32>());
}));
}
for h in handles {
h.join().unwrap();
}
println!("Original still valid: {:?}", shared);
}
Arc<T> alone only gives shared immutable access. For mutation, wrap the inner value in Mutex<T> or RwLock<T>.
Mutex<T> — Exclusive Mutable Access
Mutex<T> guarantees that only one thread accesses the data at a time. The data is locked behind a guard — you must acquire the lock to read or write it:
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0i64);
{
let mut guard = counter.lock().unwrap();
*guard += 1;
println!("Counter: {}", *guard);
} // Guard dropped here, lock released
println!("Final: {}", *counter.lock().unwrap());
}
The lock is released automatically when the MutexGuard is dropped (RAII). This means you can accidentally hold a lock longer than intended:
// BAD: holding lock while doing expensive work
let guard = mutex.lock().unwrap();
expensive_network_call(); // Lock held for entire call!
drop(guard);
// GOOD: clone what you need, release lock first
let value = mutex.lock().unwrap().clone();
drop; // lock released
expensive_computation(value);
The Canonical Pattern: Arc<Mutex<T>>
This is the standard combination for shared mutable state across threads:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("Final: {}", *counter.lock().unwrap()); // Always 10000
}
Building a Thread-Safe Cache
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
type Cache = Arc<Mutex<HashMap<String, String>>>;
fn get_or_fetch(cache: &Cache, key: &str) -> String {
// Check cache first (hold lock briefly)
{
let map = cache.lock().unwrap();
if let Some(val) = map.get(key) {
return val.clone();
}
} // Lock released here
// Simulate expensive fetch (lock NOT held)
let value = format!("fetched:{}", key);
// Insert into cache
cache.lock().unwrap().insert(key.to_string(), value.clone());
value
}
fn main() {
let cache: Cache = Arc::new(Mutex::new(HashMap::new()));
let mut handles = Vec::new();
for i in 0..5 {
let cache = Arc::clone(&cache);
handles.push(thread::spawn(move || {
let key = format!("key{}", i % 3);
let val = get_or_fetch(&cache, &key);
println!("Thread {}: {}", i, val);
}));
}
for h in handles { h.join().unwrap(); }
}
RwLock<T> — Optimized for Read-Heavy Workloads
RwLock allows many concurrent readers OR one exclusive writer — never both. Use it when reads significantly outnumber writes:
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let config = Arc::new(RwLock::new(HashMap::from([
("timeout", "30"),
("retries", "3"),
])));
let mut handles = Vec::new();
// Spawn 8 reader threads
for i in 0..8 {
let cfg = Arc::clone(&config);
handles.push(thread::spawn(move || {
let map = cfg.read().unwrap(); // Multiple readers can hold this simultaneously
println!("Reader {}: timeout={}", i, map["timeout"]);
}));
}
// Spawn 1 writer thread
{
let cfg = Arc::clone(&config);
handles.push(thread::spawn(move || {
let mut map = cfg.write().unwrap(); // Exclusive access
map.insert("timeout", "60");
println!("Writer: updated timeout to 60");
}));
}
for h in handles { h.join().unwrap(); }
}
Mutex vs RwLock — When to Use Each
| Scenario | Use |
|---|---|
| Writes are frequent | Mutex — simpler, lower overhead |
| Reads » writes (e.g., config, cache) | RwLock |
| Critical section is very short | Mutex |
| Multiple readers must proceed simultaneously | RwLock |
| Write starvation is a concern | Mutex |
RwLock on some platforms can cause write starvation (readers keep arriving, writer waits forever). For write-heavy workloads, Mutex is usually better.
Lock Poisoning
If a thread panics while holding a lock, the lock becomes “poisoned”. Subsequent lock() calls return Err(PoisonError). This is a safety mechanism — the protected data may be in an inconsistent state:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let data2 = Arc::clone(&data);
// Thread panics while holding the lock
let _ = thread::spawn(move || {
let mut guard = data2.lock().unwrap();
guard.push(4);
panic!("oops"); // Lock is now poisoned
}).join();
// Subsequent access returns Err
match data.lock() {
Ok(guard) => println!("Data: {:?}", *guard),
Err(poisoned) => {
println!("Lock was poisoned — recovering");
let guard = poisoned.into_inner(); // Explicitly recover
println!("Data (maybe inconsistent): {:?}", *guard);
}
}
}
In production, decide whether the partial state is acceptable. If not, propagate the error or restart the affected component.
Deadlock Prevention
Rust prevents data races but not deadlocks. Deadlocks occur when two or more threads wait for each other’s locks:
// DEADLOCK: Thread 1 holds lock_a, waits for lock_b
// Thread 2 holds lock_b, waits for lock_a
use std::sync::{Arc, Mutex};
let lock_a = Arc::new(Mutex::new(0));
let lock_b = Arc::new(Mutex::new(0));
Prevention rules:
- Lock ordering: always acquire locks in the same global order across all threads
- Minimize lock scope: release locks as soon as possible, never hold while doing I/O
- Try-lock with timeout: use
try_lock()which returnsErrimmediately if the lock is unavailable - Prefer message passing:
mpscchannels avoid shared state entirely for many patterns
use std::sync::Mutex;
fn main() {
let m = Mutex::new(42);
// try_lock() doesn't block
match m.try_lock() {
Ok(guard) => println!("Got lock: {}", *guard),
Err(_) => println!("Lock unavailable — try later"),
}
}
Atomics — Lock-Free for Simple Values
For simple numeric counters and flags, std::sync::atomic types provide lock-free thread safety with no overhead:
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicBool, Ordering};
use std::thread;
fn main() {
let counter = Arc::new(AtomicU64::new(0));
let running = Arc::new(AtomicBool::new(true));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..25_000 {
counter.fetch_add(1, Ordering::Relaxed);
}
}));
}
for h in handles { h.join().unwrap(); }
println!("Count: {}", counter.load(Ordering::SeqCst)); // 100000
running.store(false, Ordering::SeqCst);
}
Ordering guide:
Relaxed— no synchronization guarantees, fastest. Use for independent counters.Acquire/Release— synchronize with a paired acquire/release. Use for producer-consumer patterns.SeqCst— total global order. Use when you need the strongest guarantees. Slowest.
When to use atomics vs Mutex:
- Single numeric value (counter, flag) → atomics
- Multiple fields that must change together →
Mutex - Complex data structure →
Mutex
Thread-Safe Data Structures
Sharded Counter (Reduces Contention)
use std::sync::{Arc, Mutex};
struct ShardedCounter {
shards: Vec<Mutex<u64>>,
}
impl ShardedCounter {
fn new(num_shards: usize) -> Self {
ShardedCounter {
shards: (0..num_shards).map(|_| Mutex::new(0)).collect(),
}
}
fn increment(&self, thread_id: usize) {
let shard = thread_id % self.shards.len();
*self.shards[shard].lock().unwrap() += 1;
}
fn total(&self) -> u64 {
self.shards.iter().map(|s| *s.lock().unwrap()).sum()
}
}
fn main() {
let counter = Arc::new(ShardedCounter::new(4));
let mut handles = Vec::new();
for tid in 0..8 {
let c = Arc::clone(&counter);
handles.push(std::thread::spawn(move || {
for _ in 0..10_000 {
c.increment(tid);
}
}));
}
for h in handles { h.join().unwrap(); }
println!("Total: {}", counter.total()); // 80000
}
Thread-Safe Pool
use std::sync::{Arc, Mutex};
use std::collections::VecDeque;
struct Pool<T> {
items: Mutex<VecDeque<T>>,
}
impl<T> Pool<T> {
fn new(items: impl IntoIterator<Item = T>) -> Self {
Pool {
items: Mutex::new(items.into_iter().collect()),
}
}
fn acquire(&self) -> Option<T> {
self.items.lock().unwrap().pop_front()
}
fn release(&self, item: T) {
self.items.lock().unwrap().push_back(item);
}
}
Async Context: tokio::sync::Mutex
In async Rust, std::sync::Mutex blocks the OS thread — which blocks the entire async executor. Use tokio::sync::Mutex for async code:
// Cargo.toml: tokio = { version = "1", features = ["full"] }
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
type SharedState = Arc<Mutex<HashMap<String, String>>>;
async fn handle_request(state: SharedState, key: String) -> Option<String> {
let map = state.lock().await; // Yields control if lock is unavailable
map.get(&key).cloned()
}
#[tokio::main]
async fn main() {
let state: SharedState = Arc::new(Mutex::new(HashMap::new()));
{
let mut map = state.lock().await;
map.insert("greeting".to_string(), "hello".to_string());
}
let result = handle_request(Arc::clone(&state), "greeting".to_string()).await;
println!("{:?}", result); // Some("hello")
}
Rules for async locks:
- Never hold an async
Mutexguard across an.awaitpoint if you can avoid it - If you must, use
tokio::sync::Mutexnotstd::sync::Mutex - Consider
tokio::sync::RwLockfor read-heavy async state - For high-throughput async state, consider
DashMap(concurrent HashMap) orarc-swap
Channels vs Shared State
Channels (message passing) are often a cleaner alternative to shared locks:
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel::<String>();
// Multiple producers
for i in 0..3 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(format!("message from thread {}", i)).unwrap();
});
}
drop(tx); // Close sender side
// Single consumer
for msg in rx {
println!("{}", msg);
}
}
Decision guide:
| Pattern | Use channels | Use shared locks |
|---|---|---|
| Producer-consumer pipeline | ✅ | |
| Broadcast to multiple consumers | ✅ | |
| Workers need direct access to shared structure | ✅ | |
| In-memory cache/registry | ✅ | |
| Rate limiting / token bucket | ✅ (atomics) | |
| Complex coordination with many states | ✅ |
In practice, most systems use both: channels for coordination and shared locks for shared data structures.
Production Patterns
Metrics Registry
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, Ordering};
struct MetricsRegistry {
counters: Mutex<HashMap<String, Arc<AtomicU64>>>,
}
impl MetricsRegistry {
fn new() -> Self {
MetricsRegistry { counters: Mutex::new(HashMap::new()) }
}
fn counter(&self, name: &str) -> Arc<AtomicU64> {
let mut map = self.counters.lock().unwrap();
map.entry(name.to_string())
.or_insert_with(|| Arc::new(AtomicU64::new(0)))
.clone()
}
fn snapshot(&self) -> HashMap<String, u64> {
self.counters.lock().unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.load(Ordering::Relaxed)))
.collect()
}
}
fn main() {
let registry = Arc::new(MetricsRegistry::new());
let requests = registry.counter("http_requests_total");
let errors = registry.counter("http_errors_total");
// In request handlers — no lock held on hot path
requests.fetch_add(1, Ordering::Relaxed);
errors.fetch_add(1, Ordering::Relaxed);
println!("{:?}", registry.snapshot());
}
Summary
| Primitive | Purpose | Use when |
|---|---|---|
Arc<T> |
Shared ownership | Multiple threads need to own the same value |
Mutex<T> |
Exclusive mutation | Any shared mutable state |
Arc<Mutex<T>> |
Shared mutable state | The standard pattern for most cases |
RwLock<T> |
Reader-writer lock | Reads heavily outnumber writes |
AtomicU64 etc. |
Lock-free counters | Simple numeric values, flags |
tokio::sync::Mutex |
Async lock | Shared state in async code |
mpsc::channel |
Message passing | Producer-consumer, work dispatch |
The Rust compiler enforces thread safety through Send and Sync — if your code compiles, it has no data races. The remaining concurrency problems (deadlocks, starvation, logic errors) require careful design, but the type system has already eliminated the hardest class of bugs.
Resources
- Rust Book: Fearless Concurrency
- std::sync module
- std::sync::atomic
- Tokio Synchronization
- DashMap — concurrent HashMap
Comments