CPU多核架构下的一致性协议MESI及内存屏障介绍

1,199 阅读14分钟

1.简介

随着移动时代的繁荣发展,可使用的数据出现了爆发式的增长,为了满足大规模数据的处理需求,底层的硬件架构以及上层的软件架构都针对性的为大规模并行计算做了相应的适配。其中底层硬件CPU出现越来越多的物理和逻辑core,典型代表就是Numa架构,服务器常见的intel的Xeon Platinum 8380H芯片即是此类架构;上层的处理软件多采用多机多线程并行,典型的代表就是MPP架构,大家熟悉的clickhouse和presto即是此类架构。

随着CPU多核的出现,那么多核之间的数据交换就存在一个一致性的问题,搞清楚CPU的多核之间数据交换协议以及存在的问题能够让我们在多线程编程的时候更好的处理潜在的问题。本文试图从根本上解析CPU最常见的数据交换协议MESI协议的来龙去脉,为大家今后的多线程编程打下一个扎实的硬件基础,在高并发的多线程环境下,可能会出现很多和代码逻辑不一致的现象,那么可能就和这个多核CPU的数据交换有关。

2.CPU三级缓存介绍

2.1 三级缓存的产生背景

CPU的发展很快,几乎每隔18-24个月,CPU的计算能力就能翻一倍,然而内存的发展却相对缓慢,以至于内存的读写速度远远跟不上CPU的计算能力。也就是说,在CPU和内存的资源交换中,CPU常常需要等待内存,而浪费了大量的计算能力,三级缓存的出现正是为了弥补内存的慢和CPU的快而诞生的产物。

CPU将不再直接与内存进行数据的交换而是在二者之间加入一个缓存以解决这种处理速度不协调而导致的CPU计算资源浪费情况。缓存的性能更高,但是缓存的成本也远大于内存的成本。所谓的三级缓存正是诞生在这样的一个背景下。

存储器的层次结构及响应时间如下两图所示:

由上图可知其中L1,L2缓存是CPU多核之间各自独有的,L3缓存是多核共享的,同时可知内存和缓存的访问速度有数量级的差距。

2.2 缓存的意义及潜在的问题

缓存的意义主要应用在如下两个方面,时间局限性,如果某个数据被访问,那么它在不久的将来有可能被在次访问;空间局限性,如果某个缓存行的数据被访问,那么与之相邻的缓存行很快可能被访问到。

缓存的加入是为了解决CPU运算能力和内存读写能力的不匹配问题,简单来说就是为了提升资源利用率。那么在多CPU(一个CPU对应一个或者多个核心)或者多核心下,每个核心都会有一个一级缓存或者二级缓存,也就是说一二级缓存是核心独占的(类似JMM模型,线程的工作内存是线程独占的,主内存是共享的)而三级缓存和主内存是共享的,这样就将导致CPU缓存一致性问题。为了解决这种不一致,产生了非常多的相关协议,如MESI,MSI,MOSI,Synapse,Firefly等协议,其中MESI协议是主流的CPU缓存一致性协议。

3.MESI一致性协议

3.1 协议简介

MESI:Modified(修改)、Exclusive(独占)、Shared(共享)、Invalid(无效)由以上数据的四种状态的首字母而来。

在缓存中数据的存储单元是缓存行(Cache line),主流的CPU缓存行都是64个字节,缓存行的四种状态由两个字节标识。

当一个缓存行的状态调整,另外一个缓存行需要调整的状态如下:

模拟缓存行独占(E)过程:

核心core1发出加载数据指令,通过bus从内存中加载了一个数据A进入到该核心的缓存行,并且发现该数据是没有被别的核心加载的,此时该核心将会将该数据A状态置为独占即E状态。

模拟缓存行共享(S)过程:

上面core1已经独占了数据A,此时core2也发指令要读取数据A,此时发现该数据已经被core1占有,然后通知core1“我也要使用该数据”,所以core1将数据A状态改为S即 E >>> S。

模拟缓存行修改(M)过程:

假如有三个核心:core 1,core 2分别缓存了数据A。因为多个核心都缓存了该数据,即在各个核心中该数据的状态都是Shared。此时core 1需要修改数据A,core 1:内核计算完成,通过指令写入缓存行,数据A的状态将从S >>> M;core 2:core1将通知缓存了该数据的核心“我修改了该数据”,所以core2会将该缓存置为无效。因为数据已经发生了变化,core2缓存的数据A将不再有任何意义。埋个伏笔,以上过程提到的“通知”的过程可能是很耗时的,在此期间core1将会处于等待回应,浪费了core1的计算能力。

3.2 MESI带来的问题

多个core的缓存状态置换是需要消耗时间的,导致内核在此期间将无事可做。甚至一旦某一个内核发生阻塞,将会导致其他内核也处于阻塞,从而带来性能和稳定性的极大消耗。

所以这时指令重排开始发挥它的价值。想想这种等待有时是没有必要的,因为在这个等待时间内内核完全可以去干一些其他事情,即当内核处于等待状态时,不等待当前指令结束接着去处理下一个指令。

4.指令重排

前面介绍了指令重排将会减少处理器的等待时间进而去处理其他的指令,这种一个指令还未结束便去执行其它指令的行为称之为指令重排亦或流水线乱序,就是CPU未按用户的代码预期执行。

4.1 指令重排的实现

Store Buffere

store buffer即存储缓存。位于内核和缓存之间。当处理器需要处理将计算结果写入在缓存中处于shared状态的数据时,需要通知其他内核将该缓存置为 Invalid(无效),引入store buffer后将不再需要处理器去等待其他内核的响应结果,只需要把修改的数据写到store buffer,通知其他内核,然后当前内核即可去执行其它指令。当收到其他内核的响应结果后,再把store buffer中的数据写回缓存,并修改状态为M。(很类似分布式中,数据一致性保障的异步确认)

Invalidate Queue

简单说处理器修改数据时,需要通知其它内核将该缓存中的数据置为Invalid(失效),我们将该数据放到了Store Buffere处理。那收到失效指令的这些内核会立即处理这种失效消息吗?答案是不会的,因为就算是一个内核缓存了该数据并不意味着马上要用,这些内核会将失效通知放到Invalidate Queue,然后快速返回Invalidate Acknowledge消息(意思就是尽量不耽误正在用这个数据的内核正常工作),后续收到失效通知的内核将会从该queue中逐个处理该命令(意思就是我也不着急用,所以我也不着急处理)。

4.2 指令重排带来的可见性问题

指令重排或者说store buffer或者invalidte queue带来的问题就是可见性问题,通过以上分析,我们不难发现这其实是保障了数据的最终一致性。因为在处理器对数据的修改不是立即对其他内核可见的,因为修改了的数据被放在了store buffer中,通知其他内核的数据修改也不是达到其他内核并被立即处理的,其实有点异步处理的意思。

为了提升性能加入了指令重排技术,然而指令重排可能会导致数据在某一时刻多个核的可见不一致。

看如下的示例:

void foo(void) {

a = 1;

b = 1;

}

void bar(void) {

while (b == 0) continue;

assert(a == 1);//此处并不总是正确

}

假设,a,b初始值为0。a在CPU1中且为exclusive状态,b在CPU0中且为exclusive状态,CPU0执行foo(),CPU1执行bar()。情况如下:

CPU0执行a=1

在执行过程中发现a不在CPU0的缓存中,于是发送read invalidate给CPU1,然后将a=1写入store buffer,继续执行。

CPU1执行while(b == 0)

在执行过程中发现b不在CPU1的缓存行中,于是发送read给CPU0。

CPU0执行b=1由于b在CPU0中且为独占,于是这句话直接就执行成功了。

CPU0收到CPU1的read消息

于是将b的值1送回给CPU1,并且将缓存行状态修改为shared。

CPU1收到CPU0的read ack

于是得知b的值为1,从而跳出循环,继续向下执行。

CPU1执行assert(a == 1);

注意,此时CPU1还未收到read invalidate消息。由于a在CPU1中依然是独占,所以CPU1直接从缓存中获取到a的值0。于是assert失败。(注意,a = 1是存在于CPU0的store buffer中,而不是CPU1。)

CPU1收到CPU0的read invalidate

CPU1向CPU0传回a的值0,以及invalid ack。

CPU0收到CPU1的值以及invalid ack

CPU0使用store buffer中的条目,将cache中a的值修改为1。

5.内存屏障

上一章节中说到指令重排导致的可见性问题,CPU就给我们提供了机制,通过软件告知CPU什么指令不能重排,什么指令能重排的机制就是内存屏障。

两个指令,load:将内存中的数据拷贝到内核的缓存中;store:将内核缓存的数据刷新到内存中。

内存屏障又分为四种:

LoadLoad Barriers(读屏障),StoreStore Barriers(写屏障),LoadStore Barriers,StoreLoad Barriers。

不同的CPU架构对内存屏障的实现是不尽相同的,我们这里讨论流行的X86架构,X86中有三种内存屏障:

Store Memory Barrier:写屏障(c语言当中的smp_wmb()函数),等同于前文的StoreStore Barriers,告诉处理器在执行这之后的指令之前,执行所有已经在存储缓存(store buffer)中的修改(M)指令。即:所有store barrier之前的修改(M)指令都是对之后的指令可见。

Load Memory Barrier:读屏障(C语言当中的smp_rmb()函数),等同于前文的LoadLoad Barriers,告诉处理器在执行任何的加载前,执行所有已经在失效队列(Invalidte Queues)中的失效(I)指令。即:所有load barrier之前的store指令对之后(本核心和其他核心)的指令都是可见的。

Full Barrier:万能屏障(C语言当中的smp_mb()函数),即Full barrier作用等同于以上二者之和。即所有store barrier之前的store指令对之后的指令都是可见的,之后(本核心和其他核心)的指令也都是可见的,完全保证了数据的强一致性。

造成上一章节末问题的核心是a=1还没有被所有CPU核可见的时候,b=1已经被所有CPU都可见了。而a=1不可见的原因是store buffer中的数据还没有应用到缓存行中。解决这个问题可以有两种思路:

store buffer中还有数据时暂停执行。

store buffer中还有数据时把后续的store操作也写入store buffer。

这里就要用到内存屏障了,修改上述代码如下:

void foo(void)

{

a = 1;

smp_wmb();//内存写屏障

b = 1;

}

void bar(void)

{

while (b == 0) continue;

assert(a == 1);

}

按照思路1,CPU0执行到line4时,发现store buffer中有a=1,于是暂停执行。直到store buffer中的数据应用到cache中,再继续执行b=1,这样便没问题了。

按照思路2,CPU0执行到line4时,发现store buffer中有a=1,于是将该条目做一个标记(标记store buffer中的所有当前条目)。在执行b=1时,发现store buffer中有一个带标记的条目,于是将b=1也写入store buffer,这样b=1对于CPU1也就不可见了。只有当带标记的条目应用于缓存之后,后续条目才可以应用于缓存。这相当于只有当标记条目都应用于缓存后,后续的store操作才能进行。

其实,在我们日常的开发中,尤其是应用研发。我们根本就用不上内存屏障,这是为什么?

虽然内存屏障用不上,但是在并发编程里面锁的概念却无处不在!信号量、临界区等等。然而这些技术的背后都是内存屏障。道理其实很简单,种种的线程\进程同步的手段,实际上都相当于锁。对于临界资源的访问,我们总是希望先上锁,再访问。所以显然,我们肯定不希望加锁后的操作由于CPU的种种优化跑到了加锁前去执行。那么这种时候自然就需要使用内存屏障。

所以,对于使用了线程\进程同步的手段进行加锁的代码,不用担心内存屏障的问题。只有为了提高并发性采用的很多无锁设计,才需要考虑内存屏障的问题。当然,对于单线程开发和单核CPU也不用担心内存屏障的问题。

在java编程环境当中,通常使用volatile关键字来实现内存屏障,澄清一点,volatile并不是仅仅加入内存屏障这么简单,加入内存屏障只是volatile内核指令级别的内存语义。

除此之外:volatile还可以禁止编译器的指令重排,因为JVM为了优化性能并且不违反happens-before原则的前提下也会进行指令重排。

6.总结

从上面的介绍,我们知道了CPU为了扩展他的算力,出现了CPU算力和内存不匹配的问题,为了解决此问题,出现了类似MESI类似的cache一致性协议,同时为了提升数据同步的性能,CPU的厂商们又提出了指令重排技术,但是指令重排又带来了数据可见性的问题,需要应用程序自己根据实际情况使用内存屏障来解决可见性问题,对于c/c++程序员而言,这些必须要考虑,尤其linux内核代码,大量使用内存屏障提升性能和安全性,大家有兴趣可以查阅linux内核的无锁队列的kfifo源码;在Java语言中,如果完全将加入内存屏障的活交给程序员,相信Java程序员也会非常痛苦。所以JMM的一些规定比如happens-before和一些可见性和有序性的规则,将会减少程序员的压力,因为在一些JVM可预测的场景中,JMM规范会自动实现这种内存屏障。而程序员需要关心的就是在一些高并发场景下我们可以使用Volatile关键字去告诉JVM和CPU我要加入内存屏障,当然这不是Volatile存在的所有意义。