天天快看点丨关于多线程同步的一切:乱序执行和内存屏障
2022-08-13 15:49:10来源:码砖杂役
对单线程程序而言,代码会一行行顺序执行,就像我们编写的程序的顺序那样。比如:
(资料图)
a = 1;b = 2;
会先执行`a = 1`,再执行`b = 2`,从程序角度看到的代码行依次执行叫程序序,我们在此基础上构建软件,以此作为讨论的基础。
内存序(Memory Order)与程序序相对应叫内存序,是指从某个角度观察到的对于内存的读和写所真正发生的顺序。
内存操作顺序并不唯一,在一个包含core0和core1的CPU中,core0和core1有着各自的内存操作顺序,这两个内存操作顺序不一定相同。
从包含多个Core的CPU的视角看到的全局内存操作顺序(Global Memory Order)跟单core视角看到的内存操作顺序亦不同,而这种不同,对于有些程序逻辑而言,是不可接受的,例如:
程序序要求`a = 1`在`b = 2`之前执行,但内存操作顺序可能并非如此,对a赋值1并不确保发生在对b赋值2之前。
如果编译器认为对b赋值没有依赖对a赋值,那它完全可以在编译期为了性能调整编译后的汇编指令顺序即使编译器不做调整,到了执行期,也有可能对b的赋值先于对a赋值执行虽然对一个Core而言,如上所述,这个Core观察到的内存操作顺序不一定符合程序序,但内存操作序和程序序必定产生相同的结果,无论在单Core上对a、b的赋值哪个先发生,结果上都是a被赋值为1、b被赋值为2,如果单核上,乱序执行会影响结果,那编译器的指令重排和CPU乱序执行便不会发生,硬件会提供这项保证。
但多核系统,硬件不提供这样的保证,多线程程序中,每个线程所工作的Core观察到的不同内存操作序,以及这些顺序与全局内存序的差异,常常导致多线程同步失败,所以,需要有同步机制确保内存序与程序序的一致,内存屏障(Memory Barrier)的引入,就是为了解决这个问题,它让不同的Core之间,以及Core与全局内存序达成一致。
乱序执行(Out-of-order Execution)乱序执行会引起内存顺序跟程序顺序不同,乱序执行的原因是多方面的,比如编译器指令重排、超标量指令流水线、预测执行、Cache-Miss等。内存操作顺序无法精确匹配程序顺序,这有可能带来混乱,既然有副作用,那为什么还需要乱序执行呢?
答案是为了性能。
我们先看看没有乱序执行之前,早期的有序处理器(In-order Processors)是怎么处理指令的?
指令获取,从代码节内存区域加载指令到I-Cache译码如果指令操作数可用(例如操作数位于寄存器中),则分发指令到对应功能模块中;如果操作数不可用,通常是需要从内存加载,则处理器会stall,一直等到它们就绪,知道数据被加载到cache或拷贝进寄存器指令被功能单元执行功能单元将结果写回寄存器或内存位置乱序处理器(Out-of-order Processors)又是怎么处理指令的呢?
指令获取,从代码节内存区域加载指令到I-Cache译码分发指令到指令队列指令在指令队列中等待,一旦操作数就绪,指令就离开指令队列,那怕它之前的指令未被执行(乱序)指令被派往功能单元并被执行执行结果放入队列(Store Buffer),而不是直接写入Cache只有更早请求执行的指令结果写入cache后,指令执行结果才写入cache,通过对指令结果排序写入cache,使得执行看起来是有序的。指令乱序执行是结果,但原因并非只有CPU的乱序执行,而是由两种因素导致:
编译期:指令重排(编译器),编译器会为了性能而对指令重排,源码上先后的两行,被编译器编译后,可能调换指令顺序,但编译器会基于一套规则做指令重排,有明显依赖的指令不会被随意重排,指令重排不能破坏程序逻辑。运行期:乱序执行(CPU),CPU的超标量流水线、以及预测执行、Cache-Miss等都有可能导致指令乱序执行,也就是说,后面的指令有可能先于前面的指令执行。Store Buffer为什么需要Store Buffer?考虑下面的代码:
void set_a(){ a = 1;}假设运行在core0上的set_a()对整型变量a赋值1,计算机通常不会直接写穿通到内存,而是会在Cache中修改对应Cache Line如果Core0的Cache里没有a,赋值操作(store)会造成Cache MissCore0会stall在等待Cache就绪(比如从内存加载变量a到对应的Cache Line),但Stall会损害CPU性能,相当于CPU在这里停顿,白白浪费着宝贵的CPU时间有了Store Buffer,当变量在Cache中没有就位的时候,就先Buffer住这个Store操作,而Store操作一旦进入Store Buffer,core便认为自己Store完成,当随后Cache就位,store会自动写入对应cache。
所以,我们需要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 CACHEint 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里。
CPU0执行`a = 1;`的时候,因为a不在CPU0的local cache,CPU0会把a的新值1写入Store Buffer里,并发送Read Invalidate消息给其他CPUCPU1执行`while (b);`,因为b不在CPU1的local cache里,CPU1会发送Read Invalidate消息去其他CPU获取b的值CPU0执行`b = 2;`,因为b在CPU0的local Cache,所以直接更新local cache中b的副本CPU0收到CPU1发来的读b请求,把b的新值(2)发送给CPU1;同时存放b的Cache Line的状态被设置为Shared,以反应b同时被CPU0和CPU1 cache住的事实CPU1收到b的新值(2)后结束循环,继续执行`assert(a == 1);`,因为此时local Cache中的a值为0,所以断言失败CPU1收到CPU0发来的Read Invalidate后,更新a的值为1,但为时已晚,程序在上一步已经崩了怎么办?答案留到内存屏障一节揭晓。
Invalidate Queue为什么需要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消息标记无效。
如何解决这个问题呢?一种思路是硬件确保每次load数据的时候,需要确保Invalidate Queue被清空,这样可以保证load操作的强顺序。软件的思路,就是仿照wmb()的定义,加入rmb()约束。rmb()给我们的invalidate queue加上标记。当一个load操作发生的时候,之前的rmb()所有标记的invalidate命令必须全部执行完成,然后才可以让随后的load发生。这样,我们就在rmb()前后保证了load观察到的顺序等同于global memory order。所以,我们可以像下面这样修改代码:
//============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内存屏障:
通用barrier,保证读写操作有序, mb()和smp_mb()写操作barrier,仅保证写操作有序,wmb()和smp_wmb()读操作barrier,仅保证读操作有序,rmb()和smp_rmb()小结为了提高处理器的性能,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的顺序。