C++原子变量和普通变量的区别及原子变量的底层原理

本文分析一下C++原子变量和普通变量的区别,以及原子变量的底层原理。
本文涉及的代码测试环境:Microsoft Visual Studio Community 2022 、Windows 11、Intel x86-64。
本文内容如下:

  1. 原子变量和普通变量的功能区别
  2. 原子性底层原理
  3. 内存乱序问题原因
  4. 内存乱序解决方法
  5. std::memory_order_relaxed
  6. std::memory_order_seq_cst
  7. std::memory_order_acquire/release
  8. 普通变量和原子变量对比
  9. 总结

00 原子变量和普通变量的功能区别

相比普通变量,C++原子变量有如下两个基本特性:

  1. 原子变量的所有操作(fetch_add、store、load、compare_exchange_strong、exchange等)都具有原子性;
  2. 原子变量可以解决内存序问题,保证多个变量的可见性保持一定的一致性。

00.1 原子性

对于以上两个特性,原子性相对比较好理解:一个线程在进行某个原子变量操作时,系统中的所有线程,不可能观察到原子变量操作完成了一半;要么都完成了,要么都没开始。与通过互斥锁访问同一个数据的效果一样。

00.2 内存序

内存序问题,理解上会稍微困难一点。

简单来说,由于受到编译器的指令重排和CPU的微指令流水线乱序执行的影响,程序的代码顺序,可能与实际生效的顺序不一样。

01 原子性底层原理

这里我们对比一些原子变量和普通变量的汇编指令。

01.1 原子变量

测试代码:

int main() {
  std::atomic_int32_t counter32;
  counter32.fetch_add(10);
  int32_t tmp = 10;
  counter32.compare_exchange_strong(tmp, tmp + 1);
  counter32.exchange(3);
  counter32.store(15);
  std::cout << counter32.load() << std::endl;
  return 0;
}

使用Release配置生成,关键汇编代码如下:

fetch_add:
lock add    dword ptr [counter32],0Ah

compare_exchange_strong:
mov         ecx,0Bh // 参数2
mov         eax,0Ah // 参数1
lock cmpxchg dword ptr [counter32],ecx

exchange:
mov         ecx,3 // 参数1
xchg        ecx,dword ptr [counter32]

store:
mov         eax,0Fh // 参数1
xchg        eax,dword ptr [counter32]

load:
mov         edx,dword ptr [counter32]

这里fetch_add和compare_exchange_strong都使用了lock前缀。
lock前缀会对总线加锁,在总线加锁期间,其它CPU核心无法通过总线访问内存,直到该指令结束。
exchange和store函数使用了xchg汇编指令,该汇编指令自带lock效果。
load函数使用了普通的内存读取指令mov。
fetch_add、compare_exchange_strong、exchange和store都是变量写操作,通过对总线加锁来达到原子性的效果。如果另外线程需要读取数据(如load函数),只需要普通的内存读取指令即可。因为在总线加锁期间,读取操作会被阻塞。

01.2 普通变量

测试代码:

int normal(int32_t& counter32) {
  // 专门引入运行时变量,防止编译优化
  counter32 += (int32_t)&counter32;
  std::cout << counter32 << std::endl;
  return 0;
}

使用Release配置生成,关键汇编代码如下:

lea         edx,[counter32] // 取地址,并进行了数据转换
add         edx,dword ptr [counter32] // 普通加法,读取内存
mov         dword ptr [counter32],edx //写入内存

在这个例子中,普通变量的加法步骤如下:

  1. 读取内存数据,并与另一个数相加,将结果放在寄存器edx;
  2. 将保存在寄存器edx中的结果写回到内存中。

以上两个步骤是分开的,如果执行完第一个步骤,线程时间片用完了,那么第二个步骤需要等很长时间才能进行。如果此时另外的线程调用该函数,时序关系如下:

在这里插入图片描述

两个线程调用该函数后,预期完成两次加法,得到累计的结果。但按照以上时序运行后,变量counter32的最终值只有一次加法的结果。

普通变量无法保证原子性,有两个原因:

  1. 没有使用lock指令,在当前线程读写内存变量时,无法保证其它线程不对变量进行读写操作。
  2. 变量操作被拆分为多条汇编指令,需要先将变量加载到寄存器,完成计算后写入内存。这使得在特定时序情况下,线程被切换,从而产生不预期结果。

02 内存乱序问题原因

这里的内存序问题就是内存乱序问题,为什么编译器和CPU会引入内存乱序问题呢?

原因是为了增加单个执行流的执行速度。

02.1 编译乱序

编译器根据目标平台的CPU特性,适当调整指令顺序,使得单个线程中的执行速度更快。
测试代码:

__declspec(noinline)
bool submit(int32_t& data, int32_t& data2) {
  g_data2 = data2 - data;
  g_data1 = data;
  g_has_data = true;
  return true;
}

使用Release配置生成,汇编代码如下:

data2 - data:
mov         eax,dword ptr [rdx]
sub         eax,r8d

g_data1 = data:
mov         dword ptr [g_data1 (07FF68F9A462Ch)],r8d

g_data2 = data2 - data:
mov         dword ptr [g_data2 (07FF68F9A4630h)],eax

return true:
mov         al,1

g_has_data = true:
mov         byte ptr [g_has_data (07FF68F9A4628h)],1

以上示例,汇编指令相对于C++源码,有两处乱序:

  1. 全局变量g_data1和g_data2的赋值顺序反了;
  2. 返回值和全局变量g_has_data的赋值顺序反了。

02.2 CPU乱序

对于CPU来说,我们输入的是一条一条的汇编指令和数据,但CPU会将汇编指令进行译码,成为一系列的微指令。多条汇编指令形成的微指令会在CPU的流水线中进行执行。指令流水示意图如下:
在这里插入图片描述
即使在cache存在的情况下,写入操作仍然是比较耗时的。如果在微指令中存在写内存的操作,CPU会先将待写入的数据放入一个叫做Store Buffer的队列中,直到MESI协议将其它core中的该数据完成作废以后,再向cache/memory写入。
在这里插入图片描述
关于MESI协议,可以参考往期文章:CPU缓存

当数据进入Store Buffer后,CPU会继续执行后续的微指令,即使此时的数据还没有写入cache/memory。Store Buffer中的数据写入cache/memory就交给了MESI协议系统,对于CPU来说,就是一个异步的后台任务。于是产生了CPU的指令乱序执行。

在大部分情况下,乱序执行是没有问题的,但是在多线程环境下,一些关键变量如果无法保证原来的顺序,就会出现不预期的运行结果。

那么如何来解决呢?

03 内存乱序解决方法

为了解决编译器乱序和CPU乱序,有如下一些方法:

  1. 关闭编译器优化,在实际项目中,基本不可能。
  2. 使用volatile关键字,保证编译器不会优化变量,变量每次读写都会从存储器完成。主要用在嵌入式开发场景。
  3. 使用操作系统提供的内存屏障机制,内存屏障也是std::memory_order的底层实现。内存屏障对CPU微指令的影响,本质上是间接操作Store Buffer。
  4. 加锁。对关键代码加锁。

04 std::memory_order_relaxed

std::memory_order是在内存屏障的基础上,进行的上层抽象设计。以使得多线程读写多个变量时,达到一定程度的可见顺序保证。

下面依次介绍std::memory_order的四个常用类型。

std::memory_order_relaxed是比较容易理解的一个类型。它只保证该变量本身的原子性,不保证解决多个变量之间的读写乱序。

由于保证原子性,因此变量修改直达内存/cache。不会先读到寄存器,然后计算,最后写回内存。
由于不解决多个变量之间的读写乱序问题,因此理论上不影响编译器指令重排。

std::memory_order_relaxed在四个类型中,性能开销也是最小的。

测试代码:

__declspec(noinline)
bool relaxed_test(int32_t& data) {
  g_data1.store(data, std::memory_order_relaxed);
  return g_has_data.load(std::memory_order_relaxed);
}

使用Release配置生成,汇编代码如下:

g_data1.store(data, std::memory_order_relaxed):
mov         eax,dword ptr [rcx]
mov         dword ptr [g_data1 (07FF73D7E464Ch)],eax

return g_has_data.load(std::memory_order_relaxed):
movzx       eax,byte ptr [g_has_data (07FF73D7E4648h)]

从汇编代码可以看出,std::memory_order_relaxed使用的是基本的mov指令和movzx,并不会清空Store Buffer。

05 std::memory_order_seq_cst

std::memory_order_seq_cst理解起来也比较容易,保证顺序一致(sequentially-consistent)。具体如下:

如果两个变量都使用std::memory_order_seq_cst进行读写,其中两个变量的写操作记为W1和W2。当任意线程的读操作观测到W1先于W2,那么其它线程的读操作也会观测到W1先于W2。

在x86-64 CPU环境下,进行W1操作和W2操作后,都会立即清空Store Buffer,该操作本质上就是全局的数据同步,从而保证了顺序一致。

编译器也会保证指令的顺序。

std::memory_order_seq_cst是最强的顺序一致保证,也是c++原子变量的默认参数。但性能是四个类型中最低的。

测试代码:

__declspec(noinline)
bool seq_test(int32_t& data) {
  g_data1.store(data, std::memory_order_seq_cst);
  return g_has_data.load(std::memory_order_seq_cst);
}

使用Release配置生成,汇编代码如下:

g_data1.store(data, std::memory_order_seq_cst):
mov         eax,dword ptr [rcx]
xchg        eax,dword ptr [g_data1 (07FF61820464Ch)]

return g_has_data.load(std::memory_order_seq_cst):
movzx       eax,byte ptr [g_has_data (07FF618204648h)]

从汇编代码可以看出,std::memory_order_seq_cst写入使用的是xchg指令,xchg指令会清空Store Buffer。

06 std::memory_order_acquire/release

std::memory_order_acquire和std::memory_order_release是同时出现的。对内存的顺序保证介于std::memory_order_relaxed和std::memory_order_seq_cst之间。高性能编程用得比较多。

std::memory_order_acquire用于读操作,std::memory_order_release用于写操作。

一个操作既包含读又包含写,则可以使用std::memory_order_acq_rel,std::memory_order_acq_rel=std::memory_order_acquire+std::memory_order_release。

acquire/release的顺序保证具体如下:

对同一个变量进行两个操作:读操作R、写操作W。两个操作可以在同一个线程内,也可以在不同线程内。如果读操作R读取了写操作W写入的新值,那么R后续的所有其它变量的读操作,都能读取到W操作之前所有其它变量的写入值。

将前文的submit函数用原子变量改写,代码如下:

__declspec(noinline)
bool submit(int32_t& data, int32_t& data2) {
  g_data2 = data2 - data;
  g_data1.store(data, std::memory_order_release);
  g_has_data.store(true, std::memory_order_release);
  return true;
}

使用Release配置生成,汇编代码如下:

g_data2 = data2 - data:
sub         eax,r8d
mov         dword ptr [g_data2 (07FF77AF15648h)],eax

g_data1.store(data, std::memory_order_release):
mov         dword ptr [g_data1 (07FF77AF15644h)],r8d

g_has_data.store(true, std::memory_order_release):
mov         byte ptr [g_has_data (07FF77AF15640h)],1

return true:
mov         al,1

可以发现使用std::memory_order_release内存序,可以保证写入操作汇编代码的顺序,乱序无法穿透使用了std::memory_order_release参数的写入操作。

读取代码:

__declspec(noinline)
int32_t get_data() {
  while (!g_has_data.load(std::memory_order_acquire)) {
  } // 循环结束,代表读取到了新值
  int32_t tmp = g_data1.load(std::memory_order_acquire);
  return g_data2 + tmp;
}

使用Release配置生成,汇编代码如下:

while (!g_has_data.load(std::memory_order_acquire)):
movzx       eax,byte ptr [g_has_data (07FF7B7BA4640h)]  
test        al,al  
je          get_data+2h (07FF7B7BA1032h)

int32_t tmp = g_data1.load(std::memory_order_acquire):
mov         ecx,dword ptr [g_data1 (07FF7B7BA4644h)]

return g_data2 + tmp:
mov         eax,dword ptr [g_data2 (07FF7B7BA4648h)]
add         eax,ecx

循环读,每次都读取内存。读取的汇编代码顺序和C++代码一致。

在x86-64 CPU环境下,不需要编译器插入额外的内存屏障指令或者使用xchg这类清空Store Buffer的指令。操作不会发生穿越读操作R和写操作W的重排。

07 普通变量和原子变量对比

在x86-64 CPU环境下,普通变量和以上std::memory_order类型简单对比如下:

memory_order清空Store Buffer编译重排限制读写直接操作内存原子性
普通变量不限制
relaxed不限制
seq_cst完全限制
acquire/release部分限制

08 总结

本文介绍了普通变量和原子变量的两个基本区别:原子性和内存序。
本文基于这两个基本区别进行了汇编代码的对比分析。分析了编译器对普通变量和原子变量的重排表现。
从原理上,梳理了在x86-64 CPU架构下,受到Store Buffer和MESI协议的影响,如何产生了CPU微指令乱序。
最后对std::memory_order进行了详细说明。

std::memory_order在不同的CPU平台下,某些细节表现并不完全相同。当然在定义范围内的行为是相同的。

std::memory_order还有一个类型std::memory_order_consume,由于没有实现,目前没用。

内存屏障有关资料推荐:http://www.puppetmastertrading.com/images/hwViewForSwHackers.pdf

关注微信公众号“程序员小阳”,相互交流更多软件开发技术。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值