0
点赞
收藏
分享

微信扫一扫

多线程编程之指令重排与屏障

一ke大白菜 2022-04-14 阅读 77

一、编译器和处理器

1. 编译器的代码优化

编译器为了提升程序的性能,在编译阶段会对程序员编写的源代码进行分析和优化,比如,

// 程序员编写的代码
static bool flag = false;
void f(){
  while(!flag){...};
}

// 经过编译器优化后
static bool flag = false;
void f(){
  if(!flag){
    while(1){...}; 
  }
}

在程序是单线程运行时,编译器的优化是正确的,但是如果有其他线程修改了flag的值,会导致执行f()的线程无法正确地读到flag的值,从而产生了错误的结果。

2. 处理器的指令优化

处理器会将编译后的指令通过电路进一步变为μops,并且每个μops具有fetch/issue/execute/commit四个阶段,为了提升指令执行的效率,处理器把 没有依赖关系 的指令调整顺序执行,以最大化地并行所有指令。
如下图,指令2依赖于指令1的结果,但是指令3、4与指令1、2没有依赖关系,所以处理器有可能会将指令3、4提前到指令2之前执行,以提高处理器的使用率,减少4个指令占用的处理器资源。
处理器调整指令顺序
但是这将会产生某些在代码编写时未预见的结果,比如两个线程并发地分别执行f1和f2,

int x = 0;
int y = 0;
void f1(){
  x = 1;
  printf("y=%d\n", y);
}
void f2(){
  y = 1;
  printf("x=%d\n", x);
}

将有可能输出未预见的结果,

x = 0
y = 0

这是因为处理器在实际执行指令的过程中调整了指令的顺序,

int x = 0;
int y = 0;
void f1(){
  // 因为以下两行代码没有依赖关系,所以处理器有可能会调整它们的执行顺序,从而产生了未预见的结果
  printf("y=%d\n", y);
  x = 1;
}
void f2(){
  // 同上
  printf("x=%d\n", x);
  y = 1;
}

导致程序未能按照编写代码时的预期来输出结果。

另外,现代的处理器普遍使用较上图更为先进的乱序执行器,但是其核心思想是相同的。

3. 处理器的缓存机制

为了提升指令执行的速度,在每个处理器与内存(memory)之间增加高速缓存(cache),并加载处理器需要使用的指令和数据。对cache和memory数据的修改,会由同一个总线发出信号,各个cache和memory会监听信号,并各自执行相应的处理,实现MESI协议以达到一致性。
在MESI协议中,处理器写入数据时(Modified)总是需要等待其他处理器的应答信号,以及让出数据时(Invalid)总是要在改变cache的数据状态后才能应答。为了提高处理器的效率,进一步增加了store buffer和invalid queue来异步地处理指令。
处理器的缓存与异步机制
上面提到的的store buffer和invalid在提升性能的同时,也引入了指令被乱序执行的可能性。在store buffer中等待invalidate response应答的数据有可能晚于指令顺序被写入cache,在invalid queue中等待时机执行的invalid信号有可能晚于指令顺序被执行。
在这里插入图片描述
如上图中所展示,第一、二步骤中,cpu1在修改a时需要发送read invalidate去获取a的exclusive状态,因为cpu1需要等待其它cpu的ack,所以为了提高处理器的利用率,cpu1会把a=1暂时存入store buffer并继续执行其他指令;在第三、四步骤中,cpu1执行b=a+1使用了cache中的a=0,而a=1在其之后才被写入cache,于是导致a=1发生了类似于被重排到b=a+1之后执行的结果。

二、如何保证指令执行的结果与预期一致

1. 避免编译时指令重排

为了避免编译器对代码优化造成的指令重排,编程语言提供了volatile关键字,用于禁止编译器对被标记的代码进行优化。

// 使用volatile标记flag变量,禁止编译器对其进行优化
volatile bool flag = false;
void f(){
  while(!flag){...};
}

除了volatile关键字,使用编译器提供的指令也能够避免指令重排,如gcc的memory barrier,

int x = 0;
int y = 0;
void f1(){
  x = 1;
  asm volatile("" ::: "memory");
  printf("y=%d\n", y);
}
void f2(){
  y = 1;
  asm volatile("" ::: "memory");
  printf("x=%d\n", x);
}

2. 避免运行时指令重排

为了避免指令重排,需要设置屏障来保证处理器在运行时按代码顺序执行指令,这种屏障在概念上分为4种,

  • LoadLoad,保证屏障前的读指令不会被重排到屏障后的读指令后
  • LoadStore,保证屏障前的写指令不会被重排到屏障后的写指令后
  • StoreStore,保证屏障前的写指令不会被重排到屏障后的写指令后
  • StoreLoad,保证屏障前的读指令不会被重排到屏障后的读指令后

在实际的中,不同的处理器支持了不同的缓存机制,以Intel的x86处理器为例,其内存模型的实现名为TSO(total store order),具有以下几个特点,

  • store buffer的实现是一个FIFO的结构,并且线程一定是先从store buffer中读取数据,数据不存在时再从内存中读取;store buffer中的数据任何时间都有可能被刷入内存,除非有其他线程持有全局锁
  • 提供了mfence指令,清空指令执行时所在处理器的store buffer,将数据全部刷入内存中
  • 线程执行lock前缀指令时,需要获取全局锁,其他线程不能进行数据读取,会在释放全局锁之前清空store buffer,并将数据全部刷入内存中
  • 未实现invalid queue

因此根据以上的特点,在x86处理器中不存在LoadLoad、StoreStore、LoadStore屏障所需要避免的问题,

  • LoadLoad/LoadStore,因为没有invalid queue,所以读指令一定会同步的读取到当前最新数据,不存在读取到非当前最新数据的可能
  • StoreStore,因为在store buffer中的数据是FIFO,因此所有的写指令是都是一定按顺序刷入内存中,所以较晚的写指令一定是在较晚的时间生效

唯一存在的只有StoreLoad屏障提到的问题,较早的写指令写入store buffer的数据,有可能在后续的读指令之后才被刷入内存,

  • 通过mfence指令或者lock前缀指令来达到需要的效果,因为这些指令都会把store buffer中的数据刷入内存中,所以可以保证该指令后续的指令一定能够读取到最新的数据

可以在c++代码中添加内存屏障,

asm volatile("mfence" ::: "memory");

或者像java生成的指令中使用lock前缀指令,

lock addl $0x0,(%rsp)

并且,x86处理器为强内存模型,读写指令都具有release和acquire语义,即能够自动地保证读写指令执行时不会被流水线乱序执行。

参考文章

  • 当我们在谈论cpu指令乱序的时候,究竟在谈论什么? - 知乎
  • 处理器缓存一致性协议 - CSDN
  • 处理器中的store buffer和invalid queue - CSDN
  • 从HotSpot源码看Java volatile - Blog
  • 编译器屏障 - 华为云
  • x86与ARM架构差异 - 华为云
  • 为什么在 x86 架构下只有 StoreLoad 屏障是有效指令? - 知乎
  • x86-TSO: A Rigorous and Usable Programmer’s Model for
    x86 Multiprocessors
  • Memory Ordering at Compile Time
  • Memory Barriers Are Like Source Control Operations
  • difference in mfence and asm volatile (“” : : : “memory”)
  • 并发编程CPU的流水线
  • C++ Memory Order - Github
  • std::memory_order - cppreference.com
举报

相关推荐

0 条评论