文章目录
- CPU缓存
- CPU缓存一致性协议
2.1 局部性原理
2.2 Cache Line
2.3 cache的写方式
2.4 一致性协议MESI - 伪共享
- 复现伪共享
- 如何避免伪共享
伪共享的非标准定义为:缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享
下面我们就来详细剖析伪共享产生的前因后果。首先,我们要了解什么是缓存系统。
1. CPU缓存
CPU 缓存(Cache Memory)是位于 CPU 与内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。
高速缓存的出现主要是为了解决 CPU 运算速度与内存读写速度不匹配的矛盾,因为 CPU 运算速度要比内存读写速度快很多,这样会使 CPU 花费很长时间等待数据到来或把数据写入内存。
- 来自百度百科
CPU 缓存可以分为一级缓存,二级缓存,部分高端 CPU 还具有三级缓存。每一级缓存中所储存的全部数据都是下一级缓存的一部分,越靠近 CPU 的缓存越快也越小。所以 L1 缓存很小但很快(译注:L1 表示一级缓存),并且紧靠着在使用它的 CPU 内核。L2 大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3 在现代多核机器中更普遍,仍然更大更慢,并且被单个插槽上的所有 CPU 核共享。主内存由全部插槽上的所有 CPU 核共享。
关系如下图:
当 CPU 执行运算的时候,它先去 L1 查找所需的数据,再去 L2,然后是 L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要确保数据在 L1 缓存中。
具有高速缓存的CPU执行计算的流程
- 程序以及数据被加载到主内存
- 指令和数据被加载到CPU的高速缓存
- CPU执行指令,把结果写到高速缓存
- 高速缓存中的数据写回主内存
2. CPU缓存一致性协议
2.1 局部性原理
缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。
在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。
- 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归、方法的反复调用等。
- 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。比如顺序执行的代码、连续创建的两个对象、数组等。
2.2 Cache Line
cache line是cache与内存数据交换的最小单位,根据操作系统一般是32byte或64byte。如下图:
cache的内容除了存的数据(data)之外,还包含存的数据的物理内存的地址信息(tag),因为CPU发出的寻址信息都是针对物理内存发出的,所以cache中除了要保存数据信息之外,还要保存数据对应的地址,这样才能在cache中根据物理内存的地址信息查找物理内存中对应的数据为了加快寻找速度,cache中一般还包含一个有效位(valid),用来标记这个cache line保存着的数据的状态。一个tag和它对应的数据组成的一行称为一个cache line
Data、Valid、Tag各有多大?
cache中数据部分占的空间表示cache的容量,也就是32byte或64byte。
单核Cache中每个Cache line有2个标志:dirty和valid标志,它们很好的描述了Cache和Memory(内存)之间的数据关系(数据是否有效,数据是否被修改),而在多核处理器中,多个核会共享一些数据,占2个字节。
一个cache line中tag字段和valid位占4[2]+2[1]=6bit
2.3 cache的写方式
cache的写操作方式有四中:
- 写通 write through:每次CPU修改了cache中的内容,立即更新到内存,也就意味着每次CPU写共享数据,都会导致总线事务,因此这种方式常常会引起总线事务的竞争,高一致性,但是效率非常低;
- 写回 write back:每次CPU修改了cache中的数据,不会立即更新到内存,而是等到cache line在某一个必须或合适的时机才会更新到内存中;
- 写失效 write invalidate:当一个CPU修改了数据,如果其他CPU有该数据,则通知其为无效;
- 写更新 write update:当一个CPU修改了数据,如果其他CPU有该数据,则通知其跟新数据;
写更新会导致大量的更新操作,因此在MESI协议中,采取的是写失效(即MESI中的I:ivalid,如果采用的是写更新,那么就不是MESI协议了,而是MESU协议)。
2.4 一致性协议MESI
缓存一致性:在多核CPU中,内存中的数据会在多个核心中存在数据副本,某一个核心发生修改操作,就产生了数据不一致的问题。而一致性协议正是用于保证多个CPU cache之间缓存共享数据的一致。
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。其中最经典的MESI协议
现在主流的处理器都是用它来保证缓存的相干性和内存的相干性。M、E、S 和 I 代表使用 MESI 协议时缓存行所处的四个状态:
状态 | 描述 | |
---|---|---|
M(修改,Modified) | 本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有) | 稍微长一点的文本 |
E(专有,Exclusive) | 缓存行内容和内存中的一样,而且其它处理器都没有这行数据 | 中等文本 |
S(共享,Shared) | 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝 | 中等文本 |
I(无效,Invalid) | 缓存行失效, 不能使用 | 中等文本 |
cache操作
MESI协议中,每个cache的控制器不仅可以操作(local read和local write)自己的cache,每个核心的缓存控制器通过监听也知道其他CPU中cache的操作(remote read和remote write),确定自己cache中共享数据的状态是否需要调整。
- local read(LR):读本地cache中的数据;
- local write(LW):将数据写到本地cache;
- remote read(RR):其他cache读取本地cache数据;
- remote write(RW):其他cache写入本地cache数据;
下面说明四个状态是如何转换的:
初始:一开始时,缓存行没有加载任何数据,所以它处于 I 状态。
本地写(Local Write):如果本地处理器写数据至处于 I 状态的缓存行,则缓存行的状态变成 M。
本地读(Local Read):如果本地处理器读取处于 I 状态的缓存行,很明显此缓存没有数据给它。此时分两种情况:
- 其它处理器的缓存里也没有此行数据,则从内存加载数据到此缓存行后,再将它设成 E 状态,表示只有我一家有这条数据,其它处理器都没有;
- 其它处理器的缓存有此行数据,则将此缓存行的状态设为 S 状态。(备注:如果处于M状态的缓存行,再由本地处理器写入/读出,状态是不会改变的)
远程读(Remote Read):假设我们有两个处理器 c1 和 c2,如果 c2 需要读另外一个处理器 c1 的缓存行内容,c1 需要把它缓存行的内容通过内存控制器 (Memory Controller) 发送给 c2,c2 接到后将相应的缓存行状态设为 S。在设置之前,内存也得从总线上得到这份数据并保存。
远程写(Remote Write):其实确切地说不是远程写,而是 c2 得到 c1 的数据后,不是为了读,而是为了写。也算是本地写,只是 c1 也拥有这份数据的拷贝,这该怎么办呢?c2 将发出一个 RFO (Request For Owner) 请求,它需要拥有这行数据的权限,其它处理器的相应缓存行设为 I,除了它自已,谁不能动这行数据。这保证了数据的安全,同时处理 RFO 请求以及设置I的过程将给写操作带来很大的资源消耗。
注意:当多个处理器对相同的缓存行时(数据相同,在不同的cache中)进行写操作时,也就是当产生RFO,相当于争夺缓存的所有权会带来巨大的资源消耗,这也为造成伪共享低效率的因素。
参考:https://zh.wikipedia.org/wiki/MESI%E5%8D%8F%E8%AE%AE
3. 伪共享
回到CPU缓存,缓存行(cache line)是CPU缓存的基本单位,缓存行通常是 32/64 字节,缓存利用了局部性原理,当我们访问一个数据时,获取一个值后,其相邻的值也被缓存到就近的缓存行中。比如访问一个long类型数组,当数组中的一个值被加载到缓存中,它会额外加载另外 7 个,以致你能非常快地遍历这个数组。因此可以非常快速的遍历在连续的内存块中分配的任意数据结构。
但是天下没有免费的午餐,某种情况下有多个线程操作不同的成员变量,但是多个变量处于相同的缓存行。显然带来了PFO操作。借用一张经典的图:
一个运行在处理器 core1上的线程想要更新变量 X 的值,同时另外一个运行在处理器 core2 上的线程想要更新变量 Y 的值。但是,这两个频繁改动的变量都处于同一条缓存行。两个线程就会轮番发送 RFO 消息,占得此缓存行的拥有权。当 core1 取得了拥有权开始更新 X,则 core2 对应的缓存行需要设为 I 状态。当 core2 取得了拥有权开始更新 Y,则 core1 对应的缓存行需要设为 I 状态(失效态)。轮番夺取拥有权不但带来大量的 RFO 消息,而且如果某个线程需要读此行数据时,L1 和 L2 缓存上都是失效数据,只有 L3 缓存上是同步好的数据。从前一篇我们知道,读 L3 的数据非常影响性能。更坏的情况是跨槽读取,L3 都要 miss,只能从内存上加载。
表面上 X 和 Y 都是被独立线程操作的,而且两操作之间也没有任何关系。只不过它们共享了一个缓存行,但所有竞争冲突都是来源于共享。
4. 复现伪共享
public class FalseShareTest {
final static long iteration = 1000000;
static long totalTime = 0L;
static VolatileLong volatileLong = new VolatileLong();
public static void main(final String[] args) throws InterruptedException {
//运行十次
for (int j = 0; j < 10; j++) {
final long start = System.nanoTime();
runSys();
final long end = System.nanoTime();
totalTime += end - start;
System.out.println(j + " : " + (end - start));
}
System.out.println("平均耗时:" + totalTime / 10);
}
public static void runSys() throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < iteration; i++) {
volatileLong.value1++;
}
});
Thread thread2 = new Thread(() -> {
for (int j = 0; j < iteration; j++) {
volatileLong.value2++;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
static class VolatileLong {
public volatile long value1;
//public long p1, p2, p3, p4, p5, p6, p7,p8;
public volatile long value2;
}
}
通过两个线程修改一个类中的两个变量。value 设为 volatile 是为了让 value 的修改对所有线程都可见。JVM系列:六、Java内存模型和多线程文章中提到过:
使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效
这里如果我们通过两个线程,分别修改value1和value2,这两个变量被加载到同一个缓存行中,产生伪共享。
倒数第二行代码是为了防止这两个变量被加载到同一个缓存行中,分别执行两种情况,伪共享的情况是执行时间是破坏伪共享的两倍。
5. 如何避免伪共享
正如上一节中的做法,防止其他数据导致伪共享的问题常用增加padding,叫做缓存行填充的方式来解决,例如在前后加上无用的数据。
在JDK1.8中,新增了一种注解@sun.misc.Contended,来使各个变量在Cache line中分隔开。注意,jvm需要添加参数-XX:-RestrictContended才能开启此功能 。类前加上代表整个类的每个变量都会在单独的cache line中。属性前加代表该属性会在单独的cacheline中。
@sun.misc.Contended
static class VolatileLong {
public volatile long value1;
//public long p1, p2, p3, p4, p5, p6, p7,p8;
public volatile long value2;
}
替换上面的代码,执行时间和我们手动填充缓存行的是一样的。