What is the difference between Mutex and RwLock

Mutex grants exclusive access for any operation, while RwLock allows concurrent reads or exclusive writes for better performance in read-heavy scenarios.

The shared whiteboard problem

Imagine a team working around a single whiteboard. The board holds the current sprint plan. Most of the time, team members just need to glance at the board to check their tasks. Occasionally, the lead updates the board to reflect a change.

If you enforce a rule where only one person can stand near the board at a time, the team grinds to a halt. Developers wait in line just to read a note. The bottleneck isn't the writing; it's the reading. You need a system where multiple people can read simultaneously, but the writer gets exclusive access to prevent scribbles from overlapping.

That is the exact problem RwLock solves. Mutex is the simpler rule: only one person near the board, period. RwLock is the smarter rule: anyone can read, but writing requires the room to clear.

Mutex: the single key

Mutex stands for Mutual Exclusion. It wraps a value and ensures that only one thread can access the value at any moment. It does not distinguish between reading and writing. If you hold the lock, you own the data. No other thread can touch it, not even to look.

The pattern relies on a guard object. You call lock() to acquire the lock. This returns a guard. The guard holds the lock alive. When the guard goes out of scope, the lock releases automatically. This is RAII in action: Resource Acquisition Is Initialization. The compiler guarantees the lock drops, even if a panic occurs.

use std::sync::Mutex;

fn main() {
    // The value lives inside the Mutex.
    let counter = Mutex::new(0);

    // lock() blocks until the lock is available.
    // unwrap() panics if the lock is poisoned by a previous panic.
    let mut guard = counter.lock().unwrap();

    // The guard dereferences to the inner value.
    // We can mutate because we have exclusive access.
    *guard += 1;

    // guard drops here. The lock releases immediately.
}

Mutex is the workhorse. It has low overhead. The kernel or runtime only needs to track one state: locked or unlocked. When writes are frequent, or when the critical section is tiny, Mutex is often faster than RwLock because RwLock carries bookkeeping costs to track multiple readers.

Mutex guarantees safety by serializing access. Use it when simplicity beats throughput.

RwLock: readers and writers

RwLock stands for Read-Write Lock. It splits access into two modes. Readers can hold the lock simultaneously. Writers get exclusive access, just like with Mutex. If a writer holds the lock, no readers can enter. If any reader holds the lock, a writer must wait for all readers to leave.

The API mirrors Mutex but adds a read() method. read() returns a guard that allows shared access. write() returns a guard that allows mutable access.

use std::sync::RwLock;

fn main() {
    let data = RwLock::new(vec![1, 2, 3]);

    // Multiple readers can exist at the same time.
    // This call never blocks if no writer is active.
    let r1 = data.read().unwrap();
    let r2 = data.read().unwrap();

    // Both guards are alive. We can read through both.
    assert_eq!(*r1, vec![1, 2, 3]);
    assert_eq!(*r2, vec![1, 2, 3]);

    // r1 and r2 drop here. The read lock releases.
}

Writers behave like Mutex. write() blocks until no readers and no other writers hold the lock.

use std::sync::RwLock;

fn main() {
    let data = RwLock::new(vec![1, 2, 3]);

    // write() blocks until exclusive access is granted.
    let mut w = data.write().unwrap();

    // Only one writer can exist.
    w.push(4);

    // w drops here. The lock releases.
}

RwLock trades complexity for concurrency. The runtime must track how many readers are active and whether a writer is waiting. This bookkeeping adds overhead. If your workload is write-heavy, RwLock can be slower than Mutex because the lock structure is larger and the state transitions are more expensive.

RwLock rewards you for understanding your access patterns. Measure before you switch.

The guard pattern and scope

Both Mutex and RwLock use guards to manage lifetime. The lock is not released when you finish using the data; it is released when the guard variable is dropped. This distinction matters.

If you bind the guard to a variable that lives longer than your use of the data, you hold the lock longer than necessary. Other threads block unnecessarily. This creates contention and can lead to deadlocks.

use std::sync::Mutex;

fn main() {
    let counter = Mutex::new(0);

    // BAD: guard lives until the end of the block.
    let guard = counter.lock().unwrap();
    println!("{}", *guard);
    // Do other work here...
    // The lock is still held! Other threads are blocked.
}

Keep the guard scope tight. Use a nested block or bind the guard to a short-lived variable.

use std::sync::Mutex;

fn main() {
    let counter = Mutex::new(0);

    // GOOD: guard drops at the end of the inner block.
    {
        let guard = counter.lock().unwrap();
        println!("{}", *guard);
    }
    // Lock is released here. Other threads can proceed.
}

Treat locks like borrowed money. Return them immediately, or the debt collector arrives.

Pitfalls and compiler errors

Poisoning

If a thread panics while holding a lock, the lock becomes poisoned. Rust marks the lock as poisoned to signal that the data might be in an inconsistent state. Subsequent calls to lock() or read() return an error instead of a guard.

The error type is PoisonError. If you call unwrap() on the result, your program panics again. This cascading panic is often not what you want.

use std::sync::Mutex;

fn main() {
    let counter = Mutex::new(0);

    // Simulate a panic inside the lock.
    std::thread::spawn(move || {
        let _guard = counter.lock().unwrap();
        panic!("Something went wrong!");
    }).join();

    // The lock is now poisoned.
    // unwrap() will panic here.
    let result = counter.lock();
    // result is Err(PoisonError).
}

Handle poisoning explicitly. You can recover the inner data using into_inner(). This is safe if you can verify the data is still valid, or if the panic was unrelated to the data.

use std::sync::Mutex;

fn main() {
    let counter = Mutex::new(0);

    // ... panic happens ...

    let result = counter.lock();
    let guard = result.unwrap_or_else(|e| e.into_inner());
    // guard is a MutexGuard, not a PoisonError.
    // We recovered the data.
}

Convention aside: In production code, avoid unwrap() on locks. Use expect() with a message, or handle the error. The unwrap_or_else(|e| e.into_inner()) pattern is common for resilient services where a panic in one thread shouldn't kill the whole system.

Recursive locking

Mutex and RwLock in std are not recursive. If a thread holds a lock and tries to acquire the same lock again, it deadlocks. The thread waits for itself, which never happens.

use std::sync::Mutex;

fn main() {
    let counter = Mutex::new(0);
    let guard = counter.lock().unwrap();

    // DEADLOCK: Trying to lock again while holding the lock.
    // This blocks forever.
    let _guard2 = counter.lock().unwrap();
}

The same applies to RwLock. If you hold a read lock and try to get a write lock, you deadlock. The write lock waits for all readers to drop, but you are a reader. You never drop.

use std::sync::RwLock;

fn main() {
    let data = RwLock::new(0);
    let _r = data.read().unwrap();

    // DEADLOCK: Write waits for readers. We are a reader.
    let _w = data.write().unwrap();
}

If you need recursive locking, you must use a different crate. The community standard is parking_lot. It provides Mutex and RwLock with better performance and optional recursive variants.

Convention aside: parking_lot is the de facto standard for locks in Rust production code. It is faster than std locks and handles edge cases better. Reach for parking_lot when you build libraries or services.

Write starvation

RwLock can suffer from write starvation. If readers keep arriving, a writer might wait indefinitely. The implementation allows new readers to enter as long as no writer is currently writing. If a writer is waiting, but readers keep coming, the writer never gets a turn.

This depends on the implementation. std::sync::RwLock behavior varies by platform. On some systems, it favors writers. On others, it favors readers. parking_lot::RwLock uses a fair queue to prevent starvation.

Profile your workload. If writes are critical and readers are endless, RwLock might starve your writers. Consider batching writes or using a different synchronization strategy.

Overhead

Locks have overhead. Acquiring and releasing a lock costs CPU cycles. If your critical section is tiny, the lock overhead dominates the execution time.

use std::sync::Mutex;

fn main() {
    let counter = Mutex::new(0);

    // BAD: Locking for a single increment.
    // The lock cost is likely higher than the increment.
    for _ in 0..1000000 {
        let mut guard = counter.lock().unwrap();
        *guard += 1;
    }
}

For simple counters, use atomic types. AtomicUsize provides lock-free operations for primitive values. Atomics are faster than locks for single-value updates.

Reach for AtomicUsize when you only need to update a single primitive value and don't need complex interior mutability.

Realistic example: a shared cache

A cache is a classic use case for RwLock. Reads are frequent. Writes happen when new data arrives. Multiple threads should be able to read cached values without blocking each other.

use std::sync::{Arc, RwLock};
use std::collections::HashMap;
use std::thread;

/// A thread-safe cache with read-heavy access.
struct Cache {
    /// The underlying map protected by an RwLock.
    data: RwLock<HashMap<String, String>>,
}

impl Cache {
    fn new() -> Self {
        Cache {
            data: RwLock::new(HashMap::new()),
        }
    }

    /// Get a value from the cache.
    /// Returns None if the key is missing.
    fn get(&self, key: &str) -> Option<String> {
        // Acquire a read lock.
        // Multiple threads can call get() simultaneously.
        let guard = self.data.read().unwrap();
        guard.get(key).cloned()
    }

    /// Insert a value into the cache.
    /// Blocks until exclusive access is granted.
    fn insert(&self, key: String, value: String) {
        // Acquire a write lock.
        // Blocks if any readers or writers are active.
        let mut guard = self.data.write().unwrap();
        guard.insert(key, value);
    }
}

fn main() {
    let cache = Arc::new(Cache::new());

    // Spawn threads that read from the cache.
    let mut handles = vec![];
    for i in 0..4 {
        let cache_clone = Arc::clone(&cache);
        handles.push(thread::spawn(move || {
            // Readers run concurrently.
            let _val = cache_clone.get("key");
        }));
    }

    // Spawn a thread that writes to the cache.
    let cache_clone = Arc::clone(&cache);
    handles.push(thread::spawn(move || {
        // Writer blocks until readers finish.
        cache_clone.insert("key".to_string(), "value".to_string());
    }));

    for h in handles {
        h.join().unwrap();
    }
}

Convention aside: Arc provides shared ownership across threads. RwLock provides interior mutability. You almost always combine them. Arc<RwLock<T>> is the standard pattern for shared mutable state. Rc is for single-threaded code. Arc is for multi-threaded code.

The cache example shows the benefit. Readers don't block each other. The writer waits for readers, but doesn't block other writers from queuing. This pattern scales well for read-heavy workloads.

Decision: when to use Mutex vs RwLock

Use Mutex when you need exclusive access and the critical section is short. Use Mutex when writes are as frequent as reads, or when the overhead of tracking readers isn't worth the gain. Use Mutex when you want the simplest possible synchronization primitive.

Use RwLock when reads dominate the workload and readers hold the lock for a measurable amount of time. Use RwLock when you have a shared data structure that is updated rarely but queried often. Use RwLock when profiling shows that Mutex contention is the bottleneck and your access pattern is read-heavy.

Reach for AtomicUsize or AtomicBool when you only need to update a single primitive value and don't need complex interior mutability. Atomics provide lock-free performance for simple counters and flags.

Pick channels or the actor model when you want to avoid shared state entirely and pass messages instead. Shared state requires locks. Messages require queues. Queues can be simpler to reason about in complex systems.

Profile first. Optimizing for concurrency without data is a trap. Measure contention before switching from Mutex to RwLock.

Where to go next