Lesson 65 of 94
Shared State: Arc, Mutex & RwLock
The Threads lesson gave each thread its own data, or let scoped threads borrow it read-only. Sooner or later threads need to share and change the same data: a cache, a counter, a set of accounts. Rust has the usual building blocks (mutexes, read-write locks, condition variables), with one twist that removes a whole class of bugs:
A Rust lock owns the data it protects. You can't touch the data without holding the lock, because the only way to reach it is through the lock.
The connection between a lock and the data it guards lives in the type: Mutex<HashMap<String, User>>, not in comments and conventions.
| Need | Rust |
|---|---|
| Shared ownership across threads | Arc<T> (atomic reference counting) |
| Mutual exclusion | Mutex<T> |
| Many readers or one writer | RwLock<T> |
| Wait for a condition | Condvar |
| Wait for a group of threads | Barrier (and channels, next lesson) |
| Concurrent map | Mutex<HashMap> / RwLock<HashMap>, sharded maps, or crates like dashmap |
1. Arc: shared ownership across threads
Arc<T> is the thread-safe version of Rc<T>: several owners, and the value is dropped when the last one goes away. The count is updated atomically, so it's safe to clone and drop from many threads.
use std::sync::Arc;
use std::thread;
fn main() {
let catalog = Arc::new(vec!["keyboard", "mouse", "monitor"]);
let handles: Vec<_> = (0..3)
.map(|i| {
let catalog = Arc::clone(&catalog); // cheap: bumps a counter, doesn't copy the Vec
thread::spawn(move || format!("thread {i} sees {}", catalog[i]))
})
.collect();
for h in handles {
println!("{}", h.join().unwrap());
}
println!("owners left: {}", Arc::strong_count(&catalog));
}thread 0 sees keyboard
thread 1 sees mouse
thread 2 sees monitor
owners left: 1Each thread gets its own Arc (moved into the closure), and all of them point to the same Vec.
2. Arc alone is read-only
Arc<T> gives shared access, and shared access means &T: you can read, but not modify. Trying to change the value is a compile error.
use std::sync::Arc;
use std::thread;
fn main() {
let visits = Arc::new(0);
let v = Arc::clone(&visits);
thread::spawn(move || {
*v += 1;
})
.join()
.unwrap();
}error[E0594]: cannot assign to data in an `Arc`
--> src/main.rs:8:9
|
8 | *v += 1;
| ^^^^^^^ cannot assign
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Arc<i32>`To mutate shared data you need something that makes mutation safe: a Mutex, an RwLock, or an atomic.
3. Mutex basics
Mutex<T> wraps a value. lock() blocks until the lock is free and returns a guard, which derefs to the data. When the guard goes out of scope, the lock is released. There's no unlock() to forget.
use std::sync::Mutex;
fn main() {
let inventory = Mutex::new(vec![String::from("apple")]);
{
let mut items = inventory.lock().unwrap(); // MutexGuard<Vec<String>>
items.push(String::from("pear"));
items.push(String::from("plum"));
} // guard dropped: lock released
let items = inventory.lock().unwrap();
println!("{} items: {:?}", items.len(), *items);
}3 items: ["apple", "pear", "plum"]lock() returns a Result because of poisoning (example 11). unwrap() is the common choice.
4. Arc<Mutex<T>>: the classic shared counter
The combination you'll write most often: Arc to share, Mutex to mutate. Eight threads increment a counter a thousand times each, and no increments are lost.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let handles: Vec<_> = (0..8)
.map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
println!("count: {}", *counter.lock().unwrap());
}count: 8000For a plain counter, an atomic is faster (see Atomics). Mutex is the general tool for anything bigger than one number.
5. Scoped threads don't need Arc
With thread::scope, threads can borrow the mutex directly: &Mutex<T> is enough, because the scope guarantees the mutex outlives the threads.
use std::sync::Mutex;
use std::thread;
fn main() {
let totals = Mutex::new(Vec::new());
let regions = ["north", "south", "east", "west"];
thread::scope(|s| {
for (i, region) in regions.iter().enumerate() {
let totals = &totals;
s.spawn(move || {
let sales = (i as u32 + 1) * 1000; // pretend to compute something
totals.lock().unwrap().push((*region, sales));
});
}
});
let mut totals = totals.into_inner().unwrap(); // no threads left: take the data out
totals.sort();
println!("{totals:?}");
}[("east", 3000), ("north", 1000), ("south", 2000), ("west", 4000)]The threads push in whatever order they finish, so we sort before printing. into_inner() consumes the mutex and returns the data, with no locking needed since nobody else can have it.
6. The data is only reachable through the lock
You can't forget to lock, because the value is inside the Mutex. There's no field to access directly:
use std::sync::Mutex;
fn main() {
let scores = Mutex::new(vec![10, 20, 30]);
println!("{}", scores[0]);
}error[E0608]: cannot index into a value of type `std::sync::Mutex<Vec<{integer}>>`
--> src/main.rs:5:26
|
5 | println!("{}", scores[0]);
| ^^^The fix is scores.lock().unwrap()[0].
7. Keep critical sections short
A guard holds the lock until it's dropped. Do the slow work outside: copy what you need, release the lock, then work. Use a block or drop(guard).
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
fn send_email(to: &str) {
thread::sleep(Duration::from_millis(20)); // slow I/O
println!("emailed {to}");
}
fn main() {
let subscribers = Mutex::new(vec![String::from("ana@x.io"), String::from("ben@y.io")]);
// Bad: holding the lock while doing slow I/O blocks every other thread.
// for s in subscribers.lock().unwrap().iter() { send_email(s); }
// Good: take a snapshot, release the lock, then do the slow part.
let snapshot: Vec<String> = subscribers.lock().unwrap().clone();
for s in &snapshot {
send_email(s);
}
// Or release explicitly:
let mut guard = subscribers.lock().unwrap();
guard.push(String::from("cy@z.io"));
let count = guard.len();
drop(guard); // unlock now
println!("{count} subscribers");
}emailed ana@x.io
emailed ben@y.io
3 subscribers8. How long does a temporary guard live?
subscribers.lock().unwrap().clone() creates a guard as a temporary, which is dropped at the end of the statement. That's usually what you want. But a guard created in a match scrutinee lives until the end of the match, so the lock is held during every arm:
use std::sync::Mutex;
fn main() {
let queue = Mutex::new(vec![1, 2, 3]);
// The guard lives for the whole match: the lock is held while the arm runs.
match queue.lock().unwrap().pop() {
Some(job) => println!("processing {job} (lock still held)"),
None => println!("empty"),
}
// Pop in its own statement: the guard is dropped at the `;`.
let job = queue.lock().unwrap().pop();
if let Some(job) = job {
println!("processing {job} (lock released)");
}
println!("remaining: {:?}", queue.lock().unwrap());
}processing 3 (lock still held)
processing 2 (lock released)
remaining: [1]Locking the same mutex again inside that match would deadlock (next example). Pulling the value out into a let first avoids the problem.
9. A Mutex is not reentrant
Rust's Mutex is not reentrant: locking it again from the thread that already holds it deadlocks (or panics; the behavior is unspecified). This compiles, and hangs:
use std::sync::Mutex;
fn main() {
let balance = Mutex::new(100);
let guard = balance.lock().unwrap();
let again = balance.lock().unwrap(); // blocks forever: this thread already holds the lock
println!("{} {}", *guard, *again);
}The usual fix is structural: lock once in the public method and pass &mut T to private helpers, instead of having every helper lock for itself.
use std::sync::Mutex;
struct Account {
balance: i64,
history: Vec<i64>,
}
// Helpers take &mut Account: they never lock anything.
fn apply(acc: &mut Account, amount: i64) {
acc.balance += amount;
acc.history.push(amount);
}
fn deposit_many(account: &Mutex<Account>, amounts: &[i64]) {
let mut acc = account.lock().unwrap(); // lock exactly once
for &a in amounts {
apply(&mut acc, a);
}
}
fn main() {
let account = Mutex::new(Account { balance: 0, history: vec![] });
deposit_many(&account, &[100, -30, 45]);
let acc = account.lock().unwrap();
println!("balance {} history {:?}", acc.balance, acc.history);
}balance 115 history [100, -30, 45]10. try_lock: don't wait
try_lock() returns immediately: the guard if the lock was free, or an error if another thread holds it. Use it for optional work, like skipping a cache refresh that's already in progress.
use std::sync::Mutex;
use std::thread;
fn main() {
let cache = Mutex::new(String::from("stale"));
let guard = cache.lock().unwrap(); // main holds the lock...
thread::scope(|s| {
s.spawn(|| match cache.try_lock() {
Ok(mut c) => *c = String::from("fresh"),
Err(_) => println!("refresh already in progress, skipping"),
});
});
drop(guard);
if let Ok(mut c) = cache.try_lock() {
*c = String::from("fresh");
}
println!("cache: {}", cache.lock().unwrap());
}refresh already in progress, skipping
cache: fresh11. Poisoning: a thread panicked while holding the lock
If a thread panics while it holds a lock, the data might be half-updated. Rust marks the mutex poisoned, and later lock() calls return Err. You can still get the data with into_inner() on the error if you know how to repair it.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let ledger = Arc::new(Mutex::new(vec![100, 200]));
let l = Arc::clone(&ledger);
let result = thread::spawn(move || {
let mut entries = l.lock().unwrap();
entries.push(300);
panic!("crashed halfway through an update");
})
.join();
println!("worker failed: {}", result.is_err());
match ledger.lock() {
Ok(entries) => println!("entries: {entries:?}"),
Err(poisoned) => {
println!("lock is poisoned; recovering");
let entries = poisoned.into_inner();
println!("entries: {:?}", *entries);
}
}
println!("still poisoned: {}", ledger.is_poisoned());
}worker failed: true
lock is poisoned; recovering
entries: [100, 200, 300]
still poisoned: trueMost Rust code just calls unwrap() and lets the poison spread as a panic, which is usually the right call.
12. RwLock: many readers or one writer
RwLock<T> allows any number of simultaneous readers (read()), or one writer (write()). It suits data that's read far more than written, like configuration.
use std::collections::HashMap;
use std::sync::RwLock;
use std::thread;
fn main() {
let config = RwLock::new(HashMap::from([("timeout_ms", 500), ("retries", 3)]));
thread::scope(|s| {
// Four readers can hold read guards at the same time.
let readers: Vec<_> = (0..4).map(|_| s.spawn(|| config.read().unwrap()["timeout_ms"])).collect();
for r in readers {
assert_eq!(r.join().unwrap(), 500);
}
});
{
let mut cfg = config.write().unwrap(); // exclusive: waits for all readers to finish
cfg.insert("timeout_ms", 1000);
}
let cfg = config.read().unwrap();
let mut entries: Vec<_> = cfg.iter().collect();
entries.sort();
println!("{entries:?}");
}[("retries", 3), ("timeout_ms", 1000)]RwLock is not always faster than Mutex: it has more overhead per operation. Measure before switching; for short critical sections, a Mutex often wins.
13. Hot-swapping config with RwLock<Arc<T>>
A useful pattern for read-mostly data: store an Arc<Config> inside the lock. Readers lock only long enough to clone the Arc (a counter bump), then use the config with no lock held. A writer swaps in a whole new Arc.
use std::sync::{Arc, RwLock};
#[derive(Debug)]
struct Config {
version: u32,
banner: String,
}
fn current(cfg: &RwLock<Arc<Config>>) -> Arc<Config> {
Arc::clone(&cfg.read().unwrap()) // lock held only for this line
}
fn main() {
let cfg = RwLock::new(Arc::new(Config { version: 1, banner: String::from("Welcome!") }));
let old = current(&cfg); // a request in flight keeps using version 1...
*cfg.write().unwrap() = Arc::new(Config { version: 2, banner: String::from("Sale today!") });
let new = current(&cfg);
println!("in-flight request: v{} {:?}", old.version, old.banner);
println!("new requests: v{} {:?}", new.version, new.banner);
}in-flight request: v1 "Welcome!"
new requests: v2 "Sale today!"14. Getting data out: Arc::into_inner and Mutex::into_inner
When the threads are done, you often want the final value back without a lock. Arc::into_inner returns the value if this was the last Arc, and Mutex::into_inner unwraps the mutex.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let log = Arc::new(Mutex::new(Vec::new()));
let handles: Vec<_> = ["db", "cache", "queue"]
.into_iter()
.map(|name| {
let log = Arc::clone(&log);
thread::spawn(move || log.lock().unwrap().push(format!("{name} started")))
})
.collect();
for h in handles {
h.join().unwrap();
}
let mut lines: Vec<String> = Arc::into_inner(log).expect("other owners still alive").into_inner().unwrap();
lines.sort();
println!("{lines:?}");
}["cache started", "db started", "queue started"]15. get_mut: no locking when you have exclusive access
If you hold &mut Mutex<T>, no other thread can have access, so get_mut() returns &mut T without locking at all. The borrow checker proves it's safe.
use std::sync::Mutex;
struct Stats {
hits: Mutex<u64>,
}
impl Stats {
fn record(&self) {
*self.hits.lock().unwrap() += 1; // shared access: must lock
}
fn reset(&mut self) -> u64 {
let hits = self.hits.get_mut().unwrap(); // exclusive access: no lock needed
std::mem::take(hits)
}
}
fn main() {
let mut stats = Stats { hits: Mutex::new(0) };
stats.record();
stats.record();
println!("reset returned {}", stats.reset());
println!("now {}", stats.hits.lock().unwrap());
}reset returned 2
now 016. A shared cache: Mutex<HashMap>
A memoizing cache shared by worker threads. Note how the expensive computation happens outside the lock, so threads don't serialize on it.
use std::collections::HashMap;
use std::sync::Mutex;
use std::thread;
fn slow_square(n: u64) -> u64 {
thread::sleep(std::time::Duration::from_millis(10));
n * n
}
fn cached(cache: &Mutex<HashMap<u64, u64>>, n: u64) -> u64 {
if let Some(&v) = cache.lock().unwrap().get(&n) {
return v; // guard dropped at the end of the `if let`
}
let v = slow_square(n); // compute without holding the lock
cache.lock().unwrap().insert(n, v);
v
}
fn main() {
let cache = Mutex::new(HashMap::new());
let inputs = [3, 4, 3, 5, 4, 3];
let results: Vec<u64> = thread::scope(|s| {
let cache = &cache; // move this reference (and `n`) into each thread
let handles: Vec<_> = inputs.iter().map(|&n| s.spawn(move || cached(cache, n))).collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
println!("results: {results:?}");
println!("distinct keys cached: {}", cache.lock().unwrap().len());
}results: [9, 16, 9, 25, 16, 9]
distinct keys cached: 3Two threads might both miss and compute the same value. That's harmless here; when it isn't, hold the lock during computation, or use a per-key OnceLock (Atomics).
17. Locking several things: avoid deadlock with a fixed order
Deadlock happens when thread A holds lock 1 and waits for lock 2, while thread B holds 2 and waits for 1. The standard fix is to always acquire locks in one global order, for example by account id.
use std::sync::Mutex;
use std::thread;
struct Account {
id: u32,
balance: Mutex<i64>,
}
fn transfer(from: &Account, to: &Account, amount: i64) -> Result<(), String> {
if from.id == to.id {
return Err(String::from("same account"));
}
// Lock the lower id first, whatever the direction of the transfer.
let (first, second) = if from.id < to.id { (from, to) } else { (to, from) };
let mut a = first.balance.lock().unwrap();
let mut b = second.balance.lock().unwrap();
let (from_bal, to_bal) = if from.id < to.id { (&mut *a, &mut *b) } else { (&mut *b, &mut *a) };
if *from_bal < amount {
return Err(format!("account {} has only {}", from.id, from_bal));
}
*from_bal -= amount;
*to_bal += amount;
Ok(())
}
fn main() {
let alice = Account { id: 1, balance: Mutex::new(1000) };
let bob = Account { id: 2, balance: Mutex::new(1000) };
thread::scope(|s| {
// Opposite directions at the same time: would deadlock without the ordering.
s.spawn(|| for _ in 0..500 { transfer(&alice, &bob, 1).unwrap(); });
s.spawn(|| for _ in 0..300 { transfer(&bob, &alice, 1).unwrap(); });
});
let (a, b) = (*alice.balance.lock().unwrap(), *bob.balance.lock().unwrap());
println!("alice {a}, bob {b}, total {}", a + b);
println!("{:?}", transfer(&alice, &bob, 5000));
}alice 800, bob 1200, total 2000
Err("account 1 has only 800")18. Or use one lock for related data
Often the simplest answer is fewer locks. If accounts are always updated together, put them all behind one Mutex. There's then no ordering to get wrong, at the cost of less parallelism.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
struct Bank {
accounts: Mutex<HashMap<&'static str, i64>>,
}
impl Bank {
fn transfer(&self, from: &str, to: &str, amount: i64) -> Result<(), String> {
let mut accts = self.accounts.lock().unwrap();
let from_bal = *accts.get(from).ok_or(format!("no account {from}"))?;
if !accts.contains_key(to) {
return Err(format!("no account {to}"));
}
if from_bal < amount {
return Err(format!("{from} has only {from_bal}"));
}
*accts.get_mut(from).unwrap() -= amount;
*accts.get_mut(to).unwrap() += amount;
Ok(())
}
}
fn main() {
let bank = Arc::new(Bank { accounts: Mutex::new(HashMap::from([("ana", 500), ("ben", 500), ("cy", 0)])) });
let handles: Vec<_> = [("ana", "cy"), ("ben", "cy"), ("cy", "ana")]
.into_iter()
.map(|(from, to)| {
let bank = Arc::clone(&bank);
thread::spawn(move || (0..100).filter(|_| bank.transfer(from, to, 2).is_ok()).count())
})
.collect();
let succeeded: usize = handles.into_iter().map(|h| h.join().unwrap()).sum();
let total: i64 = bank.accounts.lock().unwrap().values().sum(); // guard dropped at `;`
println!("successful transfers: at least 200: {}", succeeded >= 200);
println!("money is conserved: {}", total == 1000);
println!("{:?}", bank.transfer("ana", "zed", 1).err());
}successful transfers: at least 200: true
money is conserved: true
Some("no account zed")19. Condvar: waiting for a condition
A Condvar lets a thread sleep until another thread signals that something changed. It's always paired with a Mutex. wait_while re-checks the condition for you, which handles spurious wakeups.
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::Duration;
fn main() {
let state = Arc::new((Mutex::new(false), Condvar::new()));
let s = Arc::clone(&state);
let loader = thread::spawn(move || {
thread::sleep(Duration::from_millis(50)); // load something
let (ready, cvar) = &*s;
*ready.lock().unwrap() = true;
cvar.notify_all();
"config loaded"
});
let (ready, cvar) = &*state;
let guard = cvar.wait_while(ready.lock().unwrap(), |ready| !*ready).unwrap();
println!("ready = {}", *guard);
drop(guard);
println!("{}", loader.join().unwrap());
}ready = true
config loaded20. Condvar with a timeout
wait_timeout_while gives up after a deadline and tells you whether it timed out. Useful for "wait for the service to become healthy, but not forever".
use std::sync::{Condvar, Mutex};
use std::thread;
use std::time::Duration;
fn wait_for_workers(done: &Mutex<u32>, cvar: &Condvar, expected: u32, timeout: Duration) -> Result<(), u32> {
let (count, result) = cvar.wait_timeout_while(done.lock().unwrap(), timeout, |n| *n < expected).unwrap();
if result.timed_out() { Err(*count) } else { Ok(()) }
}
fn main() {
let done = Mutex::new(0);
let cvar = Condvar::new();
thread::scope(|s| {
for _ in 0..3 {
s.spawn(|| {
*done.lock().unwrap() += 1;
cvar.notify_all();
});
}
println!("all 3 finished: {:?}", wait_for_workers(&done, &cvar, 3, Duration::from_secs(5)));
});
// Waiting for a 4th worker that never comes times out.
println!("waiting for 4: {:?}", wait_for_workers(&done, &cvar, 4, Duration::from_millis(50)));
}all 3 finished: Ok(())
waiting for 4: Err(3)21. A blocking queue built from Mutex + Condvar
A bounded blocking queue is a lock with two conditions ("not empty" and "not full"). Here's a small version. The Channels lesson shows the ready-made alternative, but this is what's underneath.
use std::collections::VecDeque;
use std::sync::{Condvar, Mutex};
use std::thread;
struct BoundedQueue<T> {
items: Mutex<VecDeque<T>>,
not_empty: Condvar,
not_full: Condvar,
capacity: usize,
}
impl<T> BoundedQueue<T> {
fn new(capacity: usize) -> Self {
BoundedQueue { items: Mutex::new(VecDeque::new()), not_empty: Condvar::new(), not_full: Condvar::new(), capacity }
}
fn put(&self, item: T) {
let mut items = self.not_full.wait_while(self.items.lock().unwrap(), |q| q.len() >= self.capacity).unwrap();
items.push_back(item);
self.not_empty.notify_one();
}
fn take(&self) -> T {
let mut items = self.not_empty.wait_while(self.items.lock().unwrap(), |q| q.is_empty()).unwrap();
let item = items.pop_front().unwrap();
self.not_full.notify_one();
item
}
}
fn main() {
let queue = BoundedQueue::new(2); // small capacity: the producer has to wait for the consumer
let consumed = thread::scope(|s| {
s.spawn(|| {
for order in 1..=6 {
queue.put(order);
}
queue.put(0); // 0 = "no more orders"
});
let consumer = s.spawn(|| {
let mut got = Vec::new();
loop {
match queue.take() {
0 => break got,
order => got.push(order),
}
}
});
consumer.join().unwrap()
});
println!("consumed in order: {consumed:?}");
}consumed in order: [1, 2, 3, 4, 5, 6]22. Barrier: wait until everyone arrives
A Barrier blocks threads until a given number of them have called wait(), then releases them all. It can be reused for several phases.
use std::sync::{Barrier, Mutex};
use std::thread;
fn main() {
let workers = 3;
let barrier = Barrier::new(workers);
let log = Mutex::new(Vec::new());
thread::scope(|s| {
for id in 0..workers {
let (barrier, log) = (&barrier, &log);
s.spawn(move || {
log.lock().unwrap().push(format!("phase 1: worker {id} loaded data"));
barrier.wait(); // nobody starts phase 2 until all finished phase 1
log.lock().unwrap().push(format!("phase 2: worker {id} processing"));
});
}
});
let log = log.into_inner().unwrap();
let last_phase_1 = log.iter().rposition(|l| l.starts_with("phase 1")).unwrap();
let first_phase_2 = log.iter().position(|l| l.starts_with("phase 2")).unwrap();
println!("{} log lines", log.len());
println!("every phase 1 line comes before every phase 2 line: {}", last_phase_1 < first_phase_2);
}6 log lines
every phase 1 line comes before every phase 2 line: true23. Sharing a struct with interior locks
Instead of Arc<Mutex<Service>> (one big lock), put locks on the fields that change. Methods then take &self, and unrelated operations don't block each other.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
struct UserService {
users: RwLock<HashMap<u32, String>>, // read-mostly
audit: Mutex<Vec<String>>, // append-only log
}
impl UserService {
fn name(&self, id: u32) -> Option<String> {
self.audit.lock().unwrap().push(format!("read {id}"));
self.users.read().unwrap().get(&id).cloned()
}
fn rename(&self, id: u32, name: &str) {
self.users.write().unwrap().insert(id, name.to_string());
self.audit.lock().unwrap().push(format!("rename {id}"));
}
}
fn main() {
let svc = Arc::new(UserService {
users: RwLock::new(HashMap::from([(1, String::from("ana")), (2, String::from("ben"))])),
audit: Mutex::new(Vec::new()),
});
let readers: Vec<_> = (1..=2)
.map(|id| {
let svc = Arc::clone(&svc);
thread::spawn(move || svc.name(id))
})
.collect();
let names: Vec<_> = readers.into_iter().map(|h| h.join().unwrap()).collect();
svc.rename(2, "benjamin");
println!("{names:?} -> {:?}", svc.name(2));
println!("audit entries: {}", svc.audit.lock().unwrap().len());
}[Some("ana"), Some("ben")] -> Some("benjamin")
audit entries: 424. A sharded map: less contention
One Mutex<HashMap> serializes every access. Splitting the map into shards, each with its own lock, lets threads working on different keys proceed in parallel. This is the idea behind the Rust crate dashmap.
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use std::thread;
struct ShardedMap<V> {
shards: Vec<Mutex<HashMap<String, V>>>,
}
impl<V: Clone> ShardedMap<V> {
fn new(shards: usize) -> Self {
ShardedMap { shards: (0..shards).map(|_| Mutex::new(HashMap::new())).collect() }
}
fn shard(&self, key: &str) -> &Mutex<HashMap<String, V>> {
let mut h = DefaultHasher::new();
key.hash(&mut h);
&self.shards[h.finish() as usize % self.shards.len()]
}
fn insert(&self, key: &str, value: V) {
self.shard(key).lock().unwrap().insert(key.to_string(), value);
}
fn get(&self, key: &str) -> Option<V> {
self.shard(key).lock().unwrap().get(key).cloned()
}
fn len(&self) -> usize {
self.shards.iter().map(|s| s.lock().unwrap().len()).sum()
}
}
fn main() {
let map = ShardedMap::new(8);
thread::scope(|s| {
for t in 0..4 {
let map = ↦
s.spawn(move || {
for i in 0..250 {
map.insert(&format!("user-{t}-{i}"), i);
}
});
}
});
println!("entries: {}", map.len());
println!("user-2-99 = {:?}", map.get("user-2-99"));
println!("user-9-0 = {:?}", map.get("user-9-0"));
}entries: 1000
user-2-99 = Some(99)
user-9-0 = None25. Realistic: a token-bucket rate limiter
A limiter shared by all request handlers: each client gets a bucket of tokens that refills over time. The whole state sits behind one Mutex; each check is a few arithmetic operations, so the lock is held very briefly. Time is passed in to keep the example deterministic.
use std::collections::HashMap;
use std::sync::Mutex;
use std::thread;
struct RateLimiter {
capacity: f64,
refill_per_sec: f64,
buckets: Mutex<HashMap<String, (f64, f64)>>, // client -> (tokens, last seen time)
}
impl RateLimiter {
fn allow(&self, client: &str, now: f64) -> bool {
let mut buckets = self.buckets.lock().unwrap();
let (tokens, last) = buckets.entry(client.to_string()).or_insert((self.capacity, now));
*tokens = (*tokens + (now - *last) * self.refill_per_sec).min(self.capacity);
*last = now;
if *tokens >= 1.0 {
*tokens -= 1.0;
true
} else {
false
}
}
}
fn main() {
let limiter = RateLimiter { capacity: 5.0, refill_per_sec: 1.0, buckets: Mutex::new(HashMap::new()) };
// 4 threads fire 3 requests each for the same client at t=0: only 5 may pass.
let allowed: usize = thread::scope(|s| {
let handles: Vec<_> = (0..4)
.map(|_| s.spawn(|| (0..3).filter(|_| limiter.allow("client-a", 0.0)).count()))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
});
println!("allowed at t=0: {allowed} of 12");
println!("t=0.5: {}", limiter.allow("client-a", 0.5)); // half a token refilled: not enough
println!("t=2.0: {}", limiter.allow("client-a", 2.0)); // 2 tokens refilled
println!("other client: {}", limiter.allow("client-b", 2.0)); // separate bucket
}allowed at t=0: 5 of 12
t=0.5: false
t=2.0: true
other client: true26. Realistic: a session store with expiry
A web session store: lookups are frequent (read lock), logins and cleanup are rare (write lock). A cleanup thread removes expired sessions while request threads keep reading.
use std::collections::HashMap;
use std::sync::RwLock;
use std::thread;
#[derive(Clone, Debug)]
struct Session {
user: String,
expires_at: u64,
}
#[derive(Default)]
struct SessionStore {
sessions: RwLock<HashMap<String, Session>>,
}
impl SessionStore {
fn login(&self, token: &str, user: &str, now: u64, ttl: u64) {
let s = Session { user: user.to_string(), expires_at: now + ttl };
self.sessions.write().unwrap().insert(token.to_string(), s);
}
fn user_for(&self, token: &str, now: u64) -> Option<String> {
let sessions = self.sessions.read().unwrap();
sessions.get(token).filter(|s| s.expires_at > now).map(|s| s.user.clone())
}
fn purge_expired(&self, now: u64) -> usize {
let mut sessions = self.sessions.write().unwrap();
let before = sessions.len();
sessions.retain(|_, s| s.expires_at > now);
before - sessions.len()
}
}
fn main() {
let store = SessionStore::default();
store.login("t1", "ana", 0, 100);
store.login("t2", "ben", 0, 10);
store.login("t3", "cy", 0, 50);
let (lookups, purged) = thread::scope(|s| {
let reader = s.spawn(|| ["t1", "t2", "t3", "t9"].map(|t| store.user_for(t, 20)));
let cleaner = s.spawn(|| store.purge_expired(20));
(reader.join().unwrap(), cleaner.join().unwrap())
});
println!("lookups at t=20: {lookups:?}");
println!("purged: {purged}");
println!("remaining: {}", store.sessions.read().unwrap().len());
}lookups at t=20: [Some("ana"), None, Some("cy"), None]
purged: 1
remaining: 2Whether the reader runs before or after the purge, t2 is already expired at t=20, so the output is the same either way. Designing operations so that interleavings don't matter is a big part of writing correct concurrent code.
27. Realistic: a worker pool pulling from a shared queue
A fixed set of worker threads takes jobs from a shared Mutex<VecDeque> until it's empty, and records results in a shared Mutex<Vec>. This is the core of what an ExecutorService does.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Debug)]
struct Job {
id: u32,
payload: String,
}
fn main() {
let jobs: VecDeque<Job> = (1..=10).map(|id| Job { id, payload: format!("image-{id}.png") }).collect();
let queue = Arc::new(Mutex::new(jobs));
let results = Arc::new(Mutex::new(Vec::new()));
let workers: Vec<_> = (0..3)
.map(|w| {
let queue = Arc::clone(&queue);
let results = Arc::clone(&results);
thread::spawn(move || {
let mut done = 0;
loop {
let job = queue.lock().unwrap().pop_front(); // lock released at `;`
let Some(job) = job else { break };
let thumbnail = format!("thumb-{}", job.payload);
results.lock().unwrap().push((job.id, thumbnail));
done += 1;
}
(w, done)
})
})
.collect();
let total_done: u32 = workers.into_iter().map(|h| h.join().unwrap().1).sum();
let mut results = results.lock().unwrap();
results.sort();
println!("jobs processed: {total_done}");
println!("first: {:?}", results.first());
println!("last: {:?}", results.last());
}jobs processed: 10
first: Some((1, "thumb-image-1.png"))
last: Some((10, "thumb-image-10.png"))Note let job = queue.lock().unwrap().pop_front(); on its own line: if the pop_front were inside the let ... else, the lock would still be released at the end of the statement, but writing it this way makes it obvious that the job is processed without holding the queue lock.
28. Realistic: a thread-safe metrics registry
A registry of named counters and latency samples, shared by request handlers. Each metric has its own lock, and the registry map is behind an RwLock so that adding a new metric is rare and looking one up is cheap.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
#[derive(Default)]
struct Metric {
count: u64,
total_ms: u64,
max_ms: u64,
}
#[derive(Default)]
struct Registry {
metrics: RwLock<HashMap<String, Arc<Mutex<Metric>>>>,
}
impl Registry {
fn metric(&self, name: &str) -> Arc<Mutex<Metric>> {
if let Some(m) = self.metrics.read().unwrap().get(name) {
return Arc::clone(m);
}
let mut map = self.metrics.write().unwrap();
Arc::clone(map.entry(name.to_string()).or_default()) // another thread may have added it meanwhile
}
fn record(&self, name: &str, ms: u64) {
let metric = self.metric(name);
let mut m = metric.lock().unwrap();
m.count += 1;
m.total_ms += ms;
m.max_ms = m.max_ms.max(ms);
}
fn report(&self) -> Vec<String> {
let map = self.metrics.read().unwrap();
let mut lines: Vec<String> = map
.iter()
.map(|(name, m)| {
let m = m.lock().unwrap();
format!("{name}: n={} avg={}ms max={}ms", m.count, m.total_ms / m.count, m.max_ms)
})
.collect();
lines.sort();
lines
}
}
fn main() {
let registry = Registry::default();
thread::scope(|s| {
for t in 0..4u64 {
let registry = ®istry;
s.spawn(move || {
for i in 0..25u64 {
registry.record("GET /products", 10 + (t * 25 + i) % 7);
if i % 5 == 0 {
registry.record("POST /checkout", 100 + t * 10);
}
}
});
}
});
for line in registry.report() {
println!("{line}");
}
}GET /products: n=100 avg=12ms max=16ms
POST /checkout: n=20 avg=115ms max=130msSummary
Arc<T>shares ownership across threads; alone it only gives read access.Mutex<T>andRwLock<T>own the data they protect, so you can't access it without locking. The guard releases the lock when dropped.- Keep critical sections short: copy out, drop the guard, then do slow work. Watch temporaries in
matchscrutinees. - Rust mutexes aren't reentrant; structure code so each operation locks once.
- A panic while holding a lock poisons it;
lock()then returnsErr. - Avoid deadlocks with a global lock order, or by using fewer, coarser locks.
Condvarwaits for a condition (usewait_while);Barriersynchronizes phases.- Scoped threads can share
&Mutex<T>withoutArc;into_innerandget_mutget the data back without locking.
Next: Channels, where threads communicate by sending messages instead of sharing memory.
