29. Shared state: Mutex, RwLock, atomics, Condvar, channels
29.1 Mutex<T> owns the data it protects
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let hits: Arc<Mutex<HashMap<String, u32>>> = Arc::new(Mutex::new(HashMap::new()));
let workers: Vec<_> = ["/a", "/b", "/a", "/c", "/a"]
.into_iter()
.map(|path| {
let hits = Arc::clone(&hits); // one Arc handle per thread
thread::spawn(move || {
let mut map = hits.lock().unwrap(); // MutexGuard<HashMap<..>>
*map.entry(path.to_string()).or_insert(0) += 1;
}) // guard dropped here: unlocked
})
.collect();
for w in workers { w.join().unwrap(); }
let map = hits.lock().unwrap();
println!("{:?}", map.get("/a")); // Some(3)
}
In C and Go, a mutex protects whatever the programmer remembers to access
only while holding it. In Rust, Mutex<T> contains the T. The only
way to reach the data is lock(), which returns a guard, and the guard
unlocks when it is dropped. You cannot forget to lock, and you cannot forget
to unlock.
Poisoning: if a thread panics while holding the lock, the mutex is
marked poisoned and lock() returns Err. The data might be half-updated.
.unwrap() turns that into a panic. Use .unwrap_or_else(|e| e.into_inner())
if you know the data is still consistent.
29.2 What Rust does not prevent: deadlocks and logical races
// fragment
// Thread 1 // Thread 2
let a = accounts.lock(); let b = audit.lock();
let b = audit.lock(); // waits let a = accounts.lock(); // waits -> DEADLOCK
Deadlocks are memory-safe, so the compiler allows them. Use the same
disciplines as the OS guide Ch 18:
a global lock order, holding locks for short scopes, and never calling
unknown code (callbacks, .await) while holding a lock. Check-then-act
races are also possible: if map.lock().contains(k) { map.lock().insert(..) }
takes the lock twice, and another thread can insert in between. Do the whole
operation under one guard.
29.3 RwLock and atomics
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use std::thread;
struct Metrics {
requests: AtomicU64, // lock-free counter
config: RwLock<String>, // many readers OR one writer
}
fn main() {
let m = Arc::new(Metrics { requests: AtomicU64::new(0), config: RwLock::new("v1".into()) });
thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
for _ in 0..10_000 {
m.requests.fetch_add(1, Ordering::Relaxed); // a counter needs no ordering (Ch 61)
let _cfg = m.config.read().unwrap(); // readers do not block each other
}
});
}
s.spawn(|| *m.config.write().unwrap() = "v2".into());
});
println!("{} requests, config {}", m.requests.load(Ordering::Relaxed), m.config.read().unwrap());
}
29.4 Channels: "share memory by communicating", Rust edition
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
// sync_channel(N) is BOUNDED: senders block when the buffer is full (backpressure).
let (tx, rx) = mpsc::sync_channel::<(usize, String)>(16);
for id in 0..3 {
let tx = tx.clone(); // multi-producer
thread::spawn(move || {
for j in 0..2 {
tx.send((id, format!("job {j}"))).unwrap();
thread::sleep(Duration::from_millis(5));
}
}); // this thread's tx is dropped here
}
drop(tx); // drop the original sender, or the receiver loop below never ends
for (id, msg) in rx { // ends when ALL senders are dropped
println!("worker {id}: {msg}");
}
}
| Go | Rust (std) |
Rust (Tokio, Ch 31) |
|---|---|---|
make(chan T) (unbuffered) |
sync_channel(0) |
— |
make(chan T, n) |
sync_channel(n) |
tokio::sync::mpsc::channel(n) |
close(ch) |
drop every Sender |
drop every Sender |
select |
— (use crossbeam-channel) |
tokio::select! |
| broadcast | — | tokio::sync::broadcast |
| "latest value" | — | tokio::sync::watch |
Sending a value moves it. After tx.send(buf), the sending thread can
no longer touch buf. In Go, sending a pointer or slice over a channel and
then mutating it is a common race. In Rust that is a compile error.
29.5 Condvar: waiting for a condition
Condvar works like pthread_cond_t in the C guide's producer/consumer
example: wait while holding a MutexGuard and loop on the condition,
because wakeups can be spurious. Most code should use a channel instead.
Ch 37 uses one inside a thread pool.