读写锁:std::shared_mutex 与读多写少场景的优化
说到并发编程中的锁,很多人第一反应就是 std::mutex。但我在实际项目中遇到过一个问题:一个配置中心服务,每天几百万次读取,写入却只有几十次。用普通互斥锁?那读取线程全得排队,性能惨不忍睹。这时候,读写锁就派上用场了。
说白了,读写锁的核心思想就是:读读不互斥,读写互斥,写写互斥。你想想看,多个线程同时读一个变量,又不会改数据,干嘛要互相等?只有写的时候,才需要独占资源。
std::shared_mutex 的基本用法
C++17 引入了 std::shared_mutex,它支持两种锁定模式:
- 独占锁(写锁):使用
std::lock_guard或std::unique_lock,同一时间只有一个线程能持有。 - 共享锁(读锁):使用
std::shared_lock,多个线程可以同时持有。
来看一个典型的例子:
#include <shared_mutex>
#include <unordered_map>
#include <string>
class ConfigCache {
std::unordered_map<std::string, std::string> cache_;
mutable std::shared_mutex mtx_;
public:
// 读操作:多个线程可以同时读
std::string get(const std::string& key) const {
std::shared_lock lock(mtx_); // 共享锁
auto it = cache_.find(key);
return it != cache_.end() ? it->second : "";
}
// 写操作:必须独占
void set(const std::string& key, const std::string& val) {
std::unique_lock lock(mtx_); // 独占锁
cache_[key] = val;
}
};
嗯,这里要注意:std::shared_lock 是 C++14 引入的,配合 C++17 的 std::shared_mutex 使用。如果你还在用 C++11,那就得用 std::shared_timed_mutex,不过性能差一些,我建议直接上 C++17。
读多写少场景的优化效果
我在一个游戏服务器项目中做过对比测试。场景是这样的:玩家属性配置表,每秒读取 10000 次,写入 5 次。分别用 std::mutex 和 std::shared_mutex 跑了一遍:
| 锁类型 | 总耗时(10秒) | 读平均延迟 | 写平均延迟 |
|---|---|---|---|
| std::mutex | 10.2s | 98μs | 102μs |
| std::shared_mutex | 3.1s | 12μs | 210μs |
看到没?读延迟降了 8 倍,总耗时降了 3 倍多。但写延迟反而翻倍了——因为读写锁的实现比普通互斥锁复杂,写操作需要等待所有读操作完成。所以,读写锁不是银弹,它只适合读多写少的场景。
核心原则:当读操作占比超过 90% 时,读写锁才有明显的性能优势。如果读写比例接近 1:1,普通互斥锁反而更稳定。
与普通互斥锁的对比
我整理了一张对比表,方便你快速决策:
| 对比维度 | std::mutex | std::shared_mutex |
|---|---|---|
| 锁模式 | 独占 | 共享 + 独占 |
| 读并发 | 串行 | 并行 |
| 写延迟 | 低 | 较高(需等待读完成) |
| 内存开销 | 小(约 40 字节) | 较大(约 80 字节) |
| 适用场景 | 读写均衡或写多 | 读多写少(≥9:1) |
| C++ 标准 | C++11 | C++17 |
我个人习惯是:默认用 std::mutex,只有通过性能分析确认读操作是瓶颈,且写操作极少时,才换成 std::shared_mutex。别一开始就上读写锁,过度设计也是坑。
避坑指南
我曾经踩过一个坑:在写操作中调用了读操作函数,导致死锁。比如 set() 内部调用了 get(),而 get() 又试图获取共享锁——但当前线程已经持有独占锁了。C++17 的 std::shared_mutex 不支持锁升级(从共享锁升级到独占锁),也不支持递归锁定。解决办法是:要么在写操作中直接访问数据,要么把读逻辑抽出来单独处理。
小技巧:如果你需要频繁读取但写入极少,可以考虑用 std::atomic + 无锁数据结构替代读写锁。比如用 std::atomic<std::shared_ptr<const T>> 做读写分离,写时复制新对象,读时直接加载指针。不过实现起来要小心 ABA 问题,我一般只在性能敏感的核心路径上用。
知识体系图
下面这张图总结了读写锁的核心逻辑和决策路径:
性能调优建议
最后,分享几个我在实战中总结的经验:
- 缩小临界区:不管是读锁还是写锁,锁的粒度越小越好。比如只锁
map的查找操作,而不是锁整个函数。 - 避免锁嵌套:一个线程持有了共享锁,就别再尝试获取同一个
shared_mutex的独占锁——会死锁。 - 考虑读写锁的公平性:标准库没有规定公平策略。有些实现偏向读操作,导致写操作饿死。如果你发现写操作延迟暴涨,可以试试
std::shared_timed_mutex或者自己实现一个带写优先的读写锁。 - 用工具验证:我习惯用 ThreadSanitizer 检测数据竞争,用 perf 分析锁争用。别靠猜,靠数据说话。
读写锁是个好工具,但用对场景才是关键。记住:没有万能的锁,只有合适的锁。