Skip to content
C++
Library
since C++17
Intermediate

std::shared_mutex

C++17 reader-writer mutex allowing multiple concurrent shared (read) locks alongside a single exclusive (write) lock, eliminating false serialization of reads.

std::shared_mutexsince C++17

A synchronization primitive in <shared_mutex> that implements the reader-writer lock pattern: any number of threads may hold a shared (read) lock simultaneously, but acquiring an exclusive (write) lock requires all other locks to be released first.

Overview

A plain std::mutex serializes all access regardless of intent. For workloads dominated by reads β€” configuration caches, routing tables, in-memory indexes β€” this kills concurrency unnecessarily. std::shared_mutex (C++17) separates two ownership modes:

  • Shared ownership: multiple threads hold the mutex concurrently. No thread may modify the protected data while any shared lock is held.
  • Exclusive ownership: exactly one thread holds the mutex. No shared or exclusive lock may be held by any other thread.

The canonical pairing is std::shared_lock (C++14, <shared_mutex>) for readers and std::unique_lock or std::scoped_lock for writers.

Predecessor: std::shared_timed_mutex (C++14) offers the same reader-writer semantics plus timed variants (try_lock_for, try_lock_until, try_lock_shared_for, try_lock_shared_until). Prefer std::shared_mutex when timeouts are not needed β€” implementations can be leaner without the timed machinery.

Neither shared_mutex nor shared_timed_mutex supports recursive locking. Attempting to acquire any ownership from a thread that already holds the mutex is undefined behavior.

Syntax

cpp
#include <shared_mutex>

std::shared_mutex mtx;

// Exclusive (write) lock β€” one holder, no readers
{
    std::unique_lock lock(mtx);   // C++17 CTAD; deduces unique_lock<shared_mutex>
    // ... mutate shared data ...
}

// Shared (read) lock β€” many concurrent holders
{
    std::shared_lock lock(mtx);   // C++14; deduces shared_lock<shared_mutex>
    // ... read shared data ...
}

Direct member function API (rarely called manually; prefer RAII wrappers):

FunctionModeDescription
lock()exclusiveBlocks until exclusive ownership acquired
try_lock()exclusiveNon-blocking; returns bool
unlock()exclusiveReleases exclusive lock
lock_shared()sharedBlocks until shared ownership acquired
try_lock_shared()sharedNon-blocking; returns bool
unlock_shared()sharedReleases shared lock

Examples

Thread-safe configuration cache

cpp
#include <shared_mutex>
#include <unordered_map>
#include <string>
#include <optional>

class ConfigCache {
public:
    std::optional<std::string> get(const std::string& key) const {
        std::shared_lock lock(mtx_);          // C++17: many readers at once
        auto it = data_.find(key);
        if (it == data_.end()) return std::nullopt;
        return it->second;
    }

    void set(const std::string& key, std::string value) {
        std::unique_lock lock(mtx_);          // exclusive: one writer
        data_.insert_or_assign(key, std::move(value));
    }

    void erase(const std::string& key) {
        std::unique_lock lock(mtx_);
        data_.erase(key);
    }

private:
    mutable std::shared_mutex mtx_;
    std::unordered_map<std::string, std::string> data_;
};

get() is const, and mtx_ is mutable β€” the standard pattern for logically-const, physically-synchronized accessors.

Snapshot under shared lock

Shared locks can be held long enough to take a consistent snapshot without blocking writers for longer than necessary:

cpp
std::vector<std::string> ConfigCache::snapshot() const {
    std::shared_lock lock(mtx_);
    std::vector<std::string> keys;
    keys.reserve(data_.size());
    for (const auto& [k, _] : data_) keys.push_back(k);
    return keys;
    // lock released here β€” writers unblocked
}

Upgrading from shared to exclusive

std::shared_mutex provides no upgrade operation. The correct sequence is explicit release then re-acquire, accepting that the data may have changed:

cpp
void maybe_update(const std::string& key, const std::string& value) {
    {
        std::shared_lock rlock(mtx_);
        if (data_.count(key) && data_.at(key) == value) return; // no-op
    }  // release shared lock before acquiring exclusive

    std::unique_lock wlock(mtx_);
    // Re-check: another writer may have raced between the two locks
    data_.insert_or_assign(key, value);
}

This check-then-act pattern (double-checked update) is the standard workaround for the absence of lock upgrading.

Interoperability with std::scoped_lock

std::scoped_lock (C++17) can lock a shared_mutex in exclusive mode alongside other mutexes deadlock-free:

cpp
std::shared_mutex a, b;

void transfer() {
    std::scoped_lock lock(a, b);   // C++17: acquires both atomically
    // ...
}

scoped_lock always acquires exclusive ownership. For mixed shared/exclusive across multiple mutexes, acquire manually and use std::lock() with std::defer_lock β€” but carefully.

Best Practices

Mark reader methods const and the mutex mutable. This is not just style: the type system enforces that const-qualified call paths cannot accidentally hold a unique_lock.

Prefer shared_mutex over shared_timed_mutex unless you need timeouts. The simpler type communicates intent and may be cheaper.

Hold shared locks for as short a time as possible. A long-lived shared lock delays writers indefinitely, starving updates. Copy the data out under the lock and process the copy outside it.

Never call non-const member functions of the protected type while holding a shared_lock. Shared ownership is a contract with your own code, not enforced by the compiler. Violations produce data races.

Benchmark before replacing std::mutex. On low-contention paths, shared_mutex adds overhead (a heavier internal structure, writer-fairness bookkeeping). It pays off only when read contention is the bottleneck and reads genuinely dominate writes at runtime.

Common Pitfalls

Holding a shared_lock and calling code that acquires an exclusive lock on the same mutex β€” directly or transitively β€” causes deadlock. This is the most common mistake: reader code calls a helper that internally calls a writer.

Forgetting the TOCTOU race in two-phase read-then-write patterns. Releasing a shared lock and reacquiring exclusively does not preserve what you observed. Always re-read and re-validate after acquiring the exclusive lock.

Using shared_mutex where atomic suffices. For a single integer or pointer, std::atomic<T> (C++11) has no lock overhead at all. shared_mutex is appropriate for compound data structures that cannot be made atomic.

Recursive acquisition of any kind. Calling lock_shared() from a thread that already holds a shared lock on the same shared_mutex is undefined behavior, unlike std::recursive_mutex which explicitly permits it.

Writer starvation under pathological read load. The C++ standard does not mandate fairness. On some implementations a continuous stream of readers can indefinitely delay a writer. If your workload has bursty reads, benchmark starvation behavior with your target runtime.

See Also

  • std::shared_timed_mutex β€” adds try_lock_for/try_lock_until/try_lock_shared_for/try_lock_shared_until; C++14
  • std::shared_lock β€” RAII shared ownership wrapper; C++14, <shared_mutex>
  • std::unique_lock β€” RAII exclusive ownership wrapper with deferred, timed, and adoptive modes; C++11, <mutex>
  • std::scoped_lock β€” multi-mutex deadlock-free exclusive RAII lock; C++17, <mutex>