深度剖析底层原理:CPU缓存一致性的奥秘
对于很多深入研究计算机底层架构的朋友来说,CPU 缓存一致性无疑是一个绕不开的关键话题。从单核处理器迈向多核时代,计算能力呈指数级增长,可随之而来的挑战也接踵而至。CPU 为了加速数据存取,引入了多层缓存结构,这本是提升性能的妙招,然而,当多个核心同时读写共享数据时,缓存中的数据副本如何保持同步、确保一致,就成了棘手难题。这涉及到复杂的硬件协议、缓存控制机制以及软件层面的优化策略。
CPU缓存一致性原理是指在多核CPU系统中,多个CPU的缓存副本应该保持一致,以保证数据的正确性和一致性。当一个CPU要修改内存中的数据时,它首先会把这个数据的副本从内存读入到自己的缓存中,然后修改缓存中的副本。如果其他CPU也在操作同一份数据,那么它们的缓存中的数据就是旧的,不是最新的。这样就会出现数据不一致的问题。现在,就让我们开启这场探索之旅,深入了解 CPU 缓存一致性的奥秘。
一、CPU缓存概述
1.1CPU 缓存的 “前世今生”
在计算机的发展历程中,CPU 缓存的诞生是为了解决一个关键问题:CPU 与内存之间日益增大的速度鸿沟。早期,CPU 的运算速度相对较慢,内存的读写速度尚能与之匹配。但随着科技的飞速进步,CPU 犹如装上了超级引擎,运算速度呈指数级增长,而内存的发展速度却像是在悠闲漫步,远远跟不上 CPU 的步伐。这就导致 CPU 在执行指令时,常常要花费大量时间等待内存数据的传输,就像一辆高速跑车在拥堵的慢车道上,有劲使不出,计算机的整体性能也因此大打折扣。
为了打破这个瓶颈,CPU 缓存应运而生。它就像是 CPU 身边的一位 “得力助手”,位于 CPU 和内存之间,凭借着超高的读写速度,成为了数据的 “快速中转站”。当 CPU 需要读取数据时,会首先在缓存中查找,由于缓存的速度极快,通常能在短短几个时钟周期内就将数据交付给 CPU,大大减少了 CPU 的等待时间,让计算机的运行效率得到了质的飞跃。
起初,CPU 缓存只有一级,容量较小但速度超群,犹如 CPU 的 “贴身保镖”,紧紧跟随并快速响应其需求。后来,随着计算机处理任务的愈发复杂,对缓存容量的需求也越来越大,二级缓存、三级缓存等多级缓存架构逐渐崭露头角。这些不同层级的缓存,就像是一个分工明确的团队,各自承担着不同的职责,共同协作,为 CPU 提供高效的数据支持。
在单核处理器时代,CPU 缓存的管理相对简单,数据的一致性比较容易保证。但多核处理器的出现,犹如一场风暴,彻底改变了计算机的格局。多个核心如同多个并肩作战的 “战士”,可以同时处理不同的任务,大幅提升了计算能力。然而,这也带来了一个棘手的问题:缓存一致性。每个核心都有自己的缓存,当多个核心同时操作同一份数据时,它们各自缓存中的数据副本可能会出现不一致的情况,就好比多个士兵对同一作战指令有不同的理解,这必然会导致混乱,使计算机的运算结果出错。因此,确保 CPU 缓存一致性,成为了多核时代计算机系统稳定高效运行的关键挑战。
1.2CPU多核
现代的CPU比内存系统快很多,2006年的cpu可以在一纳秒之内执行10条指令,尤其是多CPU,CPU多核。我们先讲解一些基础概念:
多核CPU和多CPU的区别主要在于性能和成本。多核CPU性能最好,但成本最高;多CPU成本小,便宜,但性能相对较差。一个CPU但是多核可以实现并行,单核就是CPU集成了一个运算核心;双核是两个运算核心,相当于两个CPU同时工作;四核是四个运算核心,相当于四个CPU同时工作;简单的比喻:完成同样的任务,由一条生产线来完成或由两条稍慢的生产线来完成或由四条更慢的生产线来完成,虽然生产线的生产速度慢,但由于同时进行的生产线多,所以任务的最终完成时间可能最短。
假如一个CPU运行多个程序,就意味着要经常进行进程上下文切换,这里说一句进程切换比线程切换成本要高出许多,即使单CPU是多核的,也只是多个处理器核心,其它设备都是公用的,所以多个线程就必然要经常进行进程上下文切换。一个现代CPU除了处理器核心之外还包括寄存器、L1L2L3缓存这些存储设备、浮点运算单元、整数运算单元等一些辅助运算设备以及内部总线等。
一个多核的CPU也就是一个CPU上有多个处理器核心,这样有什么好处呢?比如说现在我们要在一台计算机上跑一个多线程的程序,因为是一个进程里的线程,所以需要一些共享一些存储变量,如果这台计算机都是单核单线程CPU的话,就意味着这个程序的不同线程需要经常在CPU之间的外部总线上通信,同时还要处理不同CPU之间不同缓存导致数据不一致的问题,所以在这种场景下多核单CPU的架构就能发挥很大的优势,通信都在内部总线,共用同一个缓存。
二、CPU缓存核心原理
二、CPU缓存核心原理
2.1CPU缓存
即高速缓冲存储器,是位于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”。
2.2缓存一致性引发的 “混乱局面”
(1)多核心缓存修改冲突
为了更直观地感受缓存不一致带来的问题,让我们来看一个具体的例子。假设有一个多核 CPU,其中两个核心 Core A 和 Core B 同时对一个共享变量 “count” 进行累加操作,初始时 “count” 的值为 0。Core A 从内存中读取了 “count” 的值 0 到自己的缓存中,然后进行加 1 操作,此时 Core A 缓存中的 “count” 变为 1,但由于写回策略,这个新值还没有同步到内存。与此同时,Core B 也从内存读取 “count”,由于内存中的值尚未更新,它读到的依然是 0,接着 Core B 也对其加 1,并将结果 0 + 1 = 1 写回内存。现在,两个核心都完成了一次累加操作,但最终内存中的 “count” 值却为 1,而不是我们预期的 2,这显然是一个错误的结果,根源就在于两个核心的缓存数据没有及时同步,导致了不一致。
(2)写传播与事务串行化难题
在多核处理器环境下,要保证缓存一致性,关键要满足两点:写传播和事务串行化。写传播确保当某个 CPU 核心里的 Cache 数据更新时,这个更新事件必须要传播到其他核心的 Cache。这就好比在一个团队中,任何一个成员获得了新的关键信息,都要及时通知其他成员,让大家的信息保持同步。比如在上述多核累加的例子中,如果 Core A 更新了 “count” 的值后能立即通知 Core B,让 Core B 知晓这个变化,就能避免 Core B 使用旧值进行计算。
而事务串行化则要求某个 CPU 核心里对数据的操作顺序,在其他核心看来必须是一样的。想象一下,在一个多线程的项目中,不同的线程对共享数据有着不同的操作,如果这些操作的执行顺序在各个线程眼中不一样,必然会导致混乱。例如,有三个
三、CPU缓存架构详解
缓存与主存解读缓存一致性(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)。
3.1缓存行
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协议(当然这并不是唯一的缓存一致性协议)。
3.2总线嗅探机制
CPU和内存通过总线(BUS)互通消息;CPU感知其他CPU的行为(比如读、写某个缓存行)就是是通过嗅探(Snoop)线性中其他CPU发出的请求消息完成的,有时CPU也需要针对总线中的某些请求消息进行响应。这被称为”总线嗅探机制“。
在众多解决缓存一致性问题的方案中,总线嗅探是最为常见的一种,它就像是计算机系统中的 “情报员”,时刻监听着总线的一举一动。其工作原理并不复杂,当某个 CPU 核心修改了自己缓存中的数据时,会立即向总线发出通知,这个通知就像是一声 “警报”,广播给所有其他的 CPU 核心。其他核心就像警觉的 “卫士”,一直在监听总线,一旦收到这个通知,便会检查自己的缓存中是否有相同的数据。如果发现有,就会采取相应的行动,通常是将自己缓存中的该数据标记为无效,或者根据具体协议更新数据,以此来保证数据的一致性。
四、CPU缓存一致性
4.1为什么需要缓存一致
目前主流电脑的 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
。
缓存一致性是指在分布式系统中,多个节点之间的缓存数据保持一致的状态。它的重要性体现在以下几个方面:
-
数据准确性:缓存一致性确保了多个节点之间的数据一致,避免了不同节点上的缓存数据出现不一致的情况。这对于需要对实时数据进行读取和更新的应用程序特别重要,以确保用户获取到最新、准确的数据。
-
性能提升:使用缓存可以大幅度提升系统性能,通过将热门数据放入缓存中,可以减少对底层数据库或其他耗时资源的访问次数。然而,如果缓存不一致,可能会导致读取到过期或错误的数据,从而降低系统性能。
-
并发控制:当多个客户端同时对同一个资源进行读写操作时,缓存一致性可以帮助协调并发操作。例如,在某个节点上更新了某个数据后,需要通知其他节点使其缓存失效或进行相应更新。
为了维护缓存一致性,常见的策略包括使用锁机制、发布/订阅模式、版本号比较等。这样可以确保在读取和更新缓存时保持一致,并提供高性能和准确的数据访问。
4.2MESI 协议:缓存一致性的 “救星”
(1)MESI 协议的四种状态
面对总线嗅探的诸多问题,MESI 协议应运而生,它如同一位智慧的 “指挥官”,巧妙地利用四种状态来管理缓存行,为缓存一致性问题带来了高效的解决方案。这四种状态分别是:Modified(已修改)、Exclusive(独享、互斥)、Shared(共享)和 Invalid(无效)。
Modified 状态,就像是被标记了 “机密” 的文件,意味着该缓存行中的数据已经被修改,与内存中的数据不一致,并且是唯一的副本,只存在于当前 CPU 核心的缓存中。此时,这个缓存行必须时刻警惕,监听所有试图读取该缓存行对应主存地址的操作,一旦监听到,就必须在该操作执行前,争分夺秒地把缓存行中的数据写回主内存,以保证其他核心能获取到最新的数据。
Exclusive 状态,如同被一个人独占的宝藏,该缓存行的数据未被修改,与内存中的数据一致,且只存在于当前 CPU 核心的缓存中。不过,它也不能掉以轻心,要监听其他缓存读取主存中对应数据的操作,一旦发现,就得大方地将自己的状态转变为 Shared,允许其他核心共享这份数据。
Shared 状态,仿佛是被公开分享的知识,该缓存行的数据未被修改,存在于多个 CPU 核心的缓存中。每个处于此状态的缓存行都肩负着监听的重任,一旦察觉到有其他核心试图将该缓存行设置为 Modified 或 Exclusive 状态,就必须立刻将自己的状态改为 Invalid,避免数据冲突。
Invalid 状态,则像是被废弃的纸张,表明该缓存行的数据无效,不能用于读写操作。
这四种状态之间的转换并非随意,而是遵循着严格的规则,如同精密的齿轮相互咬合。下面通过一个图表来直观展示它们之间的转换条件:
当前状态 | 触发事件 | 新状态 |
M | 其他核心读取该缓存行对应主存地址 | S(先写回主存) |
M | 当前核心写回主存 | E |
E | 其他核心读取该缓存行对应主存地址 | S |
E | 当前核心修改数据 | M |
S | 当前核心修改数据 | M |
S | 其他核心将该缓存行置为 M 或 E | I |
I | 当前核心读取数据,且其他核心没有该缓存行副本 | E |
I | 当前核心读取数据,且其他核心有该缓存行副本 | S |
通过这样清晰的状态定义和转换规则,MESI 协议为缓存一致性的维护奠定了坚实的基础。
(2)MESI 协议的工作流程
让我们以一个多核 CPU 读取、修改数据的实际场景为例,深入剖析 MESI 协议是如何有条不紊地确保缓存一致性的。假设有一个多核 CPU,包含 Core A、Core B 和 Core C 三个核心,它们的缓存初始状态均为空,主内存中有一个变量 “x”,初始值为 0。
-
首先,Core A 发出指令,从主内存读取变量 “x”。此时,由于其他核心没有 “x” 的缓存副本,根据 MESI 协议,Core A 缓存中的 “x” 对应的缓存行状态被设置为 Exclusive(E),就像 Core A 独自拥有了这份珍贵的数据。
-
接着,Core B 也需要使用变量 “x”,它同样向主内存发送读取指令。当 Core A 检测到 Core B 的读取请求时,按照协议,Core A 将自己缓存中 “x” 的缓存行状态改为 Shared(S),同时 Core B 读取到数据后,其缓存中的 “x” 缓存行状态也被设置为 S,此时 “x” 的数据就像被公开分享的信息,Core A 和 Core B 都可以同时读取。
-
然后,Core A 要对 “x” 进行修改,它先将自己缓存中 “x” 的缓存行状态置为 Modified(M),这意味着这份数据已经被 Core A 独家修改,与主内存不一致了。与此同时,Core A 会向总线发出通知,告知其他核心 “x” 的数据已被修改。Core B 和 Core C 监听到这个通知后,检查自己的缓存,发现有 “x” 的副本,于是立即将 “x” 的缓存行状态改为 Invalid(I),确保不会使用旧数据。
-
之后,Core B 又需要读取 “x” 的值,由于其缓存中的 “x” 已处于无效状态,Core B 会向总线发送读取请求。Core A 收到请求后,知道其他核心需要最新的 “x” 值,便将修改后的数据写回主内存,同时把自己缓存中 “x” 的缓存行状态改回 Exclusive(E),再次独自拥有这份准确的数据。然后,Core A 将最新的 “x” 值同步给 Core B,Core B 收到数据后,将自己缓存中 “x” 的缓存行状态设置为 Shared(S),至此,数据的一致性得到了完美的维护。
与总线嗅探相比,MESI 协议的优势显而易见。总线嗅探每次核心修改数据都要向总线发出广播,无论其他核心是否需要该数据,都得耗费总线资源去通知,就像在一个大教室里,无论同学们是否感兴趣,老师都要大声向所有人重复每一个小通知,容易造成总线拥堵。而 MESI 协议则像是精准推送,只有当数据状态发生关键变化,且可能影响其他核心时,才会进行有针对性的通知,大大减轻了总线的负担,提高了系统的整体性能,让计算机的各个核心能够更加高效地协同工作。
4.3如何解决缓存一致性问题
(1)通过在总线加 LOCK 锁的方式
在锁住总线上加一个 LOCK 标识,CPU A 进行读写操作时,锁住总线,其他 CPU 此时无法进行内存读写操作,只有等解锁了才能进行操作。该方式因为锁住了整个总线,所以效率低。
(2)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 的四个不同的标记,分别是:
-
M(Modified):表示这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本 Cache 中。例如,Core 0 修改了 x 的值之后,这个 Cache line 变成了 M 状态。
-
E(Exclusive):表示这行数据有效,数据和内存中的数据一致,数据只存在于本 Cache 中。只有 Core 0 访问变量 x,它的 Cache line 状态为 E 状态。
-
S(Shared):表示这行数据有效,数据和内存中的数据一致,数据存在于很多 Cache 中。比如 3 个 Core 都访问变量 x,它们对应的 Cache line 为 S 状态。
-
I(Invalid):表示这个 Cache line 无效。当 Core 0 修改了 x 的值之后,其他 Core 对应的 Cache line 会变成 I 状态。
整个 MESI 的状态,可以用一个有限状态机来表示它的状态流转。需要注意的是,对于不同状态触发的事件操作,可能来自于当前 CPU 核心,也可能来自总线里其他 CPU 核心广播出来的信号。我把各个状态之间的流转用表格总结了一下:
当前状态 | 事件 | 行为 | 下一个状态 |
---|---|---|---|
I(Invalid) | Local Read | 如果其它 Cache 没有这份数据,本 Cache 从内存中取数据,Cache line 状态变成 E;如果其它 Cache 有这份数据,且状态为 M,则将数据更新到内存,本 Cache 再从内存中取数据,2 个 Cache 的 Cache line 状态都变成 S;如果其它 Cache 有这份数据,且状态为 S 或者 E,本 Cache 从内存中取数据,这些 Cache 的 Cache line 状态都变成 S | E/S |
I(Invalid) | Local Write | 从内存中取数据,在 Cache 中修改,状态变成 M;如果其它 Cache 有这份数据,且状态为 M,则要先将数据更新到内存;如果其它 Cache 有这份数据,则其它 Cache 的 Cache line 状态变成 I | M |
I(Invalid) | Remote Read | 既然是 Invalid,别的核的操作与它无关 | I |
I(Invalid) | Remote Write | 既然是 Invalid,别的核的操作与它无关 | I |
E(Exclusive) | Local Read | 从 Cache 中取数据,状态不变 | E |
E(Exclusive) | Local Write | 修改 Cache 中的数据,状态变成 M | M |
E(Exclusive) | Remote Read | 数据和其它核共用,状态变成了 S | S |
E(Exclusive) | Remote Write | 数据被修改,本 Cache line 无效,变成 I | I |
S(Shared) | Local Read | 从 Cache 中取数据,状态不变 | S |
S(Shared) | Local Write | 向所有拥有该 Cache line 的其它核心发送失效信号,将它们的 Cache line 置为 I,本 Cache line 状态变成 M | M |
S(Shared) | Remote Read | 其它核心读取,不影响本 Cache line 状态 | S |
S(Shared) | Remote Write | 如果是其它核心写数据,本 Cache line 无效,变成 I | I |
M(Modified) | Local Read | 从 Cache 中取数据,状态不变 | M |
M(Modified) | Local Write | 在 Cache 中修改数据,状态不变 | M |
M(Modified) | Remote Read | 将数据写回内存,其它核心从内存中读取数据,本 Cache line 和其它核心的 Cache line 状态都变成 S | S |
M(Modified) | Remote Write | 本 Cache line 无效,变成 I | I |
注意:对于M和E 的状态而言总是精确的,他们在缓存行的真正状态是一致的,二S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广 播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
(3)MESI状态转换
无效(I)状态转换
-
Local Read:如果其他处理器中没有这份数据,本缓存从内存中取该数据,状态变为 E。如果其他处理器中有这份数据,且缓存行状态为 M,则先把缓存行中的内容写回到内存,本地 cache 再从内存读取数据,这时两个 cache 的状态都变为 S。如果其他缓存行中有这份数据,并且其他缓存行的状态为 S 或 E,则本地 cache 从内存中取数据,并且这些缓存行的状态变为 S。
-
Local Write:从内存中取数据,在 Cache 中修改,状态变成 M;如果其它 Cache 有这份数据,且状态为 M,则要先将数据更新到内存;如果其它 Cache 有这份数据,则其它 Cache 的 Cache line 状态变成 I。
-
Remote Read:不影响本 Cache 状态,仍为 I。
-
Remote Write:不影响本 Cache 状态,仍为 I。
独占(E)状态转换
-
Local Read:从 Cache 中取数据,状态不变,仍为 E。
-
Local Write:修改 Cache 中的数据,状态变成 M。
-
Remote Read:数据和其它核共用,状态变成了 S。
-
Remote Write:数据被修改,本 Cache line 无效,变成 I。
共享(S)状态转换
-
Local Read:从 Cache 中取数据,状态不变,仍为 S。
-
Local Write:向所有拥有该 Cache line 的其它核心发送失效信号,将它们的 Cache line 置为 I,本 Cache line 状态变成 M。
-
Remote Read:不影响本 Cache line 状态,仍为 S。
-
Remote Write:如果是其它核心写数据,本 Cache line 无效,变成 I。
修改(M)状态转换
-
Local Read:从 Cache 中取数据,状态不变,仍为 M。
-
Local Write:在 Cache 中修改数据,状态不变,仍为 M。
-
Remote Read:将数据写回内存,其它核心从内存中读取数据,本 Cache line 和其它核心的 Cache line 状态都变成 S。
-
Remote Write:本 Cache line 无效,变成 I。
(4)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缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。应为这个等待远远比一个指令的执行时间长的多。
5.1Store 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会读到跟程序中写入的顺序不一样的结果。
5.2硬件内存模型
执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(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的效率就提高上来了。