C++17 多线程系列(六):高级同步——shared_mutex / scoped_lock / call_once
·
核心目标:掌握 C++17 新增的同步原语,理解读者-写者锁的性能优势,能根据场景选择正确的同步工具,写出正确且高效的多线程同步代码。
前置知识:Part 2 的
mutex/lock_guard/unique_lock/condition_variable基础。
6.1 std::shared_mutex——读者-写者锁
6.1.1 读共享、写独占的语义
传统的 std::mutex 无论是读还是写都互斥,这在高并发读取场景下是性能瓶颈。std::shared_mutex(C++17)提供两种锁定模式:
- 共享锁(读锁):多个线程可以同时持有,适合读取操作
- 独占锁(写锁):只有一个线程能持有,适合写入操作
6.1.2 基本 API
#include <shared_mutex>
std::shared_mutex smtx;
// ── 读线程: 共享锁 ──
void reader(int id) {
smtx.lock_shared(); // 获取共享锁
// 多个 reader 可以同时执行到这里
std::cout << "Reader " << id << " reading data: " << shared_data << "\n";
smtx.unlock_shared(); // 释放共享锁
}
// ── 写线程: 独占锁 ──
void writer(int new_val) {
smtx.lock(); // 获取独占锁 (阻塞直到所有 reader 释放)
shared_data = new_val;
std::cout << "Writer updated data to: " << shared_data << "\n";
smtx.unlock(); // 释放独占锁
}
6.1.3 为什么不直接使用 lock()/unlock()
与普通 mutex 一样,手动 lock_shared()/unlock_shared() 存在异常安全隐患:
// ❌ 危险: 如果 read_process 抛异常, unlock_shared 永远不会被调用
void reader_unsafe() {
smtx.lock_shared();
auto result = read_process(); // 可能抛异常
smtx.unlock_shared(); // 永远到不了这里!
}
// ✅ 正确: 用 RAII 自动管理
void reader_safe() {
std::shared_lock lock(smtx); // 构造时 lock_shared, 析构时 unlock_shared
auto result = read_process(); // 即使抛异常也能释放锁
}
6.2 std::shared_lock——读锁的 RAII 包装
6.2.1 基本用法
#include <shared_mutex>
std::shared_mutex smtx;
std::unordered_map<std::string, std::string> cache;
// ── 读取缓存 (多线程可并发) ──
std::optional<std::string> read_cache(const std::string& key) {
std::shared_lock lock(smtx); // ✅ 共享锁,多个读可并发
auto it = cache.find(key);
if (it != cache.end()) {
return it->second;
}
return std::nullopt;
}
// ── 更新缓存 (写独占) ──
void write_cache(const std::string& key, const std::string& value) {
std::unique_lock lock(smtx); // ✅ 独占锁,阻塞所有读写
cache[key] = value;
}
6.2.2 shared_lock vs unique_lock vs lock_guard
| 特性 | std::lock_guard |
std::unique_lock |
std::shared_lock |
|---|---|---|---|
| 锁定模式 | 独占 | 独占 | 共享 |
| RAII 自动管理 | ✅ | ✅ | ✅ |
| 手动 unlock | ❌ | ✅ | ✅ |
| 延迟加锁 | ❌ | ✅ | ✅ (defer_lock) |
| 尝试加锁 | ❌ | ✅ (try_to_lock) |
✅ (try_to_lock) |
| 转移所有权 | ❌ | ✅ (move) | ✅ (move) |
| 适用 mutex 类型 | 任意 mutex | 任意 mutex | 仅 shared_mutex |
| 配合条件变量 | ❌ | ✅ | ❌ |
| 开销 | 最小 | 稍大 | 稍大 |
6.2.3 锁升级/降级——为什么 shared_mutex 不支持
一个常见需求:先读共享锁,如果缓存缺失,升级为独占锁来更新。
// ❌ 错误期望: 锁升级 (C++ 标准库不支持)
void cache_get_or_load_bad(const std::string& key) {
std::shared_lock lock(smtx); // 持有读锁
auto it = cache.find(key);
if (it != cache.end()) return it->second;
// 想在这里升级为写锁?→ 不支持!
// 如果直接 lock(), 当前线程已经持有 shared_lock → 死锁!
}
// ✅ 正确做法: 先释放读锁, 再获取写锁 (双重检查)
std::string cache_get_or_load(const std::string& key) {
// 第一次检查 (读锁)
{
std::shared_lock lock(smtx);
auto it = cache.find(key);
if (it != cache.end()) return it->second;
} // ← 释放读锁
// 获取写锁
{
std::unique_lock lock(smtx);
// 第二次检查: 其他线程可能已加载
auto it = cache.find(key);
if (it != cache.end()) return it->second;
// 加载数据
std::string value = expensive_load_from_db(key);
cache[key] = value;
return value;
}
}
为什么 C++ 不直接支持锁升级? 因为
lock_shared→lock的原子升级可能导致死锁(多个 reader 同时请求升级)。需要时用上述双重检查模式或第三方库(如boost::upgrade_mutex)。
6.3 高性能缓存系统实战
6.3.1 完整的线程安全缓存
#include <shared_mutex>
#include <unordered_map>
#include <functional>
#include <optional>
template <typename Key, typename Value>
class ThreadSafeCache {
mutable std::shared_mutex mtx_; // mutable: const 方法中也可加锁
std::unordered_map<Key, Value> cache_;
size_t max_size_;
public:
explicit ThreadSafeCache(size_t max_size = 10000) : max_size_(max_size) {}
// read: 多个线程可并发
std::optional<Value> get(const Key& key) const {
std::shared_lock lock(mtx_);
auto it = cache_.find(key);
if (it != cache_.end()) {
return it->second;
}
return std::nullopt;
}
// write: 独占
void put(const Key& key, const Value& value) {
std::unique_lock lock(mtx_);
if (cache_.size() >= max_size_) {
evict_lru(); // 简单的淘汰策略
}
cache_[key] = value;
}
// get_or_compute: 缓存未命中时加载
Value get_or_compute(const Key& key,
std::function<Value(const Key&)> loader) {
// 先尝试读锁
{
std::shared_lock lock(mtx_);
auto it = cache_.find(key);
if (it != cache_.end()) return it->second;
}
// 未命中: 获取写锁 + 双重检查
Value value = loader(key); // ⚠️ 在锁外执行耗时加载!
{
std::unique_lock lock(mtx_);
auto it = cache_.find(key);
if (it != cache_.end()) return it->second; // 其他线程已加载
if (cache_.size() >= max_size_) {
evict_lru();
}
cache_[key] = value;
return value;
}
}
size_t size() const {
std::shared_lock lock(mtx_);
return cache_.size();
}
private:
void evict_lru() {
// 简化: 删除第一个元素
if (!cache_.empty()) cache_.erase(cache_.begin());
}
};
6.3.2 性能对比:mutex vs shared_mutex
#include <chrono>
#include <iostream>
#include <thread>
#include <random>
// 测试: 10 个读线程 + 2 个写线程, 各执行 100 万次操作
template <typename CacheType>
double benchmark_cache(int num_readers, int num_writers, int ops_per_thread) {
CacheType cache;
std::atomic<bool> start{false};
std::atomic<long> total_ops{0};
auto reader_fn = [&](int id) {
while (!start) std::this_thread::yield();
for (int i = 0; i < ops_per_thread; ++i) {
cache.get(i % 1000);
total_ops++;
}
};
auto writer_fn = [&](int id) {
while (!start) std::this_thread::yield();
for (int i = 0; i < ops_per_thread; ++i) {
cache.put(i % 1000, i * id);
total_ops++;
}
};
std::vector<std::thread> threads;
for (int i = 0; i < num_readers; ++i)
threads.emplace_back(reader_fn, i);
for (int i = 0; i < num_writers; ++i)
threads.emplace_back(writer_fn, i);
auto t1 = std::chrono::steady_clock::now();
start = true;
for (auto& t : threads) t.join();
auto t2 = std::chrono::steady_clock::now();
return std::chrono::duration<double>(t2 - t1).count();
}
// CacheMutex: 用 std::mutex 保护
// 读/写都互斥
// CacheSharedMutex: 用 std::shared_mutex 保护
// 读共享, 写互斥
6.3.3 典型测试结果(8 核,10 读线程 + 2 写线程)
| 缓存实现 | 吞吐量 (ops/s) | 相对 mutex |
读延迟 p50 | 写延迟 p50 |
|---|---|---|---|---|
std::mutex |
850,000 | 1.0x | 1.2μs | 1.2μs |
std::shared_mutex |
3,200,000 | 3.8x | 0.3μs | 2.5μs |
结论:读多写少的场景(如缓存、配置管理),
shared_mutex可以带来 3-5 倍的吞吐量提升。
6.4 std::scoped_lock 深度——多锁防死锁
6.4.1 C++14 写的痛
std::mutex mtx_a, mtx_b, mtx_c;
// C++14: 锁定 3 个 mutex 且防止死锁
void transfer_cpp14(int amount) {
std::lock(mtx_a, mtx_b, mtx_c); // ① 同时锁定 (内部防死锁)
std::lock_guard<std::mutex> lk1(mtx_a, std::adopt_lock); // ② 接管 a
std::lock_guard<std::mutex> lk2(mtx_b, std::adopt_lock); // ③ 接管 b
std::lock_guard<std::mutex> lk3(mtx_c, std::adopt_lock); // ④ 接管 c
// ... 临界区 ...
}
// ⚠️ 如果忘记对某个 mutex 写 adopt_lock → 重复加锁 → 未定义行为!
// ⚠️ 如果忘记 std::lock() → 可能死锁!
6.4.2 C++17 一行搞定
// C++17: scoped_lock 可变参数模板
void transfer_cpp17(int amount) {
std::scoped_lock lock(mtx_a, mtx_b, mtx_c); // ✅ 自动防死锁 + RAII
// ... 临界区 ...
}
// 结合 CTAD, 类型自动推导
std::scoped_lock lock(mtx_a, mtx_b); // 等价于 scoped_lock<std::mutex, std::mutex>
6.4.3 scoped_lock 内部原理
// scoped_lock 的简化实现原理:
template <typename... MutexTypes>
class scoped_lock {
std::tuple<MutexTypes&...> mutexes_;
public:
explicit scoped_lock(MutexTypes&... m) : mutexes_(m...) {
std::lock(m...); // ① 用 std::lock() 算法同时锁定所有 mutex
// 内部实现: 尝试-回退 (try-and-back-off)
// 如果某个锁获取失败, 释放所有已获取的锁再重试
}
~scoped_lock() {
std::apply([](MutexTypes&... m) {
(m.unlock(), ...); // ② C++17 fold expression 解锁所有
}, mutexes_);
}
scoped_lock(const scoped_lock&) = delete;
scoped_lock& operator=(const scoped_lock&) = delete;
};
6.4.4 实战:银行转账无死锁
#include <string>
#include <mutex>
#include <iostream>
class BankAccount {
std::string name_;
double balance_ = 0.0;
mutable std::mutex mtx_; // mutable: const 方法内部也可能需要临时加锁
public:
explicit BankAccount(std::string name, double initial = 0.0)
: name_(std::move(name)), balance_(initial) {}
// ✅ 安全的跨账户转账 —— scoped_lock 自动防死锁
static void transfer(BankAccount& from, BankAccount& to, double amount) {
// 确保锁定顺序一致, 但仍用 scoped_lock 作为安全网
if (&from == &to) return; // 自我转账
std::scoped_lock lock(from.mtx_, to.mtx_);
if (from.balance_ >= amount) {
from.balance_ -= amount;
to.balance_ += amount;
std::cout << from.name_ << " → " << to.name_
<< ": $" << amount << "\n";
}
}
double balance() const {
std::lock_guard lock(mtx_);
return balance_;
}
const std::string& name() const { return name_; }
};
// 使用示例
int main() {
BankAccount alice("Alice", 1000);
BankAccount bob("Bob", 500);
BankAccount charlie("Charlie", 300);
// 即使从不同线程以不同顺序调用, scoped_lock 也不会死锁
std::thread t1([&] { BankAccount::transfer(alice, bob, 100); });
std::thread t2([&] { BankAccount::transfer(bob, alice, 50); });
std::thread t3([&] { BankAccount::transfer(alice, charlie, 200); });
std::thread t4([&] { BankAccount::transfer(charlie, bob, 75); });
t1.join(); t2.join(); t3.join(); t4.join();
std::cout << "Alice: " << alice.balance() << "\n";
std::cout << "Bob: " << bob.balance() << "\n";
std::cout << "Charlie: " << charlie.balance() << "\n";
}
6.4.5 lock_guard vs scoped_lock——何时用哪个
| 场景 | 推荐 | 原因 |
|---|---|---|
| 单 mutex,简单临界区 | lock_guard |
最简单,零心智负担 |
| 多 mutex(2+) | scoped_lock |
自动防死锁,一行搞定 |
| 需要配合条件变量 | unique_lock |
condition_variable 要求 unique_lock |
| 需要中途解锁 | unique_lock |
支持手动 unlock() / lock() |
| C++14 项目 | lock_guard + std::lock() |
scoped_lock 仅 C++17 |
| C++17 新项目单锁 | scoped_lock |
统一风格,CTAD 更简洁 |
建议:C++17 项目中,单锁也推荐用
scoped_lock(开销与lock_guard几乎相同),保持代码风格统一。std::scoped_lock lock(mtx);习惯后就回不去了。
6.5 std::call_once 与单例模式——终极对比
6.5.1 六种单例实现对比
#include <mutex>
#include <atomic>
#include <memory>
class ExpensiveResource {
// ① ❌ 非线程安全 —— 永远不要用
static ExpensiveResource* instance_unsafe_;
static ExpensiveResource* get_unsafe() {
if (!instance_unsafe_)
instance_unsafe_ = new ExpensiveResource(); // Data Race!
return instance_unsafe_;
}
// ② ❌ DCLP (C++11 之前是 broken 的) —— 不要用
static std::mutex dclp_mtx_;
static ExpensiveResource* instance_dclp_;
static ExpensiveResource* get_dclp_broken() {
if (!instance_dclp_) { // 第一次检查 (无锁)
std::lock_guard lock(dclp_mtx_);
if (!instance_dclp_) { // 第二次检查 (有锁)
instance_dclp_ = new ExpensiveResource();
// 在 C++03 内存模型下, new 的重排可能导致
// 另一个线程看到"分配了但未构造"的对象
}
}
return instance_dclp_;
}
// ③ ✅ 函数内 static (C++11 起线程安全) —— 最推荐
static ExpensiveResource& get_static() {
static ExpensiveResource instance; // C++11 保证: 只会初始化一次
return instance;
}
// ④ ✅ call_once + once_flag —— 也可以
static std::once_flag once_flag_;
static std::unique_ptr<ExpensiveResource> instance_call_once_;
static ExpensiveResource* get_call_once() {
std::call_once(once_flag_, [] {
instance_call_once_.reset(new ExpensiveResource());
});
return instance_call_once_.get();
}
// ⑤ ✅ atomic + CAS —— 复杂但灵活
static std::atomic<ExpensiveResource*> instance_atomic_;
static ExpensiveResource* get_atomic() {
auto* ptr = instance_atomic_.load(std::memory_order_acquire);
if (!ptr) {
auto* new_instance = new ExpensiveResource();
if (instance_atomic_.compare_exchange_strong(
ptr, new_instance, std::memory_order_release,
std::memory_order_acquire)) {
return new_instance;
}
delete new_instance; // 另一个线程抢先了
}
return ptr;
}
// ⑥ ✅ call_once + 非静态单例 —— 延迟初始化集合
static std::once_flag init_flag_;
static std::unordered_map<std::string, ExpensiveResource> pool_;
static ExpensiveResource& get_from_pool(const std::string& name) {
std::call_once(init_flag_, [] {
pool_["db"] = ExpensiveResource("database");
pool_["api"] = ExpensiveResource("api_client");
pool_["log"] = ExpensiveResource("logger");
});
return pool_.at(name);
}
// 添加 requires_lock 注释标记
// 静态局部变量方式支持传入参数吗?不支持——这是它的局限性
ExpensiveResource(std::string config = "default") {}
};
6.5.2 方案选择决策
6.5.3 call_once 的更多场景
#include <mutex>
#include <memory>
#include <thread>
#include <vector>
// 场景 1: 懒加载配置
class AppConfig {
static std::once_flag config_init_flag_;
static std::unique_ptr<AppConfig> instance_;
std::unordered_map<std::string, std::string> settings_;
AppConfig() {
// 从文件加载配置 (耗时操作)
settings_["db.host"] = "localhost";
settings_["db.port"] = "5432";
settings_["cache.size"] = "10000";
}
public:
static AppConfig& instance() {
std::call_once(config_init_flag_, [] {
instance_.reset(new AppConfig());
});
return *instance_;
}
std::string get(const std::string& key,
const std::string& default_val = "") const {
auto it = settings_.find(key);
return it != settings_.end() ? it->second : default_val;
}
};
// 场景 2: 线程安全的首次访问标记
class DatabaseConnection {
std::once_flag connected_flag_;
public:
void ensure_connected() {
std::call_once(connected_flag_, [this] {
std::cout << "[Thread " << std::this_thread::get_id()
<< "] 正在连接数据库...\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// 实际的连接代码...
std::cout << "[Thread " << std::this_thread::get_id()
<< "] 连接成功!\n";
});
}
};
// 测试: 多个线程同时要求连接, 但只连接一次
void test_connect_once() {
DatabaseConnection conn;
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back([&conn] { conn.ensure_connected(); });
}
for (auto& t : threads) t.join();
// 输出: 只有一行 "正在连接数据库..." 和一行 "连接成功!"
}
6.5.4 call_once 实现原理
// std::call_once 的简化实现示意:
// 使用原子标志 + 互斥锁 + 条件变量
// 第一个线程: 获取锁, 执行函数, 设置标志为 finished
// 后续线程: 检查标志已为 finished, 直接返回
//
// once_flag 状态机:
// [未执行] ──call_once──→ [执行中] ──函数完成──→ [已完成]
// ↑ 其他线程阻塞等待 ↑ 后续调用直接返回
6.6 同步原语选择决策树
6.7 锁的性能基准测试
6.7.1 微基准代码
#include <benchmark/benchmark.h>
#include <mutex>
#include <shared_mutex>
#include <atomic>
// ── 测试: 无竞争下的锁获取/释放延迟 ──
// mutex (独占)
static void BM_Mutex_LockUnlock(benchmark::State& state) {
std::mutex mtx;
for (auto _ : state) {
mtx.lock();
benchmark::DoNotOptimize(mtx);
mtx.unlock();
}
}
// shared_mutex (独占模式)
static void BM_SharedMutex_ExclusiveLock(benchmark::State& state) {
std::shared_mutex smtx;
for (auto _ : state) {
smtx.lock();
benchmark::DoNotOptimize(smtx);
smtx.unlock();
}
}
// shared_mutex (共享模式)
static void BM_SharedMutex_SharedLock(benchmark::State& state) {
std::shared_mutex smtx;
for (auto _ : state) {
smtx.lock_shared();
benchmark::DoNotOptimize(smtx);
smtx.unlock_shared();
}
}
// atomic_flag 自旋锁
static void BM_Spinlock_LockUnlock(benchmark::State& state) {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
for (auto _ : state) {
while (flag.test_and_set(std::memory_order_acquire)) {
// 自旋等待 (真实场景应加 pause 指令)
}
benchmark::DoNotOptimize(flag);
flag.clear(std::memory_order_release);
}
}
// scoped_lock (单 mutex)
static void BM_ScopedLock_One(benchmark::State& state) {
std::mutex mtx;
for (auto _ : state) {
std::scoped_lock lock(mtx);
benchmark::DoNotOptimize(mtx);
}
}
BENCHMARK(BM_Mutex_LockUnlock);
BENCHMARK(BM_SharedMutex_ExclusiveLock);
BENCHMARK(BM_SharedMutex_SharedLock);
BENCHMARK(BM_Spinlock_LockUnlock);
BENCHMARK(BM_ScopedLock_One);
6.7.2 典型基准数据(x86-64, 无竞争)
| 锁类型 | 操作 | 延迟 (ns) | 相对 mutex |
|---|---|---|---|
std::mutex (linux futex) |
lock/unlock | ~25 | 1.0x |
std::shared_mutex 独占 |
lock/unlock | ~35 | 1.4x |
std::shared_mutex 共享 |
lock_shared/unlock_shared | ~30 | 1.2x |
atomic_flag 自旋锁 |
test_and_set/clear | ~5 | 0.2x |
std::scoped_lock (单锁) |
构造/析构 | ~26 | 1.04x |
std::lock_guard |
构造/析构 | ~25 | 1.0x |
6.7.3 竞争场景下的吞吐量(8 线程同时操作)
| 场景 | mutex |
shared_mutex |
自旋锁 |
|---|---|---|---|
| 100% 写 | 850K ops/s | 720K ops/s | 900K ops/s |
| 90% 读 + 10% 写 | 860K ops/s | 2.8M ops/s | 940K ops/s |
| 99% 读 + 1% 写 | 870K ops/s | 5.1M ops/s | 950K ops/s |
关键结论:
- 读多写少 →
shared_mutex:读比例越高,优势越明显(可达 5x+)。- 临界区极短 → 自旋锁:避免系统调用和上下文切换的开销。
scoped_lock单锁开销 ≈lock_guard,多锁场景无额外开销(std::lock本就需要)。- 写密集 → 普通
mutex:shared_mutex的独占锁比mutex略慢。
6.8 小结
| 知识点 | 掌握程度 | 核心要点 |
|---|---|---|
std::shared_mutex |
掌握 | 读共享、写独占,读多写少场景吞吐量提升 3-5x |
std::shared_lock |
掌握 | 共享锁的 RAII 包装,配合 shared_mutex 使用 |
| 锁升级问题 | 理解 | C++ 不支持锁升级,用双重检查 + 释读取写 |
std::scoped_lock (C++17) |
熟练 | 多锁场景首选,一行防止死锁,单锁也能用 |
| CTAD | 了解 | std::scoped_lock lock(mtx) 无需写模板参数 |
std::call_once |
掌握 | 线程安全单次初始化,支持传参,比 DCLP 更安全 |
| 函数内 static | 掌握 | C++11 起线程安全,最简单的单例写法 |
| 同步原语决策树 | 掌握 | 根据场景选择 atomic/shared_mutex/mutex/call_once |
| 锁性能数据 | 理解 | 无竞争 25ns,读多写少 shared_mutex 有显著优势 |
下期预告
[Part 7:线程池——从零设计到优雅关闭] 将进入并发架构设计:
- 为什么需要线程池——线程创建/销毁的开销量化
- 任务队列设计——有界 vs 无界,
std::function类型擦除 - ~200 行实现一个完整的 C++17 线程池
- 优雅关闭:状态机设计,取消 vs drain
- 异常安全与性能优化
推荐工具
- Compiler Explorer (godbolt.org) —— 查看
scoped_lock的汇编实现perf stat -e cycles,instructions—— 对比不同锁的 CPU 周期-fsanitize=thread -g—— TSAN 也能检测 shared_mutex 使用不当- Google Benchmark —— 微基准测试锁性能
更多推荐



所有评论(0)