一、编译器和处理器
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