「读多写少,那用读写锁肯定更快吧。」这句话是并发优化里最常见的直觉,也是最常翻车的一个。std::shared_mutex(C++17)确实做到了多读者并存、写者独占,但它的独占成本比 std::mutex 高,读者路径还要动一个共享计数器。下面先讲清该怎么用、什么时候别用,再顺手把 volatile 的三大误解拆掉。这两件事总是一起出现,因为动机都一样:想省掉那把锁。
读写锁的模型:多个读者并存,写者独占
一句话:shared_lock 是共享的(多个读者可以同时持有),unique_lock / lock_guard 是独占的(写者一来,读者和写者都被挡在外面)。
text
写者视角:unique_lock ------ 独占,其他谁都不许进
┌──────────────────────────────────────────────────┐
│ [ 写 W ] ← 同时也排除了所有读者 │
└──────────────────────────────────────────────────┘
读者视角:shared_lock ------ 可以并存
┌───────────┬───────────┬───────────┐
│ [ 读 R1 ] │ [ 读 R2 ] │ [ 读 R3 ] │ 三个读者同时持锁 ✓
└───────────┴───────────┴───────────┘
时间线上的互斥关系(▓ = 读者持锁,▉ = 写者持锁)
t0 t1 t2 t3 t4
写者 W1 ▉▉▉▉ ▉▉▉▉
读者 R1 ▓▓▓▓▓▓▓▓
读者 R2 ▓▓▓▓▓▓▓▓
读者 R3 ▓▓▓▓▓▓▓▓
→ R1/R2/R3 之间完全重叠(共享);任一读者与写者绝不重叠(互斥)
| 守卫类型 | 语义 | 能同时持有的人 | 典型用途 |
|---|---|---|---|
std::shared_lock<std::shared_mutex> |
共享(读锁) | 多个 | 只读访问 |
std::shared_lock 的移动构造 |
转移读锁所有权 | 同上 | 从函数里把读锁传出去 |
std::unique_lock<std::shared_mutex> |
独占(写锁) | 一个 | 修改共享状态 |
std::lock_guard<std::shared_mutex> |
独占(写锁) | 一个 | 不需要手动解锁时更轻 |
std::scoped_lock |
独占,可一次多把 | --- | 多把锁一起拿,避免死锁 |
cpp
// shared_mutex_demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread shared_mutex_demo.cpp -o smd
#include <atomic>
#include <chrono>
#include <cstdio>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <vector>
class Registry {
public:
void write(int value) {
std::unique_lock<std::shared_mutex> lock(mutex_); // 写者独占
value_ = value;
}
int read() {
std::shared_lock<std::shared_mutex> lock(mutex_); // 读者共享
const int now = active_.fetch_add(1, std::memory_order_relaxed) + 1;
bump_peak(now);
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 模拟"读操作有点慢"
active_.fetch_sub(1, std::memory_order_relaxed);
return value_;
}
int peak_readers() const { return peak_.load(std::memory_order_relaxed); }
private:
static void bump_peak(std::atomic<int>& peak, int value) {
int previous = peak.load(std::memory_order_relaxed);
while (previous < value &&
!peak.compare_exchange_weak(previous, value, std::memory_order_relaxed)) {
}
}
void bump_peak(int value) { bump_peak(peak_, value); }
mutable std::shared_mutex mutex_;
std::atomic<int> active_{0}; // 只有读者路径会动它 → 必须原子
std::atomic<int> peak_{0};
int value_{0};
};
int main() {
constexpr int kReaders = 4;
Registry registry;
std::vector<std::thread> readers;
for (int i = 0; i < kReaders; ++i) {
readers.emplace_back([®istry] { (void)registry.read(); });
}
std::thread writer([®istry] { registry.write(42); });
for (auto& reader : readers) reader.join();
writer.join();
std::printf("同时持读锁的峰值 >= 2 = %d\n",
static_cast<int>(registry.peak_readers() >= 2));
std::printf("写后读到的值 = %d\n", registry.read());
}
text
同时持读锁的峰值 >= 2 = 1
写后读到的值 = 42
第一行是共享性的证据:4 个读者线程真的同时持锁(峰值达到了 2 以上,而不是被排成一队)。第二行是互斥性的证据:写者的 42 被后续读者看到(锁保证了可见性,这里不需要额外的内存序参数)。
官方文档:std::shared_mutex --- cppreference · std::shared_lock --- cppreference
什么时候读写锁反而更慢
答案有点扫兴:当读操作很短的时候 。原因是 shared_mutex 的读者路径不是只读内存,而是一次「读-改-写」的原子计数:
text
两种锁的「无竞争」成本构成
std::mutex::lock() → 一次原子 CAS(快路径,几十纳秒量级)
↑ 成功就直接进临界区
shared_mutex::lock_shared() → 一次原子 RMW 改读者计数
+ 计数所在的缓存行会被所有读者频繁读写
→ 多个核心上的缓存行来回弹(cache line ping-pong)
↑ 核越多越疼,这是纯粹的浪费
于是判断标准可以写得很具体:
| 判据 | 选 std::mutex |
选 std::shared_mutex |
|---|---|---|
| 读操作耗时 | 只有几次内存访问(如读一个 int、查一个已在缓存里的小 map) |
明显长:要遍历大容器、做计算、访问慢存储 |
| 读写比例 | 差不多,或写不少 | 读远多于写(经验值 10:1 以上才值得考虑) |
| 读者数量 / 核数 | 少 | 多核并发读是主要收益来源 |
| 临界区里会不会阻塞 | 会(如内部再取锁、I/O) | 不会 |
| 可维护性 | 简单,不容易写错 | 多一种锁类型、多一种死锁机会 |
一句话概括:shared_mutex 是「用更贵的单次加锁开销,换读者之间的并行」 。如果读操作本身只要 20 纳秒,你换来的并行收益远小于多付的加锁成本,这时普通 mutex 反而更快,还能顺便靠「临界区短」躲开竞争。真到了读热点,通常更值得考虑的是「把只读数据做成不可变快照 + 原子指针替换」,而不是加一把读锁。
写饥饿(writer starvation)也值得一提:读者源源不断地来,写者可能长时间拿不到锁。多数实现(如 glibc 的 pthread_rwlock、libstdc++ 的 shared_mutex)会在有写者等待时拦住新读者来缓解,但标准并没有规定这一点,所以别把「写者一定会被公平对待」写进设计假设里。反过来,如果实现选择写者优先,读者的延迟抖动又会变大。
官方文档:C++ Core Guidelines · 并发章节(CP.20/CP.22「用 RAII 管锁」;「先量再优化」的基调也在这一章)
volatile 的三大误解
volatile 身上背的误解,比它真正的用途出名得多。它是从 C 语言那个「编译器不知道这块内存会被外部改」的时代来的,跟线程同步一点关系都没有。
| 维度 | volatile |
std::atomic |
|---|---|---|
| 原子性 | 不保证:v++ 依然是「读-改-写」三步 |
保证:fetch_add 是硬件级 RMW |
| 内存序 | 无:不提供任何 acquire/release 语义 | 可选 6 种内存序,默认 seq_cst |
| 编译器屏障 | 只保证「这次访问不被优化掉」(不删除、不合并、不缓存进寄存器) | 提供真正的读写屏障语义 |
| 能不能做线程同步 | 不能 | 就是为此而生 |
| 正确用途 | 内存映射 I/O(MMIO)寄存器、信号处理函数里的 sig_atomic_t 标志 |
计数器、状态标志、无锁结构的基石 |
误解 ①:「volatile 能保证线程同步」
最典型的形式是拿它当线程间标志位:
cpp
// 反例,不要这么写 ------ volatile 不是同步原语
volatile int flag = 0; // 反例:volatile 不做原子性,也不做内存屏障
volatile int counter = 0; // 反例:counter++ 是「读-改-写」,volatile 拦不住交错
// 线程 A // 线程 B
// counter = counter + 1; // counter = counter + 1; ← 两边可能都读到旧值
// flag = 1; // while (flag == 0) { } ← 可能永远看不到 1
为什么它挡不住?把 counter = counter + 1 拆开看,是加载、加一、存回三步。volatile 只承诺「加载和存回这两次访问我会老老实实做」,中间那段空隙谁来插一脚它不管。两个线程各自读到 0、各自写回 1,一次更新就这么凭空消失了(这叫丢失更新,lost update)。同理,volatile 也不建立 happens-before 关系,所以线程 B 即使「看见」了 flag == 1,也不保证能看见线程 A 在 flag = 1 之前写的其他数据。
顺带说一句混淆源:Java 的
volatile是有内存语义的,C/C++ 的没有。跨语言的经验直接搬过来,是这类 bug 的主要来源。
误解 ②:「volatile 能防止所有编译器优化」
它防的是一个很窄的子集:这次访问不许被优化掉(不删除、不合并、不缓存到寄存器里复用)。至于周围那些非 volatile 的访问被搬来搬去,它一概不管。它不是屏障:
cpp
// 片段:volatile 不是屏障
volatile int ready = 0; // 反例:指望它当"发布"屏障,不要这么写
int payload = 0;
// 线程 A // 线程 B
// payload = 42; // while (ready == 0) { }
// ready = 1; // 用 payload ← 不保证看到 42
payload = 42 和 ready = 1 之间没有任何顺序保证(编译器可以重排,CPU 也可以),线程 B 可能看到 ready == 1 却读到 payload == 0。要做这件事必须用 std::atomic<int> ready 的 release 语义,或者一把锁。
误解 ③:「volatile 是给多线程用的」
它真正的两个用途都很窄,但都很关键:
- 内存映射 I/O(memory-mapped I/O) :硬件寄存器在编译器眼里「内容不会变」,加
volatile才能让每次读写都真的落到总线上; - 信号处理函数(signal handler) :信号处理函数里的共享变量必须是
volatile std::sig_atomic_t,这是唯一可移植的保证(不能在里面用std::atomic,它不保证 async-signal-safe)。
cpp
// 片段:volatile 的正确用途 ------ 信号处理标志
volatile std::sig_atomic_t g_stop = 0; // 只有这种"信号处理 ↔ 主流程"的场景才该用 volatile
void on_signal(int) { g_stop = 1; } // 处理函数里只做最简单的赋值
官方文档:cv 类型限定符(含 volatile)--- cppreference · std::sig_atomic_t --- cppreference
换成 std::atomic 就对了
cpp
// atomic_counter.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread atomic_counter.cpp -o ac
#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>
int main() {
constexpr int kThreads = 4;
constexpr int kPerThread = 25000;
std::atomic<int> counter{0}; // 换成原子量
{
std::vector<std::thread> workers;
workers.reserve(kThreads);
for (int i = 0; i < kThreads; ++i) {
workers.emplace_back([&counter] {
for (int j = 0; j < kPerThread; ++j) {
counter.fetch_add(1, std::memory_order_relaxed); // 原子 RMW,不会丢
}
});
}
for (auto& worker : workers) worker.join();
}
std::printf("std::atomic<int> 计数 = %d, 期望 = %d\n",
counter.load(), kThreads * kPerThread);
}
text
std::atomic<int> 计数 = 100000, 期望 = 100000
100000 是确定的结果:fetch_add 是硬件级的读-改-写,10 万次自增一次都不会丢。反过来把这个 std::atomic<int> 换成裸 int(或者 volatile int),这个数每次跑都不一样、且几乎必然小于 100000。这就是「丢失更新」的真身,只是它的具体数值不稳定,没法写进预期输出里。
官方文档:std::atomic --- cppreference · std::memory_order --- cppreference
读多写的配置缓存
把读写锁用在它真正擅长的场景上:一个「读次数极多、写只在偶尔刷新」的配置缓存。读者走共享锁、写者走独占锁,命中与求和用原子量统计(读者路径上只有原子计数,没有第二把锁):
cpp
// rw_cache.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread rw_cache.cpp -o rwc
#include <atomic>
#include <cstdio>
#include <map>
#include <mutex>
#include <shared_mutex>
#include <string>
#include <thread>
#include <vector>
class ConfigCache {
public:
void set(const std::string& key, int value) {
std::unique_lock<std::shared_mutex> lock(mutex_); // 写:独占
config_[key] = value;
}
bool get(const std::string& key, int& out) const {
std::shared_lock<std::shared_mutex> lock(mutex_); // 读:共享
const auto it = config_.find(key);
if (it == config_.end()) return false;
out = it->second;
return true;
}
std::size_t size() const {
std::shared_lock<std::shared_mutex> lock(mutex_);
return config_.size();
}
private:
mutable std::shared_mutex mutex_; // const 方法里也要锁 → mutable
std::map<std::string, int> config_;
};
int main() {
constexpr int kKeys = 8;
constexpr int kReaderThreads = 4;
constexpr int kRounds = 100;
ConfigCache cache;
for (int i = 0; i < kKeys; ++i) {
cache.set("key" + std::to_string(i), i); // key0..key7 → 0..7
}
std::atomic<int> hits{0};
std::atomic<long long> sum{0};
std::vector<std::thread> readers;
readers.reserve(kReaderThreads);
for (int t = 0; t < kReaderThreads; ++t) {
readers.emplace_back([&cache, &hits, &sum] {
for (int round = 0; round < kRounds; ++round) {
for (int i = 0; i < kKeys; ++i) {
int value = 0;
if (cache.get("key" + std::to_string(i), value)) {
hits.fetch_add(1, std::memory_order_relaxed);
sum.fetch_add(value, std::memory_order_relaxed);
}
}
}
});
}
std::thread writer([&cache] { // 写者只碰另一个键
for (int round = 0; round < 50; ++round) {
cache.set("extra", round);
}
});
for (auto& reader : readers) reader.join();
writer.join();
int extra = -1;
const bool found = cache.get("extra", extra);
std::printf("读命中次数 = %d, 期望 = %d\n",
hits.load(), kReaderThreads * kRounds * kKeys);
std::printf("读到的值总和 = %lld, 期望 = %d\n",
sum.load(), kReaderThreads * kRounds * (kKeys - 1) * kKeys / 2);
std::printf("extra 存在 = %d, 最终值 = %d\n",
static_cast<int>(found), extra);
std::printf("键总数 = %d\n", static_cast<int>(cache.size()));
}
text
读命中次数 = 3200, 期望 = 3200
读到的值总和 = 11200, 期望 = 11200
extra 存在 = 1, 最终值 = 49
键总数 = 9
四行输出全部与线程调度顺序无关:命中次数 = 4 线程 × 100 轮 × 8 个键;总和 = 每个读者每轮读到 0+1+...+7 = 28,共 400 轮;extra 被写了 50 次(0 到 49),最终值是 49;键总数是 8 + 1。写者刻意只碰 extra 一个键,是为了让读者的统计结果可预期。要是写者和读者抢同一个键,读者求和的中间值就随调度漂移,那种输出没法写死,也没法当回归测试。
顺带记住这个坏味道:读者路径上再套一把 mutex 就把 shared_mutex 的意义全抵消了 (读者之间又开始互斥),所以统计量必须用 std::atomic。
延伸阅读
- std::shared_mutex --- cppreference ------ C++17 引入,注意
shared_timed_mutex(C++14)与它的差别 - std::shared_lock --- cppreference ------ 共享所有权的 RAII 守卫,可移动但通常只读
- std::unique_lock --- cppreference ------ 写者路径的标准选择,也是
condition_variable的唯一搭档 - cv 类型限定符 --- cppreference ------
volatile的标准定义,注意全文没有出现「线程」「同步」这类词 - std::atomic --- cppreference ------ 该用它的时候别用
volatile - std::memory_order --- cppreference ------ 六种内存序,理解
volatile缺的到底是什么 - std::sig_atomic_t --- cppreference ------
volatile的正当用途之一:信号处理 - C++ Core Guidelines · 并发章节 ------ CP.8「别用
volatile做同步」的官方出处
收个尾
读写锁不是「读多写少就对」的快捷键,它更像一笔交易:用单次加锁的成本去换读者之间的并行。读操作但凡只有几十纳秒,这笔交易必亏。真到了读热点上,通常优先考虑的还是不可变快照加原子指针替换。
volatile 那边更简单,它压根不在并发这个话题里。MMIO 和信号处理才是它的正经用途,线程间共享状态一律交给 std::atomic 或锁。跨语言搬经验的时候尤其要记住这一点。