shared_mutex 读写锁该不该用,以及 volatile 的三大误解

「读多写少,那用读写锁肯定更快吧。」这句话是并发优化里最常见的直觉,也是最常翻车的一个。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([&registry] { (void)registry.read(); });
    }
    std::thread writer([&registry] { 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 是给多线程用的」

它真正的两个用途都很窄,但都很关键:

  1. 内存映射 I/O(memory-mapped I/O) :硬件寄存器在编译器眼里「内容不会变」,加 volatile 才能让每次读写都真的落到总线上;
  2. 信号处理函数(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。

延伸阅读

收个尾

读写锁不是「读多写少就对」的快捷键,它更像一笔交易:用单次加锁的成本去换读者之间的并行。读操作但凡只有几十纳秒,这笔交易必亏。真到了读热点上,通常优先考虑的还是不可变快照加原子指针替换。

volatile 那边更简单,它压根不在并发这个话题里。MMIO 和信号处理才是它的正经用途,线程间共享状态一律交给 std::atomic 或锁。跨语言搬经验的时候尤其要记住这一点。

相关推荐
大侠归来3 小时前
C 语言 | 在函数中操作数组
c语言·c++·算法
朝朝辞暮i9 小时前
C++ 第 23 课:class —— 开始真正进入面向对象
开发语言·c++·算法
郑同学的笔记10 小时前
【c++随笔28】strcmp 和 memcmp对比
开发语言·c++
随意起个昵称10 小时前
【背包dp】输出方案路径
c++·动态规划
朝朝辞暮i11 小时前
C++ 第 27 课:智能指针 shared_ptr
开发语言·c++·算法
by2099911 小时前
学会使用std::string类,并理解其内部是如何管理字符串的详细阐述(上)
c++·笔记·字符串·类和对象·string
沙漠之主11 小时前
C++编程教学设计资料:从入门到实战的完整课程方案
java·前端·c++
朝朝辞暮i11 小时前
C++ 第 29 课:回调函数 Callback
开发语言·c++·算法
kaixin_learn_qt_ing12 小时前
C++多态实现原理
c++