DEV Community

Cover image for title: Mutex vs RwLock vs ArcSwap. A when-to-use-what memo
Chris
Chris

Posted on

title: Mutex vs RwLock vs ArcSwap. A when-to-use-what memo

This is a memo to future me, for when I get rusty (yes).

Coming from TypeScript and Python, shared-state concurrency is a topic we literally never had to deal with — the runtime dealt with it for us. Rust hands you the steering wheel, and in exchange makes the worst crash a compile error. This is my map of the territory: what a data race actually is, when to use Mutex vs RwLock vs ArcSwap, and why I shipped ArcSwap in a recent project.


The bug we never had to think about

A data race is: two threads touch the same memory, at least one of them writes, and nothing synchronizes them. The result isn't "sometimes wrong" — it's undefined behavior. Lost updates, torn values, and the compiler optimizing your code under the assumption that races can't happen, so "it looks fine on my machine" means nothing.

The classic minimal case — two threads doing count += 1. That's a read, a modify, and a write; interleave two of them and an increment vanishes.

Why we never saw this in our world: Python has the GIL — one thread executes bytecode at a time. JavaScript is one thread with an event loop. We had race conditions — two awaits interleaving around a check-then-act — but never data races, because there was never true simultaneous memory access.

That distinction matters, so to be precise about what Rust does and doesn't do:

  • Data race — memory-level, UB. Rust makes this fail to compile.
  • Race condition — logic-level interleaving bug. Still entirely possible in Rust. The borrow checker is not a logic checker.

Rust's actual trick

Rust's borrow rule is: any number of readers or exactly one writer — &T xor &mut T. That's usually explained as a memory-safety rule, but look again: it's literally the definition of data-race-freedom, enforced at compile time. Send and Sync extend the same rule across threads.

So when you genuinely need shared mutable state, you need something that restores that rule at runtime. That's all a Mutex<T> is — a queue to get a &mut T:

let state = Mutex::new(Stats::default());

let mut s = state.lock().unwrap(); // runtime-checked exclusive borrow
s.hits += 1;
// guard drops → unlocked → next thread's turn
Enter fullscreen mode Exit fullscreen mode

Unlike most languages, the mutex owns the data. You can't reach the Stats without going through lock(), and unlock is Drop, so you can't forget it. Two entire bug classes gone by construction.

(That .unwrap() is policy, not laziness: a poisoned mutex means another thread panicked mid-update and the data may be half-written. Crashing beats trusting it.)

What the compiler can't choose for you is which tool. That's the rest of this memo.


Mutex — the default

Use when: shared state, any read/write mix, short critical sections. Which is most of the time.

An uncontended lock() costs on the order of tens of nanoseconds — it is almost never your bottleneck. The craft is in how you hold it, not whether you use it:

  • Compute outside the lock, mutate inside it.
  • Never hold a guard across IO, an .await, or a call into code you don't own.
  • Drop guards early — an explicit drop(guard) or a scoped block reads as intent.

Avoid when: reads vastly outnumber writes and measurably contend — that's the next rung. Not before you've measured.


RwLock — parallel readers, with fine print

Use when: many readers, rare writers, and — the part everyone skips — read sections long enough to actually overlap. A reader scanning a structure for microseconds while other readers do the same: that's the RwLock use case.

The fine print that makes it a sidegrade more often than people admit:

  • Acquiring a read lock still performs an atomic read-modify-write on the shared lock word. On a hot path, that one cache line ping-pongs across every core — short, frequent reads can be slower than a plain Mutex.
  • Writer fairness in std is OS-dependent; writers can starve under heavy read load.
  • Consistency only exists while you hold the guard. Re-lock, and the world may have changed between.

Avoid when: reads are short and hot (Mutex wins), or when writes replace the whole value anyway — because then there's a tool with no lock at all.


ArcSwap — replace, don't mutate

Use when: read-mostly data whose updates are wholesale replacements — nobody ever edits one field in place; a new complete version supersedes the old one. Config reloads, routing tables, in-memory indexes.

use arc_swap::ArcSwap;

static CONFIG: ArcSwap<Config> = /* ... */;

// reader — no lock, effectively wait-free, coherent snapshot
let cfg = CONFIG.load();
serve(&cfg);

// writer — build a complete new value off to the side, swap once
CONFIG.store(Arc::new(next_config));
Enter fullscreen mode Exit fullscreen mode

Readers get an Arc snapshot: always a complete version, never a half-updated one. Writers pay the full rebuild cost, but nobody waits for them. In-flight readers keep the old version alive until they're done with it; new readers see the new one immediately.

Avoid when: updates are small and frequent relative to the value (rebuilding the world to change one field is silly), or writers need read-modify-write semantics (two concurrent build-and-swap writers will lose one update — that path needs rcu() or a lock again).


Why I shipped ArcSwap this time

Real case from a recent project: an in-memory search index, rebuilt whenever the upstream data changes (a version counter tells us when). The requirements, written out honestly:

  1. Queried on every request. The read path is the hot path; reads must be as close to free as possible.
  2. Every query must see a coherent index. Half-rebuilt is worse than stale.
  3. Rebuilds are slow — normalizing thousands of rows takes real milliseconds. Queries cannot wait behind that.
  4. Updates are rare and wholesale. Nothing ever edits one entry in place.

Score the candidates:

  • Mutex — serializes every query. Dead on requirement 1.
  • RwLock, mutating in place — the write lock is held for the entire rebuild. Dead on 3, and every query still pays the lock-word ping-pong from the fine print above.
  • RwLock, build-then-swap — build the new index off to the side, take the write lock only to swap the value. Closer! The write lock is now held for nanoseconds. But readers still pay an atomic RMW per query, and at that point you've hand-implemented half of ArcSwap with the slow half left in.
  • ArcSwap — a query is a pointer load; the swap is instant; a slow query that started before the swap simply finishes on the old index while new queries use the new one. Every requirement, no residue.

Requirement 4 is what makes it clean. ArcSwap isn't "a better RwLock" — it's the right shape because the data's lifecycle is replace-not-mutate. If my index needed in-place edits, this whole analysis flips.


The actually interesting part: who frees the old one?

Here's the question that hooked me on this topic. After the swap, a slow query is still reading the old index. When is it safe to free it?

This is the memory reclamation problem, and it's the real hard part of lock-free reading — not the swap, the free. You can't free while an invisible reader might still hold a pointer, and by definition lock-free readers don't announce themselves.

Every ecosystem has an answer:

  • GC languages — the collector solves it invisibly. This is why lock-free structures are "easy" in Java, and why we never learned any of this in Python/TS: the GC was quietly doing reclamation for us the whole time.
  • RCU (Linux kernel) — wait until every CPU passes a quiescent state, then free.
  • Hazard pointers — readers publish "I'm holding this" before dereferencing.
  • Epoch-based reclamation (crossbeam) — generation stamps; free when every thread has moved past the old epoch.
  • Arc — a reference count. The last reader to drop the snapshot frees it. Deterministic, no GC pauses, no epochs.

That last one is ArcSwap's answer, and it's worth sitting with: Rust solves the reclamation problem with the same ownership system that made the data race a compile error. The slow query owns a share of the old index; when the last share drops, the memory goes. It's ownership all the way down.


Async footnote

Two things worth pinning, because the folklore is wrong:

  • std::sync::Mutex is fine — usually preferred — in async code for short critical sections. Tokio's own docs say so. tokio::sync::Mutex exists for one job: holding a lock across an .await. And wanting that is usually a design smell — restructure to lock-copy-drop, await, re-lock. (Caveat: that changes semantics — state can move between the two locks. If the whole span genuinely must be exclusive, that's when the async mutex earns its place.)
  • ArcSwap is especially pleasant in async: load_full() gives you an owned Arc that crosses .await freely. No guard, no Send drama — the snapshot is just a value.

The memo itself

Situation Reach for
Shared state, mixed read/write, short ops Mutex
Long read sections, rare writes, measured contention RwLock
Read-mostly + wholesale replacement + hot read path ArcSwap
Counter, flag, version number AtomicU64 / AtomicBool
Must hold exclusivity across .await tokio::sync::Mutex
It's actually a pipeline, not shared state channels / ownership

The ladder underneath the table: don't share; share frozen; Mutex; and everything above that gets earned with a measurement, not a vibe.

Future me: you probably want the Mutex.

Top comments (0)