Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 59

Reader-Writer Locks with `std::shared_mutex`

Recommended resources

Hello! Welcome back to our journey through C++ concurrency.

In our previous lessons, we've explored tools for managing asynchronous tasks (std::promise, std::future, std::packaged_task, std::async) and for protecting shared data from race conditions (std::mutex, std::lock_guard, std::unique_lock). So far, our primary tool for data protection, std::mutex, has been strictly exclusive: only one thread can hold the lock at a time, for any reason.

Today, we'll examine a common pattern in systems programming: a shared resource that is read by many threads very frequently, but written to by only a few threads, very infrequently. Think of a system configuration that is loaded at startup and rarely changed, but constantly referenced by worker threads. Using a std::mutex here would be inefficient, as it would force all the non-conflicting read operations to happen one by one.

This lesson introduces a specialized tool for this exact scenario: the reader-writer lock. You will learn how to implement this pattern using std::shared_mutex to allow concurrent reads while still ensuring exclusive access for writes, significantly improving performance in read-heavy workloads.

1. The Reader-Writer Lock Concept

A reader-writer lock operates on a simple but powerful set of rules:

  1. Shared Read Access: Any number of "reader" threads can acquire the lock simultaneously.
  2. Exclusive Write Access: Only one "writer" thread can acquire the lock at any given time.
  3. Writer Priority (Conceptual): If a writer is holding the lock, no readers can acquire it. If readers are holding the lock, a writer must wait for all of them to release it.

This model allows for maximum concurrency for read operations, only serializing access when a write operation, which modifies the data, needs to occur.

Shared Mutex Behavior
This diagram illustrates the core principle of a reader-writer lock. Multiple threads can perform 'Read' operations concurrently. However, when a 'Write' operation is initiated, it gains exclusive access, and all other threads (readers or other writers) must wait.

2. Implementation in C++: std::shared_mutex

Modern C++ (since C++17) provides a standard implementation of the reader-writer lock pattern. It involves three key components:

  • std::shared_mutex: This is the mutex object itself. Unlike std::mutex, it supports two levels of locking: shared and exclusive. On Linux, this is often implemented using the POSIX pthread_rwlock_t primitive, connecting directly to your OS knowledge.
  • std::shared_lock<std::shared_mutex>: An RAII wrapper used to acquire and release a shared (read) lock on a std::shared_mutex. Multiple threads can successfully create a std::shared_lock on the same mutex at the same time.
  • std::unique_lock<std::shared_mutex>: The same std::unique_lock we've used before, but this time applied to a std::shared_mutex. It acquires an exclusive (write) lock. While a unique_lock is held, no other thread can acquire either a shared_lock or another unique_lock.

Let's look at an article that demonstrates how to combine these components.

Understanding std::unique_lock and std::shared_lock in C++

The article 'Understanding std::unique_lock and std::shared_lock in C++' provides a clear, combined example of using these locks for write and read operations respectively.

Please read section 6, titled 'Combining std::shared_lock and std::unique_lock'. It contains a complete example demonstrating a writer thread using std::unique_lock and reader threads using std::shared_lock on the same std::shared_mutex.

Practical Example: A Telemetry System

Let's solidify this with a more concrete example. Imagine a telemetry system where multiple sensors are constantly reading the latest system status, while a separate administration thread can update that status message.

#include <iostream>
#include <thread>
#include <vector>
#include <string>
#include <shared_mutex>
#include <chrono>

class Telemetry {
public:
    Telemetry() : m_status("System nominal.") {}

    // Writer function: needs exclusive access
    void update_status(const std::string& new_status) {
        // Acquire an exclusive lock. No other thread (reader or writer) can proceed.
        std::unique_lock<std::shared_mutex> lock(m_mutex);
        m_status = new_status;
        std::cout << "STATUS UPDATED to: " << m_status << std::endl;
    }

    // Reader function: can have shared access
    std::string get_status() const {
        // Acquire a shared lock. Multiple readers can acquire this lock concurrently.
        std::shared_lock<std::shared_mutex> lock(m_mutex);
        return m_status;
    }

private:
    mutable std::shared_mutex m_mutex; // `mutable` allows locking in const member functions
    std::string m_status;
};

int main() {
    Telemetry telemetry;
    std::vector<std::thread> readers;

    // Create 5 reader threads that read the status every 200ms
    for (int i = 0; i < 5; ++i) {
        readers.emplace_back([&telemetry, i]() {
            for (int j = 0; j < 5; ++j) {
                std::this_thread::sleep_for(std::chrono::milliseconds(200));
                std::cout << "Reader " << i << " reads: " << telemetry.get_status() << std::endl;
            }
        });
    }

    // Create a writer thread that updates the status after 1 second
    std::thread writer([&telemetry]() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        telemetry.update_status("System under high load!");
    });

    writer.join();
    for (auto& t : readers) {
        t.join();
    }

    return 0;
}

In this example, all five reader threads can execute get_status concurrently, acquiring shared locks. However, when the writer thread calls update_status, it attempts to acquire a unique_lock. It will wait until all current readers have finished, then acquire the exclusive lock. While it holds the lock, any new readers (or other writers) will be blocked until the update is complete.


3. The Performance Trade-off: When to Use a Reader-Writer Lock

This enhanced concurrency isn't free. A std::shared_mutex is a more complex primitive than std::mutex. It has to internally manage a counter for readers and handle the logic for prioritizing or queuing writers. This introduces overhead.

A std::shared_mutex is only a performance win when the contention from multiple concurrent readers is high enough to outweigh its own internal overhead.

If you have only one or two threads accessing a resource, or if write operations are as frequent as reads, a simple std::mutex is often faster due to its lower overhead.

The following article provides a fantastic performance analysis using Google Benchmark to demonstrate this trade-off. This will appeal to your desire to understand the low-level performance implications.

When std::shared_mutex Outperforms std::mutex - Tech For Talk

The article 'When std::shared_mutex Outperforms std::mutex' provides a detailed benchmark and analysis showing the performance crossover point between these two types of mutexes.

Please read the following parts of the article: The introduction, which frames the problem with using std::mutex in read-heavy scenarios. The 'Performance Comparison' section, focusing on the analysis of the benchmark tables for both std::mutex and std::shared_mutex. The 'Addendum' section, which explores how the crossover point changes with lighter workloads. Pay close attention to how std::shared_mutex is slower with few threads but scales much better as the number of reader threads increases.

As you read, note these key takeaways from the benchmark:

  • Low Concurrency: With only 2 threads (1 reader, 1 writer), std::mutex is significantly faster. The overhead of std::shared_mutex's state management isn't justified.
  • The Crossover Point: As the number of threads increases (e.g., to 4 or 8), the Real Time for std::shared_mutex drops dramatically because the readers can now execute in parallel. The Real Time for std::mutex continues to climb due to serialization. This is where std::shared_mutex starts to win.
  • Workload Matters: The Addendum shows that if the work done inside the read lock is very short ("light read"), you need an even higher number of concurrent readers to overcome the shared_mutex overhead.
Test your understanding!

You are designing a class that caches DNS lookups. The cache is a std::map<std::string, IPAddress>. The primary operation is resolve(hostname), which looks up an entry. If it exists, the IP is returned. If not, a slow network call is made, and the result is inserted into the map before being returned. This class will be used by hundreds of threads in a server application.

Which mutex type, std::mutex or std::shared_mutex, would you choose to protect the map? Justify your choice by explaining how you would use the corresponding lock types for the different code paths inside resolve().

Show answer

A std::shared_mutex is the ideal choice for this scenario.

Justification:
The workload is heavily skewed towards reads. Most of the time, a requested hostname will already be in the cache. This is a classic read-heavy scenario.

Implementation within resolve():

  1. Initial Read Attempt: The function would first acquire a std::shared_lock. This allows hundreds of threads to concurrently search the map for existing entries without blocking each other.

    IPAddress resolve(const std::string& hostname) {
        // 1. Acquire a shared (read) lock first.
        std::shared_lock<std::shared_mutex> read_lock(m_mutex);
        auto it = m_cache.find(hostname);
        if (it != m_cache.end()) {
            return it->second; // Found it! Return immediately.
        }
        // read_lock is still held here...
    
  2. Lock Upgrade for Writing: If the entry is not found, we need to perform a write. A common pattern is to release the read lock and acquire an exclusive write lock. This is called "lock upgrading". Note: std::shared_mutex doesn't support atomic upgrading, so you must release and re-acquire.

        // ...hostname not found, need to write.
        // 2. Release the read lock.
        read_lock.unlock();
    
        // 3. Acquire an exclusive (write) lock.
        std::unique_lock<std::shared_mutex> write_lock(m_mutex);
        
        // 3a. Re-check the condition! Another thread might have acquired the write lock
        // and inserted the entry while we were waiting.
        it = m_cache.find(hostname);
        if (it != m_cache.end()) {
            return it->second;
        }
    
        // 4. Perform the slow operation and write to the map.
        IPAddress new_ip = perform_slow_network_lookup(hostname);
        m_cache[hostname] = new_ip;
        return new_ip;
    } // write_lock is released here.
    

This strategy maximizes concurrency for the common case (cache hit) while ensuring correctness for the rare case (cache miss and insert). Using a std::mutex would have serialized all lookups, creating a massive performance bottleneck.


4. A Word on Fairness and Writer Starvation

One potential issue with simple reader-writer lock implementations is writer starvation. If there is a constant stream of new readers arriving, a writer might be stuck waiting indefinitely, because there is never a moment when no readers hold the lock.

The C++ standard does not specify the fairness policy for std::shared_mutex. Some implementations may be "read-preferring," while others might be "write-preferring" (blocking new readers if a writer is waiting). You should not assume a particular behavior, but be aware that in extremely high-contention, read-heavy scenarios, a writer might experience significant latency.


Conclusion

You have now added a powerful, specialized synchronization primitive to your C++ toolkit. It's a perfect example of a feature that requires you to understand the "why" and "when" to use it effectively.

Key Takeaways:

  • The reader-writer lock pattern is an optimization for read-heavy, low-write scenarios.
  • It is implemented in C++ using std::shared_mutex along with std::shared_lock for readers and std::unique_lock for writers.
  • std::shared_mutex allows multiple concurrent readers but only one exclusive writer.
  • This is not a "free" optimization. std::shared_mutex has higher overhead than std::mutex. It only becomes beneficial when contention among many readers is the primary bottleneck.
  • Always profile your application to confirm that std::shared_mutex is providing a performance benefit for your specific workload.

What's Next?

So far, all our synchronization has involved locks, which instruct threads to block and wait. This is effective, but blocking and waking threads involves context switches, which are expensive operations managed by the OS scheduler. In the next lesson, we will descend deeper into the machine and explore lock-free programming using std::atomic. You will learn how to coordinate threads without using mutexes at all, relying instead on special atomic CPU instructions for the ultimate level of performance in certain scenarios.