How to use Arc RwLock for shared mutable state

Share mutable state across threads in Rust by wrapping data in Arc<RwLock<T>> for safe concurrent access.

Shared state across threads

You are building a chat server. Multiple threads need to read the list of connected users. One thread needs to update the list when a user joins or leaves. If two threads write at the same time, the list corrupts. If a thread reads while another writes, the reader sees half-updated garbage. You need a way to let many readers in, but only one writer at a time, and you need to share this across threads without copying the whole list every time.

Rust's ownership rules prevent this by default. You cannot move a value into multiple threads. You cannot share a reference across threads because the compiler cannot guarantee the data stays alive. You need a wrapper that handles both the lifetime and the synchronization.

Arc<RwLock<T>> is the standard solution. Arc stands for Atomic Reference Counted. It lets you share ownership of a value across threads. RwLock stands for Read-Write Lock. It lets multiple threads read the value simultaneously, but blocks writers until all readers are done. Combine them and you get thread-safe shared mutable state.

The whiteboard and the key

Think of a whiteboard in a conference room. The whiteboard holds your data. Arc is the key to the room. You can make copies of the key. Anyone with a key can enter. RwLock is the rule at the door. You can have a dozen people looking at the whiteboard at the same time. They are all reading. But if someone wants to erase and write new text, they have to kick everyone else out, lock the door, do their work, and then let people back in.

Arc handles the sharing. It keeps a count of how many keys exist. When the last key is destroyed, the room is locked and the whiteboard is wiped. RwLock handles the access rules. It tracks how many readers are inside and whether a writer is working. The Arc ensures the whiteboard stays alive as long as any thread holds a key. The RwLock ensures no two threads corrupt the data.

Use Arc<RwLock<T>> when you have shared mutable state that lives across thread boundaries and you need concurrent reads. The Arc manages the memory. The RwLock manages the access.

Minimal example

Here is the basic pattern. You wrap the value in RwLock, then wrap that in Arc. You clone the Arc to share it. You call read() or write() to get a guard. The guard gives you access to the data.

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

fn main() {
    // Wrap the value in RwLock for synchronization, then Arc for thread-safe sharing.
    // The value lives on the heap.
    let counter = Arc::new(RwLock::new(0));

    // Clone the Arc to share ownership.
    // This bumps the reference count, not the value.
    // Convention: use Arc::clone(&x) to make it clear this is a shallow clone.
    let counter_clone = Arc::clone(&counter);

    // Acquire a read lock.
    // Multiple threads can hold read locks simultaneously.
    // The guard implements Deref, so you can dereference it to get the value.
    let val = *counter.read().unwrap();
    println!("Value: {}", val);

    // Acquire a write lock.
    // This blocks until all read locks are released.
    // The guard implements DerefMut, so you can modify the value.
    let mut guard = counter_clone.write().unwrap();
    *guard += 1;
    // The lock releases when `guard` goes out of scope.
    // This is RAII: Resource Acquisition Is Initialization.
}

The lock stays open only as long as the guard lives. Drop the guard, drop the lock.

How the guards work

When you call read() or write(), the RwLock returns a guard object. The guard holds the lock open. The guard also holds a reference to the data. The guard implements Deref or DerefMut. This means you can use *guard to access the value directly. You can also call methods on the value through the guard.

The guard implements Drop. When the guard variable goes out of scope, the Drop implementation releases the lock. This happens automatically. You do not need to call unlock(). If you want to release the lock early, you can call drop(guard) explicitly.

The guard also prevents you from moving the data out of the lock. The lock owns the data. The guard only borrows it. If you try to move the value out, the compiler rejects you with E0507 (cannot move out of borrowed content). You must use into_inner() to take ownership of the value, which consumes the RwLock.

Trust the guard. It is the only thing keeping the lock alive and giving you access to the data.

Realistic thread spawning

In real code, you spawn threads and move the shared state into them. Each thread needs its own Arc clone. The closure must take ownership of the clone. This is where move comes in.

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

fn main() {
    // Shared state wrapped in Arc<RwLock<T>>.
    let shared_data = Arc::new(RwLock::new(Vec::new()));

    let mut handles = vec![];

    // Spawn threads that share the data.
    for i in 0..5 {
        // Clone the Arc for each thread.
        // Each thread gets its own key to the room.
        let data_clone = Arc::clone(&shared_data);

        // The closure moves the clone into the thread.
        // The thread owns this Arc.
        let handle = thread::spawn(move || {
            // Writer: exclusive access.
            // This blocks if another thread is writing or if readers are active.
            let mut guard = data_clone.write().unwrap();
            guard.push(i);
            // Guard drops here, releasing the lock.
        });
        handles.push(handle);
    }

    // Wait for threads to finish.
    for handle in handles {
        handle.join().unwrap();
    }

    // Reader: verify the result.
    // No threads are running, so no contention.
    let final_data = shared_data.read().unwrap();
    println!("Collected: {:?}", *final_data);
}

Clone the Arc, move the clone, keep the original. That is the thread-spawning dance.

Performance and writer starvation

RwLock is not free. Every lock acquisition involves atomic operations. The lock must update counters and check conditions. If you have low contention, the overhead is small. If you have high contention, the overhead grows.

RwLock has a specific weakness called writer starvation. The lock prefers readers. If a writer is waiting, but new readers keep arriving, the writer may wait forever. The lock lets readers in because they do not conflict with each other. The writer blocks until all readers leave. If readers never stop, the writer never runs.

This happens in workloads with frequent reads and occasional writes. If the read rate is very high, the writer can starve. In that case, RwLock can be slower than Mutex. A Mutex treats all requests equally. It does not distinguish between readers and writers. It processes requests in order.

Measure your access pattern. If reads dominate and writes are rare, RwLock is faster. If writes are frequent or reads are continuous, Mutex may be better.

Watch for writer starvation. If writes are common, a mutex is often faster and fairer.

Poisoned locks and recovery

When a thread panics while holding a lock, the RwLock marks itself as poisoned. The next call to read() or write() returns a PoisonError. Rust makes this choice to protect invariants. A panic suggests the data is corrupted. The lock prevents other threads from seeing that corruption.

You can recover the data by calling .into_inner() on the error. This gives you the guard and the data. You must verify the data is safe. In most cases, unwrapping is fine. The panic was a bug, and crashing is the right response. In critical systems, you might log the error and reset the state.

let result = shared_data.write();
match result {
    Ok(guard) => {
        // Normal case.
        *guard += 1;
    }
    Err(poisoned) => {
        // Another thread panicked.
        // Recover the guard.
        let guard = poisoned.into_inner();
        // Reset the data if needed.
        *guard = 0;
    }
}

Treat a poisoned lock as a signal that something broke. Recover only if you can prove the data is still valid.

Reentrancy and deadlocks

RwLock is not reentrant. If a thread holds a read lock and tries to acquire a write lock, it deadlocks. The thread waits for all readers to release, including itself. It will wait forever. The same happens with a write lock trying to acquire another write lock.

let data = Arc::new(RwLock::new(0));
let read_guard = data.read().unwrap();
// This deadlocks.
let write_guard = data.write().unwrap();

If you need reentrancy, you need a different strategy. Usually, you restructure the code to drop the lock before the recursive call. You can also use a third-party crate that provides reentrant locks, but those are rare in Rust. The standard library prefers non-reentrant locks because they are simpler and safer.

Never nest locks on the same RwLock. Drop the guard before you call back in.

Pitfalls and compiler errors

Poison panics. If you call unwrap() on a poisoned lock, your thread panics. This can cascade. Handle the error if you need resilience.

Holding locks too long. If you hold a lock while doing slow work, you block other threads. Keep the critical section small. Do the work, then release the lock.

E0277 trait bounds. RwLock<T> requires T: Send + Sync. If T contains Rc or Cell, the compiler rejects you with E0277 (the trait bound Send is not satisfied). Rc is not thread-safe. Cell is not thread-safe. Use Arc and Atomic types instead.

E0382 use of moved value. If you move the Arc into a thread and try to use it in the main thread, the compiler rejects you with E0382. You must clone the Arc before moving it.

E0502 borrow conflicts. If you try to borrow the RwLock immutably while holding a mutable guard, the compiler rejects you with E0502. The guard holds a mutable borrow. You cannot borrow again until the guard drops.

Handle the poison. If you unwrap blindly, your panic becomes a cascade.

Decision matrix

Use Arc<RwLock<T>> when you need shared mutable state across threads and reads are frequent but writes are rare. Use Arc<Mutex<T>> when you cannot guarantee read-heavy access patterns or when the cost of RwLock overhead outweighs the benefits. Use Arc<AtomicT> when you only need simple operations like incrementing a counter and want to avoid locking entirely. Use Arc<T> with immutable data when threads only need to read and never modify the state.

Measure your contention. An rwlock under heavy write load is a performance trap.

Where to go next