18. Shared State
📋 Jump to Takeaways🎁 Channels pass ownership from one thread to another. But what if ten threads all need to read the same config, or a counter that every thread increments? Passing it through a channel means only one thread has it at a time. You need something different — shared ownership with controlled access.
Shared State: Arc and Mutex
You already know Rust allows multiple immutable references to the same data with &T. So why do you need Arc at all?
Because &T is a borrow tied to a lifetime. When you spawn a thread, the compiler can't guarantee the spawning scope outlives it — thread::spawn requires the closure to be 'static, meaning it must own everything it touches:
fn main() {
let config = Config { timeout_ms: 5000 };
thread::spawn(|| {
println!("{}", config.timeout_ms); // ❌ `config` may not live long enough
});
// main() could return before the thread finishes — dangling reference
}Arc solves this by giving each thread ownership of a handle, not a borrow. The data lives until the last handle is dropped, regardless of which thread drops it last.
Arc stands for atomically reference-counted. Breaking that down:
- Reference-counted — multiple variables can point to the same data. The data stays alive as long as at least one handle exists; when the last one is dropped, the data is freed.
Rc<T>does this for single-threaded code — same idea, but without the atomic overhead, so slightly faster. The compiler won't let you send anRcto another thread, so you can't use it by mistake in concurrent code. - Atomic — the reference count is updated using atomic CPU instructions, safe to do from multiple threads simultaneously without a lock.
Rc<T>is not atomic — two threads updating the count at the same time would corrupt it. - Shared ownership across threads —
Arc::clonegives you a new handle to the same data, not a copy of it. Each clone can be sent to a different thread.
Use Arc<T> alone when the data is read-only — set it up once, share it across threads without any lock:
use std::sync::Arc;
use std::thread;
struct Config {
timeout_ms: u64,
max_retries: u32,
}
fn main() {
let config = Arc::new(Config { timeout_ms: 5000, max_retries: 3 });
let mut handles = vec![];
for i in 0..4 {
let config = Arc::clone(&config); // cheap refcount bump, not a copy
handles.push(thread::spawn(move || {
println!("worker {}: timeout={}ms", i, config.timeout_ms);
}));
}
for h in handles { h.join().unwrap(); }
}Arc::clone gives each thread its own handle to the same data in memory. The data is freed when the last handle is dropped. No lock needed because no thread is writing.
When you need to mutate shared data, add Mutex inside: Arc<Mutex<T>>. Each thread clones the Arc to get its own handle, then calls .lock() to get exclusive access. Arc::clone is a convention over .clone() — it signals a cheap reference-count bump, not a deep copy of the data.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // returns MutexGuard; * dereferences it
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("final count: {}", *counter.lock().unwrap()); // final count: 5
}You'll notice there's no explicit unlock call. .lock() returns a MutexGuard — a smart pointer that releases the lock automatically when it goes out of scope via the Drop trait. In Go you'd write mu.Lock() / defer mu.Unlock(). In Rust the defer is implicit:
{
let mut num = counter.lock().unwrap(); // lock acquired
*num += 1;
} // num drops here — lock released automaticallyYou can't use a plain Rc across threads — the compiler rejects it because Rc doesn't implement Send. You can't use Mutex without Arc across threads because the mutex would be dropped when the spawning scope ends. The type system guides you to the correct combination.
Mutex Performance and RwLock
A Mutex is an OS-level synchronization primitive — the same cost applies in Rust as in Go. Two sources of overhead:
- Lock contention — when multiple threads try to lock at the same time, all but one block and wait
- Cache invalidation — the protected value bounces between CPU cores' caches, expensive even without contention
For data that's read frequently but written rarely — a shared config, a cache, a lookup table — RwLock<T> is a better fit. It allows multiple readers at the same time, only blocking when a writer needs access:
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let config = Arc::new(RwLock::new(vec!["host", "port"]));
let mut handles = vec![];
for _ in 0..4 {
let config = Arc::clone(&config);
handles.push(thread::spawn(move || {
let data = config.read().unwrap(); // multiple threads can hold read lock simultaneously
println!("config has {} entries", data.len());
}));
}
for h in handles { h.join().unwrap(); }
config.write().unwrap().push("timeout"); // exclusive write lock
println!("updated: {:?}", config.read().unwrap());
}Mutex<T> |
RwLock<T> |
|
|---|---|---|
| Multiple readers at once | ❌ | ✅ |
| Exclusive writer | ✅ | ✅ |
| Best for | Write-heavy or mixed | Read-heavy |
When writes are rare and reads are frequent, Arc<RwLock<T>> can be significantly faster than Arc<Mutex<T>>. For counters and flags, atomics are faster still — no lock at all.
Send and Sync Traits
Rust's thread safety guarantees come from two marker traits:
Send— a type can be transferred to another thread. Almost everything isSend.Sync— a type can be referenced from multiple threads.TisSyncif&TisSend.
These are auto-implemented by the compiler. Types like Rc<T> are not Send, and RefCell<T> is not Sync. You never implement them manually in normal code — the compiler checks them for you.
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
// This won't compile:
// thread::spawn(move || {
// println!("{}", data);
// });
// ❌ ERROR: `Rc<i32>` cannot be sent between threads safely
// Fix: use Arc instead
use std::sync::Arc;
let data = Arc::new(5);
thread::spawn(move || {
println!("{}", data); // 5
}).join().unwrap();
}The compiler enforces Send and Sync at every thread boundary. You cannot accidentally share non-thread-safe data. In practice, you rarely think about these directly — the compiler tells you when something isn't Send, and the fix is usually switching from Rc to Arc.
Atomic Types
For simple shared values like counters and flags, std::sync::atomic gives you lock-free thread safety — no Mutex needed. This is the equivalent of sync/atomic in Go.
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(AtomicI32::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
counter.fetch_add(1, Ordering::SeqCst); // increment atomically, no lock
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("count: {}", counter.load(Ordering::SeqCst)); // count: 5
}The second argument to every atomic operation is an Ordering — it tells the CPU and compiler how strictly to synchronize this operation with surrounding memory accesses. It's not about the operation itself, it's about what other threads are guaranteed to see before and after it.
| Ordering | Meaning |
|---|---|
SeqCst |
All threads see all atomic ops in the same global order — safest, tiny performance cost |
Release / Acquire |
Used in pairs: Release on write, Acquire on read — guarantees the write is visible before the read |
Relaxed |
No ordering guarantees — just atomicity. Fastest, safe when operations are independent |
For a plain counter where you only care that increments don't get lost, Relaxed is correct and slightly faster:
counter.fetch_add(1, Ordering::Relaxed); // fine for an independent counterSeqCst is the safe default when you're unsure. The difference is negligible on x86 (hardware already enforces strong ordering), but matters on ARM where the CPU can reorder memory operations more aggressively.
Common atomic types: AtomicBool, AtomicI32, AtomicU64, AtomicUsize. Use these over Arc<Mutex<T>> when you only need to increment a counter or flip a flag — they're faster and can't deadlock.
Rayon: Easy Parallelism
The rayon crate turns sequential iterators into parallel ones with a single method change. It handles thread pool management and work-stealing automatically.
use rayon::prelude::* imports Rayon's extension traits — these are what add .par_iter() to standard collections. Without this import, .par_iter() simply doesn't exist on Vec. The * pulls in all traits at once so you don't have to name them individually.
The code looks identical to single-threaded iterator code. That's the point — Rayon intercepts the chain and distributes the work across threads behind the scenes:
// single-threaded
let sum: i64 = numbers.iter().sum();
// parallel — one method change, Rayon handles the rest
let sum: i64 = numbers.par_iter().sum();The rest of your chain (.filter(), .map(), .collect()) works exactly the same — Rayon knows how to run those in parallel too:
// Add to Cargo.toml: rayon = "1"
use rayon::prelude::*;
fn main() {
let numbers: Vec<i64> = (1..=1_000_000).collect();
let sum: i64 = numbers.par_iter().sum();
println!("sum: {}", sum); // sum: 500000500000
// .filter() and .map() run in parallel across all CPU cores
let results: Vec<i64> = numbers
.par_iter()
.filter(|&&x| x % 2 == 0)
.map(|x| x * x)
.collect();
println!("even squares count: {}", results.len()); // 500000
}Rayon is the go-to choice for data parallelism. It uses all available CPU cores and requires no manual thread management.
Under the hood, Rayon maintains a global thread pool (one thread per CPU core by default) and uses work-stealing — if one thread finishes its chunk early, it steals work from another thread's queue instead of sitting idle. This keeps all cores busy without you having to think about it.
The key difference from spawning threads manually: Rayon is designed for data parallelism — splitting a collection and processing each piece independently. If your pieces have side effects, share mutable state, or need to communicate results back, raw threads with channels are a better fit.
A rough guide:
| Situation | Use |
|---|---|
| Process each item in a collection independently | Rayon .par_iter() |
| Items need to communicate or share state | Threads + channels |
| I/O-bound work (network, files) | Async / tokio |
Key Takeaways
Arc<T>gives threads shared ownership via atomic reference counting — use it alone for read-only dataArc<Mutex<T>>adds mutual exclusion for shared mutable state;.lock()returns aMutexGuardthat unlocks on dropArc<RwLock<T>>allows multiple concurrent readers but exclusive writers — prefer it overMutexfor read-heavy shared dataRc<T>is not thread-safe; the compiler rejects it at thread boundaries — switch toArc<T>Sendmeans a type can be transferred to another thread;Syncmeans it can be referenced from multiple threads- Both traits are auto-implemented and compiler-enforced — you can't accidentally share unsafe types
- Atomic types (
AtomicBool,AtomicI32, etc.) give lock-free thread safety for counters and flags - Rayon's
.par_iter()turns any iterator parallel — no manual thread management needed - Rust guarantees no data races at compile time; "fearless concurrency" is real
🎁 Threads are perfect for CPU-bound work. But what if you need to handle 10,000 network connections at once? Spawning 10,000 OS threads would crush your machine. Next up: async/await — concurrency without a thread per task.