ARTICLE DETAIL

资讯详情

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

内存序实战:release/acquire 三种经典模式与 seq_cst 的代价

内存序实战:release/acquire 三种经典模式与 seq_cst 的代价 多线程代码里最隐蔽的 bug 不是「少加了锁」而是「锁加了、原子操作也用了但内存序memory order选错了」。std::atomic默认用seq_cst顺序一致因为它最不容易写错可它同时也是最贵的。这一篇把release/acquire这套性价比最高的内存序讲透配三种经典模式的真实代码再用 ThreadSanitizer 证明「选错会怎样」。1. 引子内存序到底在「序」什么先明确一件事std::atomic的原子性保证「读改写不被撕裂」但不保证相邻的普通变量访问的顺序。看这个反直觉的场景数据是普通int标志位是atomic但标志用了relaxed// 反例不要这么写数据是非原子 int标志用 relaxed → data raceintpayload0;// 非原子std::atomicboolready{false};// 发布者payload42;// 普通写ready.store(true,std::memory_order_relaxed);// relaxed不「发布」不建立 happens-before// 订阅者while(!ready.load(std::memory_order_relaxed)){}// relaxed不「获取」std::printf(%d\n,payload);// 普通读与发布者的写并发 → data race把这段喂给 ThreadSanitizerclang 19-fsanitizethread它当场抓到 data race下面截取了报告的关键行完整报告还带符号化的调用栈WARNING: ThreadSanitizer: data race (pid2) Read of size 4 at 0x7fffffffe9d4 by thread T2: #0 main::$_1::operator()() const /app/example.cpp:17 ← 订阅者读 payload Previous write of size 4 at 0x7fffffffe9d4 by thread T1: #0 main::$_0::operator()() const /app/example.cpp:11 ← 发布者写 payload SUMMARY: ThreadSanitizer: data race /app/example.cpp:17 ThreadSanitizer: reported 1 warnings编译器或 CPU完全有权把payload 42挪到ready.store之后因为两个语句在「单线程视角」下没有依赖relaxed明确告诉编译器「它们之间的顺序我不管」。于是订阅者看到ready true时payload可能还没写。这是货真价实的数据竞争data race属于未定义行为。这就是内存序要解决的问题给「看似无关、实则需要顺序」的访问之间建立一种跨线程可见的顺序。release/acquire就是为此而生的一对。2. release/acquire 的配对语义一句话release写和acquire读在同一个原子变量上配对时release之前的所有写对acquire之后的读都可见。更精确地说它们建立了一条 happens-before 边。release/acquire 建立的 happens-before 关系 线程 A发布者 线程 B订阅者 ────────────────────── ────────────────────── payload 42 ─┐ ready.store(true, │ release 发布A 在 release 之前 memory_order_release) ─┘ 的所有写都「提交」了 │ │ acquire 获取B 在 acquire 之后 若 B 的 acquire 读到 true ─┐ 能看到 A release 之前的全部写 ready.load(memory_order_acquire) payload // 保证读到 42关键在「同一个原子变量」release和acquire必须作用在同一个ready上这条边才成立。换成两个不同的原子变量就什么都没保证。把这个反例改成正确写法就是消息传递模式// message_pass.cpp — 编译: g -stdc17 -Wall -O2 -pthread message_pass.cpp -o mp#includeatomic#includecstdio#includethreadintmain(){std::atomicintpayload{0};std::atomicboolready{false};std::threadpublisher([]{payload.store(42,std::memory_order_relaxed);// ① 先写数据可以 relaxedready.store(true,std::memory_order_release);// ② 再「发布」release});std::threadsubscriber([]{while(!ready.load(std::memory_order_acquire)){}// ③ acquire 等到发布constintvpayload.load(std::memory_order_relaxed);// ④ 保证读到 42std::printf(subscriber 读到 %d\n,v);});publisher.join();subscriber.join();}subscriber 读到 42注意payload这里用relaxed就够了因为它的可见性由ready的release/acquire兜底。这正是 release/acquire 的精髓把昂贵的同步放在一个标志位上数据本身可以廉价。用 ThreadSanitizerclang 19验证这段正确代码stderr 干净无 data race$ clang -stdc17 -O1 -fsanitizethread -g -pthread message_pass.cpp -o mp_tsan ./mp_tsan subscriber 读到 42 stderr 无任何 ThreadSanitizer 报告 —— release/acquire 建立 happens-before没有 data race官方文档std::memory_order · std::atomic::store3. 模式二双重检查锁定正确版单例singleton最经典的优化只有第一次才加锁之后都无锁地读指针。第一版大家常写错正确版依赖acquire/release把「对象构造完成」这件事发布出去// dcl.cpp — 编译: g -stdc17 -Wall -O2 -pthread dcl.cpp -o dcl#includeatomic#includecstdio#includemutex#includethread#includevectorclassSingleton{staticstd::atomicSingleton*instance_;staticstd::mutex mtx_;intid_;explicitSingleton(intid):id_(id){}public:staticSingleton*get(){Singleton*pinstance_.load(std::memory_order_acquire);// ① 无锁快路径acquireif(!p){std::lock_guardstd::mutexlock(mtx_);// ② 只有首轮才加锁pinstance_.load(std::memory_order_relaxed);// ③ 锁内二次检查relaxed 够if(!p){pnewSingleton(42);// 演示 DCL 内存序真实工程可用函数内 static 局部变量instance_.store(p,std::memory_order_release);// ④ 发布release}}returnp;}intid()const{returnid_;}};std::atomicSingleton*Singleton::instance_{nullptr};std::mutex Singleton::mtx_;intmain(){constexprintkThreads8;std::atomicintsame{0};std::vectorstd::threadws;for(intt0;tkThreads;t){ws.emplace_back([same]{Singleton*pSingleton::get();if(p-id()42)same.fetch_add(1,std::memory_order_relaxed);});}for(autow:ws)w.join();std::printf(拿到 id42 实例的线程数 %d期望 %d\n,same.load(),kThreads);std::printf(instance 非空 %d\n,static_castint(Singleton::get()!nullptr));}拿到 id42 实例的线程数 8期望 8 instance 非空 1内存序在这里精确分工①acquireload如果读到非空指针说明别人已经release发布过了我能看到「构造完成」的完整对象③relaxedload此刻持锁没有并发写relaxed就够④releasestore把「对象构造完成」发布给所有后续acquire的读者。如果 ① 用relaxed读到的可能是一个「指针已写入、但成员还没构造完」的半个对象。这就是当年双重检查锁定被骂「坏」的原因acquire/release把它修好了。4. 模式三引用计数release 用 acq_rel引用计数的内存序有一句口诀加引用用relaxed减引用用acq_rel。原因如下加引用时新引用者还没开始读对象不需要同步减引用时「减到 0 的那一个」要负责销毁对象它必须能看到之前所有引用者做过的全部写所以减要用acq_rel既是 acquire 又是 release。// refcount.cpp — 编译: g -stdc17 -Wall -O2 -pthread refcount.cpp -o rc#includeatomic#includecstdio#includethread#includevector// 引用计数对象归零自动销毁演示内存序选择真实代码用 std::shared_ptrclassRefCounted{std::atomicintrefs_{1};// 创建者持有一个引用public:voidadd_ref(){refs_.fetch_add(1,std::memory_order_relaxed);}// 增引用relaxed 够voidrelease(){// 减引用acq_rel —— 减到 0 的那个线程要销毁对象必须先「获取」此前所有写if(refs_.fetch_sub(1,std::memory_order_acq_rel)1){deletethis;// 最后一个引用者负责销毁演示内存序真实代码用 shared_ptr}}};intmain(){constexprintkThreads4;RefCounted*objnewRefCounted();// 初始 refs 1std::vectorstd::threadws;for(intt0;tkThreads;t){ws.emplace_back([obj]{obj-add_ref();// 每个线程先借一个引用obj-release();// 用完归还});}for(autow:ws)w.join();obj-release();// 创建者归还最后一个引用 → 触发 delete thisstd::printf(引用计数走完全程恰好销毁一次无泄漏、无 double free\n);}引用计数走完全程恰好销毁一次无泄漏、无 double freefetch_sub(1, acq_rel)返回的是减之前的旧值所以 1意味着「减完归零我是最后一个」由它来delete this。acq_rel保证这一刻它能看到所有引用者的写而add_ref用relaxed就够因为引用者还什么都没做。5. relaxed 的边界与 seq_cst 的代价把这三种模式里relaxed能用的地方总结成一条规则relaxed只用于「没有跨线程依赖」的孤立操作计数器、统计量、自增序号。一旦「操作 A 的结果依赖操作 B 的可见性」就必须升级到acquire/release或更强。那为什么标准库默认是seq_cst最贵的而不是relaxed最便宜的因为默认值要「安全」而seq_cst提供全局唯一的顺序最容易推理。它的代价在不同 CPU 上差异巨大内存序x86-64 上的代价ARM 上的代价典型用途relaxed普通指令几乎免费普通指令计数器、统计acquire加载天然带 acquire 语义免费需要dmb ld屏障读后依赖别的数据release存储天然带 release 语义免费需要dmb st屏障写后发布seq_cststore 要xchg隐式全屏障略贵每次都要全屏障dmb明显更贵默认值最强保证x86-64 的硬件内存模型TSO本身就够强acquire/release几乎零成本所以「把seq_cst降级成acquire/release」在 x86 上省不了多少但在 ARM 这类弱内存模型上省下的是一次次全屏障收益就很可观。结论是「先写对、再优化」默认用seq_cst把逻辑写对跑通了、测出热点、确认硬件是弱内存模型再把不需要全局顺序的地方降级成acquire/release甚至relaxed—— 而且每降一处都要重新用 TSan 验证。6. 完整示例relaxed 计数 release/acquire 消息传递把「relaxed 只用于无依赖计数器」和「release/acquire 用于有依赖的消息」放进同一个程序用 ack 握手让发布者和订阅者来回传递一万次全程验证// memory_order_full.cpp — 编译: g -stdc17 -Wall -O2 -pthread memory_order_full.cpp -o mofull#includeatomic#includecstdio#includethreadintmain(){constexprintkRounds10000;std::atomicintpayload{0};std::atomicboolready{false};std::atomiclonglongpublished{0};// relaxed 计数器只统计次数无依赖std::threadpublisher([]{for(inti1;ikRounds;i){payload.store(i,std::memory_order_relaxed);// 数据relaxedready.store(true,std::memory_order_release);// 发布releasepublished.fetch_add(1,std::memory_order_relaxed);// 计数relaxed 够while(ready.load(std::memory_order_acquire)){}// 等订阅者 ackacquire}});std::threadsubscriber([]{intlast0;for(inti1;ikRounds;i){while(!ready.load(std::memory_order_acquire)){}// 等发布acquirelastpayload.load(std::memory_order_relaxed);// 读数据保证正确ready.store(false,std::memory_order_release);// ackrelease}std::printf(订阅者最后读到 %d期望 %d\n,last,kRounds);});publisher.join();subscriber.join();std::printf(发布者计数 %lld期望 %d\n,published.load(),kRounds);}订阅者最后读到 10000期望 10000 发布者计数 10000期望 10000这个程序里一共有三组内存序在协作payload的数据传递靠ready的release/acquireack 反向握手是另一组release/acquirepublished计数器和payload本身的store/load则安全地用relaxed因为它们要么无依赖、要么被标志位兜底。一万次来回最后读到10000说明每一轮的 happens-before 都建立得严丝合缝。7. 延伸阅读std::memory_order — cppreference —— 六种内存序的正式语义与 happens-before 规则写并发代码的案头参考std::atomic::store / load — cppreference —— 每个原子操作都能显式指定内存序这篇三种模式的入口C Core Guidelines CP.2 / CP.8 — isocpp —— 「不要用 volatile 做同步」「先别上无锁」的指导Compiler Explorer — godbolt.org —— 把seq_cst和acquire/release分别编译看 x86 和 ARM 生成什么屏障指令本知识库内的相关篇目《锁进阶atomic_flag 自旋锁、shared_mutex 读写锁与无锁的代价》 —— std::mutex 一竞争就把失败线程挂进内核futex《无锁队列MPSC/MPMCVyukov 环形队列与 slot 未就绪问题》 —— 队列比栈难因为它有两个端点——头尾都要协调。《无锁内存回收hazard pointer 与 epoch-based reclamation 原理》 —— 无锁栈 pop 出来的节点为什么不能立刻 delete因为另一个线程可能还攥着它的指针。8. 一句话总结release写和acquire读在同一个原子变量上配对时会建立 happens-before让release前的所有写对acquire后的读可见 —— 三种经典模式分别是消息传递数据relaxed 标志release/acquire、双重检查锁定快路径acquire读、构造后release发布、引用计数加引用relaxed、减引用acq_relrelaxed只能用于无跨线程依赖的计数器选错就是 data raceTSan 会抓seq_cst是默认值最安全也最贵在 x86 上几乎免费、在 ARM 上每次都要全屏障所以「先写对、再优化」降级内存序后务必用 TSan 重新验证。
返回列表