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.

std::shared_timed_mutex (C++14)

std::shared_timed_mutex is the C++14 predecessor to std::shared_mutex. It exposes the same shared/exclusive ownership model plus four timed variants:

FunctionModeDescription
try_lock_for(dur)exclusiveBlocks up to dur; returns bool
try_lock_until(tp)exclusiveBlocks until time point tp; returns bool
try_lock_shared_for(dur)sharedBlocks up to dur; returns bool
try_lock_shared_until(tp)sharedBlocks until time point tp; returns bool

The timed reader variants (try_lock_shared_for / try_lock_shared_until) are useful when a reader should give up rather than block indefinitely β€” for example, a UI thread polling a background cache that may be under a prolonged write:

cpp
#include <shared_mutex>
#include <chrono>

std::shared_timed_mutex mtx;

bool try_read_within(std::string& out, std::chrono::milliseconds timeout) {
    // Shared (read) lock with deadline β€” does not block forever
    std::shared_lock<std::shared_timed_mutex> lk(
        mtx, std::defer_lock);

    if (!lk.try_lock_for(timeout)) {
        return false;   // writer held the mutex too long
    }
    out = cached_value; // safe to read
    return true;
}

When to use shared_timed_mutex vs shared_mutex

  • Prefer std::shared_mutex (C++17) when timeouts are not needed. Implementations can omit the timing machinery and may be lighter.
  • Use std::shared_timed_mutex (C++14) when you need to support a C++14 toolchain, or when the reader or writer must be able to bail out after a deadline β€” for instance, a real-time thread that cannot afford to block indefinitely.

Note: try_lock_shared_for may return false spuriously even if no thread holds the mutex (permitted by the standard). Always handle the false return defensively.

The Upgrade Lock Pattern (Two-Step Promotion)

Why C++ Has No Atomic Upgrade

The C++ standard deliberately omits an "upgrade" primitive β€” a direct in-place promotion from shared to exclusive ownership. The reason is a deadlock potential: if two threads each hold a shared lock and simultaneously attempt to upgrade, each is waiting for the other to release, but neither will.

Boost provides boost::upgrade_mutex with a third ownership level (upgrade) that permits at most one upgrader to exist at a time, enabling an atomic promotion from upgrade to exclusive. The standard chose not to carry this over, partly because of the complexity and the possibility of upgrade-holder starvation.

The Correct Two-Step in Standard C++

Without a dedicated upgrade primitive, the safe pattern is: release the shared lock, re-acquire exclusive, then re-validate the condition because another writer may have intervened during the gap.

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

class Cache {
public:
    // Returns true if the item was inserted (not already present)
    bool insert_if_missing(const std::string& key,
                           const std::string& value) {
        // Phase 1 β€” optimistic read under shared lock
        {
            std::shared_lock rlock(mtx_);
            if (data_.count(key)) {
                return false;   // already present, nothing to do
            }
        }
        // Shared lock released here.
        // Another thread may insert the same key in the gap below.

        // Phase 2 β€” exclusive write lock
        std::unique_lock wlock(mtx_);

        // Re-validate: the gap above means another thread may have
        // inserted the key.  Skip the write if that happened.
        auto [it, inserted] = data_.emplace(key, value);
        return inserted;
    }

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

Key points:

  • The {} block around the shared_lock ensures the shared lock is destroyed (released) before unique_lock construction begins. Never try to construct a unique_lock on the same shared_mutex while a shared_lock on that mutex is still in scope on the same thread β€” that is a deadlock.
  • After acquiring the exclusive lock, always re-read the guarded state. The optimistic check in Phase 1 is a hint, not a guarantee.
  • This is sometimes called the double-checked update or check-unlock-recheck pattern, analogous to double-checked locking.

Why TOCTOU Re-Validation Is Mandatory

Time-of-check to time-of-use (TOCTOU) is the window between reading a value under a shared lock and acting on it under an exclusive lock. Consider:

cpp
Thread A (reader→writer)          Thread B (writer)
─────────────────────────────     ─────────────────────────
shared_lock: key absent βœ“
                                  unique_lock: inserts key
                                  unique_lock released
unique_lock acquired
  ← without re-check, A would insert a duplicate

If insert_if_missing did not re-check inside the exclusive lock, both threads could insert the same key. The re-validation collapses this race by making the second writer's check authoritative.

The same principle applies to any read-then-conditional-write pattern: lazy initialisation, eviction decisions, quota checks, and cache invalidation all require re-reading state after the exclusive lock is acquired.

Performance: When Does shared_mutex Help?

std::shared_mutex is not a free upgrade over std::mutex. It carries additional internal state to track shared-holder counts and pending writers, and it must enforce writer-priority (on many implementations) to prevent writer starvation. That overhead is only amortised when enough readers run concurrently to offset it.

What benchmarks show

A representative Google Benchmark study (Intel i7-3770, 4 cores / 8 threads, heavy read workload of 50 trigonometric operations per critical section) measured wall-clock time per iteration across thread counts:

Threadsstd::mutex (ns/iter)std::shared_mutex (ns/iter)
2 (1 writer + 1 reader)8487 875
4 (1 writer + 3 readers)2 137344
8 (1 writer + 7 readers)1 791222

At 2 threads, shared_mutex is roughly 9x slower: one reader and one writer still serialize, but now pay the heavier bookkeeping cost with no parallelism benefit. At 4+ threads, concurrent readers amortise that cost and shared_mutex is 6–8x faster.

With a very light read critical section (a single map lookup), the crossover point shifts higher β€” shared_mutex may not win until 8+ threads on a 4-core machine.

Rule of thumb

std::shared_mutex earns its overhead when all three conditions hold:

  1. Reads dominate writes at runtime (rough guideline: 80 %+ reads).
  2. The read critical section is non-trivial (not a single integer load β€” use std::atomic for that).
  3. Multiple reader threads contend simultaneously (at least 3–4 threads competing).

If any condition fails, a plain std::mutex is likely faster and simpler. Always benchmark with realistic thread counts and workload sizes on your target hardware before committing to shared_mutex.

False sharing and alignment

When embedding a shared_mutex alongside other synchronisation objects in the same struct, separate them with alignas(64) to prevent false sharing β€” two mutexes sharing a CPU cache line can degrade performance as if they were contended even when they are not:

cpp
struct alignas(64) ProtectedTable {
    alignas(64) std::shared_mutex mtx;
    std::unordered_map<int, double> data;
};

When NOT to Use shared_mutex

shared_mutex carries real overhead β€” choose it only when the numbers justify it. Internally, an implementation must maintain a shared-holder count (often an atomic integer), a pending-writer flag, and a fairness queue. Every lock_shared() call performs at least one atomic read-modify-write and potentially a futex wait; unlock_shared() does the same in reverse. On a low-contention path this bookkeeping dwarfs the actual work inside the critical section.

Consider the following concrete situations where a plain std::mutex is the better choice:

cpp
// C++17
// BAD β€” shared_mutex for a single integer is wasteful.
// The atomic increment inside lock_shared()/unlock_shared()
// costs more than the actual work.
std::shared_mutex cfg_mu;
int read_counter = 0;

int get_counter() {
    std::shared_lock lk(cfg_mu);
    return read_counter;        // one integer load β€” use std::atomic instead
}

// GOOD β€” zero mutex overhead for a single value.
std::atomic<int> counter{0};

int get_counter_fast() {
    return counter.load(std::memory_order_acquire);
}

Three heuristics for when to avoid shared_mutex:

  1. Only one reader thread at a time. Two threads cannot hold a shared lock simultaneously if they are serialized by something else (a task queue, a single event loop, etc.). The parallel-read benefit never materialises.
  2. Write rate is high. If writes arrive faster than reads complete, writers spend most of their time waiting for the shared-holder count to drop to zero, and the write-priority logic adds latency on top. A plain mutex is more predictable.
  3. Critical section is a single load or store. Use std::atomic<T> instead β€” lock-free atomics have no kernel transitions and no heap-allocated internal state.

When in doubt: start with std::mutex, measure, and migrate to std::shared_mutex only if profiling confirms read-lock contention is the bottleneck.

Combining shared_lock with std::condition_variable_any

std::condition_variable only works with std::unique_lock<std::mutex>, but std::condition_variable_any accepts any lockable type β€” including std::shared_lock. This allows a reader thread to wait on a condition while holding a shared lock: the wait atomically releases the shared lock, suspends the thread, and reacquires the shared lock on wake-up.

cpp
// C++17
#include <shared_mutex>
#include <condition_variable>
#include <optional>
#include <string>

class ReadyCache {
public:
    // Writer: store a value and wake all waiting readers
    void publish(std::string value) {
        {
            std::unique_lock wlock(mtx_);
            value_ = std::move(value);
            ready_ = true;
        }
        cv_.notify_all();   // wake every thread blocked in wait_read()
    }

    // Reader: block until a value is available, then return it
    // without holding an exclusive lock during the potentially slow read.
    std::string wait_read() {
        std::shared_lock rlock(mtx_);
        // condition_variable_any::wait releases rlock atomically,
        // then reacquires it (as a shared lock) before returning.
        cv_.wait(rlock, [this] { return ready_; });
        return value_;
    }

private:
    mutable std::shared_mutex mtx_;
    std::condition_variable_any cv_;
    bool ready_{false};
    std::string value_;
};

The predicate overload of wait re-checks ready_ under the shared lock on each spurious wake-up, which is correct because no writer can modify value_ while any shared lock is held. Multiple reader threads can therefore proceed through wait_read() concurrently once publish() calls notify_all().

One important limitation: notify_one() wakes exactly one waiter regardless of whether it holds a shared or exclusive lock. If your design mixes shared-lock waiters and exclusive-lock waiters on the same condition_variable_any, reason carefully about which waiter is selected β€” a writer waiting for unique_lock may be starved if notify_one() repeatedly selects shared-lock waiters.

Priority Inversion and Writer Starvation

The C++ standard does not prescribe a fairness policy for shared_mutex, so the risk of writer starvation is real and implementation-defined. In a naΓ―ve reader-preference implementation, a steady stream of readers can hold at least one shared lock at all times, preventing a writer from ever acquiring the exclusive lock. libstdc++ and libc++ both implement writer priority (new shared-lock requests are queued behind a pending writer), but this is not mandated by the standard and may not hold on every platform.

The classic starvation scenario unfolds as follows:

cpp
Time β†’
Reader A: [shared]─────────────────────────────────────────────────
Reader B:        [shared]───────────────────────────────────────────
Reader C:               [shared]────────────────────────────────────
Writer W:                     tries exclusive ← blocked, waiting ...
Reader D:                         [shared]   ← new readers keep arriving
Reader E:                                [shared]
                                               Writer W still waiting

Under writer-priority implementations this is solved by refusing new shared-lock acquisitions once a writer is queued. But priority inversion β€” where a medium-priority writer is stuck behind a flood of low-priority readers β€” can still occur if the scheduler promotes reader threads or if readers hold locks across I/O calls.

Practical mitigations:

  • Keep critical sections short. A shared lock held while doing file I/O or network calls is almost certainly wrong. Copy the data, release the lock, then do I/O outside it.
  • Use a write-bias wrapper. If your use case is sensitive to write latency, wrap shared_mutex in a class that introduces a brief std::this_thread::yield() or a small sleep before each lock_shared() when a writer flag is set β€” effectively yielding the CPU to pending writers.
  • Switch to a different primitive. For extreme write-sensitive workloads, consider a seqlock (sequenced lock) pattern or std::atomic<std::shared_ptr<T>> (see next section) where readers never block writers at all.

Note that priority inversion is distinct from starvation: priority inversion occurs when a high-priority thread is blocked by a lower-priority thread holding a shared lock, even in the absence of a continuous stream of readers. Avoiding it requires keeping shared lock durations strictly bounded.

Alternatives: std::atomic<std::shared_ptr<T>> for Lock-Free Read-Mostly Data

For read-mostly data structures where the entire value can be atomically replaced, std::atomic<std::shared_ptr<T>> (C++20) provides reader-writer semantics with zero mutex overhead on reads. Readers call load(), which atomically increments a reference count and returns a snapshot; the snapshot remains valid for as long as the reader holds it, independent of any concurrent writer. Writers call store() or compare_exchange_*() to publish a new object, after which old readers finish against their snapshot and the old allocation is freed when the last shared_ptr is released.

cpp
// C++20
#include <memory>
#include <atomic>
#include <unordered_map>
#include <string>

using Config = std::unordered_map<std::string, std::string>;

class AtomicConfig {
public:
    // Readers: lock-free, can proceed concurrently with a writer
    std::shared_ptr<const Config> get() const {
        return config_.load(std::memory_order_acquire);
    }

    // Writer: build a new Config, then atomically publish it.
    // Old readers keep their snapshot; new readers see the update.
    void update(std::shared_ptr<const Config> next) {
        config_.store(std::move(next), std::memory_order_release);
    }

private:
    std::atomic<std::shared_ptr<const Config>> config_{
        std::make_shared<Config>()
    };
};

Reads are genuinely lock-free: no kernel call, no futex, no cache-line ping-pong on an internal atomic counter. Writers pay a heap allocation to construct the new value before publishing it, but that allocation happens outside any lock.

Trade-offs vs shared_mutex:

Propertyshared_mutexatomic<shared_ptr<T>>
Reader costlock_shared() + atomic RMWload() β€” typically 1–2 atomics
Writer costlock() blocks until all readers donestore() is non-blocking; old readers finish independently
Partial updateSupported (mutate fields in place)Requires copy-on-write (build new object, swap)
C++ versionC++17C++20 (std::atomic<shared_ptr>)
GranularityArbitrary guarded scopeEntire pointed-to object is replaced atomically

atomic<shared_ptr<T>> is the right choice when the protected data is replaced wholesale (configuration reload, routing table refresh, cached computation result). shared_mutex remains necessary when data must be modified in place β€” for example, inserting into a std::map that cannot be cheaply copied on each write.

For C++17 targets, std::atomic_load / std::atomic_store on a plain std::shared_ptr offer similar semantics but are deprecated in C++20 in favour of std::atomic<std::shared_ptr<T>>.

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>