How to Use RwLock<T> in Rust

Use `RwLock<T>` when you need to share data across multiple threads where reads are frequent but writes are rare, as it allows unlimited concurrent readers or a single exclusive writer.

When reads dominate writes

You're building a leaderboard service. Thousands of players fetch their rank every second. The rank only changes when a match ends, which happens once a minute. If you wrap the leaderboard in a Mutex, every rank request has to wait in line behind every other request. The CPU sits idle while threads queue up for a lock that doesn't actually need to be exclusive. Reading the rank doesn't change the data, so blocking readers makes no sense.

RwLock<T> solves this. It allows any number of threads to read the data simultaneously. Writers still get exclusive access, but they only block when someone is actually writing. If reads vastly outnumber writes, RwLock<T> can dramatically increase throughput compared to a Mutex.

The whiteboard rule

Think of a whiteboard in a busy office. Anyone can walk by and read what's written on it. Five people can stare at the board at the same time without interfering. But if someone grabs a marker to write, they need exclusive access. Everyone else has to wait until the writing is done.

RwLock<T> works the same way. It tracks two pieces of state: the number of active readers and whether a writer is active. When you call .read(), the lock increments the reader counter and returns a guard. Multiple guards can exist at once. When you call .write(), the lock waits until the reader counter is zero and no other writer is active. It then sets a writer flag and returns a mutable guard.

The guard object is the key to safety. The guard holds a reference to the lock. When the guard is dropped, it decrements the reader counter or clears the writer flag. This RAII pattern ensures the lock is always released, even if a function returns early or panics. You never call unlock() manually.

Minimal example

Here is the basic pattern. You need Arc to share ownership across threads, and RwLock to protect the data inside.

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

fn main() {
    // Arc shares ownership. RwLock protects the data.
    // Convention: use Arc::clone(&var) to make it clear you're cloning the Arc, not the data.
    let data = Arc::new(RwLock::new(String::from("Hello")));

    let mut handles = vec![];

    // Spawn readers. They can all hold the lock at once.
    for i in 0..3 {
        let data_clone = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            // .read() returns a Result. Unwrap for examples, handle errors in prod.
            // The guard holds the read lock until it drops.
            let guard = data_clone.read().unwrap();
            println!("Reader {} sees: {}", i, *guard);
        }));
    }

    // Spawn a writer. It blocks until all readers finish.
    let data_clone = Arc::clone(&data);
    handles.push(thread::spawn(move || {
        // .write() returns a mutable guard. Only one writer allowed.
        let mut guard = data_clone.write().unwrap();
        *guard = String::from("Updated");
        println!("Writer updated the value.");
    }));

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

How the lock tracks state

The internal implementation maintains a reader counter and a writer flag. When a thread calls .read(), the lock checks if the writer flag is set. If a writer is active, the thread blocks. If not, the counter increments and the guard returns.

When a thread calls .write(), the lock waits until the counter is zero and the writer flag is clear. It then sets the flag and returns the guard. While the flag is set, new readers block. Existing readers must finish before the writer can proceed.

This design means readers never block other readers. Writers block readers and other writers. Readers block writers. The asymmetry is the whole point. If your workload is read-heavy, readers proceed without contention. Writers pay the cost of waiting, but that cost is amortized across the many reads that happen without waiting.

Scope your guards tightly. Holding a lock longer than necessary kills concurrency. If you need a value from the lock, copy it out and drop the guard immediately. The lock should protect the data, not the work.

Realistic example: Config cache

A common use case is a configuration cache. The config is read by every request but updated rarely. Here is a pattern that scopes the guard correctly.

use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;

struct Config {
    max_connections: u32,
    debug_mode: bool,
}

fn main() {
    let config = Arc::new(RwLock::new(Config {
        max_connections: 100,
        debug_mode: false,
    }));

    let mut workers = vec![];

    // Workers read config frequently.
    for id in 0..5 {
        let config_clone = Arc::clone(&config);
        workers.push(thread::spawn(move || {
            for _ in 0..10 {
                // Scope the guard to a block.
                // This ensures the lock is released before any heavy work.
                let should_debug = {
                    let cfg = config_clone.read().unwrap();
                    cfg.debug_mode
                };
                // Guard drops here. Lock is free.

                if should_debug {
                    println!("Worker {} running in debug mode.", id);
                }
                // Simulate work without holding the lock.
                thread::sleep(Duration::from_millis(10));
            }
        }));
    }

    // Admin updates config rarely.
    let config_clone = Arc::clone(&config);
    workers.push(thread::spawn(move || {
        thread::sleep(Duration::from_millis(50));
        // Write lock blocks until all workers finish their current reads.
        let mut cfg = config_clone.write().unwrap();
        cfg.max_connections = 200;
        cfg.debug_mode = true;
        println!("Config updated.");
    }));

    for w in workers {
        w.join().unwrap();
    }
}

Notice the block around the read guard in the workers. The guard is created, the value is extracted, and the guard drops at the closing brace. The lock is released before the println and the sleep. If you held the guard across the sleep, all writes would block for the duration of the sleep. That turns a fast read lock into a bottleneck.

Pitfalls and error handling

RwLock<T> has specific failure modes you need to handle.

Poisoning

If a thread panics while holding a lock, the lock becomes poisoned. The next call to .read() or .write() returns Err. This prevents you from reading corrupted state. In production, you usually want to recover.

Calling .unwrap() on a poisoned lock will panic your thread too. Use .into_inner() on the error to recover the guard, or use unwrap_or_else.

let guard = data.read().unwrap_or_else(|e| e.into_inner());

This pattern recovers the lock even if a previous thread panicked. Treat the poison as a signal that something went wrong, but don't let it take down your service unless the data is truly unrecoverable.

Writer starvation

Writer starvation happens when readers keep arriving faster than they leave. The writer waits forever because the reader counter never hits zero. std::sync::RwLock prioritizes readers. If you have a read-heavy workload with bursts of writes, writers might starve.

If starvation becomes a problem, switch to Mutex<T>. A Mutex is fairer in high-contention scenarios. Or re-architect to reduce shared state. Measure first. Starvation is rare in typical read-heavy workloads, but it can appear under load spikes.

No lock upgrade

You cannot upgrade a read lock to a write lock. If you need to read, check a condition, and then write, you must release the read lock and acquire a write lock. This creates a race condition window where another thread can modify the data between the read and the write.

Design your logic to avoid this pattern. Batch updates. Or use a Mutex if you need atomic check-and-update semantics.

Overhead

RwLock<T> has more overhead than Mutex<T>. It tracks a counter and manages fairness. If writes are frequent, or if reads are short and writes are rare but contention is low, a Mutex might be faster. The counter operations add cost. Profile your code. Don't reach for RwLock just because it sounds faster.

If you try to share a RwLock across threads without Arc, the compiler rejects you with E0277 (trait bound not satisfied). RwLock<T> implements Sync if T implements Send. This allows shared access, but you still need Arc to clone the reference.

Decision matrix

Use RwLock<T> when reads vastly outnumber writes and you need interior mutability across threads. Use RwLock<T> when readers can proceed concurrently without interfering, and writes are infrequent enough that writer blocking is acceptable. Use Mutex<T> when writes are frequent, or when the overhead of tracking reader counts outweighs the benefit of concurrent reads. Use Mutex<T> when you need fair locking under high contention to prevent starvation. Use AtomicUsize or AtomicBool when you only need to update a single primitive value and don't need complex state. Use channels when threads should communicate by sending messages rather than sharing mutable state. Use tokio::sync::RwLock when you are in an async runtime and need non-blocking lock acquisition.

Measure before you optimize. RwLock has more overhead than Mutex for simple cases. Scope your guards tightly. Holding a lock longer than necessary kills concurrency. Handle the poison. A panic in one thread shouldn't freeze your whole application.

Where to go next