Day 5 · Concurrency
On Day 4 the store was single-threaded: one REPL, one
&mut MemStore, no contention. A real KV store is hit by many clients at once. The instant two threads
want to touch one HashMap — and at least one of them writes — you are staring down a data race, the
single most-cited bug class in systems programming. Today we make the store safe to share, and the way
Rust makes us do it is the headline feature of the whole language: fearless concurrency. The compiler
will not let us write the race in the first place.
The bug we are preventing
Section titled “The bug we are preventing”A data race needs four things at once: two threads, shared data, at least one writer, and no synchronization. In C, all four are one careless line away, and the failure is the worst kind — intermittent, non-reproducible, corrupting memory silently. Here’s the shape:
thread A shared HashMap thread B ──────── ────────────── ──────── read bucket ◀─────── count: 5 ───────▶ read bucket write 6 ──────▶ ◀────── write 6 count: 6 one update lost, (should be 7!) or worse: corruptIn Rust you simply can’t hand a plain &mut MemStore to two threads — the borrow checker’s “shared XOR
mutable” rule already forbids two mutable borrows. And you can’t dodge it with the single-threaded sharing
tools either: Rc<RefCell<T>> (shared mutable state on one thread) is not Send, so the compiler
refuses to move it into a thread at all. The error is blunt and early:
error[E0277]: `Rc<RefCell<MemStore<..>>>` cannot be sent between threads safelyThat refusal is the feature. To share across threads you must reach for tools that are safe to share — and Rust has exactly two families.
Tool 1: shared state behind a lock — Arc<RwLock<T>>
Section titled “Tool 1: shared state behind a lock — Arc<RwLock<T>>”Two pieces, each doing one job:
Arc<T>— atomically reference-counted shared ownership. Many threads can each hold anArcto the same value; the value is dropped only when the lastArcdoes. Cloning anArcjust bumps an atomic counter — cheap, and thread-safe (that’s the “A”).RwLock<T>— a lock enforcing many readers XOR one writer at runtime. This is the exact same “shared XOR mutable” rule from The Rust Mindset, now applied to data shared between threads instead of within one.
Stack them and you get kvlite’s thread-safe handle:
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone)] pub struct SharedStore<K, V> { inner: Arc<RwLock<MemStore<K, V>>>, }
impl<K: Eq + Hash, V: Clone> SharedStore<K, V> { pub fn get(&self, key: &K) -> Result<Option<V>> { let guard = self.inner.read().map_err(|_| KvError::Poisoned)?; // shared read lock Ok(guard.get(key)) // clone the value *inside* the lock... } // ...guard drops HERE, lock released
pub fn set(&self, key: K, value: V) -> Result<Option<V>> { let mut guard = self.inner.write().map_err(|_| KvError::Poisoned)?; // exclusive write lock guard.set(key, value) } }Three things to notice, because each is the compiler teaching you:
gettakes&self, not&mut self— yet it can mutate the lock’s internals. That’s interior mutability: theRwLockmoves the mutable-access check from compile time to runtime, which is the only way many threads can share one writable thing. The lock is the runtime stand-in for&mut.- The guard’s
Dropreleases the lock. You never callunlock(). Whenguardgoes out of scope, its destructor frees the lock — ownership, the same rule from page one, now managing a lock instead of memory. Forgetting to unlock is structurally impossible. - We clone the value inside the lock and return the clone (this is why Day 4’s
getreturns ownedV). The borrow into the map cannot escape the guard’s lifetime, so we copy out and let go. Holding a lock longer than necessary is how you build a slow server; the design hands the lock back immediately.
Under the hood — Send, Sync, and how the compiler knows it’s safe
Section titled “Under the hood — Send, Sync, and how the compiler knows it’s safe”How does Rust decide Arc<RwLock<MemStore>> is shareable but Rc<RefCell<MemStore>> is not? Two
marker traits the compiler auto-derives:
Send— a type is safe to move to another thread.Sync— a type is safe to share by reference (&T) across threads. (T: Syncexactly when&T: Send.)
These propagate structurally: a struct is Send/Sync only if all its fields are. Rc is deliberately
neither (its refcount is a plain, non-atomic integer — racing on it would corrupt the count), which is
why moving one into a thread is a compile error. Arc is Send + Sync (atomic count), and RwLock<T>
is Sync as long as T: Send + Sync. So Arc<RwLock<MemStore>> is Send + Sync — and thread::spawn requires
its closure be Send, so the check happens automatically when you spawn. The data-race guarantee isn’t
a lint or a runtime monitor; it’s the type system. Unsynchronized sharing is a type error.
Tool 2: don’t share at all — message passing with mpsc
Section titled “Tool 2: don’t share at all — message passing with mpsc”The other way to be safe is to not share mutable state in the first place: give one thread sole
ownership of the data and have everyone else send it messages. Rust ships std::sync::mpsc — a
multi-producer, single-consumer channel — for exactly this. The store lives in one “owner” thread that
needs no lock at all, because nothing else can reach it:
use std::sync::mpsc;
enum Cmd { Set(String, String), Get(String, mpsc::Sender<Option<String>>), // carries a reply channel }
let (tx, rx) = mpsc::channel::<Cmd>();
// The owner thread: it alone holds the store. std::thread::spawn(move || { let mut store: MemStore<String, String> = MemStore::new(); for cmd in rx { // iterate until every Sender is dropped match cmd { Cmd::Set(k, v) => { let _ = store.set(k, v); } Cmd::Get(k, reply) => { let _ = reply.send(store.get(&k)); } } } });
// Any number of producers can clone `tx` and send commands. tx.send(Cmd::Set("name".into(), "ada".into())).unwrap();This is the “do not communicate by sharing memory; share memory by communicating” philosophy. The
channel itself moves ownership of each message from sender to receiver — so there’s never two owners of
the same data, and never a lock to forget. kvlite uses the lock approach (it fits a read-mostly store and
keeps the code small), but knowing both models is the point: shared-state-plus-lock and
message-passing are the two pillars of std concurrency, and you’ll choose between them constantly.
Honest trade-offs: locks don’t make you correct, only race-free
Section titled “Honest trade-offs: locks don’t make you correct, only race-free”RwLock removes data races — torn memory, corrupt counts. It does not remove logic races. Watch:
let n = store.get("hits")?.unwrap(); // lock taken and RELEASED store.set("hits", n + 1)?; // lock taken again — but n is stale now!Between the two calls, another thread can bump hits, and your set overwrites it — a classic lost
update (a time-of-check-to-time-of-use race). The fix is to do the read-modify-write inside one lock
(a single increment method that takes the write lock once) or use an atomic. The compiler can’t catch
this for you — it’s about what you do under the lock, not whether you took it. Two other hazards round
out the honesty:
- Deadlock — take two locks in different orders on two threads and both wait forever. Rust prevents data races, not deadlocks. Discipline (a fixed lock order, or just one lock) is on you.
- Poisoning — if a thread panics while holding a lock, the lock becomes poisoned; later
.lock()calls returnErr. kvlite maps that toKvError::Poisonedinstead of re-panicking, so one bad request can’t take down the server.
Build it: concurrent access from threads
Section titled “Build it: concurrent access from threads”The Day 5 deliverable is hammering one SharedStore from many threads and proving nothing is lost. The
crate’s integration test (tests/concurrency.rs) does exactly this:
let store: SharedStore<String, u64> = SharedStore::new(); let handles: Vec<_> = (0..8).map(|t| { let store = store.clone(); // Arc bump: same map, new handle std::thread::spawn(move || { for i in 0..1000 { store.set(format!("t{t}-k{i}"), 0).unwrap(); } }) }).collect(); for h in handles { h.join().unwrap(); } assert_eq!(store.len().unwrap(), 8 * 1000); // exact — no update lostEight threads, eight thousand distinct keys, exact final count. Run it with cargo test. Then change the
threads to all write the same key while incrementing across two calls, and watch the lost-update race
appear — the count comes out less than expected, with no panic and no compiler complaint. That contrast
is the whole lesson: what does building this force you to understand, and what is the compiler protecting
you from? It protects you absolutely from data races (they’re type errors) and not at all from logic
races (they’re your design) — and knowing which is which is what makes you dangerous in the good way.
Next we make those writes survive a crash.
→ Next: Day 6 · Persistence & Errors · Prev: Day 4 · Traits & Generics · Back to the Project 2 overview
Check your understanding
Section titled “Check your understanding”- Name the four ingredients of a data race. Which one does
Arc<RwLock<T>>remove, and how? Rc<RefCell<MemStore>>won’t compile when moved into a thread, butArc<RwLock<MemStore>>will. In terms ofSend/Sync, why is one rejected and the other accepted?SharedStore::gettakes&selfyet mutates the lock’s internals and returns an owned clone of the value. Explain both: why&selfis enough, and why it returns a clone rather than a&V.- Describe the lost-update race in the read-modify-write example. Why can’t
RwLockprevent it, and what is the fix? - Contrast the two
stdconcurrency models (shared-state-plus-lock vsmpscmessage passing). Why does the message-passing owner thread need no lock?
Show answers
- Two threads, shared data, at least one writer, and no synchronization.
Arc<RwLock<T>>removes the “no synchronization” ingredient:Arcgives safe shared ownership, andRwLockenforces many readers XOR one writer at runtime, so reads and writes can never overlap unsafely. Rc’s reference count is a non-atomic integer, soRcis notSend— racing threads could corrupt the count — andthread::spawnrequires aSendclosure, so it’s rejected.Arcuses an atomic count (so it’sSend + Sync) andRwLock<T>isSyncwhenT: Send + Sync, makingArc<RwLock<MemStore>>safe to move/share; the compiler accepts it.&selfis enough because theRwLockprovides interior mutability — it does the mutable-access check at runtime, so the method doesn’t need&mut self. It returns a clone because a&Vwould borrow data owned by the lock guard; the borrow can’t outlive the guard, so we copy the value out and release the lock immediately.- The code takes the lock for the
get, releases it, then takes it again for theset— between the two, another thread can update the key, so thesetwrites a stale value and that other update is lost.RwLockonly guarantees no data race per call; it can’t span two separate lock acquisitions. Fix: do the whole read-modify-write under one lock (one method holding the write lock) or use an atomic. - Shared-state: many threads share one value through
Arcand coordinate with a lock. Message-passing: one thread owns the data and others send it commands over anmpscchannel. The owner needs no lock because nothing else can reach the data — there is no sharing to synchronize; the channel transfers ownership of each message instead.