std::atomic<T>把对一个对象的相应操作变成原子操作;std::memory_order进一步规定这些操作如何约束线程之间的可见性和先后关系。它讨论的是并发内存模型,与 new/delete、堆分配器不是同一主题。以下示例采用C++17。
先分清三件事:一次访问不可分割、多个操作组成一个事务、其他对象的数据已经同步。这三件事不能相互替代。
1. 原子操作与数据竞争
跳转到“1. 原子操作与数据竞争”如果两个线程并发访问同一普通对象、至少一个访问是写入,而且没有规定的同步关系,就可能产生C++数据竞争,行为未定义。把最终结果理解成“偶尔少加一次”不足以描述它。
原子计数器的 fetch_add(1)是一次读—改—写操作;load()后再 store(old + 1)却是两个操作,其他线程可以插在中间。即使二者都用最强内存序,也不能把这两步自动合成不可分割的加法。
#include <atomic>#include <cassert>#include <thread>
int main(){ std::atomic<int> count{0}; std::thread worker([&] { for (int i = 0; i != 10000; ++i) count.fetch_add(1, std::memory_order_relaxed); }); for (int i = 0; i != 10000; ++i) count.fetch_add(1, std::memory_order_relaxed); worker.join(); assert(count.load(std::memory_order_relaxed) == 20000);}这里只有独立计数,没有用计数器发布另一块数据,所以 relaxed足够。join()保证工作线程已经完成后才检查总数。对同一个原子对象的修改仍有统一的修改顺序,relaxed并不是“任何重排和丢更新都可以”。C++工作草案:原子顺序
2. 常用内存序分别约束什么
跳转到“2. 常用内存序分别约束什么”| 内存序 | 主要作用 | 典型操作 |
|---|---|---|
relaxed | 保留该对象的原子性和修改顺序,不借此为其他数据建立线程间同步。 | 独立统计计数。 |
release | 发布当前线程在它之前完成的操作,供匹配的获取操作同步。 | 写就绪标志、发布指针。 |
acquire | 获取匹配发布操作之前的数据,使本线程后续相应访问有同步依据。 | 读就绪标志、读已发布指针。 |
acq_rel | 同一次原子读—改—写兼具获取和发布。 | 某些交换、计数状态机。 |
seq_cst | 提供适用的获取/发布语义,并为这些顺序一致操作规定一个总序。 | 未显式指定内存序时的常用默认选择。 |
这里的“之前/之后”是程序中需要建立的同步关系,不能简化成“让所有CPU缓存立即刷新”。seq_cst的总序也不是对整个程序里每一次普通读写、每一个其他内存序操作都无条件排成一条线。
有效组合也有限制:load不能用 release或 acq_rel,store不能用 consume、acquire或 acq_rel;读—改—写操作才可以同时获取和发布。C++17还定义 memory_order_consume,它围绕依赖关系建立约束;它的实现和演进比较复杂,本页示例不依赖它,常见应用优先学习明确的 acquire。C++工作草案:原子加载和存储
3. 一次性发布:普通数据为何能够安全读取
跳转到“3. 一次性发布:普通数据为何能够安全读取”生产者先写普通数据,再把原子标志设为就绪;消费者只有在获取加载读到该次发布值后,才读取数据。
#include <atomic>#include <cassert>#include <thread>
int main(){ int payload = 0; std::atomic<bool> ready{false}; std::thread producer([&] { payload = 42; ready.store(true, std::memory_order_release); });
while (!ready.load(std::memory_order_acquire)) std::this_thread::yield(); const int received = payload; producer.join(); assert(received == 42);}论证顺序如下:
payload = 42在生产者中先于release存储。- 消费者的
acquire加载读到这个存储写入的true,因此二者建立同步关系。 - 消费者读取
payload在该获取之后;通过传递性,生产者写入发生在消费者读取之前,因此这里的普通int不存在数据竞争。
只是两个线程都写了 acquire/release单词还不够,获取操作必须与相应发布值等建立标准要求的关系。将这里两个标志操作都改为 relaxed,就没有足够依据同步 payload。后面的 join不能追溯修复已经先执行的无同步读取。
这个例子只发布一次,生产者发布后不再修改 payload。如果复用同一个布尔标志、连续覆盖消息,消费者可能和下一次写入冲突,或无法区分哪一轮消息。多次传输优先使用有明确所有权和结束协议的条件变量队列。
4. 自旋、锁与进展保证
跳转到“4. 自旋、锁与进展保证”yield()只是向调度器提示让出执行机会,不保证某个线程立刻运行,也不使忙等变成阻塞等待。长时间等待会占用资源;通常应使用条件变量,C++20还提供原子的 wait/notify接口,不能直接把它们写进要求C++17的程序。
atomic<T>不保证所有类型、所有平台都无锁;可用 is_lock_free()或 is_always_lock_free检查相应保证。无锁也不等于每个线程在固定步数内完成,更不等于整段算法没有阻塞。
需要同时维护多个字段的不变量时,先考虑一个互斥量覆盖完整操作。例如“余额足够才同时扣款和记账”不能仅把两个字段改成原子类型便认为完成了事务同步。volatile同样不能代替原子或互斥量。
5. 如何验证
跳转到“5. 如何验证”示例通过有限循环、确定的最终值和 join()检查实际执行。这样的测试能发现代码或接口错误,却不能证明放松内存序后的程序在所有CPU上安全。一次运行没有出现旧值,不是内存顺序的证明;正确性仍要写出对象寿命、每次共享访问、同步关系和关闭条件。