原子操作
原子变量和原子内存序是多线程无锁编程里面的重要工具,本文是对齐核心概念和使用方法的总结。
原子操作的必要性
首先,我们需要来探讨一个场景,我们实现一个简单的多线程计数程序:
#include <thread>
#include <iostream>
int counter = 0; // 共享变量,非原子
void increment() {
++counter; // 这看起来是单一操作,但实际不是原子性的
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Counter: " << counter << std::endl; // 预期输出2,但可能为1(bug)
return 0;
}这个程序的结果可能不是你预期的2,很有可能可能一开始的你无法理解为什么会这样,没关系,我们接着往下看。
++counter 操作其实可以拆解为 counter = counter + 1, 在汇编指令方面,可以拆解为三条伪汇编指令(假设为x86架构):
MOV eax, [addr] // 读取counter的值到寄存器eax(读操作) // 指令1
ADD eax, 1 // 在寄存器中加1(修改操作) // 指令2
MOV [addr], eax // 将新值写回内存[addr](写操作) // 指令3知道底层的一个指令集表现后,我们知道这样一个简单的自增操作并不是在一条指令完成,在多任务的操作系统下,CPU在执行完第二条指令后可能被操作系统调度器中断(线程切换),导致指令序列被打断,我们来使用时序图模拟下出问题的情况:
| 时间轴 | Thread1 | Thread2 | 内存(Counter) |
|---|---|---|---|
| t1 | 线程进入运行状态 | 线程等待调度 | 0 |
| t2 | MOV eax, [addr] → eax=0 | 线程等待调度 | 0 |
| t3 | ADD eax, 1 → eax=1 | 线程等待调度 | 0 |
| t4 | 时间片轮转,线程切出 | 线程进入运行状态 | 0 |
| t5 | 线程等待调度 | MOV ebx, [addr] → ebx=0 | 0 |
| t6 | 线程等待调度 | ADD ebx, 1 → ebx=1 | 0 |
| t7 | 线程等待调度 | MOV [addr], ebx → 写回内存 | 1 |
| t8 | 线程进入运行状态 | 时间片轮转,线程切出 | 1 |
| t9 | MOV [addr], eax → 写回内存 | 线程退出 | 1 |
| t10 | 线程退出 | 1 (最终值为1,不是2) |
从上面的模拟流程可以看出,问题的根因是因为相关指令集的执行不是原子的,这个操作被拆分为了多个操作,因为线程间切换发生了错误。
所以,为了解决问题,操作系统提供了一系列原子性指令,用于简化指令集,并且通过缓存一致性协议,不可中断性,内存顺序模型等多种手段, 让高级语言能够定义出自己的原子类型和原子内存顺序,通过这些手段,你不必担心你程序中再出现类似问题。
对于上述示例代码的改正,我们可以修改如下:
#include <atomic>
std::atomic<int> counter; // 共享变量,非原子
void increment()
{
counter.fetch_add(1); // 这看起来是单一操作,但实际不是原子性的
}具体的原子类型的支持,请参考 cppreference | atomic 查看。
为什么需要原子内存序
我们来讲讲什么是原子内存序,以及为什么需要原子内存序,我们接着来看一段代码:
#include <atomic>
#include <thread>
std::atomic<int> data(0); // 原子变量
std::atomic<bool> ready(false); // 标志位
void producer() {
data.store(42); // 步骤1: 写数据
ready.store(true); // 步骤2: 设置ready为true
}
void consumer() {
while (!ready.load()) {} // 等待ready为true
int value = data.load(); // 读取数据,预期42
// 但由于重排序,可能读取到0!
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join(); t2.join();
return 0;
}很多C++的开发者,以为原子变量的默认用法就解决所有问题,其实并不然,有时候我们会发现,有时候程序中赋值的顺序并不是你一眼看上去那样,正如上面的代码一样,producer 函数里面 data 和 ready的赋值,看起来好像是data先于ready,其实不然。
随着技术的发展,为了提高并行化处理的性能,编译器和CPU(尤其是多核CPU)会重新排列指令的执行顺序,例如:
- 编译器可能在代码编译时调整指令顺序;
- CPU在运行时使用乱序执行(Out-of-Order Execution)、分支预测和缓存机制,进一步重排序;
回到以上代码,data 和 ready的赋值顺序对CPU来说并不重要,但是对我们的程序逻辑是至关重要的,我们期望,再ready变为true之后data应该已经变为42了。对CPU来说,并不是这么回事儿,它只会使用它认可的策略来尽可能优化它的工作效率。
所以,我们需要一个机制来限制CPU对指令执行顺序的重排,比如刚刚那个场景,我们就需要告诉CPU,别把ready赋值指令放在data之前啦!
怎么做,让我们一起看看C++为我们提供的能力
C++ 支持的原子内存序
C++11(及后续标准)通过头文件 atomic 中的 std::memory_order 枚举提供了原子内存序。这些内存序用于控制原子操作(如 std::atomic::load、store)的可见性和顺序约束, 具体内容如下:
| 内存序名称 | 描述 | 强度 | 典型用途 | 注意事项 |
|---|---|---|---|---|
memory_order_relaxed | 最松弛。只保证原子性,不提供顺序或可见性约束。允许最大程度的指令重排序。 | 最弱 | 非依赖的计数器(如日志ID生成)、独立操作。 | 可能导致可见性bug(如其他线程看不到更新)。性能最高,但需小心使用。 |
memory_order_consume | 提供“消费”语义:确保依赖于本次load的操作可见,但不保证全局顺序。(C++17中弃用) | 弱 | 依赖关系图中的数据加载(如指针解引用)。 | 实际很少用,已被acquire取代。编译器可能将其视为acquire。 |
memory_order_acquire | 用于load:确保本次load之后的操作不会重排序到之前。建立“获取”同步点。 | 中等 | 消费者线程等待信号后读取数据。 | 常与release配对,形成happens-before关系。防止后续操作看到旧值。 |
memory_order_release | 用于store:确保本次store之前的操作不会重排序到之后。建立“释放”同步点。 | 中等 | 生产者线程写入数据后设置标志。 | 常与acquire配对,确保修改对其他线程可见。不能用于load。 |
memory_order_acq_rel | 结合acquire和release:用于读-改-写操作(如fetch_add)。同时提供获取和释放语义。 | 较强 | 无锁数据结构中的更新操作(如队列push/pop)。 | 适用于需要双向同步的场景。性能介于acquire/release和seq_cst之间。 |
memory_order_seq_cst | 最严格。保证全局顺序一致性,像单线程执行一样。默认内存序。 | 最强 | 需要强一致性的场景,如简单同步或调试。 | 易用但性能稍低(引入更多barrier)。适用于大多数情况,避免复杂bug。 |
C++中,所有原子操作的默认内存徐都是代价最高,一致性最严格的memory_order_seq_cst。
讲完分类,我们来讲讲如何理解它们。
原子内存序是对CPU指令重排的一种限制规则,摊开指令集来讲,让我们最关心的指令类型无非就是读写指令两种,内存序限制其实就是限制的读写相关指令的重排限制规则。内存序就好像一个栅栏,栅栏里外有一个强制的指令执行顺序约定先执行哪一部分,后执行哪一部分。
拿 memory_order_release 来说,其实就是希望以操作对应原子变量的指令位置为分界线,按照编译出来的指令顺序,强制规定CPU一定要保证前面的指令不要被CPU调整到其之后执行。memory_order_acquire的规则和这个恰恰相反。memory_order_acq_rel是这两个规则的结合。
memory_order_seq_cst比较特殊,它看到的是该全序中“最近的”对同一对象的写(或读改写)结果;不会看到“将来”的写,也不会跳过中间写,且同时具备memory_order_acquire和memory_order_release的语义。