并发编程二、CPU多级缓存架构与MESI协议的诞生

​前言:

  1. 文章内容:线程与进程、线程生命周期、线程中断、线程常见问题总结
  2. 本文章内容来源于笔者学习笔记,内容可能与相关书籍内容重合
  3. 偏向于知识核心总结,非零基础学习文章,可用于知识的体系建立,核心内容复习,如有帮助,十分荣幸
  4. 相关文献:并发编程实战、计算机原理

CPU多级缓存架构


要学习多级缓存架构,我们首先要了解一些计算机的小知识

CPU缓存行(CPU Cache Line):

  • CPU缓存中可分配的最小存储单元,通常64字节,缓存行是分段的,一个段对应一块。
  • CPU看到一条读取内存的指令时,会把内存地址传递给一级数据缓存,一级数据缓存会检查它是否有对这个内存地址对应的缓存段,如果没有就把整个缓存段从主存或更高一级的缓存中加载进来。

寄存器:

  • 特点:每个处理器都有自己的寄存器,CPU内部元件,拥有非常高的读写速度,寄存器之间数据传输非常快
  • 能力:寄存器内的数据可以执行算术及运算逻辑、存于寄存器内的地址可用来寻址,寄存器可以用来读写数据到电脑的周边设备
  • 可见性问题的产生:变量有时候会被放进寄存器中暂时存储,多个处理器各自运行一个线程时,可能导致某个变量放到寄存器中,会导致各个线程没法看到其他处理器寄存器里修改过的变量值,这就出现了可见性问题

写缓冲器:

  • 多个处理器交互时因为都要跟各自的主内存,或者高速缓存进行读写操作,而这个读写操作可能非常耗时,为了解决这个耗时问题,引入写缓冲器
  • 一个处理器在写数据的时候,不直接将数据写入主内或高速缓存,而是写入写缓冲器后直接返回,在特殊场景下才最终写入主存或高速缓存。写缓冲器加快处理器在寄存器间的计算交互

 CPU多级缓存架构

  相关概念:

  • BUS:负责将CPU连到内存,BUS频率直接影响到CPU与内存数据交换速度。频率越高CPU与内存之间数据传输量越大。所有的内存传输都发生在一条共享总线上,所有处理器都能看到这条总线,缓存本身是独立的,但是内存是共享资源,所有的内存访问都经过总线。同一个指令周期中,只有一个CPU缓存可以读写内存。
  • 高速缓存:介于主存和CPU之间,系统将一些CPU在近几个时间段经常访问的数据存入高速缓存,当CPU需要时先在高速缓存中找。

CPU执行计算流程:

  1. 程序及数据被加载到主内存、指令和数据被加载到CPU高速缓存
  2. CPU执行指令,将结果存到高速缓存、高速缓存把数据写回到主内存

为什么要高速缓存呢?

  • CPU频率太快,主存跟不上,CPU常常需要等待主存浪费了资源,利用高速缓存,缓解主存和CPU之间的速度不匹配问题。
  • 高速缓存容量远小于主存,高速缓存无法包含CPU所需要的所有数据,它存在的意义是局部性原理:
    • 时间局部性:如果某个数据被访问,在不久的将来可能被再次访问
    • 空间局部性:如果某个数据被访问,与它相邻的数据很快也可能被访问

高速缓存的结构:

        CPU的高速缓存分为L1、L2、L3三级缓存

  1. L1:第一层高速缓存,容量较小一般在256KB。主要缓存指令和数据,对CPU性能影响十分大
  2. L2:会影响到CPU性能,容量第二大
  3. L3:进一步降低内存的延迟,同时提升海量数据计算时的性能,三级缓存不同于前两级,它是CPU共享的,容量最大
  4. CPU访问内存逻辑:如果寄存器有这个数据,CPU 就直接从寄存器取数据即可,如果寄存器没有这个数据,CPU 就会查询 L1 ⾼速缓存,如果 L1 没有,则查询 L2 ⾼速缓存,L2 还是没有的话就查询 L3 ⾼速缓存,L3 依然没有的话,才去内存中取数据

 MESI


 缓存一致性问题的诞生:

  • 单核CPU多线程:多个线程同时访问进程中的共享数据,不同线程访问相同的物理地址都会映射到相同的缓存位置,发生线程切换缓存也不会失效,并且任何时间只能一个线程在执行,也不会出现缓存访问冲突。
  • 多核CPU多线程:每个核都有自己的多级缓存,多线程访问进程中某个共享内存,多个线程在不同核心执行,每个核心都在自己的缓存中保留一份共享内存的数据。多核并行的情况下,多个线程可能同时写各自的缓存,导致同一个数据的缓存内存不一致这就存在缓存一致性问题。
  • 缓存一致性协议MESI:定义了Cache Line的四种状态,CPU对Cache line的四种操作可能导致不一致的状态,因此缓存控制器监听到本地和远程操作时,会对地址一致的Cache line状态进行一致性修改,从而保证数据在多个缓存之间保持一致性
  • MESI规定对一个共享变量的读操作可以多处理器并发执行,对于一个共享变量的写操作,只有一个处理器可以执行,通过排它锁机制保证
  • MESI规定一组消息,各个处理器在操作内存数据时,都会往总线发消息,而且各个处理器还会不停从总线嗅探最新的消息,通过这个总线消息传递来保证各个处理器的协作。
  • 上面的特性保证了可见性和有序性问题

MESI协议处理cache entry的flag状态:

  1. invalid:L,无效,当前cache entry无效,里面数据不可用
  2. shared:S,共享,当前cache entry有效,里面的数据在各个处理器有各自的副本,副本的值跟主存一致,各个处理器就是并发的在读
  3. exclusive:E,独占,当前处理器对这个数据独占,只有它可以有这个副本,其他处理器不能包含这个副本
  4. modified:M,修改过,只能有一个处理器对共享数据更新,所以只有更新数据的处理器的cache entry,才是独占状态,表明当前线程更新了这个数据,这个副本的数据跟主存不一致

下面我们从更底层来分析可见性问题的产生,首先我们来学习下高速缓存的拉链散列表结构:

 

 

  高速缓存的底层结构是拉链散列表结构,很多个bucket,每个bucket挂了很多的cache entry。每个cache entry由tag、cache line和flag组成。cache line是缓存的数据,可以包含多个变量的值,tag指向缓存数据在主存中的数据地址,flag标识缓存行的状态。

那么CPU操作变量时,如何在高速缓存中进行定位呢?

        处理器在读写高速缓存的时候,实际上会根据变量名执行一个内存地址解码操作,解析出来index,tag和offset

  1. index用于定位到拉链散列表中某个bucket
  2. tag用于定位cache entry,指向这个缓存数据在主存中的数据的地址
  3. offset是定位一个变量在cache line中的位置。如果成功定位且flag还标志有效,则缓存命中。不满足就是未命中。如果连续未命中,会从主内存重新加载数据到高速缓存中。

了解了这些知识后,我们再来详细聊一下MESI的工作过程:

  1. 处理器读取某个变量数据时,首先根据index、tag、offset从高速缓存的拉链散列表读取数据。如果发现状态是无效,会发送read消息到总线
  2. 接着主内存会返回对应数据给总线,通过read返回值返回给处理器。处理器把数据放到高速缓存,同时cache entry的flag状态设置为共享
  3. 在处理器对一个数据更新时,如果数据状态是共享,就需要发送一个invalidate消息到总线,尝试让其他处理器的高速缓存的cache entry全部变无效,已获得数据的独占锁
  4. 其他处理器会从总线嗅探到invalidate消息,此时把自己的cache entry设置为无效,即过期掉自己的本地缓存,然后返回ack消息给总线,传递回更新数据的处理器,必须收到所有处理器返回的ack
  5. 然后处理器就会将自己的cache entry设置为独占,在独占期间其他处理器不能修改数据,因为处理器此时发出invalidate消息,这个处理器是不会返回ack的,除非他先修改完了。
  6. 接着处理器修改这个数据,将数据设置为修改过,也可能是把数据强制写回主存。
  7. 然后其他处理器看到这个数据状态都是无效了,如果要读,全部需要重新发read消息,从主存或其他处理器来加载,看具体底层硬件实现。
  8. 这套机制就是缓存一致性在硬件缓存模型下的完整执行原理,解决存在的可见性和有序性问题,但是存在串行的性能问题

        串行化问题:每次写数据都要发送invalidate消息并等待所有处理器ack,获取到独占锁才能写数据。会导致性能很差,对于共享变量的写操作,在硬件级别变成串行。因此在硬件层面引入写缓冲器和无效队列。

写缓冲器和无效队列:

  • 写缓冲器
  1. 作用是处理器写数据,直接把数据写入缓冲器,同时发送invalidate消息,就认为写完成了。
  2. 然后处理器之后收到其他处理器的ack,会把写缓冲器中的写结果拿出来,通过对cache entry设置为独占,同时修改数据,设置为修改过,独占这条数据的读写操作
  3. 写缓冲器通过不同步阻塞等待ack,提升硬件层面执行效率。查询数据时,会先从缓冲器查,因为有可能刚修改的值在这里,然后才会走高速缓存。通过存储转发,同样提升了效率
  • 无效队列:其他处理器收到invalidate消息,不需要立马过期本地缓存,直接把消息放入无效队列,然后返回ack给写处理器,加速性能。然后从无效队列中取出消息,过期本地缓存
  •  通过这两个方式解决串行化效率问题。但是写缓冲器和无效队列的数据不能立马回刷高速缓存的话,会导致可见性问题。

写缓冲器和无效队列导致的可见性问题:

        由于写缓冲器和无效队列,可能导致写数据时不一定立马写入高速缓存或主存,因为可能写入了写缓冲器。读数据不一定立马从别人的高速缓存或主存刷新最新值,invalidate消息在无效队列里,有可能获取到的值还是高速缓存或主存的旧值

写缓冲器和无效队列导致的有序性问题:

  • Store Load重排序
a = 1; int b = c;
  1. a=1是store,b=c是load。可能处理器对store操作先写入写缓冲器,此时这个写操作就相当于没有执行。然后就load操作了。这就导致第二行代码的load先执行了,第一行代码的store后执行。
  2. 第一个store操作写到写缓冲器,导致其他线程读不到看不到。代表第二个load操作成功的执行StoreLoad重排,常见的有store先执行,load后执行,load先执行,store后执行。
  • Store Store重排序
resoure = loadResource(); loaded = true;

  两个写操作,如果第一个写到写缓冲器,第二个直接修改的高速缓存,就会导致两个写操作的重排序。

        有序性问题就是如此,会因为MESI的机制而发生。可见性也是如此,写入写缓冲器后,没有刷入高速缓存,就导致别人读不到;读数据时候,可能invalidate消息在无效队列里,导致没发立马感知到过期的缓存,立马加载到最新数据。

引入内存屏障,解决硬件级别的可见性和有序性问题:

  • Store+Load解决可见性问题:
  1. Store屏障:该屏障强制性要求对一个写操作必须阻塞等待其他处理器返回invalidate ack后,对数据加锁,然后修改数据到高速缓存中,必须在写数据后强制执行flush操作。
  2. flush处理器缓存:把自己更像的值刷新到高速缓存或主存。因为必须要刷新后才有可能通过一些特殊机制让其他处理器从自己的高速缓存或主存读取到最新的值。
  3. Load屏障:在从高速缓存中读取数据时,如果发现无效队列里有一个invalidate消息,会立马强制根据那个invalidate消息把自己本地高速缓存的数据,设置为无效,然后强制从其他处理器的高速缓存中加载最新值。这就是refresh操作。解决本地读操作对自己处理器数据过期的可见性问题
  4. refresh处理器缓存:除了flush外,还会发送一个消息到总线,通知其他处理器,某个变量值被他修改。refresh表示处理器中的线程在读取一个变量值时,如果发现其他处理器的线程更新了变量的值,必须从其他处理器的高速缓存或主存,读取这个最新的值,更新到自己的高速缓存。
  5. 可见性保障底层是通过MESI协议、flush处理器缓存、refresh处理器缓存实现。
  6. flush是强制刷新数据到高速缓存或主存,不要仅仅停留在写缓冲器里面。refresh是从总线嗅探发现某个变量被修改,必须强制从其他必须从其他处理器的高速缓存或主存,读取这个最新的值,更新到自己的高速缓存。
  • Acquire+Release解决有序性问题:
  1. Acquire屏障:即StoreStore屏障,会强制让写操作全部按照顺序写入写缓冲器,不会让两个写操作一个写到写缓冲器,另一个直接修改高速缓存。
  2. Release屏障:即StoreLoad屏障,会强制先将写缓冲器的数据写入高速缓存,接着读数据时强行清空无效队列,对里面的validate消息全部过期掉高速缓存中的条目,然后强制从主存重新加载数据。

内存模型  


作用:

        保障共享内存的正确性(三特性),其定义了共享内存系统中多线程程序读写操作的规范。主要采用限制处理器优化(通过优化屏障避免编译器的重排序优化操作)和使用内存屏障(通过读写屏障强制限制内存访问顺序)。

JAVA内存模型(JMM):

  1. 符合内存模型规范,屏蔽了各种硬件和操作系统访问差异,保证java程序在各种平台下对内存的访问都能保证效果一致的机制及规范
  2. 该模型规定所有变量都存储在主内存中,每个线程有自己的工作内存,保存了该线程用到的变量在主内存中的副本拷贝。线程对变量的操作都必须在自己的工作内存中进行不能直接读写主内存,不同线程之间工作内存不能直接访问,线程间变量的传递需要在自己工作内存和主内存直接进行数据同步。
  3. 在JMM中,数据用read从主存读取数据,用load将数据加入工作内存,user将工作内存数据读出并计算,assign将计算好的数据赋值到工作内存,store将工作内存数据写入主内存,write将写入store过去的变量赋值给主内存中顺序不能打乱,保证使用变量前一定是从主存拿的新值、assign、write顺序不能打断,保证赋值后马上写入主内存。 因此在use前插入读屏障,从主存拿最新值,让工作内存中数据失效。在assign之后插入写屏障,保证写入工作内存的最新数据更新到主内存被其他线程可见。

Happens-before(先行发生原则):

  1. 程序顺序性规则:在一个线程中,按代码顺序,前面的操作先行发生于后续的任意操作
  2. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
  3. 传递性:如果A先行发生于B且B先行发生于C,那A先行发生于C
  4. 锁的规则:一个锁的解锁操作先行发生于后续对这个锁的加锁
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作,在主线程执行过程中,启动子线程,那么主线程在启动子线程之前对共享变量的修改结果对子线程可见。
  6. 线程join()规则:在主线程执行过程中,子线程终止,那么子线程在终止之前对共享变量的修改结果在主线程中可见(join方法)
  7. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  8. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
posted @ 2022-08-30 10:07  难得  阅读(642)  评论(0编辑  收藏  举报