前言:CPU缓存一致性原理是指在多核CPU系统中,多个CPU的缓存副本应该保持一致,以保证数据的正确性和一致性。当一个CPU要修改内存中的数据时,它首先会把这个数据的副本从内存读入到自己的缓存中,然后修改缓存中的副本。如果其他CPU也在操作同一份数据,那么它们的缓存中的数据就是旧的,不是最新的。这样就会出现数据不一致的问题。
一、概述
1.1CPU多核
现代的CPU比内存系统快很多,2006年的cpu可以在一纳秒之内执行10条指令,尤其是多CPU,CPU多核。我们先讲解一些基础概念:
多核CPU和多CPU的区别主要在于性能和成本。多核CPU性能最好,但成本最高;多CPU成本小,便宜,但性能相对较差。一个CPU但是多核可以实现并行,单核就是CPU集成了一个运算核心;双核是两个运算核心,相当于两个CPU同时工作;四核是四个运算核心,相当于四个CPU同时工作;简单的比喻:完成同样的任务,由一条生产线来完成或由两条稍慢的生产线来完成或由四条更慢的生产线来完成,虽然生产线的生产速度慢,但由于同时进行的生产线多,所以任务的最终完成时间可能最短。
假如一个CPU运行多个程序,就意味着要经常进行进程上下文切换,这里说一句进程切换比线程切换成本要高出许多,即使单CPU是多核的,也只是多个处理器核心,其它设备都是公用的,所以多个线程就必然要经常进行进程上下文切换。一个现代CPU除了处理器核心之外还包括寄存器、L1L2L3缓存这些存储设备、浮点运算单元、整数运算单元等一些辅助运算设备以及内部总线等。
一个多核的CPU也就是一个CPU上有多个处理器核心,这样有什么好处呢?比如说现在我们要在一台计算机上跑一个多线程的程序,因为是一个进程里的线程,所以需要一些共享一些存储变量,如果这台计算机都是单核单线程CPU的话,就意味着这个程序的不同线程需要经常在CPU之间的外部总线上通信,同时还要处理不同CPU之间不同缓存导致数据不一致的问题,所以在这种场景下多核单CPU的架构就能发挥很大的优势,通信都在内部总线,共用同一个缓存。
1.2CPU缓存
即高速缓冲存储器,是位于CPU与主内存间的一种容量较小但速度很高的存储器。由于CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,Cache中保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调用,减少CPU的等待时间,提高了系统的效率。
现在我们来看一下每级的缓存的处理速度对比:
从上图可知,这里面产生了至少两个数量级的速度差距。在这样的问题下,cpu cache应运而生。CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。
CPU高速缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存,二级缓存,如今主流CPU还有三级缓存,甚至有些CPU还有四级缓存。每一级缓存中所储存的全部数据都是下一级缓存的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。目前流行的多级缓存结构如下截图:
缓存行:缓存系统中是以缓存行为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64字节。当多线程修改互相独立的变量时,如果这些变量共享一个缓存行,就会无意中影响彼此的性能,这就是伪共享。缓存行上的写竞争是运行在SMP系统中并行线程实现可伸缩性最重要的限制因素。有人将伪共享描述成无声的性能杀手,因为从代码中很难看清楚是否会出现伪共享。
伪共享问题:
图中说明了伪共享的问题。在核心1上运行的线程想更新变量X,同时核心2上的线程想要更新变量Y。不幸的是,这两个变量在同一个缓存行中。每个线程都要去 竞争缓存行的所有权来更新变量。如果核心1获得了所有权,缓存子系统将会使核心2中对应的缓存行失效。当核心2获得了所有权然后执行更新操作,核心1就要 使自己对应的缓存行失效。这会来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。
java中避免伪共享参数:-XX:-RestrictContended
但是现在大部分服务器都是多CPU,数据的读写就会变得异常复杂。我们在进行读写cache的时候,不能简单地读写,因为如果只修改本地cpu的cache,而不处理其他cpu上的同一个数据,那么就会造成一份数据多个不同副本,这就是数据冲突。而解决这个数据冲突的方法就是“缓存一致性协议MESI”。
【文章福利】小编推荐自己的Linux内核技术交流群:【 865977150】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!
1.3CPU架构
缓存与主存解读缓存一致性(Cache Coherency),先看一下CPU的架构:
图示一个4核CPU,有三个级别的缓存,分为是L1 Cache(一级缓存)、L2 Cache(二级缓存)、L3 Cache(三级缓存)
其中一级缓存有两部分组成:L1I Cache(一级指令缓存)和L1D Cache(一级数据缓存)。
越靠近CPU的缓存速度越快,单价也更昂贵。其中一级和二级如今都属于片内缓存(在CPU核内,早期L2缓存是片外的)独立归属给各个CPU,而三级缓存是CPU间共享的。
查询缓存的时候也是由近及远,优先从一级缓存去查找,找到就结束查找,找不到则再去二级缓存查找。二级缓存找不到去三级缓存查找。三级缓存还找不到就去主存(Main Memory)查找。这里说的主存,就是我们平常说的内存。
内存是DRAM(Dynamic RAM),缓存是SRAM(Static RAM)。
缓存行
CPU操作缓存的单位是”缓存行“(cacheline),也就是说如果CPU要读一个变量x,那么其实是读变量x所在的整个缓存行。
缓存行大小
好了,既然我们知道了CPU读写缓存的单位是缓存行,那么缓存行的大小是多少呢?
查看机器缓存行大小的方法有很多,在Linux上你可以查看如下文件确认缓存行大小:
# L1D Cache cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size # L1I Cache cat /sys/devices/system/cpu/cpu0/cache/index1/coherency_line_size # L2 Cache cat /sys/devices/system/cpu/cpu0/cache/index2/coherency_line_size # L3 Cache cat /sys/devices/system/cpu/cpu0/cache/index3/coherency_line_size
或者用getconf命令:
# L1D Cache getconf LEVEL1_DCACHE_LINESIZE # L1I Cache getconf LEVEL1_ICACHE_LINESIZE # L2 Cache getconf LEVEL2_CACHE_LINESIZE # L3 Cache getconf LEVEL3_CACHE_LINESIZE
一般会看到:64。表示的是64字节。
注意,单核CPU上,可能没有L3缓存。比如我在腾讯云买的最低配云主机……
MESI
并发场景下(比如多线程)如果操作相同变量,如何保证每个核中缓存的变量是正确的值,这涉及到一些”缓存一致性“的协议。其中应用最广的就是MESI协议(当然这并不是唯一的缓存一致性协议)。
总线嗅探机制
CPU和内存通过总线(BUS)互通消息。
CPU感知其他CPU的行为(比如读、写某个缓存行)就是是通过嗅探(Snoop)线性中其他CPU发出的请求消息完成的,有时CPU也需要针对总线中的某些请求消息进行响应。这被称为”总线嗅探机制“。
二、为什么需要缓存一致
目前主流电脑的 CPU 都是多核心的,多核心的有点就是在不能提升 CPU 主频后,通过增加核心来提升 CPU 吞吐量。每个核心都有自己的 L1 Cache 和 L2 Cache,只是共用 L3 Cache 和主内存。每个核心操作是独立的,每个核心的 Cache 就不是同步更新的,这样就会带来缓存一致性(Cache Coherence)的问题。
有 2 个 CPU,主内存里有个变量x=0
。CPU A 中有个需要将变量x
加1
。CPU A 就将变量x
加载到自己的缓存中,然后将变量x
加1
。因为此时 CPU A 还未将缓存数据写回主内存,CPU B 再读取变量x
时,变量x
的值依然是0
。
缓存一致性是指在分布式系统中,多个节点之间的缓存数据保持一致的状态。它的重要性体现在以下几个方面:
- 数据准确性:缓存一致性确保了多个节点之间的数据一致,避免了不同节点上的缓存数据出现不一致的情况。这对于需要对实时数据进行读取和更新的应用程序特别重要,以确保用户获取到最新、准确的数据。
- 性能提升:使用缓存可以大幅度提升系统性能,通过将热门数据放入缓存中,可以减少对底层数据库或其他耗时资源的访问次数。然而,如果缓存不一致,可能会导致读取到过期或错误的数据,从而降低系统性能。
- 并发控制:当多个客户端同时对同一个资源进行读写操作时,缓存一致性可以帮助协调并发操作。例如,在某个节点上更新了某个数据后,需要通知其他节点使其缓存失效或进行相应更新。
为了维护缓存一致性,常见的策略包括使用锁机制、发布/订阅模式、版本号比较等。这样可以确保在读取和更新缓存时保持一致,并提供高性能和准确的数据访问。
三、如何解决缓存一致性问题
通过在总线加 LOCK 锁的方式
在锁住总线上加一个 LOCK 标识,CPU A 进行读写操作时,锁住总线,其他 CPU 此时无法进行内存读写操作,只有等解锁了才能进行操作。该方式因为锁住了整个总线,所以效率低。
MESI协议中的状态
CPU
中每个缓存行(caceh line
)使用4种状态进行标记(使用额外的两位(bit
)表示):
M: 被修改(Modified)
该缓存行只被缓存在该CPU
的缓存中,并且是被修改过的(dirty
),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU
读取请主存中相应内存之前)写回(write back
)主存。
当被写回主存之后,该缓存行的状态会变成独享(exclusive
)状态。
E: 独享的(Exclusive)
该缓存行只被缓存在该CPU
的缓存中,它是未被修改过的(clean
),与主存中数据一致。该状态可以在任何时刻当有其它CPU
读取该内存时变成共享状态(shared
)。
同样地,当CPU
修改该缓存行中内容时,该状态可以变成Modified
状态。
S: 共享的(Shared)
该状态意味着该缓存行可能被多个CPU
缓存,并且各个缓存中的数据与主存数据一致(clean
),当有一个CPU
修改该缓存行中,其它CPU
中该缓存行可以被作废(变成无效状态(Invalid
))。
I: 无效的(Invalid)
该缓存是无效的(可能有其它CPU
修改了该缓存行)。
该方式对单个缓存行的数据进行加锁,不会影响到内存其他数据的读写。
在学习 MESI 协议之前,简单了解一下总线嗅探机制(Bus Snooping)。要对自己的缓存加锁,需要通知其他 CPU,多个 CPU 核心之间的数据传播问题。最常见的一种解决方案就是总线嗅探。
这个策略,本质上就是把所有的读写请求都通过总线广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。MESI 就是基于总线嗅探机制的缓存一致性协议。
MESI 协议的由来是对 Cache Line 的四个不同的标记,分别是:
整个 MESI 的状态,可以用一个有限状态机来表示它的状态流转。需要注意的是,对于不同状态触发的事件操作,可能来自于当前 CPU 核心,也可能来自总线里其他 CPU 核心广播出来的信号。我把各个状态之间的流转用表格总结了一下:
注意:对于M和E 的状态而言总是精确的,他们在缓存行的真正状态是一致的,二S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广 播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
MESI状态转换
理解该图的前置说明:
1,触发事件:
2,cache分类:
前提:所有的cache共同缓存了主内存中的某一条数据。
本地cache:指当前cpu的cache。
触发cache:触发读写事件的cache。
其他cache:指既除了以上两种之外的cache。
注意:本地的事件触发 本地cache和触发cache为相同。
上图的切换解释:
下图示意了,当一个cache line的调整的状态的时候,另外一个cache line 需要调整的状态。
MESI协议中的运行机制
假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。
单核读取
那么执行流程是:CPU A发出了一条指令,从主内存中读取x。从主内存通过bus读取到缓存中(远端读取Remote read),这是该Cache line修改为E状态(独享)。
双核读取
那么执行流程是:
CPU A发出了一条指令,从主内存中读取x。
CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态。
CPU B发出了一条指令,从主内存中读取x。
CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。
修改数据
那么执行流程是:
CPU A 计算完成后发指令需要修改x.
CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)
CPU A 对x进行赋值。
同步数据:
那么执行流程是:
CPU B 发出了要读取x的指令。
CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改为E(独享)
CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。
四、MESI优化和他们引入的问题
缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。
CPU切换状态阻塞解决-存储缓存(Store Bufferes)
比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。应为这个等待远远比一个指令的执行时间长的多。
Store Bufferes
为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。
这么做有两个风险。
第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。
第二、保存什么时候会完成,这个并没有任何保证。
举例说明:
value = 3; void exeToCPUA(){ value = 10; isFinsh = true; } void exeToCPUB(){ if(isFinsh){ //value一定等于10?! assert value == 10; } }
试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,value会比 finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10。
即isFinsh的赋值在value赋值之前。
这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。
硬件内存模型
执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:
对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送。
Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。
即便是这样处理器已然不知道什么时候优化是允许的,而什么时候并不允许。干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(Memory Barriers)。
写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。
读屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。
void executedOnCpu0() { value = 10; //在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。 storeMemoryBarrier(); finished = true; } void executedOnCpu1() { while(!finished); //在读取之前将所有失效队列中关于该数据的指令执行完毕。 loadMemoryBarrier(); assert value == 10; }
现在确实安全了。完美无暇。
五、总结
操作系统的CPU和内存并不是直接交互操作的。我们的CPU有一级缓存,CPU直接操作一级缓存,由一级缓存和内存进行交互。
当然,有的CPU有二级缓存,甚至三级缓存等。实际上,大概二十年前,一级缓存是直接和内存交互的,现在,一般是二级缓存和内存直接通讯。每个CPU都有一级缓存,但是,我们却无法保证每个CPU的一级缓存数据都是一样的。所以同一个程序,CPU进行切换的时候,切换前和切换后的数据可能会有不一致的情况。那么这个就是一个很大的问题了。
如何保证各个CPU缓存中的数据是一致的。就是CPU的缓存一致性问题。一种处理一致性问题的办法是使用Bus Locking(总线锁)。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。
但是用锁的方式总是避不开性能问题。总线锁总是会导致CPU的性能下降。所以出现另外一种维护CPU缓存一致性的方式,MESI。
MESI是保持一致性的协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种状态:
- M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了
- E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据
- S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段
- I: Invalid,实效缓存,这个说明CPU中的缓存已经不能使用了
CPU的读取遵循下面几点:
- 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
- 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
- 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M。
这样,每个CPU都遵循上面的方式则CPU的效率就提高上来了。
精品文章推荐阅读: