Mutex and RwLock in Rust

In previous posts, we looked at Cell<T> and RefCell<T>, which allow us to mutate data through shared references. However, neither of these types is thread-safe.

What happens when multiple threads need to access and modify the same data?

This is where Mutex<T> and RwLock<T> come in.

The Problem

Imagine we have a counter shared between multiple threads. Each thread needs to increment it.

An increment might look like a single operation:

counter += 1;

But it involves reading the current value, incrementing it, and writing the result back.

If two threads do this concurrently without synchronization, they might both read the same value and overwrite each other’s updates. This is called a race condition.

We need a way to ensure that only one thread modifies the counter at a time.

Mutex

Mutex stands for mutual exclusion. It allows only one thread to access the protected data at a time.

A thread must acquire a lock before accessing the data. If another thread already holds the lock, it waits until that lock is released.

Let’s implement our counter:

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

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..4 {
        let counter = Arc::clone(&counter);

        handles.push(thread::spawn(move || {
            let mut value = counter.lock().unwrap();
            *value += 1;
        }));
    }

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

    println!("{}", *counter.lock().unwrap());
}

This prints 4, as expected.

There are two important types here:

  • Arc<T> provides shared ownership across threads using atomic reference counting.
  • Mutex<T> provides exclusive access to the underlying value.

Arc alone doesn’t make mutable data thread-safe. It only makes sharing ownership safe. We still need a synchronization mechanism to coordinate access.

How Does the Lock Work?

When we call:

let mut value = counter.lock().unwrap();

Rust returns a MutexGuard<T>.

This guard gives us mutable access to the protected value. More importantly, it releases the lock automatically when the guard goes out of scope.

We don’t need to manually unlock anything.

This is an example of Rust’s RAII pattern: the lifetime of a resource is tied to the lifetime of the value managing it.

RwLock

A Mutex works well, but it has a limitation: even if multiple threads only want to read the data, they must take turns.

There is no reason to prevent concurrent reads when nobody is modifying the value.

RwLock, short for reader-writer lock, solves this by distinguishing between two kinds of access:

  • Read access: Multiple threads can hold read locks simultaneously.
  • Write access: Only one thread can hold a write lock, and no readers can hold the lock at the same time.

Consider the following:

use std::sync::RwLock;

fn main() {
    let config = RwLock::new(String::from("dark"));

    {
        let reader1 = config.read().unwrap();
        let reader2 = config.read().unwrap();

        println!("{}", reader1.as_str());
        println!("{}", reader2.as_str());
    }

    {
        let mut writer = config.write().unwrap();
        *writer = String::from("light");
    }
}

Notice that we can acquire two read locks simultaneously, even while the first read guard is still alive.

If we tried to acquire a write lock while those read guards were held, the writer would have to wait.

The same applies in reverse: if a writer holds the lock, readers must wait until the writer releases it.

This makes RwLock useful for data that is read frequently but modified occasionally, such as application configuration or an in-memory cache.

Mutex vs. RwLock

MutexRwLock
Concurrent readersOne at a timeMultiple
Concurrent writersOneOne
Readers during a writeBlockedBlocked
APIlock()read(), write()
Typical useShared mutable stateRead-heavy shared state

It might seem that RwLock is always the better choice because it allows concurrent reads.

However, coordinating readers and writers introduces additional overhead. For small critical sections or workloads with frequent writes, a Mutex may perform just as well or better.

Also, std::sync::RwLock does not guarantee a particular priority policy between waiting readers and writers. That behavior depends on the underlying implementation.

More concurrency does not automatically mean better performance.

What About RefCell?

There is an interesting parallel between these types.

RefCell<T> allows multiple immutable borrows or one mutable borrow, checking those rules at runtime.

RwLock<T> provides a similar shared-versus-exclusive access model, but coordinates access between threads using synchronization.

The important difference is what happens when access conflicts.

With RefCell, attempting an incompatible borrow causes a panic when using borrow() or borrow_mut().

With RwLock, an incompatible lock request normally blocks until access becomes available.

Mutex is simpler: it only provides exclusive access.

A Few Things to Watch Out For

Locks solve data races, but they don’t eliminate every concurrency problem.

Deadlocks: If a thread tries to acquire a lock it already holds, or two threads wait on locks held by one another, the program can become stuck. Keep critical sections short and use a consistent lock acquisition order.

Poisoning: A standard-library Mutex becomes poisoned if a thread panics while holding its lock. An RwLock can become poisoned when a writer panics while holding the write lock. This is why lock acquisition returns a Result. Poisoning indicates that the protected state might be inconsistent, but recovery is possible.

Async code: Standard-library locks block threads while waiting. They can still be appropriate for short critical sections in async applications, but avoid holding their guards across .await points. If a lock needs to be held across asynchronous operations, consider an async-aware primitive.

Conclusion

Cell, RefCell, Mutex, and RwLock all provide ways to work with interior mutability, but they solve different problems.

Cell and RefCell are useful within a single thread.

Mutex and RwLock allow us to safely coordinate access to shared mutable state across threads.

My default would be to start with Mutex when shared mutable state is necessary. If concurrent reads are important and contention becomes a problem, RwLock is worth considering.


Further reading