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.
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>