浅谈volatile与计算机缓存一致性协议之间的联系

Posted 默辨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈volatile与计算机缓存一致性协议之间的联系相关的知识,希望对你有一定的参考价值。




写在前面:本文涉及内容计算机组成原理知识相关,并没有涉及太多Java相关知识。文末有通过volatile关键字测试案例反推计算机组成原理相关概念。


在计算机体系结构中,缓存一致性就是共享资源数据的一致性,共享数据存储在多个本地缓存中。当系统在处理公共内存资源的缓存时,就会出现数据不一致的问题,在多处理系统中更是如此。


在共享内存多处理器系统体系结构中,每个处理器都有一个单独的缓存内存,共享数据可能有多个副本,一个副本在主内存中,一个副本在请求它的每个处理器的本地缓存中。当数据的一个副本发生更改时,其他副本必须反映该更改。缓存一致性是确保共享操作数(数据)值的变化能够及时地在整个系统中传播的规程规则




一、缓存一致性的要求

写传播(Write Propagation)

对缓存中的任何数据更改,都会传播到对应缓存中的其他副本。

缓存以缓存行为最小单位,影响该缓存行的副本数据时,只会影响到其对应的最小单位行。即未来缓存失效的部分,也只会是被修改的缓存数据所在的缓存行数据。



事务串行化(Transaction Serialization)

对单个内存位置的读/写必须被所有处理器以相同的顺序看到。理论上,一致性可以在加载/存储粒度上执行。然而,在实践中,它通常在缓存块的粒度上执行。



一致性机制(Coherence mechanisms)

确保一致性的两种最常见的机制是窥探机制(snooping )和基于目录的机制(directory-based),这两种机制各有优缺点。如果有足够的带宽,基于协议的窥探往往会更快,因为所有事务都是所有处理器看到的请求/响应。其缺点是窥探是不可扩展的。每个请求都必须广播到系统中的所有节点,这意味着随着系统变大,(逻辑或物理)总线的大小及其提供的带宽也必须增加。

基于目录机制的方式往往有更长的延迟,但使用更少的带宽,因为消息是点对点的,而不是广播的。由于这个原因,许多较大的系统(>64处理器)使用这种类型的缓存一致性。






二、总线相关概念

1、总线裁决概念

在计算机中,数据通过总线在处理器和内存之间传递。处理器和内存每次进行数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。

总线事务包括读事务(Read Transaction)和写事务(Write Transaction)。读事务从内存传送数据到处理器;写事务从处理器传送数据到内存。



假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁(Bus Arbitration)会对竞争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其他两个处理器则要等待处理器A的总线事务完成后才能再次执行内存访问。假设在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的请求会被总线禁止。

总线的这种工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性(这里可以类比理解数据库隔离级别中的串行化)



2、总线锁定和缓存锁定

原子操作是指不可被中断的一个或者一组操作。处理器会自动保证基本的内存操作的原子性,也就是一个处理器从内存中读取或者写入一个字节时,其他处理器是不能访问这个字节的内存地址。最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。


总线锁定

总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

缓存锁定

由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大。



缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不会在总线上声明LOCK#信号(总线锁定信号),而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。


缓存锁定不能使用的特殊情况:

  • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定。
  • 有些处理器不支持缓存锁定。

类比理解mysql中的行锁和表锁。select * from table where column= ‘xx’,如果column是索引字段,那么就是行锁,如果是普通字段,那就是表锁




3、总线窥探概念(Bus Snooping)

总线窥探(Bus Snooping)是缓存中的一致性控制器(snoopy cache)监视或窥探总线事务的一种方案,其目的是在分布式共享内存系统中维护缓存一致性。包含一致性控制器(snooper)的缓存称为snoopy缓存。该方案由Ravishankar和Goodman于1983年提出。


工作原理

当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性,数据变更的通知可以通过总线窥探来完成。

所有的窥探者都是监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否含有共享块的相同副本。如果缓存中有共享块的副本,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效。它还涉及到缓存块状态的改变,这取决于具体的缓存一致性协议(cache coherence protocol)。



窥探协议类型

根据管理写操作的本地副本的方式,有两种窥探协议:

Write-invalidate

当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议,MSI、MESI、MOSI、MOESI和MESIF协议都属于该类型。

Write-update

当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。






三、一致性协议(Coherence protocol)

一致性协议在多处理器系统中应用于高速缓存一致性。为了保持一致性,人们设计了各种模型和协议,如MSI、MES、MOSI、MOESI、MERSI、MESIF、write-once、Synapse、Berkeley、Firefly和Dragon协议。


1、MESI协议

MESI协议是一个基于写失效的缓存一致性协议,是支持回写(write back)缓存的最常用协议。也称作伊利诺伊协议(Illinois protocol,因为是在伊利诺伊大学厄巴纳-香槟分校被发明的)。


状态

缓存行有4种不同的状态:

  1. 已修改Modified(M):缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S);
  2. 独占Exclusive(E):缓存行只在当前缓存中,但是干净的–缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态;
  3. 共享Shared(S):缓存行也存在于其它缓存中且是未修改的。缓存行可以在任意时刻抛弃;
  4. 无效Invalid(I):缓存行是无效的。



示例说明

演示Java中,变量被volatile关键字修饰后,数据加载过程中,一致性协议的对应体现:

  1. 线程1将主存中的num = 1加载到线程1私有的缓存空间中。由于num被volatile关键字修饰,所以会执行对应的一致性协议(添加对应的LOCK前缀指令)。此时线程1的缓存状态为独占独占(E);
  2. 如果此时线程2也加载了num变量,那么线程1和线程2的各自的缓存空间对应的缓存状态就会变成共享(S);
  3. 此时线程1将缓存中的数据加载到CPU,在ALU(算数逻辑单元)的配合下完成对应的逻辑运算;
  4. 线程1的CPU完成逻辑运算后,将数据返回给缓存。此时总线发现了数据发生变化,就会修改对应缓存空间的缓存状态,将当前线程的缓存状态修改为已修改(M),其他线程中对应的缓存块状态修改为无效(I)。于此同时,还需要将线程1中的缓存变量num,立即刷新回主存;
  5. 那么当线程2想加载自己缓存空间中对应的变量时,就无法命中(原因就是第四步),所以又重新去主存中加载,此时线程2加载到的num就变成了最新的数据;
  6. 最后线程2在将数据加载到CPU完成自己的逻辑运算,最终返回给主存。




相关的理论补充:

  1. 两个线程的加载顺序一定是顺序进行的,因为有总线锁定
  2. 第一步背后的理论基础就是缓存锁定
  3. 第四步背后的理论基础是总线窥探
  4. 如果第四步线程2的逻辑运算位于线程1执行完逻辑运算之后,回写到对应缓存之前,那么线程2在回写数据到缓存时。如果线程1已经完成缓存数据回写,那么线程2的缓存状态就失效;相反,如果如果线程2先于线程1将数据回写到缓存,那么线程1的缓存状态就变成失效



当然,真实代码测试,依然会有数据安全问题,这是由于volatile关键字对于num变量的运算这类复合操作是不具备原子性的

测试结果:




2、伪共享的问题

如果多个核的线程在操作同一个缓存行中的不同变量数据,那么就会出现频繁的缓存失效,即使在代码层面看这两个线程操作的数据之间完全没有关系。这种不合理的资源竞争情况就是伪共享(False Sharing)。

验证伪共享测试代码:

线程1不断的修改x变量

线程2不断的修改y变量

测试结果:

结论:两个int变量是不足以占用到64Byte(即一个缓存块的大小是64Byte)的,所以理论上x和y变量最终会加载到同一块缓存块(前文有说明缓存是以块为单位,即缓存锁定)。基于上面的缓存一致性协议的分析,在程序其中的过程中,是经常会出现缓存失效,即线程的缓存状态被修改为无效(I),这就导致线程会重新去主存中加载数据。这是十分影响效率的。




3、解决方法

1、添加变量

既然缓存是以块为单位,那么我们就让两块分离开,这样就不会出现两个缓存块相互影响,继而导致缓存失效(因为线程1和线程2操作的变量互不影响,所以才能这么做)

填充的无意义字段长度 + x字段长度 + y字段长度 > 缓存块的长度



2、去掉volatile关键字

既然x和y变量之间不存在线程相互影响的问题,那么我们可以直接去掉volatile关键字,这依然不影响我们的最终结果。



观察两种方法,显然后者时间更快。这告诉我们如果一个变量没有必要添加volatile关键字,那就不要加,否则缓存一致性协议带来得缓存失效十分影响性能。

以上是关于浅谈volatile与计算机缓存一致性协议之间的联系的主要内容,如果未能解决你的问题,请参考以下文章

深入理解volatile可见性原理与MESI缓存一致性协议

Java并发关键字Volatile 详解

计算机多级缓存架构和MESI缓存一致性协议

volatile关键字?MESI协议?指令重排?内存屏障?这都是啥玩意

缓存一致性协议MESI

深入解析volatile关键字