
对单线程程序而言,代码会一行行顺序执行,就像我们编写的程序的顺序那样。比如:
a = 1; b = 2;
会先执行`a = 1`,再执行`b = 2`,从程序角度看到的代码行依次执行叫程序序,我们在此基础上构建软件,以此作为讨论的基础。
与程序序相对应叫内存序,是指从某个角度观察到的对于内存的读和写所真正发生的顺序。
内存操作顺序并不唯一,在一个包含core0和core1的CPU中,core0和core1有着各自的内存操作顺序,这两个内存操作顺序不一定相同。

从包含多个Core的CPU的视角看到的全局内存操作顺序(Global Memory Order)跟单core视角看到的内存操作顺序亦不同,而这种不同,对于有些程序逻辑而言,是不可接受的,例如:
程序序要求`a = 1`在`b = 2`之前执行,但内存操作顺序可能并非如此,对a赋值1并不确保发生在对b赋值2之前。
虽然对一个Core而言,如上所述,这个Core观察到的内存操作顺序不一定符合程序序,但内存操作序和程序序必定产生相同的结果,无论在单Core上对a、b的赋值哪个先发生,结果上都是a被赋值为1、b被赋值为2,如果单核上,乱序执行会影响结果,那编译器的指令重排和CPU乱序执行便不会发生,硬件会提供这项保证。

但多核系统,硬件不提供这样的保证,多线程程序中,每个线程所工作的Core观察到的不同内存操作序,以及这些顺序与全局内存序的差异,常常导致多线程同步失败,所以,需要有同步机制确保内存序与程序序的一致,内存屏障(Memory Barrier)的引入,就是为了解决这个问题,它让不同的Core之间,以及Core与全局内存序达成一致。
乱序执行会引起内存顺序跟程序顺序不同,乱序执行的原因是多方面的,比如编译器指令重排、超标量指令流水线、预测执行、Cache-Miss等。内存操作顺序无法精确匹配程序顺序,这有可能带来混乱,既然有副作用,那为什么还需要乱序执行呢?
答案是为了性能。
我们先看看没有乱序执行之前,早期的有序处理器(In-order Processors)是怎么处理指令的?

乱序处理器(Out-of-order Processors)又是怎么处理指令的呢?
指令乱序执行是结果,但原因并非只有CPU的乱序执行,而是由两种因素导致:
为什么需要Store Buffer?
考虑下面的代码:
void set_a()
{
a = 1;
}
所以,我们需要Store Buffer,每个Core都有独立的Store Buffer,每个Core都访问私有的Store Buffer, Store Buffer帮助CPU遮掩了Store操作带来的延迟。

Store Buffer会带来什么问题?
a = 1; b = 2; assert(a == 1);
上面的代码,断言a==1的时候,需要读(load)变量a的值,而如果a在被赋值前就在Cache中,就会从Cache中读到a的旧值(可能是1之外的其他值),所以断言就可能失败。
但这样的结果显然是不能接受的,它违背了最直观的程序顺序性。
问题出在变量a除保存在内存外,还有2份拷贝,一份在Store Buffer里,一份在Cache里,如果不考虑这2份拷贝的关系,就会出现数据不一致。那怎么修复这个问题呢?
可以通过在Core Load数据的时候,先检查Store Buffer中是否有悬而未决的a的新值,如果有,则取新值;否则从cache取a的副本。这种技术在多级流水线CPU设计的时候就经常使用,叫Store Forwarding。有了Store Buffer Forwarding,就能确保单核程序的执行遵从程序顺序性,但多核还是有问题,让我们考查下面的程序:
int a = 0; // 被CPU1 CACHE
int b = 0; // 被CPU0 CACHE
// CPU0执行
void x() {
a = 1;
b = 2;
}
// CPU1执行
void y() {
while (b);
assert(a == 1);
}
假设a和b都被初始化为0;CPU0执行x()函数,CPU1执行y()函数;变量a在CPU1的local Cache里,变量b在CPU0的local Cache里。
怎么办?答案留到内存屏障一节揭晓。
为什么需要Invalidate Queue
当一个变量加载到多个core的Cache,则这个CacheLine处于Shared状态,如果Core1要修改这个变量,则需要通过发送核间消息Invalidate来通知其他Core把对应的Cache Line置为Invalid,当其他Core都Invalid这个CacheLine后,则本Core获得该变量的独占权,这个时候就可以修改它了。
收到Invalidate消息的core需要回Invalidate ACK,一个个core都这样ACK,等所有core都回复完,Core1才能修改它,这样CPU就白白浪费。
事实上,其他核在收到Invalidate消息后,会把Invalidate消息缓存到Invalidate Queue,并立即回复ACK,真正Invalidate动作可以延后再做,这样一方面因为Core可以快速返回别的Core发出的Invalidate请求,不会导致发生Invalidate请求的Core不必要的Stall,另一方面也提供了进一步优化可能,比如在一个CacheLine里的多个变量的Invalidate可以攒一次做了。
但写Store Buffer的方式其实是Write Invalidate,它并非立即写入内存,如果其他核此时从内存读数,则有可能不一致。

那有没有方法确保对b的赋值一定先于对a的赋值呢?有,内存屏障被用来提供这个保障。
内存屏障(Memory Barrier),也称内存栅栏、屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,同步点之前的所有读写操作都执行后,才可以开始执行此点之后的操作。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。
内存屏障,其实就是提供一种机制,确保代码里顺序写下的多行,会按照书写的顺序,被存入内存,主要是解决StoreBuffer引入导致的写入内存间隙的问题。
void x() {
a = 1;
wmb();
b = 2;
}
像上面那样在a=1后、b=2前插入一条内存屏障语句,就能确保a=1先于b=2生效,从而解决了内存乱序访问问题,那插入的这句smp_mb(),到底会干什么呢?
回忆前面的流程,CPU0在执行完`a = 1`之后,执行smp_mb()操作,这时候,它会给Store Buffer里的所有数据项做一个标记(marked),然后继续执行`b = 2`,但这时候虽然b在自己的cache里,但由于store buffer里有marked条目,所以,CPU0不会修改cache中的b,而是把它写入Store Buffer;所以CPU0收到Read消息后,会把b的0值发给CPU1,所以继续在`while (b);`自旋。
简而言之,Core执行到write memory barrier(wmb)的时候,如果Store Buffer还有悬而未决的store操作,则都会被mark上,直到被标注的Store操作进入内存后,后续的Store操作才能被执行,因此wmb保障了barrier前后操作的顺序,它不关心barrier前的多个操作的内存序,以及barrier后的多个操作的内存序,是否与Global Memory Order一致。
a = 1; b = 2; wmb(); c = 3; d = 4;
wmb()保证`a = 1; b = 2;`发生在`c = 3; d = 4;`之前,不保证`a = 1`和`b = 2`的内存序,也不保证`c = 3`和`d = 4`的内部序。
Invalidate Queue的引入的问题

就像引入Store Buffer会影响Store的内存一致性,Invalidate Queue的引入会影响Load的内存一致性:
因为Invalidate queue会缓存其他核发过来的消息,比如Invalidate某个数据的消息被delay处置,导致core在Cache Line中命中这个数据,而这个Cache Line本应该被Invalidate消息标记无效。
如何解决这个问题呢?
所以,我们可以像下面这样修改代码:
//============
a = 1;
wmb();
b = 2;
//=============
while(b != 2) {};
rmb();
assert(a == 1);
gcc编译器在遇到内嵌汇编语句`asm volatile("" ::: "memory");`将以此作为一条内存屏障,重排序内存操作,即此语句之前的各种编译优化将不会持续到此语句之后。
Linux 内核提供函数 barrier()用于让编译器保证其之前的内存访问先于其之后的完成。
```c
#define barrier() __asm__ __volatile__("" ::: "memory")
```
CPU内存屏障:
为了提高处理器的性能,SMP中引入了store buffer(以及对应实现store buffer forwarding)和invalidate queue。
store buffer的引入导致core上的store顺序可能不匹配于global memory的顺序,对此,我们需要使用wmb()来解决。
invalidate queue的存在导致core上观察到的load顺序可能与global memory order不一致,对此,我们需要使用rmb()来解决。
由于wmb()和rmb()分别只单独作用于store buffer和invalidate queue,因此这两个memory barrier共同保证了store/load的顺序。