C++ 六种 memory_order:从原子性到线程间可见性

C++ 六种 memory_order:从原子性到线程间可见性 C 六种 memory_orderC 六种 memory_order从“原子性”到线程间可见性一、atomic 只保护变量是原子的二、怎样让 stage 1 真正代表结果已经就绪三、六种 memory_order 分别怎么用3.1 memory_order_relaxed只保证原子变量本身3.2 memory_order_release发布此前完成的写入3.3 memory_order_acquire接收已经发布的数据3.4 memory_order_acq_rel既接收数据又发布数据3.5 memory_order_seq_cst再加一条全局一致顺序3.6 memory_order_consume工程中通常不再使用五、实际写代码时应该怎么选六、memory_order 和内存屏障是什么关系C 六种 memory_order从“原子性”到线程间可见性先来看一个再普通不过的问题。两个线程同时给同一个整数加一最后的结果一定是2吗intcount0;// 线程 A // 线程 Bcount;count;答案是不一定。count看起来只有一行背后却包含“读取旧值、加一、写回新值”三个步骤。两个线程可能同时读到0然后分别写回1。更准确地说多个线程在没有同步的情况下读写count会产生数据竞争程序行为未定义。把它改成原子变量以后问题就解决了std::atomicintcount{0};count.fetch_add(1);无论多少线程同时执行fetch_add这次“读取—修改—写回”都不会被其他线程从中间打断。看到这里一个新的问题就出现了既然atomic已经保证操作不会被打断为什么它还要提供下面这个参数count.fetch_add(1,std::memory_order_relaxed);为什么 C 还设计了relaxed、acquire、release、acq_rel、seq_cst和consume六种不同的内存序要回答这个问题得先看看atomic没有解决什么。一、atomic 只保护变量是原子的计数器只关心count自己所以保证自增不丢失就够了。但真实程序经常会用一个原子变量表示执行阶段再根据这个阶段读取其他数据。例如线程 A 先把计算结果改成100再把阶段改成1线程 B 看到阶段1后输出结果intresult0;std::atomicintstage{0};// 线程 Aresult100;stage.store(1,std::memory_order_relaxed);// 线程 Bif(stage.load(std::memory_order_relaxed)1)std::coutresult;现在请先不要往下看想一想线程 B 既然已经看到了stage 1它输出的result一定是100吗很多人的第一反应是“一定”。因为在线程 A 的代码里result 100明明写在stage 1前面。但 C 给出的答案是不能这样推断。stage的读写确实是原子的它不会被读坏。但是relaxed只保护stage自己没有在线程 A 写result和线程 B 读result之间建立同步。对普通变量result的这组访问存在数据竞争程序行为未定义。这并不是说程序“一定会输出旧值0”。未定义行为意味着任何结果都不能依赖。根本原因在于线程 A 中的源代码顺序只能说明这两行在当前线程里的关系并没有规定线程 B 必须按照相同顺序观察它们。编译器可能重排指令CPU 也可能让不同写入以不同时间对其他核心可见。原来正确的并发程序需要同时解决三件事原子性一次操作不能做到一半就被另一个线程插入有序性编译器和 CPU 的重排不能破坏程序依赖的先后关系可见性一个线程完成的写入要能被另一个线程安全地观察到。atomic首先解决原子性memory_order则进一步描述这次原子操作如何约束前后的内存访问以及它能否在线程之间建立同步。C 一共提供六种内存序它们并不是简单的六档“强弱开关”。acquire负责接收数据release负责发布数据二者的方向就不同。选择哪一种取决于这个原子操作在多线程协作中承担什么职责。那么怎样才能让stage 1真正代表“结果已经准备好了”二、怎样让 stage 1 真正代表结果已经就绪我们需要在线程 A 和线程 B 之间完成一次数据交接// 线程 A发布结果result100;stage.store(1,std::memory_order_release);// 线程 B接收结果if(stage.load(std::memory_order_acquire)1)std::coutresult;release表示在发布stage 1之前本线程前面的写入已经准备好一起交给其他线程。acquire表示当我读到stage 1后可以接收发布这个值的线程在此前完成的写入。当 acquire load 读取到 release store 写入的1时完整关系是result 100 ↓ release store ↓ 跨线程同步 acquire load ↓ 读取 result于是线程 A 的result 100happens-before 线程 B 对result的读取。此时线程 B 看到的不只是一个阶段数字还包括线程 A 在发布这个数字之前完成的修改。这就是memory_order真正解决的问题一个原子变量不仅可以保存自己的值还可以成为其他数据跨线程传递的交接点。不过release 和 acquire 并不是只要同时出现就能自动配对。acquire 必须读取到相应 release 发布的值这次交接才真正发生。三、六种 memory_order 分别怎么用3.1 memory_order_relaxed只保证原子变量本身relaxed是最弱的内存序但“最弱”不等于“不安全”。它仍然保证原子性也保证同一个原子对象的修改存在统一的先后顺序。它不负责同步周围的普通数据。最典型的场景是统计计数std::atomicintrequests{0};requests.fetch_add(1,std::memory_order_relaxed);我们只希望每次加一都不丢失并不根据requests的值判断其他数据是否已经准备完成。因此只要原子性就够了。适合使用relaxed的场景包括请求次数、命中次数等统计数据不承担同步职责的引用计数唯一编号或事件序号的生成只关心原子对象自身最终结果的操作。如果一个原子变量还是其他数据的“就绪信号”通常就不能只使用 relaxed。3.2 memory_order_release发布此前完成的写入release用在发布数据的一端。result100;stage.store(1,std::memory_order_release);这里的含义是当其他线程通过 acquire 读取到stage 1时也应该能够看到result 100。常见场景包括生产者写入就绪标志发布已经完成初始化的对象把任务或节点交给另一个线程释放锁。锁内部通常也需要类似 release 的语义。release可以用于原子 store 和读—改—写操作不能用于普通原子 load因为 load 没有新值可以发布。3.3 memory_order_acquire接收已经发布的数据acquire用在接收数据的一端。if(stage.load(std::memory_order_acquire)1)use(result);如果这次 load 读取到了 release 发布的值那么发布之前的写入就对当前线程可见。常见场景包括消费者读取就绪标志读取另一个线程发布的对象指针获取锁读取某个阶段已经完成的状态。acquire可以用于原子 load 和读—改—写操作不能用于普通原子 store。release 和 acquire 经常配合使用可以把它们理解成一次交接release 负责把数据交出去acquire 负责把数据接进来。3.4 memory_order_acq_rel既接收数据又发布数据有些原子操作会先读取旧值再写入新值例如exchange、fetch_add和 CAS。这类操作统称为读—改—写操作。如果它既要接收上一个线程发布的数据又要把当前线程的数据发布给下一个线程就需要acq_relstate.compare_exchange_strong(expected,next,std::memory_order_acq_rel);CAS 成功时acquire 部分负责获取旧状态关联的数据release 部分负责发布新状态以及此前完成的写入。常见场景包括无锁数据结构中的状态更新多个线程依次推进任务阶段同时承担“接收上一棒”和“交出下一棒”的 CAS 或exchange。acq_rel只能用于读—改—写操作不能直接用于普通 load 或 store。3.5 memory_order_seq_cst再加一条全局一致顺序seq_cst是 sequentially consistent 的缩写中文通常称为顺序一致性。它包含 acquire/release 的同步能力还要求所有seq_cst原子操作能够放进一条全局一致的顺序中。不同线程可能不知道真实的执行时间但它们必须对这些原子操作的先后形成一致认识。更重要的是seq_cst是 C 原子操作的默认内存序stage.store(1);// 默认 seq_cstintvaluestage.load();适合使用seq_cst的场景包括并发代码的第一个正确版本同时涉及多个原子变量的复杂协议正确性和可维护性比极限性能更重要的代码还不能证明较弱内存序一定正确的场景。它可能比弱内存序限制更多优化但实际代价与 CPU 架构和操作类型有关不能简单地认为seq_cst一定很慢。在没有性能数据之前不要为了“看起来更底层”而主动削弱内存序。3.6 memory_order_consume工程中通常不再使用consume原本想提供一种比 acquire 更弱的保证只约束依赖于原子加载结果的后续操作。Node*nodehead.load(std::memory_order_consume);use(node-value);这里对node-value的访问依赖于先读取到的node。理论上编译器只需要维护这条数据依赖链不必约束所有后续操作。问题在于编译器很难在复杂优化中可靠追踪这种依赖。主流实现通常直接把 consume 按 acquire 处理它也已经在 C26 中弃用。因此它没有值得推荐的日常应用场景。理解设计目的即可新代码通常直接使用memory_order_acquire。五、实际写代码时应该怎么选实际工程中可以按照下面的顺序判断先使用默认的seq_cst保证正确性。如果只关心原子对象本身考虑relaxed。如果原子变量负责发布其他数据写端使用release。如果原子变量负责接收已发布的数据读端使用acquire。如果读—改—写操作同时需要获取和发布使用acq_rel。新代码通常不使用consume。只有性能测试确认这里是瓶颈并且能够证明同步关系仍然正确时才削弱内存序。可以把最常用的选择压缩成一句话只管自己用 relaxed发布用 release接收用 acquire两者都要用 acq_rel拿不准就用 seq_cst。六、memory_order 和内存屏障是什么关系memory_order是 C 语言层面的规则内存屏障则是编译器和 CPU 实现这些规则时可能使用的手段。同一个memory_order_release在不同处理器上可能生成不同的机器指令。x86 的内存模型相对较强某些 acquire load 和 release store 不需要额外的硬件屏障ARM 的内存模型更弱编译器可能选择带有 acquire/release 语义的指令。所以把 release 简单理解成“刷新缓存”、把 acquire 理解成“重新读取内存”并不准确。写 C 并发代码时首先应该判断是否建立了正确的 happens-before 关系而不是猜测某一款 CPU 会怎样操作缓存。