当前位置: 首页 > 科技观察

关于多线程同步的一切:乱序执行和内存壁垒

时间:2023-03-13 19:36:34 科技观察

程序顺序(ProgramOrder)对于单线程程序来说,代码会一行行执行,就像我们写程序的顺序一样。例如:a=1;b=2;`a=1`将首先执行,然后执行`b=2`。从程序的角度来看,代码行将被顺序执行称为程序。我们在此基础上构建软件,以此作为讨论的基础。内存顺序(MemoryOrder)称为程序顺序对应的内存顺序,是指从某个角度看,内存的读写实际发生的顺序。内存操作顺序不是唯一的。在包含core0和core1的CPU中,core0和core1有自己的内存操作顺序,两个内存操作顺序不一定相同。从包含多个Core的CPU角度看的GlobalMemoryOrder也不同于从单核角度看的内存操作顺序,这种差异对于某些程序逻辑来说是不可接受的,例如:程序需要`a=1`要先于`b=2`执行,但是内存操作的顺序可能不是这样,1给a赋值并不能保证在2给b赋值之前发生。如果编译器认为对b的赋值不依赖于对a的赋值,那么它完全可以为了性能在编译时调整编译后的汇编指令的顺序。赋值执行虽然对于一个Core,如前所述,这个Core观察到的内存操作顺序不一定符合程序顺序,但是内存操作顺序和程序顺序必须产生相同的结果,不管是a还是b在单个核心上执行。哪个先赋值,结果就是a赋值1,b赋值2。如果在单核上,乱序执行会影响结果,然后编译器的指令重排和CPUout-of-order执行将不会发生。硬件将提供这种保证。但是,在多核系统中,硬件不提供这样的保证。在多线程程序中,每个线程工作的核观察到的不同的内存操作顺序,以及这些顺序与全局内存顺序的差异,往往会导致多线程同步失败。因此,需要一种同步机制来保证内存顺序与程序顺序一致。内存屏障(MemoryBarrier)的引入就是为了解决这个问题。它允许不同的Core,以及Core和globalmemory的顺序达成一致。乱序执行(Out-of-orderExecution)乱序执行会导致内存顺序与程序顺序不同。乱序执行的原因有很多,比如编译指令重排、超标量指令流水线、预测执行、Cache-Miss等。内存操作的顺序不能完全匹配程序的顺序,容易造成混乱。既然有副作用,为什么还要乱序执行呢?答案是性能。我们先来看看早期的有序处理器(In-orderProcessors)在乱序执行之前是如何处理指令的?指令获取,从代码段内存区加载指令到I-Cache译码如果指令操作数可用(例如操作数位于寄存器中),则将指令分发给相应的功能模块;如果操作数不可用,通常需要从内存中读取Load,处理器会停顿并等待,直到它们准备好,知道数据被加载到缓存或复制到寄存器指令由函数执行unit功能单元将结果写回寄存器或内存位置乱序处理器如何处理指令?指令获取,从代码段内存区加载指令到I-Cache译码并派发指令到指令队列指令在指令队列中等待,一旦操作数就绪,指令离开指令队列,即使之前的指令还没有被执行(随机序列)的指令被调度到功能单元,执行结果被放入队列(StoreBuffer),而不是直接写入Cache。指令结果按顺序写入高速缓存,以便执行看起来是有序的。指令乱序执行是结果,但原因不仅仅是CPU乱序执行,而是两个因素造成的:编译时间:指令重排(编译器),编译器会重排性能指令和源代码的顺序经过编译器编译后,指令的顺序可能会发生变化,但编译器会根据一组规则重新排列指令。具有明显依赖关系的指令不会随意重排,指令重排不会破坏程序逻辑。Runtime:乱序执行(CPU)、CPU的超标量流水线、预测执行、Cache-Miss等都可能导致指令乱序执行,即后面的指令可能先于前面的指令执行。为什么StoreBuffer需要StoreBuffer?考虑如下代码:voidset_a(){a=1;}假设运行在core0上的set_a()将1赋值给整型变量a,计算机通常不会直接写通到内存中,而是修改内存中对应的值CacheCacheLine如果Core0的Cache中没有a,赋值操作(store)会导致CacheMissCore0停顿,等待Cache就绪(比如从内存中加载变量a到对应的CacheLine),但是Stall会损害CPU性能,相当于这里的CPU处于Pause状态,白白浪费了宝贵的CPU时间。使用StoreBuffer,当变量在Cache中没有到位时,Store操作会先存储到Buffer中。一旦Store操作进入StoreBuffer,core就认为Store完成了。然后Cache就位了,store会自动写入对应的cache。因此,我们需要StoreBuffer,每个Core都有一个独立的StoreBuffer,每个Core访问一个私有的StoreBuffer,StoreBuffer帮助CPU掩盖Store操作带来的延迟。StoreBuffer会导致什么问题?a=1;b=2;断言(a==1);上面的代码,在断言a==1时,需要读取(加载)变量a的值,如果a在赋值前在Cache中,则会读取a的旧值(可能不是1)来自缓存,因此断言可能会失败。但是这样的结果显然是不能接受的,它违反了最直观的程序顺序。问题是变量a除了保存在内存中,还有两份,一份在StoreBuffer,一份在Cache。如果不考虑这两个副本之间的关系,就会出现数据不一致的情况。那么如何解决这个问题呢?可以在CoreLoad数据时检查StoreBuffer中是否有a的pending新值,如果有则取新值;否则,从缓存中获取a的副本。这种技术常用于多级流水线CPU设计中,称为StoreForwarding。有了StoreBufferForwarding,单核程序的执行可以保证按照程序顺序执行,但是多核还是有问题,让我们看下面的程序:inta=0;//通过CPU1CACHEintb=0;//通过CPU0CACHE//CPU0执行voidx(){a=1;b=2;}//CPU1执行voidy(){while(b);assert(a==1);}假设a和b都初始化为0;CPU0执行x()函数,CPU1执行y()函数;变量a在CPU1的本地Cache中,变量b在CPU0的本地Cache中。当CPU0执行`a=1;`时,由于a不在CPU0的本地缓存中,CPU0会将a的新值1写入StoreBuffer,并向其他CPU发送ReadInvalidate消息CPU1执行`while(b);`,因为b不在CPU1的本地缓存中,所以CPU1会向其他CPU发送ReadInvalidate报文获取b的值。CPU0执行`b=2;`。因为b在CPU0的本地缓存中,所以直接更新b在本地缓存中的副本。对CPU1发送的读b请求,将b(2)的新值发送给CPU1;同时将存放b的CacheLine的状态设置为Shared,反映出b同时被CPU0和CPU1缓存。CPU1收到b的新值(2)的新值(2)后,循环结束,继续执行`assert(a==1);`,因为此时a在本地Cache中的值为0,所以断言失败。CPU1收到CPU0发来的ReadInvalidate后,更新a的值为1,但是来不及了,如果上一步程序已经崩溃了怎么办?答案留给内存屏障部分。为什么InvalidateQueue需要InvalidateQueue?当一个变量被加载到多核的Cache中时,CacheLine处于Shared状态。如果Core1要修改这个变量,需要发送核间消息Invalidate通知其他Core将对应的CacheLine设置为Invalid,当其他Core使这个CacheLine无效时,本Core获得对这个变量的独占权,它这个时候可以修改。收到Invalidate消息的core需要返回一个InvalidateACK,每个core都这样进行ACK。等所有的core都回复完了,Core1就可以修改了,这样就浪费了CPU。实际上,其他核收到Invalidate消息后,会将Invalidate消息缓存到InvalidateQueue中,并立即回复ACK。实际的Invalidate动作可以延迟,这样一方面Core可以快速返回其他Core发送的Invalidate请求,它不会在Invalidate请求发生的地方造成不必要的Core停顿。另一方面也提供了进一步优化的可能。比如一个CacheLine中多个变量的Invalidate可以一次性保存。但是写StoreBuffer的方式其实是WriteInvalidate,并不是马上写入内存,如果此时其他核从内存中读取,可能会不一致。内存屏障有没有办法确保对b的赋值必须先于对a的赋值?是的,内存屏障用于提供此保证。内存屏障(MemoryBarrier),又称内存栅栏、屏障指令等,是一种同步屏障指令,是CPU或编译器对内存进行随机访问操作中的一个同步点。同步点之前的所有读写操作都执行完了,这个点之后的操作就可以开始了。从语义上讲,内存屏障之前的所有写操作都必须写入内存;内存屏障之后的所有读操作都可以获得同步屏障之前的写操作的结果。内存屏障其实就是提供一种机制,保证代码中顺序写入的多行会按照写入的先后顺序存储到内存中,主要解决StoreBuffer的引入带来的写入内存间隙问题。voidx(){a=1;wmb();b=2;}如上,在a=1之后,b=2之前插入一个内存屏障语句,保证a=1在b=2之前生效,这样就解决了内存乱序访问的问题,那又怎样插入的语句smp_mb()会起作用吗?回顾之前的流程,CPU0在执行完`a=1`后,执行了smp_mb()操作。这时候它会标记StoreBuffer中的所有数据项,然后继续执行`b=2`,但是此时,虽然b在自己的缓存中,但是因为storebuffer中有标记的条目,CPU0不会修改缓存中的b,而是将其写入StoreBuffer;所以CPU0收到Read报文后,会将b的0值发送给CPU1,于是在`while(b);`中继续自旋。简而言之,当Core执行到writememorybarrier(wmb)时,如果StoreBuffer中有pending的store操作,它们会被标记,直到被标记的Store操作进入内存后才能执行后续的Store操作,所以wmb保证了屏障前后的操作顺序。不关心barrier前多个操作的内存顺序和barrier后多个操作的内存顺序是否与GlobalMemoryOrder一致。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`的内部顺序。引入InvalidateQueue的问题在于,引入StoreBuffer会影响Store的内存一致性,而引入InvalidateQueue会影响Load的内存一致性:因为Invalidatequeue会缓存其他core发送的消息,比如invalidating某个数据的消息是延迟处理导致core在CacheLine中命中这个数据,这个CacheLine应该已经被Invalidate消息标记为无效了。如何解决这个问题呢?一种思路是,硬件保证每次加载数据时清空InvalidateQueue,从而保证加载操作的强序。软件的思路是沿用wmb()的定义,加上rmb()的约束。rmb()标记我们的无效队列。当发生加载操作时,必须先执行前面rmb()中标记的所有无效命令,然后才能发生后续加载。这样我们就保证了load观察到的顺序等同于rmb()前后的全局内存顺序。所以,我们可以这样修改代码://============a=1;wmb();b=2;//=============while(b!=2){};rmb();assert(a==1);系统支持内存屏障。gcc编译器遇到一条内联汇编语句`asmvolatile(""::::"memory");`会作为内存屏障对内存操作重新排序,即这条语句之前的各种编译优化不会在这条语句之后继续陈述。Linux内核提供了函数barrier()来让编译器保证之前的内存访问先于之后的访问完成。```c#definebarrier()__asm____volatile__(""::::"memory")```CPU内存屏障:通用屏障,保证读写操作有序,mb()和smp_mb()写操作屏障,only保证写操作的顺序,wmb()和smp_wmb()读操作barrier,只保证读操作的顺序,总结rmb()和smp_rmb()为了提高处理器的性能,storeSMP中引入了buffer(以及相应的实现storebufferforwarding)和invalidatequeue。storebuffer的引入可能会导致core上store的顺序与全局内存的顺序不匹配。为此,我们需要使用wmb()来解决。invalidatequeue的存在可能会导致在core上观察到的loadorder与globalmemoryorder不一致。为此,我们需要使用rmb()来解决。由于wmb()和rmb()分别只作用于storebuffer和invalidatequeue,这两个内存屏障共同保证了store/load的顺序。