原子操作的内存顺序

张开发
2026/5/31 11:34:02 15 分钟阅读
原子操作的内存顺序
前置准备所有代码均使用 C17在 Linux 下用g编译。请确保安装了编译工具链sudoaptinstallg通用编译命令模板# 普通编译用于看正常逻辑g-stdc17-pthread-odemo demo.cpp# 带 ThreadSanitizer 编译用于抓数据竞争g-stdc17-pthread-fsanitizethread-g-odemo_tsan demo.cpp示例 1std::memory_order_relaxed(纯原子计数器)场景只需要保证“自增”操作本身是原子的不需要线程间同步其他数据。1.1 完整代码 (demo1_relaxed.cpp)#includeatomic#includethread#includeiostream#includecassert// 全局原子计数器std::atomicintg_counter(0);// 线程函数循环自增 100 万次voidincrement_1m_times(){for(inti0;i1000000;i){// 关键点使用 relaxed// 只保证 fetch_add 是原子的不保证任何内存顺序g_counter.fetch_add(1,std::memory_order_relaxed);}}intmain(){std::coutStart (Relaxed)...std::endl;// 启动两个线程同时自增std::threadt1(increment_1m_times);std::threadt2(increment_1m_times);t1.join();t2.join();// 验证结果intresultg_counter.load(std::memory_order_relaxed);std::coutFinal counter: resultstd::endl;// Relaxed 保证了原子性结果一定是 2000000assert(result2000000);return0;}1.2 编译与运行g-stdc17-pthread-odemo1 demo1_relaxed.cpp ./demo1输出Start (Relaxed)... Final counter: 20000001.3 一步一步详解原子性保证fetch_add(1, relaxed)确保了“读取-修改-写入”这三步是不可分割的。如果这里用的是普通int g_counter和g_counter结果会小于 200万。无顺序保证虽然结果正确但线程 A 并不知道线程 B 已经自增到哪一步了。它们只是各算各的最后汇总。性能最高这是最快的内存序因为 CPU 和编译器可以自由重排指令。示例 2std::memory_order_releasestd::memory_order_acquire(生产者-消费者)场景线程 A 准备数据线程 B 读取数据。我们需要保证B 看到“就绪信号”时一定能看到 A 准备好的数据。2.1 完整代码 (demo2_release_acquire.cpp)#includeatomic#includethread#includeiostream#includecassert// 普通共享数据非原子intg_data0;// 原子信号量std::atomicboolg_ready(false);voidproducer(){// 步骤 1: 先写数据这是普通的非原子操作g_data42;std::coutProducer: Data prepared.std::endl;// 步骤 2: 发布信号 (使用 Release)// 关键规则g_data 42 绝对不会被重排到这行 store 之后g_ready.store(true,std::memory_order_release);}voidconsumer(){// 步骤 1: 自旋等待信号 (使用 Acquire)while(!g_ready.load(std::memory_order_acquire)){// 空转等待}std::coutConsumer: Signal received.std::endl;// 步骤 2: 读取数据// 关键规则因为上面是 Acquire这里一定能看到 g_data 42assert(g_data42);std::coutConsumer: Data is g_data (Success!)std::endl;}intmain(){std::threadt_prod(producer);std::threadt_cons(consumer);t_prod.join();t_cons.join();return0;}2.2 错误示范 (如果误用 Relaxed)我们把上面代码中的release和acquire都改成relaxed保存为demo2_wrong.cpp。在 x86 CPU 上直接运行你可能依然会看到 Success因为 x86 硬件是强内存模型 TSO自动帮你做了很多事。但这是错的我们用 ThreadSanitizer 来抓它g-stdc17-pthread-fsanitizethread-g-odemo2_wrong_tsan demo2_wrong.cpp ./demo2_wrong_tsanTSAN 输出 (关键部分)WARNING: ThreadSanitizer: data race (pid12345) Read of size 4 at 0x55c... by thread T2: #0 consumer() demo2_wrong.cpp:32 #1 ... Previous write of size 4 at 0x55c... by thread T1: #0 producer() demo2_wrong.cpp:12 #1 ...这就证明了用 Relaxed 会导致数据竞争g_data的读写没有被正确同步。2.3 一步一步详解Release (写者)像是“关门”动作。我在关门store之前做的所有事写 data都必须在关门之前完成。Acquire (读者)像是“开门”动作。我开了门load 到 true之后门里的东西data我就都能看见了。配对使用这是无锁编程中最常用的“黄金组合”性能仅次于 Relaxed但安全性极高。示例 3std::memory_order_acq_rel(获取释放用于 RMW)场景当一个操作既是“读者”又是“写者”时即Read-Modify-Write, RMW操作比如fetch_add、exchange、compare_exchange。例子我们用原子操作实现一个简单的“接力棒”传递。3.1 完整代码 (demo3_acq_rel.cpp)#includeatomic#includethread#includeiostreamstd::atomicintg_baton(0);// 0: 没人拿, 1: 线程1拿, 2: 线程2拿intg_shared_value0;voidthread1_work(){g_shared_value100;// 先干活// RMW 操作把 0 换成 1// 使用 acq_rel// 1. Acquire 部分确保我们看到了之前的状态 (0)// 2. Release 部分确保 g_shared_value 100 对下一个拿到的人可见intexpected0;while(!g_baton.compare_exchange_weak(expected,1,std::memory_order_acq_rel)){expected0;// 失败了重置 expected}std::coutThread 1: Passed the baton.std::endl;}voidthread2_work(){// 等待拿到 baton (1)然后设为 2intexpected1;while(!g_baton.compare_exchange_weak(expected,2,std::memory_order_acq_rel)){expected1;}// 因为 Thread 1 用了 Release这里用了 Acquire所以能看到 100std::coutThread 2: Got baton. Shared value g_shared_valuestd::endl;}intmain(){std::threadt1(thread1_work);std::threadt2(thread2_work);t1.join();t2.join();return0;}3.2 一步一步详解RMW 的特殊性compare_exchange先读看是不是 expected再写改成 desired。Acq_Rel 的作用读的那一瞬间它是Acquire确保能看到之前线程的写入。写的那一瞬间它是Release确保自己的写入对后续线程可见。适用场景实现自旋锁Spinlock、无锁队列的节点插入等。示例 4std::memory_order_seq_cst(顺序一致性默认选项)场景需要全局总序(Total Order)。即所有线程看到的所有原子操作的发生顺序是完全一致的。这是 C 原子操作的默认内存序如果你不写参数就是这个。4.1 完整代码 (demo4_seq_cst.cpp)这是一个经典的 IRIW (Independent Read Independent Write) 示例。#includeatomic#includethread#includeiostream#includecassertstd::atomicboolx{false},y{false};std::atomicintz{0};voidwrite_x(){x.store(true,std::memory_order_seq_cst);}voidwrite_y(){y.store(true,std::memory_order_seq_cst);}voidread_x_then_y(){while(!x.load(std::memory_order_seq_cst));// 等 xif(y.load(std::memory_order_seq_cst)){// 看 yz.fetch_add(1,std::memory_order_relaxed);}}voidread_y_then_x(){while(!y.load(std::memory_order_seq_cst));// 等 yif(x.load(std::memory_order_seq_cst)){// 看 xz.fetch_add(1,std::memory_order_relaxed);}}intmain(){// 循环跑 1000 次增加概率观察结果for(inti0;i1000;i){xfalse;yfalse;z0;std::threada(write_x),b(write_y);std::threadc(read_x_then_y),d(read_y_then_x);a.join();b.join();c.join();d.join();// Seq_Cst 保证z 绝对不可能是 0// 因为所有操作有一个全局顺序要么 x 在 y 前要么 y 在 x 前// 至少有一个读线程会看到两个都是 trueassert(z.load()!0);}std::coutAll tests passed (Seq_Cst guarantees no z0).std::endl;return0;}4.2 一步一步详解全局时钟seq_cst就像给整个程序安了一个全局时钟。所有原子操作按时间戳排列所有线程看到的顺序都一样。为什么 z 不能为 0如果是acq_rel理论上可能出现线程 C 看到xtrue但yfalse同时线程 D 看到ytrue但xfalse因为没有全局总序导致z0。但seq_cst禁止了这种情况。性能代价这是最慢的内存序在 x86 上通常需要MFENCE指令。但它是最直观、最不容易出错的。 最终总结与决策树为了让你好记我做了一个简单的决策流程我只是做个计数器/统计不依赖它同步别的数据✅ 用std::memory_order_relaxed我有两个线程一个发信号一个收信号收信号后要读发信号前写的数据✅ 发信号用std::memory_order_release✅ 收信号用std::memory_order_acquire我在做fetch_add/compare_exchange这种 RMW 操作需要它承上启下✅ 用std::memory_order_acq_rel我不知道该用什么 / 逻辑很复杂 / 图省事 / 需要全局顺序✅ 用std::memory_order_seq_cst(默认)

更多文章