原文链接:缓存一致性MESI与内存屏障
一,高速缓存
问题:cpu执行速度很快,内存读取速度较慢,但是cpu计算需要的指令和数据都存储在速率较低的内存中。
解决方案:高速缓存
1.1 什么是高速缓存?
常用内存:DRAM(Dynamic RAM),动态随机访问存储器。每个存储单元包含晶体管和电容器,需要固定的刷新充电,否则会丢失数据
高速缓存:SRAM(Static RAM),静态随机访问存储器,无需刷新电路就能保存内部数据,工作速度在ns级别,但集成度较低,巨贵&体积大
CPU 缓存模型增加了多级缓存解决单个高速缓存容量较小的问题,架构如下图:
- 缓存容量: 内存>L3>L2> L1
- 缓存速度: 内存<L3<L2< L1
- 层级关系: L3 多核之间共享,L2和L1核内私有
- L1进一步拆分为:
- L1d: data 数据缓存
- L1i: instruction 指令缓存
1.2 缓存行
空间局部性:位置相邻的数据常常在相近的时间被访问
缓存行:高速缓存和内存交互的最小单元,一般是64个字节。每次缓存从内存中获取数据时,会把包含目标数据的一整块内存数据都放入到cache中
1.3 伪共享问题
伪共享问题:多核修改互相独立的变量时,如果这些变量共享在同一个缓存行中,就会互相影响。
如下图中 core0 和 core1 都共享了xyz的缓存行,但是core0更改了其中的x,core1更改了其中的y
解决方案:当core0修改了缓存行的内容后,需要通知core1把core1中缓存行失效。eg:将涉及到共享的变量拆分开,把y独享缓存行
1.4 总线锁
为了解决缓存一致性问题,cpu天然支持了总线锁,即单个cpu核锁住bus总线。单个处理器发出lock指令后,其他处理器的请求都会被阻塞,这样处理器就可以独占共享内存。
问题:一旦一个处理器获取到了总线锁,其他处理器都只能阻塞等待。
1.5 缓存锁
总线锁的成本太高,又进一步发展出了缓存锁。 缓存锁:维护本处理器内部缓存和其他处理器缓存的一致性,而无需锁定总线
- 内部实现逻辑:缓存一致性协议
二,缓存一致性协议
2.1 MESI
这里介绍常用的 MESI,注意这里的状态指的都是缓存行的状态:
- M(Modified): 缓存行被修改之后变为 modified 状态,只有对应修改的CPU核的缓存行会是有效状态,缓存行被该核独占
- 修改缓存行的cpu核负责将缓存行写回内存或传递给其他核
- 其他核要么没有该缓存行,要么处于 invalid 状态,需要重新读取
- E(Exclusive): 类似于 M,对应独占/排它。但缓存行可能还没修改
- 一旦缓存行处于 E,只有一个 cpu核会持有,其他核不能拥有副本
- 和 M 的区别就是宣示了主权,但是还没修改
- S(Shared): 缓存行可以同时出现在多核中,且各个高速缓存中的缓存行和内存中的数据一致
- cpu核不能在未协商的情况下,修改处于S状态的缓存行
- I(Invalid): 缓存行不包含数据,被打上 invalid的标签,以便缓存行更新到cache后,优先放入处于 invalid 的缓存行中
那么cpu核之间是怎么彼此通信的,他们的沟通被称为“缓存一致性消息”
2.2 缓存一致性消息
处理器获取数据的消息包含:请求 & 响应。处理器一方面会向总线下发请求消息,另一方面会嗅探总线中其他处理器的请求消息并在一定条件下做出响应。
消息类型包含几种:
- Read: 读取某个缓存行,携带目标缓存行对应的物理地址
- Read Response: 对read的响应,包含目标缓存行,可能来自于内存,也可能来自其他核
- Invalidate: 使某个缓存行失效。其他收到该命令的核会删除对应缓存行中的数据,并做ack响应
- Invalidate Acknowledge: 针对Invalidate的反馈,意味着发出该消息的cpu核已经清除对应目标缓存行的数据
- 如果多个核同时发出invalidate消息,则由总线裁决,只会有一个核获胜,其他核需要清空消息并响应ack
- Read Invalidate: 包含Read+Invalidate,既要读取缓存行,又要让其他核的缓存行失效
- Writeback: 写回内存,包含写回内存的地址和数据,通用是modified状态的数据
- 写回后,cache可以根据需要删除对应的缓存行
下图为read & read ack的示例:
三,CPU 闲置优化
按照上面的协议和通信机制,可以保证缓存的一致性。但是一个cpu核想要执行write操作,必须收到其他核的ack指令才可以操作缓存行,这期间又会导致cpu出现空闲。至此,解决I/O和CPU计算的速度差异过大的态势演变为:
- CPU 出现比较多的空闲
- I/O 操作变得更加快速
两个平均下来,貌似白忙活。聪明的工程师们为了解决这个问题,引入了 store buffers。
3.1 store buffers
没有什么是增加中间层解决不了的,于是又在cpu和cache之间增加了一层store buffer。工作机制如下:
- cpu0 想执行write,先下发 invalidate
- cpu1 开始响应命令,并给出ack
- cpu0 发出命令之后,立即计算,并将结果写入到 store buffer
- cpu0 等待其他核的ack结果之后,决定丢弃或者将 store buffer中的最新值写入到缓存行
这种做法相当于增加了一层buffer来暂存一些提前计算的结果,但也可能引起cpu的空跑。
聪明的你可能意识到了store buffer的一些问题。
问题1: 多数据存储导致的结果错乱
解决方案:Store Forwarding
cpu 数据加载操作时直接从 store buffer 读数据,而无需从cache获取。对应上图,直接从store buffer读取最新的 a,从而解决问题
问题2: cpu并发导致写操作指令重排
解决方案:内存屏障
内存屏障:该命令之后的所有针对cache的写操作开始之前,必须先把store buffer中的数据全部刷新到cache中 (也就是需要保证store buffer中的数据有序的刷新到cache中,避免指令冲排序)
保证有序的两个方案:
- 方案1: cpu等待
- 方案2: 写入数据在store buffer中排队,先等待的先处理
问题3: store buffer 容量问题
store buffer 通常容量较小,如果是一个写任务,cpu需要处理较多的数据写入,容易导致store buffer存储空间占满,导致cpu再次空闲。
问题原因:invalidate acknowledge 响应太慢。响应慢的原因为cpu只有等到cache状态设置为invalid之后才会ack,但是会容易受cache本身的阻塞限制。
解决方案:invalidate queues (异步消息队列)
3.2 invalidate queue
新的系统架构变为下图。收到invalidate消息的cpu会把invalidate的消息直接存储到"queue"中,然后立即返回ack消息,不需要再等着「缓存行」被设置为invalid状态。由invalidate queue保证最终一致性。
但是引入这个组件之后,也进一步引发了其他的问题。
问题: queue ack 之后 cpu 使用未失效的cache
会遇到可见性问题,数据在被invalidate之前,已经被使用。参考下图:
解决方案:读内存屏障
3.3 内存屏障
内存屏障的功能:
- 全内存屏障(smp_mb): 包含读&写
- 写屏障:在语句后的所有针对 cache 的写操作开始之前,必须先把 store buffer 中的数据全部刷新到 cache
- 读屏障:在语句后的所有针对 cache 的读操作开始之前,必须先把 invalidate queue 中的数据全部作用到 cache
四,总结
- 高速缓存:解决内存和cpu速度差异过大的问题,但是导致缓存一致性问题
- 缓存一致性协议:解决了缓存一致性问题,但是导致了cpu的间断性闲置
- store buffer: 解决了cpu的写入等待问题,但是带来了其他的问题
- 读取数据不一致:store forwarding, 直接从store buffer 读取 cache
- 操作重排序:写内存屏障
- store buffer空间有限:Invalidate Queue
- 进一步导致乱序和可见性问题: 读内存屏障