在做多线程开发的时候,很多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核写入了x,B核也可能在几百纳秒或者更长时间后“看不见”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_cst,B最终一定能看到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 release);B从邮筒拿到信(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。
代码大致长这样:
template<typename T> class TreiberStack { struct Node { T value; Node* next; }; std::atomic<Node*> 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_ptr<T> 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_ptr<T> 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::atomic<bool> locked{false}; public: void lock() { while (locked.exchange(true, std::memory_order_acquire)) { // spin } } void unlock() { locked.store(false, std::memory_order_release); } };这里lock用的acquire,unlock用的release,已经足够了。为什么不需要seq_cst?因为自旋锁的核心需求是:如果线程B成功从exchange获得锁(也就是它看到了locked从false变成true),那么它必须能看到线程A持有锁期间所有普通写入。这正好是release写和acquire读配对的场景。
但如果你用seq_cst,其实大概率也能工作,只是在ARM这种弱内存模型上会有额外的障碍指令,影响性能。自旋锁场景本身在临界区很短、自旋频率很高,这种差异容易被放大。所以自旋锁算是最适合做内存序降级的案例之一——从seq_cst换成acquire/release,逻辑不变,性能提升还比较明显。
顺带提醒一句,自旋锁在高争用场景下其实不是好选择,频繁CAS会让缓存行来回横跳,吞吐率骤降。生产环境我更建议用mutex,除非你能证明临界区极短且争用本就极低。
3.3 双重检查锁定和单例模式里藏着的陷阱
单例模式的双重检查锁定(DCLP)是很多C++面试题里绕不开的坎。以前在C++11之前,DCLP在C++里是没法正确实现的,因为普通指针的读写本身不是原子的,更谈不上内存序。C++11之后可以用std::atomic修正。
考虑下面这个懒汉单例:
class Singleton { public: static Singleton* instance() { Singleton* p = inst.load(std::memory_order_acquire); if (!p) { std::lock_guard<std::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::atomic<Singleton*> inst; static std::mutex mutex_; };第一层检查用acquire load,是为了在已经初始化完成后,其他线程能直接读到对象,并且能看到完整的构造结果。第二层检查在lock内,用relaxed就行,因为锁已经保证了内部互斥和顺序,不需要额外的原子顺序约束。最后store用release,是为了保证new Singleton()的构造过程排在store之前,让其他线程通过acquire load拿到指针后,看到的对象是完整构造好的。
这个案例其实就是把acquire/release配对用到了极致。很多人会疑惑为什么第一层load不用seq_cst,原因很简单:我们只关心“要么看到空指针、要么看到已经构造好的对象指针”,两种状态之间不需要和其他原子操作构成全局全序,acquire逻辑上足够。
顺便说一句,C++11之后的Meyers Singleton(函数内静态局部变量)在编译器实现上基本都保证了线程安全初始化,所以除非你明确需要可控的析构顺序或者特殊生命周期,否则直接用局部静态变量是更省心的选择。DCLP更多地是理解内存序的一个经典习题。
4. 从“玄学”到“科学”:排查工具与常见问题速查
4.1 ThreadSanitizer能检测到内存序问题吗?
先说结论:ThreadSanitizer(TSan)只能检测数据竞争(data race),它对简单relaxed原子操作引发的“逻辑顺序问题”是无能为力的。也就是说,如果你用std::atomic+memory_order_relaxed同步数据,却没有导致真正未定义行为的数据竞争,TSan不会报错,但程序跑起来可能依然不符合预期。
TSan在什么场景下有用?一是当你忘记用atomic,直接用普通变量跨线程读写的时候,它能很快抓到race;二是在你已经使用了atomic但配对关系搞错(比如release之后没有acquire读同一个变量)的时候,可能因为代码里存在其他普通变量读写而产生可见的race,从而间接发现有疑点。
我实际排查内存序问题的工具链通常是三步:先编译加上-fsanitize=thread跑一轮压测,排除掉数据竞争;再用编译器生成的汇编确认关键位置的内存屏障有没有落到预期位置;如果还不行,就在关键load/store上加日志和性能计数器,做小规模对比实验。
用汇编确认内存序这件事非常实用。以x86为例,release store就是普通mov;seq_cst store往往需要mfence或者xchg;ARM上release会生成dmb ish之类的屏障指令。通过objdump查看生成的汇编,能很清楚地看到编译器到底在哪个位置加了什么样的屏障,再和自己的预期对比,能快速定位是编译器处理不如预期,还是自己代码里的内存序配对本来就错了。
4.2 常见错误模式速查表
下面这个表是我自己在团队评审代码时反复看到的高频错误,也建议你收藏:
| 错误模式 | 具体表现 | 正确做法 |
|---|---|---|
| 用relaxed做发布标记 | 生产者写数据后置flag置true,消费者看到true后读数据,但读到旧值或半新值 | flag的写用release,读用acquire |
| acquire/release配对错乱 | 生产者对变量A做release写,消费者却对变量B做acquire读,或者读的不是同一个变量 | acquire必须与release作用于同一个原子变量 |
| 在RMW操作上只用了acquire或release | 比如CAS只用release,导致读回旧值时没有acquire约束 | RMW操作改用memory_order_acq_rel |
| 双重检查锁定里忘加acquire | 第一层load用relaxed或普通load,拿到的指针可能指向构造未完成的对象 | 第一层load使用acquire,store使用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链,就说明可见性是正确的;如果链断掉了,那就是内存序选错了。这个方法我用了很多年,比任何工具都管用。