ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

shared_mutex 读写锁该不该用,以及 volatile 的三大误解 「读多写少那用读写锁肯定更快吧。」这句话是并发优化里最常见的直觉也是最常翻车的一个。std::shared_mutexC17确实做到了多读者并存、写者独占但它的独占成本比std::mutex高读者路径还要动一个共享计数器。下面先讲清该怎么用、什么时候别用再顺手把volatile的三大误解拆掉。这两件事总是一起出现因为动机都一样想省掉那把锁。读写锁的模型多个读者并存写者独占一句话shared_lock是共享的多个读者可以同时持有unique_lock/lock_guard是独占的写者一来读者和写者都被挡在外面。写者视角unique_lock —— 独占其他谁都不许进 ┌──────────────────────────────────────────────────┐ │ [ 写 W ] ← 同时也排除了所有读者 │ └──────────────────────────────────────────────────┘ 读者视角shared_lock —— 可以并存 ┌───────────┬───────────┬───────────┐ │ [ 读 R1 ] │ [ 读 R2 ] │ [ 读 R3 ] │ 三个读者同时持锁 ✓ └───────────┴───────────┴───────────┘ 时间线上的互斥关系▓ 读者持锁▉ 写者持锁 t0 t1 t2 t3 t4 写者 W1 ▉▉▉▉ ▉▉▉▉ 读者 R1 ▓▓▓▓▓▓▓▓ 读者 R2 ▓▓▓▓▓▓▓▓ 读者 R3 ▓▓▓▓▓▓▓▓ → R1/R2/R3 之间完全重叠共享任一读者与写者绝不重叠互斥守卫类型语义能同时持有的人典型用途std::shared_lockstd::shared_mutex共享读锁多个只读访问std::shared_lock的移动构造转移读锁所有权同上从函数里把读锁传出去std::unique_lockstd::shared_mutex独占写锁一个修改共享状态std::lock_guardstd::shared_mutex独占写锁一个不需要手动解锁时更轻std::scoped_lock独占可一次多把—多把锁一起拿避免死锁// shared_mutex_demo.cpp — 编译: g -stdc17 -Wall -O2 -pthread shared_mutex_demo.cpp -o smd#includeatomic#includechrono#includecstdio#includemutex#includeshared_mutex#includethread#includevectorclassRegistry{public:voidwrite(intvalue){std::unique_lockstd::shared_mutexlock(mutex_);// 写者独占value_value;}intread(){std::shared_lockstd::shared_mutexlock(mutex_);// 读者共享constintnowactive_.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);returnvalue_;}intpeak_readers()const{returnpeak_.load(std::memory_order_relaxed);}private:staticvoidbump_peak(std::atomicintpeak,intvalue){intpreviouspeak.load(std::memory_order_relaxed);while(previousvalue!peak.compare_exchange_weak(previous,value,std::memory_order_relaxed)){}}voidbump_peak(intvalue){bump_peak(peak_,value);}mutablestd::shared_mutex mutex_;std::atomicintactive_{0};// 只有读者路径会动它 → 必须原子std::atomicintpeak_{0};intvalue_{0};};intmain(){constexprintkReaders4;Registry registry;std::vectorstd::threadreaders;for(inti0;ikReaders;i){readers.emplace_back([registry]{(void)registry.read();});}std::threadwriter([registry]{registry.write(42);});for(autoreader:readers)reader.join();writer.join();std::printf(同时持读锁的峰值 2 %d\n,static_castint(registry.peak_readers()2));std::printf(写后读到的值 %d\n,registry.read());}同时持读锁的峰值 2 1 写后读到的值 42第一行是共享性的证据4 个读者线程真的同时持锁峰值达到了 2 以上而不是被排成一队。第二行是互斥性的证据写者的42被后续读者看到锁保证了可见性这里不需要额外的内存序参数。官方文档std::shared_mutex — cppreference · std::shared_lock — cppreference什么时候读写锁反而更慢答案有点扫兴当读操作很短的时候。原因是shared_mutex的读者路径不是只读内存而是一次「读-改-写」的原子计数两种锁的「无竞争」成本构成 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 语言那个「编译器不知道这块内存会被外部改」的时代来的跟线程同步一点关系都没有。维度volatilestd::atomic原子性不保证v依然是「读-改-写」三步保证fetch_add是硬件级 RMW内存序无不提供任何 acquire/release 语义可选 6 种内存序默认seq_cst编译器屏障只保证「这次访问不被优化掉」不删除、不合并、不缓存进寄存器提供真正的读写屏障语义能不能做线程同步不能就是为此而生正确用途内存映射 I/OMMIO寄存器、信号处理函数里的sig_atomic_t标志计数器、状态标志、无锁结构的基石误解 ①「volatile能保证线程同步」最典型的形式是拿它当线程间标志位// 反例不要这么写 —— volatile 不是同步原语volatileintflag0;// 反例volatile 不做原子性也不做内存屏障volatileintcounter0;// 反例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的访问被搬来搬去它一概不管。它不是屏障// 片段volatile 不是屏障volatileintready0;// 反例指望它当发布屏障不要这么写intpayload0;// 线程 A // 线程 B// payload 42; // while (ready 0) { }// ready 1; // 用 payload ← 不保证看到 42payload 42和ready 1之间没有任何顺序保证编译器可以重排CPU 也可以线程 B 可能看到ready 1却读到payload 0。要做这件事必须用std::atomicint ready的 release 语义或者一把锁。误解 ③「volatile是给多线程用的」它真正的两个用途都很窄但都很关键内存映射 I/Omemory-mapped I/O硬件寄存器在编译器眼里「内容不会变」加volatile才能让每次读写都真的落到总线上信号处理函数signal handler信号处理函数里的共享变量必须是volatile std::sig_atomic_t这是唯一可移植的保证不能在里面用std::atomic它不保证 async-signal-safe。// 片段volatile 的正确用途 —— 信号处理标志volatilestd::sig_atomic_t g_stop0;// 只有这种信号处理 ↔ 主流程的场景才该用 volatilevoidon_signal(int){g_stop1;}// 处理函数里只做最简单的赋值官方文档cv 类型限定符含 volatile— cppreference · std::sig_atomic_t — cppreference换成std::atomic就对了// atomic_counter.cpp — 编译: g -stdc17 -Wall -O2 -pthread atomic_counter.cpp -o ac#includeatomic#includecstdio#includethread#includevectorintmain(){constexprintkThreads4;constexprintkPerThread25000;std::atomicintcounter{0};// 换成原子量{std::vectorstd::threadworkers;workers.reserve(kThreads);for(inti0;ikThreads;i){workers.emplace_back([counter]{for(intj0;jkPerThread;j){counter.fetch_add(1,std::memory_order_relaxed);// 原子 RMW不会丢}});}for(autoworker:workers)worker.join();}std::printf(std::atomicint 计数 %d, 期望 %d\n,counter.load(),kThreads*kPerThread);}std::atomicint 计数 100000, 期望 100000100000是确定的结果fetch_add是硬件级的读-改-写10 万次自增一次都不会丢。反过来把这个std::atomicint换成裸int或者volatile int这个数每次跑都不一样、且几乎必然小于 100000。这就是「丢失更新」的真身只是它的具体数值不稳定没法写进预期输出里。官方文档std::atomic — cppreference · std::memory_order — cppreference读多写的配置缓存把读写锁用在它真正擅长的场景上一个「读次数极多、写只在偶尔刷新」的配置缓存。读者走共享锁、写者走独占锁命中与求和用原子量统计读者路径上只有原子计数没有第二把锁// rw_cache.cpp — 编译: g -stdc17 -Wall -O2 -pthread rw_cache.cpp -o rwc#includeatomic#includecstdio#includemap#includemutex#includeshared_mutex#includestring#includethread#includevectorclassConfigCache{public:voidset(conststd::stringkey,intvalue){std::unique_lockstd::shared_mutexlock(mutex_);// 写独占config_[key]value;}boolget(conststd::stringkey,intout)const{std::shared_lockstd::shared_mutexlock(mutex_);// 读共享constautoitconfig_.find(key);if(itconfig_.end())returnfalse;outit-second;returntrue;}std::size_tsize()const{std::shared_lockstd::shared_mutexlock(mutex_);returnconfig_.size();}private:mutablestd::shared_mutex mutex_;// const 方法里也要锁 → mutablestd::mapstd::string,intconfig_;};intmain(){constexprintkKeys8;constexprintkReaderThreads4;constexprintkRounds100;ConfigCache cache;for(inti0;ikKeys;i){cache.set(keystd::to_string(i),i);// key0..key7 → 0..7}std::atomicinthits{0};std::atomiclonglongsum{0};std::vectorstd::threadreaders;readers.reserve(kReaderThreads);for(intt0;tkReaderThreads;t){readers.emplace_back([cache,hits,sum]{for(intround0;roundkRounds;round){for(inti0;ikKeys;i){intvalue0;if(cache.get(keystd::to_string(i),value)){hits.fetch_add(1,std::memory_order_relaxed);sum.fetch_add(value,std::memory_order_relaxed);}}}});}std::threadwriter([cache]{// 写者只碰另一个键for(intround0;round50;round){cache.set(extra,round);}});for(autoreader:readers)reader.join();writer.join();intextra-1;constboolfoundcache.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_castint(found),extra);std::printf(键总数 %d\n,static_castint(cache.size()));}读命中次数 3200, 期望 3200 读到的值总和 11200, 期望 11200 extra 存在 1, 最终值 49 键总数 9四行输出全部与线程调度顺序无关命中次数 4 线程 × 100 轮 × 8 个键总和 每个读者每轮读到01…7 28共 400 轮extra被写了 50 次0 到 49最终值是 49键总数是 8 1。写者刻意只碰extra一个键是为了让读者的统计结果可预期。要是写者和读者抢同一个键读者求和的中间值就随调度漂移那种输出没法写死也没法当回归测试。顺带记住这个坏味道读者路径上再套一把mutex就把shared_mutex的意义全抵消了读者之间又开始互斥所以统计量必须用std::atomic。延伸阅读std::shared_mutex — cppreference —— C17 引入注意shared_timed_mutexC14与它的差别std::shared_lock — cppreference —— 共享所有权的 RAII 守卫可移动但通常只读std::unique_lock — cppreference —— 写者路径的标准选择也是condition_variable的唯一搭档cv 类型限定符 — cppreference ——volatile的标准定义注意全文没有出现「线程」「同步」这类词std::atomic — cppreference —— 该用它的时候别用volatilestd::memory_order — cppreference —— 六种内存序理解volatile缺的到底是什么std::sig_atomic_t — cppreference ——volatile的正当用途之一信号处理C Core Guidelines · 并发章节 —— CP.8「别用volatile做同步」的官方出处收个尾读写锁不是「读多写少就对」的快捷键它更像一笔交易用单次加锁的成本去换读者之间的并行。读操作但凡只有几十纳秒这笔交易必亏。真到了读热点上通常优先考虑的还是不可变快照加原子指针替换。volatile那边更简单它压根不在并发这个话题里。MMIO 和信号处理才是它的正经用途线程间共享状态一律交给std::atomic或锁。跨语言搬经验的时候尤其要记住这一点。
返回列表