FEATURED · 精选文章

C++内存序实战:从std::atomic到memory_order的并发编程指南

发布时间 / 2026/9/10 7:06:25
来源 / 创域科博编辑部
栏目 / 资讯中心
C++内存序实战:从std::atomic到memory_order的并发编程指南 在做多线程开发的时候很多C程序员第一次被“内存序”这个词打懵往往是在线上出现了一个完全无法解释的Bug明明用std::atomic做了原子变量一个线程写、另一个线程读数据偶尔还是不对甚至永远读不到新值。这种场景我遇到过太多次了最后排查下来问题几乎都出在std::atomic默认的memory_order_seq_cst以外的内存序选择上或者说根本没有想清楚内存序到底在解决什么问题。这篇文章我想用实际项目的视角把C内存序这件事彻底讲透。我会从硬件和编译器“重排指令”的底层逻辑讲起再把6种memory_order逐个拆开配合无锁栈、自旋锁、单例模式这些经典场景最后分享一些我实际踩过的坑和排查工具。适合那些已经能写简单多线程代码、但一碰到std::atomic和memory_order就发怵的C开发者也适合准备面试想系统补一遍“并发八股文”的朋友。1. 为什么内存序成了多线程编程的“分水岭”1.1 一个真实到让人抓狂的Bug现场先讲一个我印象特别深的生产事故。当时我们有一个全局的配置状态用std::atomic 来标记“配置是否已更新”线程A负责从远端拉取配置然后写入一个普通全局结构体写完把flag设为true线程B在业务逻辑里不断读取这个flag如果为true就去读那个配置结构体。看起来完全没有问题对吧原子变量保证读写不撕裂而且flag置true一定发生在配置写入之后逻辑顺序也没问题。但实际表现是线上偶发出现线程B读到了flag为true但配置结构体里的数据还是旧值的情况。代码review了不知道多少遍怎么看顺序都是对的。后来上了ThreadSanitizer和数据竞争检测折腾了很久才发现问题并不在数据结构而在“内存序”——编译器和CPU在优化时把线程B读取配置结构体的操作重排到了读取flag之前。换句话说程序源码里是“先读flag再读配置”但真正执行的机器指令可能是“先读配置再读flag”。这就是内存序问题的本质你脑子里那套“代码从上往下执行”的直觉在现代编译器和硬件面前根本不成立。1.2 三层“乱序”叠加编译器、CPU、缓存一致性很多人以为只有CPU会乱序执行其实不是乱序至少来自三个层面。第一层是编译器。编译器在生成汇编时只要它认为“不改变单线程语义”就可以调整访存指令的顺序。比如它看到你的代码里先写flag再写data但由于data和flag之间没有编译期可见的依赖完全可能把data的写入延后到flag之后。这是静态重排。第二层是CPU的乱序执行。现代CPU都是多发射、乱序执行的指令在流水线里可以按照依赖关系动态调整。一个load指令如果缓存未命中可能被后面的load越过因为后面的load在缓存里已经命中CPU没必要傻等。第三层是缓存一致性协议MESI等带来的可见性延迟。每个CPU核心有自己的L1/L2缓存写入先落到本核心的缓存再异步同步到其他核心。即使CPU没有乱序执行A核写入了xB核也可能在几百纳秒或者更长时间后“看不见”x的新值。在x86这种强内存模型TSO下缓存一致性协议做得比较“老实”代价相对低在ARM和PowerPC这类弱内存模型下硬件几乎不做任何排序保证全靠软件显式加屏障。理解这三层叠加之后你会明白一件事内存在多核环境里并不是一个统一的大黑板更像每个核心手里有一份“私有草稿”什么时候和“公共公告栏”同步取决于硬件、编译器和你的内存序指令。1.3 原子操作不等于内存同步这里必须澄清一个特别常见的误区std::atomic只是保证操作“原子性”也就是读取或写入不会撕裂、不会出现半个值它本身并不自动保证“顺序”更不保证“可见性”。原子性解决的是并发访问时的数据竞争data race而内存序解决的是“访问之间的顺序约束”和“可见性时机”。举个例子说明。用std::atomic x{0}; 线程A执行x.store(1); 线程B执行while (x.load() 0); 如果用默认的memory_order_seq_cstB最终一定能看到1并且能看到A写x之前所有普通写入的效果。但如果把store改成memory_order_relaxed把load改成memory_order_relaxed那B可能一直循环出不来因为relaxed只保证“这次load是原子的”不保证它能及时看到另一个线程的写入。所以选内存序本质上是在回答三个问题我希望哪些操作的重排边界在哪里我期望一个线程的写什么时候对另一个线程可见我是否需要在多线程之间建立“happens-before”关系2. 六种memory_order全景每个都该在什么时候用2.1 memory_order_relaxed——只保证原子性不承诺任何顺序relaxed是最弱的内存序语义就是“只要你不撕裂就行”。所有乱序都可以发生其他线程可以以任何顺序观察到这次修改甚至可能很久都观察不到。它适合什么场景我实际用得最多的是两类一类是统计计数器比如线上服务里统计请求次数、错误次数、QPS累加值这种数据不需要靠它去同步别的数据也不需要精确有序只要最终值能不断累加即可另一类是像hazard pointer里的计数或者一些只做“心跳”的标记只要知道它“变了”不关心变之前的数据顺序。但relaxed有一个特别容易踩的坑不要用它来做“发布语义”。比如常见的“生产者和消费者通过flag来传递数据”消费者看到flag变化后去读数据如果flag是relaxed那消费者可能先看到数据被并发修改时的旧状态或者读了半个状态。我看过不少新人代码为了性能把默认seq_cst一律改成relaxed结果就是线上诡异的偶发问题。一句话总结除非你能证明这段代码不需要任何跨线程顺序和可见性否则不要用relaxed。2.2 acquire/release——多线程通信中真正的“主力”acquire和release是我在实际项目里用得最多的两个内存序它们必须成对出现才能建立同步关系。先理解“release写”和“acquire读”这个组合。线程A执行release语义的store表示“在我写入这个原子变量之前发生的所有普通内存操作都不能被移到这次写之后”。线程B执行acquire语义的load表示“在这次读到这个原子变量之后发生的所有普通内存操作都不能被移到这次读之前”。两个加起来效果就是如果B读到了A写入的值那么A在release写之前做的所有普通写操作B在acquire读之后一定都能看见。这个机制非常像“信件贴邮票开锁”A先写好一封信各种普通写入然后贴上release邮票投进邮筒store releaseB从邮筒拿到信load acquire打开信封里面所有A写的内容B都能看到。如果B没有拿到A投的那封信那A信里的内容B可能什么也看不到。实际场景中我经常用它来实现“单生产者单消费者的数据发布”。比如生产者把数据填到一个普通结构体然后做data.store(ptr, std::memory_order_release)消费者拿到ptr之后再data.load(std::memory_order_acquire)然后读ptr指向的内容。这里用release和acquire就够了不需要seq_cst因为seq_cst额外保证的“全局一致顺序”在这个场景根本没有用。要注意acquire/release不是双向屏障。release只限制“前面的写不能往下跑”不限制“后面的读不能往上跑”acquire只限制“后面的读不能往上跑”不限制“前面的写不能往下跑”。所以想要同时约束“之前写不后移”和“之后读不前移”就需要memory_order_acq_rel。2.3 acq_rel与seq_cst——RMW操作和全局一致性的代价memory_order_acq_rel一般只用在“读改写”RMW操作上比如compare_exchange_strong、fetch_add这些。它表示这次操作同时具备acquire和release的效果读的那一边像acquire写的那一边像release。最典型的就是无锁栈里CAS修改head指针这个操作既要读取head当前值又要写入新值所以用acq_rel最合适。memory_order_seq_cst是最强的约束也是std::atomic默认值。它保证了所有线程观察到的所有原子操作顺序是同一个“全序”就像所有cpu核心面前有一个完全一致的时钟每一次原子操作都被排在一个全局队列里。所有线程看到的事件顺序都是一样的绝对不会出现“线程A看到x先于y线程B看到y先于x”这种不一致。但seq_cst的代价也最高。在x86这种强内存模型上seq_cst的读大致相当于普通load但seq_cst的写在很多实现里需要额外的mfence指令或者使用xchg因为不这样做的话后续的读操作可能被提前到写之前破坏全序在ARM这类弱内存模型上seq_cst就需要在store和load之间插内存屏障代价更明显。我的开发习惯是新写的并发代码一律先用默认seq_cst把逻辑跑对跑对之后再用性能剖析工具看热点确认某个原子操作真的是瓶颈再考虑降级成acquire/release或者relaxed。直接用弱内存序起步很容易在逻辑上埋雷而且这种雷极难排查。3. 经典案例实操无锁栈、自旋锁、双重检查锁定3.1 用release/acquire实现一个Treiber无锁栈Treiber栈是无锁数据结构里最简单的入门案例栈顶是一个head指针push操作创建新节点然后用CAS把head指向新节点pop操作从head取值然后CAS把head移到next。这里我们就得仔细思考内存序的选择。push过程分两步第一步给新节点赋值第二步CAS修改head。我们希望的是一旦新节点通过CAS发布到栈顶其他线程通过head读到的那个节点内部数据必须是完整可用的。所以CAS写head用的应该是release才能保证“新节点内部数据写入”不会被重排到“head写入”之后。pop过程也一样我先通过load head读到栈顶指针然后再去读节点的next和data。这时候load必须用acquire才能保证“我读到的head指向的节点内部数据是发布者release时已经写好的”。所以pop的head.load应该是acquire而修改head的CAS用的是acq_rel。代码大致长这样templatetypename T class TreiberStack { struct Node { T value; Node* next; }; std::atomicNode* head{nullptr}; public: void push(T val) { Node* new_node new Node{std::move(val), nullptr}; Node* old_head head.load(std::memory_order_relaxed); do { new_node-next old_head; } while (!head.compare_exchange_weak( old_head, new_node, std::memory_order_release, std::memory_order_relaxed)); } std::unique_ptrT pop() { Node* old_head head.load(std::memory_order_acquire); while (old_head) { Node* next old_head-next_acquire(); if (head.compare_exchange_weak( old_head, next, std::memory_order_acq_rel, std::memory_order_acquire)) { std::unique_ptrT res(new T(std::move(old_head-value))); delete old_head; return res; } } return nullptr; } };注意我这里push的第一次head.load用的是relaxed因为这只是CAS循环的起点不承载同步语义真正的同步发生在CAS成功的那一次release写。而pop的head.load用acquire是因为它直接决定了后面读取的node数据可见性。这里还有一个老生常谈的坑无锁栈一定会有ABA问题。线程A读出head指针指向节点X还没开始CAS时线程B把X弹出去释放了又push了一个新节点恰好复用了X的地址。等线程A的CAS执行时head还是等于X比较通过但实际上栈的拓扑已经变了结果就是错误。解决ABA的常见方式包括hazard pointer、epoch based reclamation或者更简单地在节点里加入一个全局唯一的tag和CAS一起比较。这个跟内存序是两件事但很多人一碰到无锁结构就会两个坑一起踩。3.2 自旋锁用acq_rel还是seq_cst自旋锁看起来简单其实对内存序的要求很有意思。最朴素的自旋锁用std::atomic_flag的test_and_set实现默认就是seq_cst。你也可以用std::atomic 加exchange来实现。先看用exchange实现的自旋锁class SpinLock { std::atomicbool locked{false}; public: void lock() { while (locked.exchange(true, std::memory_order_acquire)) { // spin } } void unlock() { locked.store(false, std::memory_order_release); } };这里lock用的acquireunlock用的release已经足够了。为什么不需要seq_cst因为自旋锁的核心需求是如果线程B成功从exchange获得锁也就是它看到了locked从false变成true那么它必须能看到线程A持有锁期间所有普通写入。这正好是release写和acquire读配对的场景。但如果你用seq_cst其实大概率也能工作只是在ARM这种弱内存模型上会有额外的障碍指令影响性能。自旋锁场景本身在临界区很短、自旋频率很高这种差异容易被放大。所以自旋锁算是最适合做内存序降级的案例之一——从seq_cst换成acquire/release逻辑不变性能提升还比较明显。顺带提醒一句自旋锁在高争用场景下其实不是好选择频繁CAS会让缓存行来回横跳吞吐率骤降。生产环境我更建议用mutex除非你能证明临界区极短且争用本就极低。3.3 双重检查锁定和单例模式里藏着的陷阱单例模式的双重检查锁定DCLP是很多C面试题里绕不开的坎。以前在C11之前DCLP在C里是没法正确实现的因为普通指针的读写本身不是原子的更谈不上内存序。C11之后可以用std::atomic修正。考虑下面这个懒汉单例class Singleton { public: static Singleton* instance() { Singleton* p inst.load(std::memory_order_acquire); if (!p) { std::lock_guardstd::mutex lock(mutex_); p inst.load(std::memory_order_relaxed); if (!p) { p new Singleton(); inst.store(p, std::memory_order_release); } } return p; } private: static std::atomicSingleton* inst; static std::mutex mutex_; };第一层检查用acquire load是为了在已经初始化完成后其他线程能直接读到对象并且能看到完整的构造结果。第二层检查在lock内用relaxed就行因为锁已经保证了内部互斥和顺序不需要额外的原子顺序约束。最后store用release是为了保证new Singleton()的构造过程排在store之前让其他线程通过acquire load拿到指针后看到的对象是完整构造好的。这个案例其实就是把acquire/release配对用到了极致。很多人会疑惑为什么第一层load不用seq_cst原因很简单我们只关心“要么看到空指针、要么看到已经构造好的对象指针”两种状态之间不需要和其他原子操作构成全局全序acquire逻辑上足够。顺便说一句C11之后的Meyers Singleton函数内静态局部变量在编译器实现上基本都保证了线程安全初始化所以除非你明确需要可控的析构顺序或者特殊生命周期否则直接用局部静态变量是更省心的选择。DCLP更多地是理解内存序的一个经典习题。4. 从“玄学”到“科学”排查工具与常见问题速查4.1 ThreadSanitizer能检测到内存序问题吗先说结论ThreadSanitizerTSan只能检测数据竞争data race它对简单relaxed原子操作引发的“逻辑顺序问题”是无能为力的。也就是说如果你用std::atomicmemory_order_relaxed同步数据却没有导致真正未定义行为的数据竞争TSan不会报错但程序跑起来可能依然不符合预期。TSan在什么场景下有用一是当你忘记用atomic直接用普通变量跨线程读写的时候它能很快抓到race二是在你已经使用了atomic但配对关系搞错比如release之后没有acquire读同一个变量的时候可能因为代码里存在其他普通变量读写而产生可见的race从而间接发现有疑点。我实际排查内存序问题的工具链通常是三步先编译加上-fsanitizethread跑一轮压测排除掉数据竞争再用编译器生成的汇编确认关键位置的内存屏障有没有落到预期位置如果还不行就在关键load/store上加日志和性能计数器做小规模对比实验。用汇编确认内存序这件事非常实用。以x86为例release store就是普通movseq_cst store往往需要mfence或者xchgARM上release会生成dmb ish之类的屏障指令。通过objdump查看生成的汇编能很清楚地看到编译器到底在哪个位置加了什么样的屏障再和自己的预期对比能快速定位是编译器处理不如预期还是自己代码里的内存序配对本来就错了。4.2 常见错误模式速查表下面这个表是我自己在团队评审代码时反复看到的高频错误也建议你收藏错误模式具体表现正确做法用relaxed做发布标记生产者写数据后置flag置true消费者看到true后读数据但读到旧值或半新值flag的写用release读用acquireacquire/release配对错乱生产者对变量A做release写消费者却对变量B做acquire读或者读的不是同一个变量acquire必须与release作用于同一个原子变量在RMW操作上只用了acquire或release比如CAS只用release导致读回旧值时没有acquire约束RMW操作改用memory_order_acq_rel双重检查锁定里忘加acquire第一层load用relaxed或普通load拿到的指针可能指向构造未完成的对象第一层load使用acquirestore使用release以为seq_cst能解决所有问题把所有atomic都换成seq_cst却仍存在普通变量跨线程读写产生未定义行为普通变量跨线程必须用atomic或锁保护内存序救不了数据竞争忽略了x86与ARM的差异在x86上测得好好的部署到ARM服务器上才暴露问题弱内存模型上不要依赖x86的强排序老老实实按语义写内存序4.3 我踩过的几个坑和几条经验讲几个具体的故事。第一个坑是很早以前写无锁队列为了让性能更好把本该用acquire的地方改成了relaxed。压测工具压出来的数据确实快了几个百分点结果线上消费者线程偶发读到未完全发布的数据一个错误直接导致订单状态回滚。排查了整整一天才定位到问题。后来我给自己定下规矩先正确再优化。每次改弱内存序之前必须先写清楚这段代码依赖哪些顺序关系如果写不出来就不允许降级。第二个坑是在ARM服务器上部署一个原本在x86上开发的功能。功能本身没问题但有一处release/acquire配对用错位置导致worker线程拿到的配置有时是上一轮的。之所以只在ARM上暴露就是因为x86有较强的总存储序把很多问题掩盖了。从那以后我但凡写涉及跨平台的高并发代码都会在编译期用模板或者预处理器区分架构以外的逻辑确保内存序语义本身正确而不是依赖某个CPU模型的“好心”。第三个经验是关于注释。真实项目中内存序不能靠“读代码”看出意图因为std::memory_order_relaxed在代码里根本看不出为什么这里可以弱化。我现在要求自己在所有用到非默认内存序的地方必须写注释写明为什么这个relaxed/acquire/release是正确的。比如“这里的relaxed只是用于统计不需要同步其他数据”或者“这里的release会把前面的config写入发布给消费者”。这不只是给同事看的更是在倒逼自己把逻辑想清楚。还有一条实用性很强的建议在性能调试之前先确认你使用的锁和atomic操作是不是真正的瓶颈。用perf或vtune看cache miss、锁竞争、总线流量很多时候性能问题来自锁的粒度和数据布局而不是内存序多了那一两条屏障指令。无脑把seq_cst换成relaxed属于用正确性换性能非常不划算。最后再分享一个小技巧。如果你对某段并发逻辑的内存序是否正确没有把握可以试着用“happens-before”关系手动画一遍。写出两个线程标记每一个关键load/store然后看它们之间是否构成了“release写发生在acquire读之前”的配对。如果能形成一条完整的happens-before链就说明可见性是正确的如果链断掉了那就是内存序选错了。这个方法我用了很多年比任何工具都管用。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻