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++17A 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
#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):
| Function | Mode | Description |
|---|---|---|
lock() | exclusive | Blocks until exclusive ownership acquired |
try_lock() | exclusive | Non-blocking; returns bool |
unlock() | exclusive | Releases exclusive lock |
lock_shared() | shared | Blocks until shared ownership acquired |
try_lock_shared() | shared | Non-blocking; returns bool |
unlock_shared() | shared | Releases shared lock |
Examples
Thread-safe configuration cache
#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:
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:
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:
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:
| Function | Mode | Description |
|---|---|---|
try_lock_for(dur) | exclusive | Blocks up to dur; returns bool |
try_lock_until(tp) | exclusive | Blocks until time point tp; returns bool |
try_lock_shared_for(dur) | shared | Blocks up to dur; returns bool |
try_lock_shared_until(tp) | shared | Blocks 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:
#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.
#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 theshared_lockensures the shared lock is destroyed (released) beforeunique_lockconstruction begins. Never try to construct aunique_lockon the sameshared_mutexwhile ashared_lockon 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:
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 duplicateIf 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:
| Threads | std::mutex (ns/iter) | std::shared_mutex (ns/iter) |
|---|---|---|
| 2 (1 writer + 1 reader) | 848 | 7 875 |
| 4 (1 writer + 3 readers) | 2 137 | 344 |
| 8 (1 writer + 7 readers) | 1 791 | 222 |
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:
- Reads dominate writes at runtime (rough guideline: 80 %+ reads).
- The read critical section is non-trivial (not a single integer load β use
std::atomicfor that). - 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:
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:
// 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:
- 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.
- 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.
- 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.
// 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:
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 waitingUnder 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_mutexin a class that introduces a briefstd::this_thread::yield()or a small sleep before eachlock_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.
// 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:
| Property | shared_mutex | atomic<shared_ptr<T>> |
|---|---|---|
| Reader cost | lock_shared() + atomic RMW | load() β typically 1β2 atomics |
| Writer cost | lock() blocks until all readers done | store() is non-blocking; old readers finish independently |
| Partial update | Supported (mutate fields in place) | Requires copy-on-write (build new object, swap) |
| C++ version | C++17 | C++20 (std::atomic<shared_ptr>) |
| Granularity | Arbitrary guarded scope | Entire 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β addstry_lock_for/try_lock_until/try_lock_shared_for/try_lock_shared_until; C++14std::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>