在阅读很多底层的代码时,经常会碰到一个所谓内存屏障的概念,经常搞得一头雾水。本文将对这个概念进行一个系统的介绍。
内存屏障的引入,本质上是由于CPU重排序指令引起的。重排序问题无时无刻不在发生,主要源自以下几种场景:
下面分别解释一下:
编译器在不改变单线程程序语义的前提下,也就是保证单线程程序执行结果正确的情况下,可以重新安排语句的执行顺序。编译器在优化的时候是不知道当前程序是在哪个线程中执行的,因此它只能保证单线程的正确性。
例如,有如下程序:
if (a)
b = a;
else
b = 42;
在经过编译器优化后可能变成:
b = 42;
if (a)
b = a;
这种优化在单线程下是没有问题的,但是如果有另外一个线程要读取变量b的值时,有可能会有问题。前面的程序只有当变量a的值为0时,才会将b赋值42,后面的程序无论变量a的值是多少,都有一段时间会将b赋值为42。
造成这个问题的原因是,编译器优化的时候只注重“结果”,不注重“过程”。这种优化在单线程程序中没有问题,代码一直都是自己运行,只要结果对就可以了,但是在多线程程序下,代码执行过程中的某些状态可能会对别的线程产生影响,这个是编译器优化无法考虑到的。
现代处理器基本上都是支持多发射的,也就是在一个指令周期内可以同时执行多条指令。但是,处理器的资源就那么多,可能不能同时满足处理这些指令的要求。比如,处理器就只有一个加法器,如果同时有两条指令都需要算加法,那么有一条指令必须等待。如果这时候再下一条指令是读取指令,并且和前两条指令无关,那么这条指令将在前面某条加法指令之前完成。还有一种可能,就是前后指令之间具有相关性,比如对同一个地址先读取再写入,后面的写入操作必须等待前面的读取操作完成后才能执行。但是如果这时候第三条指令是写入一个无关的地址,那它可以在前面的写入操作之前被执行,执行顺序再次被打乱了。
所以,一般情况下指令乱序并不是CPU在执行指令之前刻意去调整顺序。CPU总是顺序的去内存里面取指令,然后将其顺序的放入指令流水线。但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终不是按照放入的顺序执行完成,在外边看起来仿佛是“乱序”一样,这就是所谓的“顺序流入,乱序流出”。
CPU有可能根据情况,将相临的两条读取或写入操作合并成一条。
例如,对于如下的两条读取操作:
X = *A; Y = *(A + 4);
可能被合并成一条读取操作:
{X, Y} = LOAD {*A, *(A + 4) };
同样的,对于如下两条写入操作:
*A = X; *(A + 4) = Y;
有可能会被合并成一条:
STORE {*A, *(A + 4) } = {X, Y};
以上这几种情况,由于编译器或CPU,出于“优化”的目的,按照某种规则将指令重新排序的行为称作“真”重排序。不同的是,编译器重排序是在编译程序时进行的,一旦编译成功后执行次序就定下来了。而后面几种是在CPU运行程序时实时进行的,CPU架构不同可能起到的效果完全不同。
编译器或CPU在执行各种优化的时候,都有一些必须的前提,就是至少在单一CPU上执行不能出现问题。有一些数据访问明显是相互依赖的,就不能打乱它们的执行顺序。比如:
1)在一个给定的CPU上,有依赖的内存访问:
比如如下两条指令:
A = Load B;
C = Load *A
第二条加载指令的地址是由第一条指令加载的,第二条指令要能正确执行,必须要等到第一条指令执行完成后才行,也就是说第二条指令依赖于第一条指令。这种情况下,无论如何处理器是不会打乱这两条指令的执行次序的。不过,有可能会在这两条指令间插入别的指令,但必须保证第二条指令在第一条指令执行完后才能执行。
2)在一个给定的CPU上,交叉的加载和存储操作,它们访问的内存地址有重叠:
例如,先存储后加载同一个内存地址上的内容:
Store *X = A;
B = Load *X;
或者先加载后读取同一个内存地址上的内容:
A = Load *X;
Store *X = B;
对同一个内存地址的存取,如果两条指令执行次序被打乱了,那肯定会发生错误。但是,如果是两条加载或两条存储指令(中间没有加载),哪怕是对同一个内存地址的操作,也可能由于优化产生变化。
有了上面两条限制,很容易想到,那如果所有加载或存储指令都没有相关性呢?这时候就要看CPU的心情了,可以以任何次序被执行,可以完全不按照它们在程序中出现的次序。
上面的几种情况都比较好理解,最诡异的是所谓的缓存同步顺序的问题。要把这个问题说清楚首先要说一下缓存是什么。
现代CPU的运算速度比现代内存系统的速度快得多,它们的速度差了几个数量级,那怎么办呢?硬件设计者想到了在内存和CPU之间加入一个速度足够快,但空间不是很大的存储空间,这个就是所谓的缓存。缓存的速度足够快,但是它一般是某个或某些CPU核独享的,而不像计算机的主存,一般认为是系统中所有CPU共享的。
一旦引入了缓存,就会引入多个地方存放同一个数据的问题,就有可能出现数据不一致的问题。假设变量X所在内存同时被两个CPU都缓存了,但是这时候CPU0对变量X的值做出了修改,这之后CPU1如果试图读取变量X的值时,其实读到的是老的值。
这个时候就需要所谓的缓存一致性协议了,一般常用的是MESI协议。MESI代表“Modified”、“Exclusive”、“Shared”和“Invalid”四种状态的缩写,特定缓存行可以处在该协议采用的这四种状态上:
为了维护这个状态机,需要各个CPU之间进行通信,会引入下面几种类型的消息:
通过上面的介绍可以看到,MESI缓存一致性协议可以保证系统中的各个CPU核上的缓存都是一致的。但是也带来了一个很大的问题,由于所有的操作都是“同步”的,必须要等待远端CPU完成指定操作后收到响应消息才能真正执行对应的存储或加载操作,这样会极大降低系统的性能。比如说,如果CPU0和CPU1上同时缓存了同一段数据,如果CPU0想对其进行更改,那么必须先发送使无效消息给CPU1,等到CPU1真的将该缓存的数据段标记成“Invalid”状态后,会向CPU0发送使无效应答消息,理论上只有CPU0收到这个消息后,才可以真的更改数据。但是,从要更改到真的能更改已经经过了好几个阶段了,这时CPU0只能等在那里。
鱼和熊掌都兼得是不可能的,想提高性能,只能稍微放松一下对缓存一致性的要求。具体的,会引入如下两个模块:
加入了这两个模块之后,CPU的性能是提高了,但缓存一致性就遭到了一定程度的破坏。假设变量X所在内存同时被两个CPU都缓存了,但是这时候CPU0对变量X的值做出了修改,这之后CPU1如果试图读取变量X的值时,有可能读到的是老的值,当然也有可能读到的是新的值。但是,在经过一段不确定的时间后,CPU1一定是可以读到变量X新的值,可以理解为满足所谓的最终一致性。
但这只是对单个变量来说的,如果程序中有多个变量,那么在其它CPU看来,它们之间的读写次序将完全无法得到保证。
假设有CPU0上要执行对三个变量的写操作:
Store A = 1;
Store B = 2;
Store C = 3;
但是,这三个变量在缓存中的状态不一样,假设A变量和B变量在CPU0和CPU1中的缓存都存在,也就是处于“Shared”状态,而C变量是CPU0独占的,也就是处于“Exclusive”状态。假设系统经历了如下几个步骤:
通过以上的步骤可以看到,虽然在CPU0上是先对变量A赋值,接着对B赋值,最后对C赋值,但是在CPU1上“看到”的顺序刚好是相反的,先“看到”C,接着“看到”B,最后看到“C”。在CPU1上会产生一种错觉,方式CPU0是先对C赋值,再对B赋值,最后对A赋值一样。这种由于缓存同步顺序的问题,让程序看起来好像指令被重排序了的情况,称作“伪”重排序。
所以,总结一下,以上几种场景都有可能产生所谓指令重排序的效果。由于编译器优化引起的是静态的,是由编译器决定的,一旦程序编译完成就定下来了。而其它剩下的场景都是动态的,在处理器执行的时候,根据当前系统状态动态的调整。并且不同的架构的处理器提供不同级别的数据一致性保证,这也称作所谓的内存模型。有的平台提供很强的保证(比如X86),有的比较弱(比如Arm)。
由于缓存同步顺序引入的乱序问题称作“伪”重排序,就是说即使某个CPU按照代码的次序执行了程序更新了数据,但是在其它CPU看来,看到数据的次序和代码执行的次序也不一样。而其它剩下的情况都是所谓的“真”重排序,也就是说代码执行的顺序确实是和程序中的顺序不一样。不管是“真”重排序还是“伪”重排序,对于系统中其它的CPU来说,叠加后产生的影响是一样的,都是看到数据的次序和代码执行的次序不一样。这其实可以分解成三个问题:
而对于第二个问题,其实又可以分解成两个问题:
因此,对于修改数据的CPU0来说,由于有问题1和2a的存在,可以总结为其提交给内存和缓存系统的写数据并不是按照其代码的顺序执行的;而对于感知数据更改的CPU1来说,由于有问题2b和3来说,也可以总结为其不是按照代码的顺序感知的。
还是假设有三个变量A、B和C,它们的初始值都是0。如果在CPU0上分别执行三个赋值语句,将A赋值成1,B赋值成2,C赋值成3,而在CPU1上则执行三个加载语句,分辨按顺序读取变量A、B和C,它们的执行过程可能如下图所示:
图像 小部件
图中左边的序列表示CPU0提交到内存系统的顺序,而图中右边的序列表示CPU1感知的顺序。由于问题1和2a的存在,导致CPU0提交到内存系统的顺序是打乱的。由于问题2b的存在,导致CPU1能够感知到的赋值顺序又一次被打乱。由于问题3的存在,导致CPU1执行加载语句的顺序也不是固定的。这么多问题全部叠加起来,使得CPU1获得的三个变量的值是不确定的,即使CPU1已经获得变量A的最新值1了,也不能保证获得变量B和C的值也是最新的。
但是,程序本身有时候就是需要保证某种程度上的执行次序。例如下面的程序:
thread0(void)
{
A = 1;
B = 2;
}
thread1(void)
{
while (B != 2)
continue;
assert(A != 0);
}
直觉上来说B应该在A之后再赋值,因此第二个线程如果读到B是2的话,那么A应该已经被赋值成1了。不幸的是,正如前面分析的那样,这个假设是完全错误的。这时候,如果要保证这个假设的正确性,就需要后面说的内存屏障。
内存一致性模型(Memory Consistency Model)是用来描述多线程对共享存储器的访问行为,在不同的内存一致性模型里,多线程对共享存储器的访问行为有非常大的差别。这些差别会严重影响程序的执行逻辑,甚至会造成软件逻辑问题。
不同的处理器架构,使用了不同的内存一致性模型,目前有多种内存一致性模型,从上到下模型的限制由强到弱:
注意,这里说的内存模型是针对可以同时执行多线程的平台,如果只能同时执行一个线程,也就是系统中一共只有一个CPU核,那么它一定是满足顺序一致性模型的。
对于内存的访问,我们只关心两种类型的指令的顺序,一种是读取,一种是写入。对于读取和加载指令来说,它们两两一起,一共有四种组合:
顺序存储模型是最简单的存储模型,也称为强定序模型。CPU会按照代码来执行所有的读取与写入指令,即按照它们在程序中出现的次序来执行。同时,从主存储器和系统中其它CPU的角度来看,感知到数据变化的顺序也完全是按照指令执行的次序。也可以理解为,在程序看来,CPU不会对指令进行任何重排序的操作。在这种模型下执行的程序是完全不需要内存屏障的。但是,带来的问题就是性能会比较差,现在已经没有符合这种内存一致性模型的系统了。
为了提高系统的性能,不同架构都会或多或少的对这种强一致性模型进行了放松,允许对某些指令组合进行重排序。注意,这里处理器对读取或写入操作的放松,是以两个操作之间不存在数据依赖性为前提的,处理器不会对存在数据依赖性的两个内存操作做重排序。
这种内存一致性模型允许对StoreLoad指令组合进行重排序,如果第一条指令是写入,第二条指令是读取,那么有可能在程序看来,读取指令先于写入指令执行。但是,对于其它另外三种指令组合还是可以保证按照顺序执行。
这种模型就相当于前面提到的,在CPU和缓存中间加入了存储缓冲,而且这个缓冲还是一个满足先入先出(FIFO)的队列。先入先出队列就保证了对StoreStore这种指令组合也能保证按照顺序被感知。
我们非常熟悉的X86架构就是使用的这种内存一致性模型。
这种内存一致性模型除了允许对StoreLoad指令组合进行重排序外,还允许对StoreStore指令组合进行重排序。但是,对于其它另外两种指令组合还是可以保证按照顺序执行。
这种模型就相当于也在CPU和缓存中间加入了存储缓冲,但是这个缓冲不是先入先出的。
这种内存一致性模型允许对上面说的四种指令组合都进行重排序。
这种模型就相当于前面说的,既有存储缓冲,又有无效队列的情况。
这种内存模型下其实还有一个细微的差别,就是所谓的数据依赖性的问题。例如下面的程序,假设变量A初始值是0:
CPU 0 | CPU 1 |
A = 1; | Q = P; |
B = *Q; | |
P = &A; |
CPU0上两条指令之间有一个写内存屏障,可以保证对变量A的写入一定在对变量P的写入之前被执行并提交给内存。在CPU1上,由于两条指令之间有数据关联,因此保证不会被重排序。那么,自然可以很容易的得到,如果Q等于&A,那么应该一定有B等于1。不幸的是,在Alpha平台下,这个假设是错误的,有可能在Q等于&A的情况下,B还是等于0。也就是虽然两条指令有明显的依赖关系,仍然不能保证后面一条指令读取到的是最新的值。
有的平台可以保证如果两条指令有数据依赖性,那么后一条指令一定可以感知到最新的数据。例如,Arm、PowerPC和安腾都满足这种模型。最特殊的是Alpha平台,它不满足数据依赖性条件,是最弱的一种内存一致性模型。
三、内存屏障的种类
编译屏障只是告诉编译器,不要对当前代码进行过度的优化,保证生成的汇编代码的次序与当前高级语言的次序保持一致。编译屏障对CPU执行时产生的重排序没有任何作用。
一个写内存屏障可以提供这样的保证,站在系统中的其它组件的角度来看,在屏障之前的写操作看起来将在屏障后的写操作之前发生。
如果映射到上面的例子来说,首先,写内存屏障会对处理器指令重排序做出一些限制,也就是在写内存屏障之前的写入指令一定不会被重排序到写内存屏障之后的写入指令之后。其次,在执行写内存屏障之后的写入指令之前,一定要保证清空当前CPU存储缓冲中的所有写操作,将它们全部“提交”到缓存中。这样的话系统中的其它组件(包括别的CPU),就可以保证在看到写内存屏障之后的写入数据之前先看到写内存屏障之前的写入数据。
写内存屏障仅仅限制了CPU对写操作的排序,对加载操作没有任何效果,对其它的指令也没有作用。而且,写内存屏障只是保证在写内存屏障之后的写入操作一定是在写内存屏障之前的写入操作之后被系统其它组件感知,它并不能保证在写内存屏障之前的所有写入操作的顺序,也不能保证在写内存屏障之后的所有写入操作的顺序。
写内存屏障只管自己CPU上的写入操作能够按照一定次序被系统中其它部件感知,但是如果其它部件有缓存将旧数据缓存下来了,这它管不着。这个是下面介绍的读内存屏障要管的事,因此一般写内存屏障要和读内存屏障配对使用。
一个读内存屏障可以提供这样的保证,站在系统中其它组件的角度来看,所有在读内存屏障之前的加载操作将在读内存屏障之后的加载操作之前发生。
还是用上面的例子来说明,首先,读内存屏障也会对处理器指令重排做出一些限制,也就是在读内存屏障之前的读取指令一定不会被重排序到读内存屏障之后的读取指令之后。其次,在执行读内存屏障之后的读取指令之前,一定要保证处理完当前CPU的无效队列。这样的话,当前CPU的缓存状态将完全遵照MESI协议,可以保证缓存数据一致性。
读内存屏障仅仅限制了CPU对加载操作的排序,对存储操作没有任何效果,对其它指令也没有任何作用。而且,读内存屏障只是保证在读内存屏障之后的读取操作一定是在读内存屏障之前的读取操作之后才去感知内存数据变化的,它并不能保证读内存屏障之前的所有读取操作顺序,也不能保证读内存屏障之后的所有读取操作的顺序。
读内存屏障只管自己CPU上的读取操作能够按照一定次序去感知系统内存中的值,但是对于其它CPU写入系统内存的次序没有任何约束。这个是上面介绍的写内存屏障要管的事,因此一般读内存屏障要和写内存屏障配对使用。
一个通用内存屏障可以提供这样的保证,站在系统中其它组件的角度来看,通用内存屏障之前的加载、存储操作都将在通用内存屏障之后的加载、存储操作之前发生。
还是用上面的例子来说明,首先,通用内存屏障也会对处理器指令重排做出一些限制,也就是在通用内存屏障之前的写入和读取指令一定不会被重排序到通用内存屏障之后的写入和读取指令之后。其次,在执行通用内存屏障之后的任何写入和读取取指令之前,一定要保证清空当前CPU存储缓冲中的所有写操作,并且还要处理完当前CPU的无效队列。
通用内存屏障等同于同时包含了读和写内存屏障的功能,因此也可以替换它们中的任何一个,只不过可能会一定程度上影响性能。
通用内存屏障同时限制了CPU对加载操作和存储操作的排序,但是对其它指令没有任何作用。而且,通用内存屏障只是保证在通用内存屏障之后的所有写入和读取操作一定是在通用内存屏障之前的写入和读取操作之后才执行,它并不能保证通用内存屏障之前的所有读取和写入操作的顺序,也不能保证通用内存屏障之后的所有读取和写入操作的顺序。
一般写内存屏障、读内存屏障和通用内存屏障都会默认包含编译屏障。
其实还有一种叫做数据依赖屏障的东西,但这个只是在Alpha架构下才有用,这里不做讨论了。
前面提到过了,读、写内存屏障应该配对使用,或者换做通用内存屏障也需要成对的使用,否则起不到想要的效果。
首先,来看最常用的组合,一个CPU上执行两个写入操作,中间用写内存屏障分割,另一个CPU上执行两个读取操作,中间用读内存屏障分割:
CPU 0 | CPU 1 |
A = 1; | Y = B; |
B = 2; | X = A; |
注意,在这种场景下写入变量的顺序和读取变量的顺序刚好要是相反的。加了这一对读、写内存屏障后,可以保证,在两个CPU都执行完上面的代码后,如果Y的值等于2,那么X的值一定等于1。Y的值等于2,意味着在CPU0上对B赋值2的语句已经执行过了,由于有写内存屏障的存在,也就意味着对A赋值1的语句在之前肯定也被执行过了。在CPU1上,由于有读内存屏障的存在,表示读取变量A值的语句一定在读取变量B值的语句之后被执行,也就可以保证,这时候变量A的值一定已经被赋值成了1。
第二种场景,两个CPU上都执行一个读取操作,接着一个写入操作,中间用通用内存屏障分割:
CPU 0 | CPU 1 |
X = A; | Y = B; |
B = 2; | A = 1; |
假设变量A和B的初始值都是0,当两个CPU都执行完上面的代码后,如果有Y等于2,那么X一定等于0。如果Y等于2,也就意味着在CPU1上对A赋值1的语句,一定在CPU0上读取变量A值的语句之后被执行。同时,由于对称性,如果有X等于1,那么X一定等于0。
最后一种场景,一个 CPU 执行一个读取操作,后面跟一个通用内存屏障,再后面是一个写入操作;而另外一个CPU执行一对由
写内存屏障分开的写入操作:
CPU 0 | CPU 1 |
X = A; | B = 2; |
B = 1; | A = 1; |
这种情况下,如果X的值等于1,那么必然有B的值等于1。如果X的值等于1,就意味着在这之前CPU1上已经执行过了对A赋值1的语句,由于写内存屏障的存在,也就能够保证在CPU1上已经执行过了对B赋值2的语句,而在CPU0上由于有通用内存屏障的存在,那么对B赋值1的语句一定会在对X赋值的语句之后执行。也就是说,可以保证在CPU0上对B赋值1的语句,一定会在CPU1上对B赋值2的语句之后被执行。
还要说明一下,无论如何,即使某一个体系的内存模型再弱,有一些基本规则还是必须要遵守的(当然,对于编译器优化来说也要遵循这些规则。):
还有,如果程序一直可以保证只在单个CPU核上执行,也就不存在所谓的缓存一致性问题。因此,仅仅在两个CPU之间或者CPU与设备之间存在需要交互的可能性时,才需要内存屏障。任何代码只要能够保证没有这样的交互就不需要使用内存屏障。也就是说,如果当前系统中只有一个CPU核,并且程序没有和设备打交道,即使是多线程的,也不需要使用内存屏障。
第一个规则从直观上说感觉和前面讲的重排序有点矛盾,不是说可以按照任何次序执行嘛,怎么又可以保证按照编程顺序来感知了。我们还是用前面的例子来说,代码如下:
thread0(void)
{
A = 1;
B = 2;
}
thread1(void)
{
while (B != 2)
continue;
assert(A != 0);
}
这段程序在没有编译器优化重排序的情况下,在单CPU系统上其实是可以正确运行的。CPU在执行的时候是不知道所谓的线程的,线程是操作系统的概念,CPU执行的时候只能感知到的是一个指令序列。对于上面的例子,这个指令序列应该是这样的:
Store A = 1;
Store B = 2;
......
Back:
Load B;
Test B != 2;
Jump Back If True;
Load A;
Test A != 0;
中间省略的是一些可能的线程切换的代码。然后,CPU会对这组指令序列进行重排序优化。但是,前面说了重排序优化是有前提条件的,因此无论如何不会将前面的将变量A赋值为1的语句重排序到后面读取变量A的语句之后,当然也不会将变量B赋值为2的语句重排序到后面读取变量B的语句之后,从而保证了这个两线程程序的正确运行。所以,前面的第一个规则其实和所谓的指令重排序是不矛盾的。
因此,在单CPU核的系统下,硬件保证程序至少看上去是按照指令的顺序被执行的,唯一可能更改指令顺序的就是编译器了,这时候所有内存屏障都将退化成编译器屏障。
但是,不使用内存屏障不代表不使用相应的同步机制。如果某个操作不是原子的,那么多线程访问它即使在单CPU的系统上也会出现问题,只不过这个问题不是因为重排序引起的。