Reader-Writer Locks and std::shared_mutex

Learn how to protect stateful data structures with minimal contention using critical section granularity, std::shared_mutex, and deadlock-safe lock acquisition.

Ryan McCombe
Published

In the previous lesson, we saw that the fastest way to implement multithreading is to reduce the amount of thread synchronization required. By giving each thread its own local variables and merging the results at the end with std::reduce(), we completely bypassed lock contention and cache ping-pong.

That works when we are running a batch calculation over a static dataset. Unfortunately, real-world software isn't just batch math. Often, we have to build long-lived, stateful data structures where dozens of threads are continuously querying and modifying the exact same container at the exact same time.

In these scenarios, we cannot use std::reduce(), and we cannot wrap an entire std::vector inside a std::atomic. When an operation modifies multiple pointers and sizes in memory, we are forced to use locks.

In this lesson, we will learn how to use locks without destroying our multi-core scaling. We will cover shrinking our critical sections, exploiting read-heavy workloads with std::shared_mutex, and preventing deadlocks.

Critical Section Granularity

Before we reach for a new type of mutex, we need to fix the most common mistake made with regular mutexes: holding the lock for way too long.

The region of code between acquiring a lock and releasing it is called the critical section. While a thread is inside a critical section, every other thread waiting on that mutex is locked out.

Consider this function that generates a new player profile and adds it to a shared std::vector:

#include <vector>
#include <mutex>
#include <string>

struct PlayerProfile {
  int id;
  std::string name;
  float rating;
};

class Matchmaker {
  std::vector<PlayerProfile> active_players;
  std::mutex mtx;

public:
  void RegisterPlayer(int id, std::string_view raw_name) {
    // Lock acquired at the very top of the function
    std::scoped_lock lock{mtx};

    // 1. Expensive heap allocation and string formatting
    std::string formatted_name{"[Verified] "};
    formatted_name += raw_name;

    // 2. Expensive database or math calculation
    float initial_rating{CalculateInitialRating(id)};

    // 3. Actually touching the shared vector
    active_players.push_back({
      id, std::move(formatted_name), initial_rating
    });
  }

  float CalculateInitialRating(int id);
};

Look at what happens while mtx is locked. We allocate memory on the heap for a std::string, format text, and run an expensive CalculateInitialRating() function.

None of that work touches active_players. Those variables live entirely on the calling thread's private stack. Yet, because we placed std::scoped_lock at the top of the function, no other thread can touch active_players while we do that local preparatory work.

Shrinking the Lock Scope

Because std::scoped_lock releases its mutex automatically when it goes out of scope, we can control the exact lifespan of a lock by introducing a dedicated {} block.

We do all the heavy allocations and math in parallel outside the lock, and only acquire the mutex for the brief nanoseconds required to move the finished object into the shared container:

void RegisterPlayer(int id, std::string_view raw_name) {
  // 1. Heavy prep work happens in parallel (No Lock)
  std::string formatted_name{"[Verified] "};
  formatted_name += raw_name;
  float initial_rating{CalculateInitialRating(id)};

  // 2. Open a tight scope just for the shared mutation
  {
    std::scoped_lock lock{mtx};
    active_players.push_back({
      id, std::move(formatted_name), initial_rating
    });
  } // <-- Lock is immediately released here
}

By shrinking the critical section to a simple pointer move, we drastically reduce the probability that two threads will collide at the door.

The Asymmetry of Reads and Writes

Shrinking the critical section helps, but std::mutex still has a fundamental architectural flaw: it is completely blind to intent. When a thread locks a std::mutex, it claims exclusive access. It assumes the thread is about to mutate memory, so it locks everyone else out.

Think back to the MESI cache coherency protocol from . Why do cores have to invalidate each other's cache lines? Because a core modified the data.

If eight cores are only reading the same data without modifying it, the hardware doesn't stall at all. Every core holds a valid copy of the cache line in the Shared state, crunching through the data simultaneously at full cache speed.

In most real-world systems, workloads are heavily skewed toward reading. A server might look up configuration settings or session tokens 10,000 times a second, while only updating them once a minute.

If we protect a read-heavy container with a standard std::mutex, we force all of our harmless reader threads to form a single-file line and sleep in the OS scheduler, even though the hardware could easily let them read at the same time.

Enter std::shared_mutex

To align our software locks with hardware reality, C++17 introduced std::shared_mutex in the <shared_mutex> header. This is commonly known as a reader-writer lock.

Unlike a standard mutex, a std::shared_mutex has two distinct locking modes:

  • Shared Mode (Readers): Multiple threads can acquire shared ownership simultaneously. As long as no writer holds the lock, any number of readers can enter the critical section concurrently without blocking one another.
  • Exclusive Mode (Writers): Only one thread can acquire exclusive ownership. If a writer wants the lock, it must wait until all active readers have left the room. Once the writer is inside, no new readers or writers can enter until the writer finishes.

Using std::shared_lock and std::unique_lock

To tell std::shared_mutex which mode we want, we pair it with different RAII lock guards:

  • For Reading (Shared): We wrap the mutex in a std::shared_lock. Under the hood, its constructor calls mtx.lock_shared().
  • For Writing (Exclusive): We wrap the mutex in a std::unique_lock (or std::scoped_lock, which we cover later in this lesson). Under the hood, its constructor calls the standard exclusive mtx.lock().

Let's build a thread-safe Leaderboard class that allows any number of threads to query scores concurrently, while safely locking down the container whenever a score is added:

#include <shared_mutex> 
#include <mutex>
#include <vector>
#include <algorithm>

struct ScoreEntry {
  int player_id;
  int score;
};

class Leaderboard {
  std::vector<ScoreEntry> entries;

  // Marked mutable so `const` reader methods can lock it
  mutable std::shared_mutex rw_mutex; 

public:
  // WRITER: Needs exclusive access to mutate the vector
  void UpdateScore(int player_id, int new_score) {
    std::unique_lock lock{rw_mutex}; 

    auto it{std::ranges::find(
      entries, player_id, &ScoreEntry::player_id
    )};

    if (it != entries.end()) {
      it->score = new_score;
    } else {
      entries.push_back({player_id, new_score});
    }
  }

  // READER: Only inspects data. Multiple threads can run
  // this function at the exact same time as long as no
  // writer is currently processing UpdateScore()
  int GetScore(int player_id) const {
    std::shared_lock lock{rw_mutex}; 

    auto it{std::ranges::find(
      entries, player_id, &ScoreEntry::player_id
    )};

    return (it != entries.end()) ? it->score : 0;
  }
};

Benchmarking Read-Heavy Workloads

Let's measure the difference between std::mutex and std::shared_mutex on a realistic read-heavy workload.

We will simulate 4 concurrent worker threads querying a 1,000-element lookup table where 98% of operations are reads - GetScore() - and 2% of operations are writes - UpdateScore():

benchmarks/main.cpp

#include <benchmark/benchmark.h>
#include <vector>
#include <mutex>
#include <shared_mutex>
#include <future>
#include <algorithm>

struct Entry {
  int id;
  int value;
};

// 1. Exclusive Mutex for everything static void BM_ExclusiveMutex(benchmark::State& state) { auto table{CreateInitialTable()}; std::mutex mtx; for (auto _ : state) { auto worker = [&](int thread_id) { int local_sum{0}; for (int i{0}; i < 10000; ++i) { int target{(i + thread_id * 97) % 1000}; if (i % 50 == 0) { // 2% Writes std::scoped_lock lock{mtx}; table[target].value++; } else { // 98% Reads (Forced to wait in line!) std::scoped_lock lock{mtx}; auto it{std::ranges::find( table, target, &Entry::id )}; local_sum += it->value; } } benchmark::DoNotOptimize(local_sum); }; auto f1{std::async(std::launch::async, worker, 1)}; auto f2{std::async(std::launch::async, worker, 2)}; auto f3{std::async(std::launch::async, worker, 3)}; worker(0); f1.get(); f2.get(); f3.get(); } } // 2. Shared Mutex (Reader-Writer Lock) static void BM_SharedMutex(benchmark::State& state) { auto table{CreateInitialTable()}; std::shared_mutex rw_mtx; for (auto _ : state) { auto worker = [&](int thread_id) { int local_sum{0}; for (int i{0}; i < 10000; ++i) { int target{(i + thread_id * 97) % 1000}; if (i % 50 == 0) { // 2% Writes (Exclusive lock) std::unique_lock lock{rw_mtx}; table[target].value++; } else { // 98% Reads (Shared lock - runs in parallel!) std::shared_lock lock{rw_mtx}; auto it{std::ranges::find( table, target, &Entry::id )}; local_sum += it->value; } } benchmark::DoNotOptimize(local_sum); }; auto f1{std::async(std::launch::async, worker, 1)}; auto f2{std::async(std::launch::async, worker, 2)}; auto f3{std::async(std::launch::async, worker, 3)}; worker(0); f1.get(); f2.get(); f3.get(); } } BENCHMARK(BM_ExclusiveMutex) ->Unit(benchmark::kMillisecond) ->UseRealTime(); BENCHMARK(BM_SharedMutex) ->Unit(benchmark::kMillisecond) ->UseRealTime();

Because scanning the 1,000-element vector takes real CPU work, allowing our 4 threads to execute those scans simultaneously instead of sleeping in a queue yields a massive speedup.

Remember, Google Benchmark's CPU time measurement does not include the time our CPU was asleep. To measure real-world performance, including delays caused by our locks, it is the wall time that we care about:

------------------------------------------------
Benchmark                         Time       CPU
------------------------------------------------
BM_ExclusiveMutex/real_time    18.4 ms   4.92 ms
BM_SharedMutex/real_time       3.85 ms   3.61 ms

With BM_ExclusiveMutex, the wall clock time is nearly 5x higher than the main thread's CPU time because the threads spent most of their lives blocked and waiting on each other. With BM_SharedMutex, all four cores stay fed and productive.

The Hardware Catch: Nothing is Free

If std::shared_mutex can cover all the use cases of std::mutex, why does std::mutex even exist? Why not just use std::shared_mutex everywhere? We should consider the mechanical question: how does std::shared_mutex know when the last reader has left the room so a waiting writer can enter?

Inside std::shared_mutex, there is an atomic integer counter tracking the number of active readers.

  1. Every time a reader calls lock_shared(), it executes an atomic increment (readers++) on the mutex's internal state.
  2. Every time a reader leaves the scope and calls unlock_shared(), it executes an atomic decrement (readers--).

We already saw what happens when 8 CPU cores hammer the same std::atomic integer in a tight loop: cache ping-pong. Even though the protected data isn't being modified by the readers, the mutex itself is being modified by every single std::shared_lock construction and destruction.

If our read operation is trivial - like reading a single int at a known index - the work inside the critical section takes 1 CPU cycle, while the atomic cache ping-pong inside std::shared_mutex costs hundreds of cycles. In those ultra-short critical sections, a simpler std::mutex or std::atomic will often beat std::shared_mutex.

The std::shared_mutex approach shines when two conditions are met:

  • High Read-to-Write Ratio: Reads vastly outnumber writes.
  • Non-Trivial Read Work: The read operation does enough work (like searching a container, hashing a string, or copying a large struct) to easily amortize the cost of updating the mutex's internal atomic counter.

As always, if performance is critical, we should benchmark both approaches in our specific situation to see which one works best for our use case.

Locking Multiple Resources and Deadlocks

As our systems grow, we will eventually need to perform an operation that touches two protected objects at the same time - for example, transferring an item from Player A's inventory to Player B's inventory.

If we lock two mutexes one after another, we walk straight into a classic concurrency trap called a deadlock:

#include <mutex>
#include <vector>

struct Inventory {
  std::mutex mtx;
  int gold{100};
};

void TransferGold(Inventory& from, Inventory& to, int amount) {
  std::unique_lock lock_a{from.mtx}; 
  std::unique_lock lock_b{to.mtx};   

  from.gold -= amount;
  to.gold += amount;
}

Imagine Thread 1 calls TransferGold(Player1, Player2, 50) at the same microsecond that Thread 2 calls TransferGold(Player2, Player1, 20):

  1. Thread 1 locks Player1.mtx.
  2. Thread 2 locks Player2.mtx.
  3. Thread 1 tries to lock Player2.mtx, sees it is held by Thread 2, and goes to sleep waiting for Thread 2.
  4. Thread 2 tries to lock Player1.mtx, sees it is held by Thread 1, and goes to sleep waiting for Thread 1.

Neither thread will ever wake up. Our program hangs permanently.

Deadlock Avoidance with std::scoped_lock

To prevent this, C++17 introduces std::scoped_lock as an alternative to std::lock_guard. The scoped lock accepts multiple mutexes at once:

void TransferGold(Inventory& from, Inventory& to, int amount) {
  // Never try to lock the exact same mutex twice!
  if (&from == &to) return;

  // Safely acquires both locks without deadlocking
  std::scoped_lock lock{from.mtx, to.mtx}; 

  from.gold -= amount;
  to.gold += amount;
}

When we pass two or more mutexes into std::scoped_lock, it uses an internal deadlock-avoidance algorithm - std::lock(). If it grabs the first mutex but fails to immediately grab the second, it automatically releases the first mutex and backs off, breaking the circular wait condition so both threads can complete their work safely.

Sometimes std::scoped_lock is used with a single mutex where there is no risk of deadlocks. With a single mutex, std::scoped_lock behaves identically to std::lock_guard - the choice of which to use is just a style preference.

Summary

In this lesson, we completed our foundational concurrency toolkit by learning how to protect shared, stateful data structures efficiently.

  1. Critical Section Granularity: Locks serialize execution. Always perform heavy allocations and computations outside the lock, using tight {} scopes to hold mutexes only for the minimum time necessary.
  2. Reader-Writer Locks (std::shared_mutex): Standard mutexes block concurrent readers unnecessarily. Pairing std::shared_mutex with std::shared_lock allows unlimited threads to read data simultaneously in the CPU's Shared cache state, while std::unique_lock grants exclusive access to writers.
  3. Internal Atomic Overhead: std::shared_mutex maintains an internal atomic reader counter. It provides massive speedups when read operations do meaningful work, but can suffer from cache ping-pong if spammed on 1-cycle reads.
  4. Multi-Lock Safety (std::scoped_lock): Acquiring multiple locks sequentially risks freezing the program in a deadlock. Passing multiple mutexes into a single std::scoped_lock guarantees deadlock-free acquisition.

We now have a complete mechanical understanding of how multi-core CPUs execute parallel tasks, coordinate caches, and synchronize memory.

In the next chapter, we shift our focus back to memory architecture. We will begin building a custom Structure of Arrays (SoA) container from scratch, designing a cache-friendly data layout built to feed these hungry CPU cores at maximum bandwidth.

Next Lesson
Lesson 27 of 75

Domain-Specific Containers

Build a custom container that combines the performance of data-oriented design with the ergonomics of object-oriented programming.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate