知识点整理(十)——cpu的缓存一致性和伪共享

知识点整理(十)——cpu的缓存一致性和伪共享
逐暗者L1、L2、L3、内存
要了解cache的缓存一致性和伪共享问题,就要先了解L1 cache 、L2 cache 、L3 cache和内存之间的关系。
通常cpu内有3级缓存,即L1、L2、L3缓存。其中L1缓存分为数据缓存和指令缓存,cpu先从L1缓存中获取指令和数据,如果L1缓存中不存在,那就从L2缓存中获取。每个cpu核心都拥有属于自己的L1缓存和L2缓存。如果数据不在L2缓存中,那就从L3缓存中获取。而L3缓存就是所有cpu核心共用的。如果数据也不在L3缓存中,那就从内存中获取了。当然,如果内存中也没有那就只能从硬盘中获取了。
为什么要设计成这个样子呢?因为从不同的设备中获取数据的耗时是不一样的。
设备(模块) | 硬件类型 | 成本(美元/MB) | 通常大小 | 随机读取耗时 |
---|---|---|---|---|
L1 cache | SRAM | 7 | 几十到几百KB | 2~4个周期 1纳秒左右 |
L2 cache | SRAM | 7 | 几百KB到几MB | 10~20个周期 5纳秒左右 |
L3 cache | SRAM | 7 | 几MB到几十MB | 20~60个周期 10纳秒左右 |
内存 | DRAM | 0.015 | 几GB至几十GB | 200~300个周期 100纳秒左右 |
固态硬盘 | SSD | 0.0004 | 几百GB至几TB | 1000倍内存耗时 150微妙左右 |
机械硬盘 | HDD | 0.00004 | 几百GB至几十TB | 10~100倍固态硬盘耗时 10毫秒左右 |
L1、L2、L3 都是SRAM(静态内存),只要通电就一直储存数据。通常由6个晶体管组成,电路比较简单,所以访问速度快,但密度不高,所以同样物理空间下能存储的数据有限。
其中L1距离cpu最近,所以效率最高,几乎2 ~ 4个时钟周期就能访问到数据。而L3距离cpu较远,访问需要20 ~ 60个时钟周期。
内存在cpu外部,属于DRAM(动态内存),一般使用一个晶体管和一个电容,因为电容会漏电,所以需要定时刷新电容,所以被称为动态内存。数据访问和刷新的电路都比较复杂,所以访问速度会比较慢。通常在100纳秒左右。
SSD(固态硬盘),使用类似内存的结构,但是不再需要刷新,所以断电后数据还在,同时成本较低。随机读取大约在一百微妙左右。
HDD(机械硬盘),通过磁头定位和读写,所以随机读写速度更慢。
cache的读取和写入
读取
数据是怎么从内存读取到cpu cache中的呢?
如果程序需要读取一个a变量,cpu cache并不是只从内存中读取一个a变量的数据。
在L1、L2、L3中,通常定义64字节为一个cache line,当cache中不存在数据,需要从内存中读取时,也是每次至少读取一个cache line的数据加载到cache中。
为什么这么设计呢?其一,每次只读写1个字节或者4个字节(int)有点浪费。内存速度要比cpu慢很多,中间需要经过各种环节(设置内存地址,控制总线等等)只为了取1个字节的数据太过浪费。其二,很多情况下我们访问了一个数据,很有可能会立刻访问附近的数据。(例如数组等顺序申明的结构)
例如定义一个int[16] a ,那cpu取a[0]的时候,会顺序取入64个字节的数据,于是接下来遍历a数组的时候,由于cpu cache中已经有了,就不用再频繁从内存中获取了。
写入
读取可以通过cache line来提高效率,那修改数据怎么办呢?如果每次都写入内存,那效率又会降低。
其实cpu在写入数据时,只是修改了cache中的数据,并标记为”脏”状态。等待cache中的数据需要被覆盖时,再将cache中的数据写回内存。这样有2个好处,如果频繁修改某个变量,cpu不会一直不停的回写内存,而只是修修改cache,真正写回可能只发生一次。其二,修改顺序数据的时候也能将多次修改合并成一次写回。(例如数组等顺序申明的结构)
缓存一致性问题
当cpu有多个内核的时候,由于L1、L2缓存是每个内核各自独有的,这就造成了可能内核1修改了某个数据,但内核2根本不知道。
例如,我们有一个a变量值为1。首先内核1读取了这个变量,并修改为2。由于修改只发生在L1的cache中,并没有触发策略写回内存。接着内核2也读取这个变量,此时内存的a还是1,于是内核2读取到了旧值。
为解决缓存一致性问题,就有了MESI协议。
M(Modified 已修改)
E(Exclusive 独占)
S(Shared 共享)
I(Invalidated 已失效)
就是用这四个状态来标记cache line的四个不同的状态。
已修改,就是cache中的数据被修改了,但还没有写回到内存里。已失效表示这个数据不能使用,不可以读取数据。独占和共享代表数据是干净的,和内存一直的。区别在于独占表示只有自己内核里有这个数据,共享表示还有其他内核里有这个数据。
当前状态 | 动作 | 处理 |
---|---|---|
I(已失效) | Local Read | 其他内核无数据,则自己标记为E(独占) 其他内核有数据并且状态为M(已修改),则先把数据写回内存,然后读取内存数据标记自己和其他内核状态为S(共享) 其他内核有数据,状态为E(独占)或者S(共享),则自己读取后标记自己和其他内核状态为S(共享) |
I(已失效) | Local Write | 其他内核无数据,则自己标记为M(已修改) 其他内核有数据并且状态为M(已修改),则先把数据写回内存,然后读取并修改数据标记自己状态为M(已修改),标记其他内核数据为I(已失效) 其他内核有数据,状态为E(独占)或者S(共享),则自己读取并修改数据后标记自己状态为M(已修改),标记其他内核数据为I(已失效) |
I(已失效) | Remote Read | 自身内核数据已失效,其他内核读取数据不影响,状态不变 |
I(已失效) | Remote Write | 自身内核数据已失效,其他内核修改数据不影响,状态不变 |
E(独占) | Local Read | 直接读取cache中数据,状态不变 |
E(独占) | Local Write | 直接修改cache中数据,状态变成M(已修改) |
E(独占) | Remote Read | 状态修改为S(共享) |
E(独占) | Remote Write | 状态修改为I(已失效) |
S(共享) | Local Read | 直接读取cache中数据,状态不变 |
S(共享) | Local Write | 直接修改cache中数据,状态变成M(已修改),其他核心共享状态变成I(已失效) |
S(共享) | Remote Read | 状态不变 |
S(共享) | Remote Write | 状态修改为I(已失效) |
M(已修改) | Local Read | 直接读取cache中数据,状态不变 |
M(已修改) | Local Write | 直接修改cache中数据,状态不变 |
M(已修改) | Remote Read | 把数据写入内存,状态变成S(共享) |
M(已修改) | Remote Write | 先写入内存,然后其他内核读取修改,自己内核状态变为I(已失效) |
伪共享
cache无论是读取、写入还是按照MESI协议标记,最小的颗粒都是cache line(通常64字节)。但是如果我们有2个变量a和b,分别在不同的cache line中,内核1只读取修改a变量,内核2只读取修改b变量。按照MESI协议,会频繁的发生标记失效,写回内存,重新读取操作。但实际上程序并没有让2个内核竞争同一个资源。
- 如何避免?
将多个频繁访问的变量设置成不同的cache line上。linux中使用__cacheline_aligned_in_smp宏定义。
Java 并发框架 Disruptor 使⽤「字节填充 + 继承」的⽅式,来避免伪共享的问题。
p1~p7 是为了填充cache line的,加上真正使用的long变量,一共正好8个long类型,64个字节。防止不同的变量分配给同一个cache line上。