rust.
rust12 min read

rust-rwlock-vs-mutex-when

Last year I sat next to a teammate staring at a flame graph that made no sense. At the very top, eating roughly a third of our CPU, was a thick bar of __pthread_rwlock_rdlock. The service was a routing proxy where reads outnumbered writes a hundred to one, and we'd done the textbook thing \u2014 reached for RwLock. Swapping it for Mutex cut the contention nearly in half. That was the moment I stopped trusting the read/write ratio as a heuristic and started paying attention to what these locks actually cost.

When two threads want the same data

Why does Rust hand you two locks that look nearly interchangeable in the docs, and how do you tell \u2014 before you ever run a benchmark \u2014 which one will quietly ruin your throughput? The compiler refuses to let you share a mutable value across threads without a synchronization primitive, and the standard library offers two obvious candidates: std::sync::Mutex and std::sync::RwLock. They look almost interchangeable on a quick read of the docs. Both wrap a value, both hand out guards, both block when contention shows up. Yet picking the wrong one is one of the most common performance footguns in real Rust services, and it shows up in production traces long after the code compiled cleanly and passed CI.

I want to walk through what these two locks actually do at the OS and library level, when each one wins, when each one quietly loses, and what to reach for when neither is the right answer. My aim isn't to leave you with a rule of thumb but to build the mental model that lets you predict, before you benchmark, which primitive will behave well under your real workload.

The contract each lock promises

The two standard locks share nearly identical APIs, but a single word in each signature is the reason they behave nothing alike under load \u2014 and the simpler of the two, Mutex<T>, enforces strict exclusive access. At any moment, exactly zero or one thread is inside the critical section. Every other caller blocks until the holder releases the guard. The guard implements Deref and DerefMut, so the wrapped value is reachable through &T or &mut T regardless of whether you intend to read or write. The mutex does not know or care which you do.

A RwLock<T> enforces a weaker invariant called readers-writer exclusion. Multiple threads may hold a read guard at the same time, but a write guard is exclusive: no other readers and no other writers may be active while one writer holds it. The lock distinguishes the two access modes at the API level. You call .read() to get a &T guard and .write() to get a &mut T guard, and the lock decides whom to admit based on what is already in flight.

The vocabulary suggests an obvious heuristic: if you read more than you write, use RwLock. The heuristic is correct in spirit and misleading in practice, because the cost of each operation is not what the API surface suggests. To see why, you have to look one level down.

What it actually costs to take a lock

Why does swapping RwLock for Mutex sometimes speed up a read-heavy workload? Look one level below the API: a Mutex is built on a single atomic word that the OS or runtime treats as a futex (on Linux), a WaitOnAddress slot (on Windows), or an os_unfair_lock (on macOS). The fast path, where no one else is contending, is a single compare-and-swap that flips the word from unlocked to locked. That is a handful of nanoseconds on modern hardware. Releasing the lock is another atomic store. Both operations touch one cache line.

A RwLock has to track strictly more state. It must remember how many readers are currently holding the lock, whether a writer is queued or active, and whether new readers should be admitted ahead of a waiting writer or starved in its favor. The fast path involves at least one atomic read-modify-write to bump a reader counter, and on most implementations a second atomic to check writer state. Releasing a read guard decrements the counter and, if it falls to zero with a writer waiting, signals the writer. None of this is expensive in absolute terms, but it is two to three times the cost of acquiring an uncontended mutex.

The consequence is that for short critical sections, a Mutex often outperforms a RwLock even when the workload is read-heavy. The bookkeeping overhead of the reader-writer scheme costs more than the serialization it avoids, because the critical section was so short that serializing it was nearly free. This is the trap behind the read-heavy heuristic.

The line where RwLock starts to win

The break-even point depends on three things: how long the read section is, how many cores are competing, and how often a writer shows up. As a rough rule, if your read section is shorter than the time it takes to perform the two extra atomics in RwLock::read, the Mutex will win even with twenty readers. If your read section involves a hash lookup, a clone, a system call, or a string parse, the RwLock starts to pull ahead because the parallelism it unlocks dwarfs its bookkeeping cost.

The Rust standard library documents this trade-off explicitly in the RwLock module, and the parking_lot crate's documentation goes further by listing concrete microbenchmark numbers for its own implementation against the standard library's. The numbers in both places point the same direction: RwLock is a tool for protecting substantial read-only work, not a drop-in replacement for Mutex on data that happens to be read more than written.

A small but real example

Consider a service that maintains a routing table. Requests arrive, look up a destination, and forward the payload. A config reload thread occasionally rewrites the table. Reads are vastly more frequent than writes, and each read involves a hash lookup plus a clone of a small struct.

use std::collections::HashMap;
use std::sync::RwLock;

pub struct Router {
    table: RwLock<HashMap<String, Destination>>,
}

#[derive(Clone)]
pub struct Destination {
    host: String,
    port: u16,
}

impl Router {
    pub fn route(&self, key: &str) -> Option<Destination> {
        let guard = self.table.read().ok()?;
        guard.get(key).cloned()
    }

    pub fn reload(&self, fresh: HashMap<String, Destination>) {
        let mut guard = self.table.write().expect("poisoned");
        *guard = fresh;
    }
}

This is the textbook case for RwLock. The read section does enough work that parallel reads on multiple cores translate directly into throughput. The writer is rare and tolerates being queued. A Mutex here would serialize every lookup, which on a busy multicore box throws away almost all of the available parallelism.

Now contrast that with a counter that every thread bumps and a background thread occasionally reads.

use std::sync::Mutex;

pub struct Counter {
    inner: Mutex<u64>,
}

impl Counter {
    pub fn bump(&self) {
        let mut g = self.inner.lock().expect("poisoned");
        *g += 1;
    }

    pub fn snapshot(&self) -> u64 {
        *self.inner.lock().expect("poisoned")
    }
}

The critical section is a single integer increment. Switching to RwLock would make bump and snapshot both more expensive without unlocking any parallelism, because there is nothing parallel about a one-instruction critical section. In fact, for this exact case the right answer is neither lock: a single AtomicU64 with fetch_add outperforms both by an order of magnitude.

Writer starvation and fairness

Readers-writer locks have a policy choice baked in that mutexes do not. If a writer is waiting and a new reader arrives, does the new reader join the active readers or queue behind the writer? The first policy maximizes read throughput but can starve writers indefinitely under sustained read load. The second prevents starvation but caps read throughput when writers show up frequently.

The Rust standard library does not guarantee either policy. The official docs say the order in which contending threads acquire the lock is unspecified, and on Linux the implementation delegates to the platform's pthread_rwlock_t, which historically prefers readers. The parking_lot crate explicitly documents its fairness behavior and offers both eager and fair variants. If you have a workload where writes must make progress under heavy read contention, you almost certainly want parking_lot::RwLock or one of its fair variants rather than the standard library's primitive, because debugging a starvation issue in production is far more painful than picking up an extra dependency.

Mutexes face a milder version of the same issue: under contention, who gets the lock next is implementation-defined. But because a mutex has only one waiting queue, the failure mode is less dramatic. The worst case is a thread that gets unlucky on scheduling, not a thread that never runs.

Poisoning and the cost of panics

Both standard library locks implement poisoning. If a thread panics while holding the guard, the lock is marked poisoned and every subsequent acquire returns Err. The intent is to alert callers that the protected invariants may be broken, since the panicking thread did not get to clean up.

In practice, most application code calls .unwrap() or .expect() on the result and treats poisoning as fatal. That's usually correct: a panic inside a critical section means a logic bug, and proceeding with potentially-corrupt state is worse than crashing. But the API cost is real. Every lock acquisition returns a Result, every guard binding requires an unwrap, and the resulting code is noisier than the equivalent in many other languages.

The parking_lot crate drops poisoning entirely. Its lock and read and write methods return guards directly. If you want poisoning back, you opt into it. For most application code this is a net win in readability, though it shifts responsibility to the developer for noticing partial state.

Async code and the wrong kind of lock

A recurring bug in Rust web services is reaching for std::sync::Mutex inside an async function and holding the guard across an .await. The compiler will let you do it if the guard is Send, but the runtime semantics are usually disastrous. Tokio worker threads are a small fixed pool, and a blocking mutex held across an await pins one of them. A handful of contending requests can deadlock the executor.

The rule is simple: if your critical section never awaits, a regular std::sync::Mutex is fine and is in fact faster than its async counterpart. If your critical section does await, you need an async-aware lock such as tokio::sync::Mutex or tokio::sync::RwLock. These cost more on the fast path because they integrate with the executor's wakeup machinery, but they yield instead of blocking the worker thread when contended.

The same logic applies to RwLock. If your read section computes synchronously and does not await, std::sync::RwLock works. If your read section awaits a database query while holding the read guard, you need the async variant, and you should also stop and ask whether holding the lock that long is really what you want.

When the answer is neither lock

Quite often the right move is to step off the locking treadmill entirely. A few patterns dominate.

For primitives that fit in a word, std::sync::atomic types like AtomicU64, AtomicBool, and AtomicPtr give you lock-free updates with a memory-ordering knob you control. Counters, flags, and version numbers should almost always live here.

For copy-on-write data with many readers and rare writers, arc_swap::ArcSwap from the arc-swap crate lets readers take a snapshot in a few nanoseconds with no locking at all. The writer atomically swaps in a new Arc, and old readers finish with the old value. This is dramatically faster than RwLock for the routing-table pattern shown earlier, at the cost of forcing the writer to rebuild the entire structure each time.

For concurrent maps where both reads and writes happen at high rates, sharded structures like the dashmap crate avoid the global lock by partitioning keys across many internal locks. The trade-off is memory overhead and a more complex API, but the throughput gain on multi-core systems is substantial.

For message-passing designs, channels (std::sync::mpsc, tokio::sync::mpsc, or crossbeam-channel) often eliminate the lock by giving each piece of state a single owning thread. Other threads send requests; the owner replies. This is more work to refactor toward but produces code that is easier to reason about than any locking scheme.

A decision checklist

Before picking a lock, walk through these questions in order. Stop at the first one that gives you a definitive answer.

Is the protected value a single integer or boolean that you only read, write, or do arithmetic on? Use an atomic.

Is the protected value an Arc that you swap wholesale on writes and clone on reads? Use arc_swap.

Does the critical section span an .await? Use the async-aware lock from your executor, or refactor to avoid holding state across yields.

Is the critical section a handful of instructions, regardless of the read/write ratio? Use Mutex. The bookkeeping cost of RwLock will dominate.

Is the critical section substantial work, with many more reads than writes, and is writer starvation acceptable or unlikely? Use RwLock, and consider parking_lot::RwLock for better performance and explicit fairness control.

Is the structure a key-value map with high contention on both reads and writes? Use a sharded map like dashmap or partition the data yourself.

Does the problem fit a message-passing model where one thread owns the data? Use a channel and skip locks entirely.

The checklist is not exhaustive, but it covers the cases where most teams pick wrong. The instinct to reach for RwLock whenever reads outnumber writes deserves the most scrutiny. It's the choice that looks obviously correct, costs nothing to write, and shows up in flame graphs six months later as a dense bar of __pthread_rwlock_rdlock that nobody can explain.

Closing thoughts

Locks are not a moral failing. Used in the right places, they are the simplest, most debuggable, most portable concurrency primitive available. The mistake is treating them as a uniform tool when they encode different cost models and different fairness policies. Mutex is the right default for short critical sections and low-overhead bookkeeping. RwLock is a specialized tool for protecting substantial read-only work with rare writers. Everything else, from atomics to channels to sharded maps, exists because real workloads sometimes fit neither pattern, and forcing them through one anyway is how you end up rewriting hot paths in production.

The payoff for thinking carefully here is real. A service that picks the right primitive for each piece of shared state will scale across cores naturally, without surprising contention cliffs as load ramps. A service that picks lazily will work fine until it does not, and the diagnosis is rarely fun. Spend the ten minutes up front.