跳转到内容
新建笔记

C++ 原子操作与内存序:从计数到数据发布

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);
}

论证顺序如下:

  1. payload = 42在生产者中先于 release存储。
  2. 消费者的 acquire加载读到这个存储写入的 true,因此二者建立同步关系。
  3. 消费者读取 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同样不能代替原子或互斥量。

示例通过有限循环、确定的最终值和 join()检查实际执行。这样的测试能发现代码或接口错误,却不能证明放松内存序后的程序在所有CPU上安全。一次运行没有出现旧值,不是内存顺序的证明;正确性仍要写出对象寿命、每次共享访问、同步关系和关闭条件。