加载中...
一次性讲清楚voliate的可见性
发表于:2022-09-23 | 分类: 程序人生
字数统计: 3.2k | 阅读时长: 11分钟 | 阅读量:

背景


int value = 3;
boolean isFinsh = false;
void exeToCPUA(){
  value = 10;
  isFinsh = true;
}
void exeToCPUB(){
  if(isFinsh){
    //value一定等于10?!
    assert value == 10;
  }
}

当2个线程分别执行exeToCPUA和exeToCPUB方法时,由其是2个线程分给了不同cpu内核执行的情况下,在exeToCPUB方法中读到的value值不一定是10
这就需要我们使用java的关键字voliate来保证其可见性。可是为什么呢?

CPU与内存


为了了解最根本的原因,我们需要了解CPU和内存。
先来看看CPU和内存的工作效率,CPU的时钟周期就是CPU频率的倒数,频率越高的CPU时钟周期约小。那CPU执行一个指令需要多少时钟周期呢?这根据指令的复杂程度而不一样,简单的指令只需要2个时钟周期。
那么操作一次内存需要多少时间呢?大概需要100多个CPU的时钟周期。可以看到两者相差2个数量级,如果CPU直接读写内存,就会有大量的时间用来等待内存处理,白白浪费了CPU的性能。
为了解决这个问题,现代计算机引入了高速缓存。这就是L1 L2 L3 Cache

可以看到对L1 Cache的操作只要4个时钟周期,而对L2 Cache的操作需要十几个时钟周期,对L3 Cache的操作需要二十、三十个时钟周期。他们都小于内存的操作耗时,用来辅助CPU操作内存可以提高效率。

高速缓存


介绍到这里,就产生了2个疑问:第一,为什么L1 Cache能比内存快呢?第二,为什么加一个比内存快的缓存就能解决CPU和内存之间性能差距带来的问题呢?

高速缓存为什么高速?

高速缓存之所以速度比内存高,主要因为2个方面原因。

  • 第一个,L1 Cache比内存距离CPU物理上更近,L1容量远远小于内存大小。

即便电脑使用的是电来驱动各个硬件,但物理距离上的远近也会或多或少影响着性能。而从10K数据中查找内容和从10G数据中查找内容,明显从10K数据中查找内容更快。

L1 Cache 分为指令缓存和数据缓存,被封装于CPU内核中,所以每个CPU内核都有自己的L1 Cache,通常单个L1 Cache只有64K左右,整个L1 Cache在512K左右.

L2 Cache 不再区分指令和数据,也被封装与CPU内核中,通常单个在512K左右,整体在4M - 8M 左右。

L3 Cache 不是每个内核独立的了,而是整个CPU共享,依旧被封装在CPU内部,通常整体在10M - 64M 左右。

由此可以看出,从L1到L3,容量是越来越大,距离CPU内核也是越来越远,所以他们读写操作耗时也是越来越多。

  • 第二个原因是高速缓存和内存的物理构造不一样。

内存(DRAM),是动态内存,采用了电容器和晶体管制成,元件较少。因此带来了2个好处:成本便宜和体积小。于是可以在相同的空间下,使用更低廉的价格存储更多的数据。但也带来了一个坏处,由于DRAM会漏电(非常短的时间内就会漏电完),所以数据无法一直保存,需要通过电来不断刷新来保持。在刷新过程中,内存是无法操作的,因此也带来了处理延迟高的情况。通常内存在一秒内会刷新1000M以上次。

而高速内存(SRAM)是静态内存,采用了更多的晶体管制成,带来的好处是不需要刷新,不存在漏电。但带来的坏处是成本高,体积大。在相同空间下,能保存的数据较少。

由以上2个原因,造成了高速缓存性能比内存要高出1个数量级。

高速缓存如何提高效率?

为什么增加一个高速缓存就能解决CPU和内存之间性能差距带来的问题呢?
这就要提到局部性原理: CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。
简单来说就是CPU访问数据的时候,有很大的概率是访问一片连续区域或者经常访问相同的区域。这个很好理解,因为我们的程序、数据通常都是有规律的顺序存放。例如这篇文章的文字也是从上到下顺序排布的,那读取到第一句话的时候,很有可能接下来就会读第二句话。我们编写的程序代码,大部分也是顺序指令执行的,所以在存储上来看都具有连续性。

正因为如此,增加高速缓存就能提高效率了。假设我们要读取连续的4句话(数据)。在CPU处理时,是需要处理4条命令,分别读取4句话(数据)。
如果CPU直接操作内存,那就需要等待4*100多个时钟周期,这将被浪费。
而有高速缓存的时候,CPU第一次依旧只读取1句话(数据),但高速缓存会一次从内存中加载4句话(数据)到高速缓存中,此时依旧消耗100多个时钟周期。(在数据量及其小的情况下,内存延迟主要是内存刷新机制导致的,和内存操作吞吐量关系不大)当CPU读取第2句话时,会发现高速缓存中已经存在数据了,所以效率会提高。

除此之外,反复读取同一个数据,高速缓存也比内存效率更高。常见的就是i++ ,对同一个数据反复进行读取和写入。

至此,我们知道了高速缓存如何带来效率的提升,同时也知道高速缓存在加载数据是并非只加载CPU需要操作的数据,而是会顺序加载相邻区域的数据。高速缓存中如此操作的一个单位叫做缓存行(Cache Line),通常缓存行大小为64Bytes。值的注意的是,这个大小通常可以存入好几个整型(int)和长整型(long),所以有时候我们需要注意缓存行造成的伪共享问题。(由于这和可见性无关,这里就不展开了。)

高速缓存带来的问题


高速缓存会带来什么问题呢?这里主要讲L1 L2所带来的问题,也就是每个CPU内核中有独立的一个高速缓存,这将导致缓存不一致的问题。
为了简化问题,下面不再区分L1 L2 L3 缓存,简便的将模型转换成:CPU内核中的高速缓存+CPU外的主内存模式。

如果2个CPU内核同时持有同一个数据的缓存,其中一个CPU内核修改了这个数据,那如何保证其他CPU内核能够知道缓存失效了呢?

MESI


MESI 协议是一个基于失效的缓存一致性协议,是支持写回(write-back)缓存的最常用协议,也是现在一种使用最广泛的缓存一致性协议,它基于总线嗅探实现,用额外的两位给每个缓存行标记状态,并且维护状态的切换,达到缓存一致性的目的。

MESI 是四个单词的缩写,每个单词分别代表缓存行的一个状态:
M:modified,已修改。缓存行与主存的值不同。如果别的 CPU 内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享状态(S)。
E:exclusive,独占的。缓存行只在当前缓存中,但和主存数据一致。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态(M)。
S:shared,共享的。缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。
I:invalid,无效的。缓存行是无效的。

MESI演示


想自己演示的可以去这个网站自己玩一下各种情况。

这里简单介绍一下几个常见的场景。

  • CPU0单独读取a0数据

通过总线读取数据后 CACHE0 将a0标记为E(独占)

  • CPU1在CPU0已读取a0数据的情况下再次读取a0数据

CPU0会将数据标记为S(共享),CPU1从内存读取数据后也标记为S(共享)

  • 在CPU0 CPU1 都缓存有a0数据时,CPU0修改这个数值

CPU0会修改缓存状态为E(独占),同时通知其他CPU,CPU1会将自己缓存的行设置为I(废弃)

Store Buffer


我们来看一下这种场景,如果CPU0缓存了a0数据,CPU1没有缓存a0数据,这个时候CPU1直接写a0数据会发生什么呢?

可以看到,CPU1就会发送一个 Read Invalidate 消息去读取对应的数据,并让其他的缓存副本失效。CPU1需要等待其他CPU内核响应这个缓存副本失效的消息后才能写入缓存。这个时间CPU1能否做其他事情呢?
为了提高效率,让CPU1在等待CPU0 CPU2的响应的过程中做其他事情,就需要引入Store Buffer 。
CPU发送Read Invalidate 消息后,就将需要写入的指令缓存到Store Buffer中,接着继续做其他事情,等其他CPU内核做出响应之后再继续写入高速缓存。
但这有2个问题,第一,在响应之前当前CPU内核再次读取这个数据怎么办?这个很好解决,读取L1 Cache之前先读Store Buffer就可以了,这就是store forwarding。

第二个问题就是开头我们列举的代码了

int value = 3;
boolean isFinsh = false;
void exeToCPUA(){
  value = 10;
  isFinsh = true;
}
void exeToCPUB(){
  if(isFinsh){
    //value一定等于10?!
    assert value == 10;
  }
}

当CPUA执行value=10的时候,CPUA发送Read Invalidate给CPUB,接着将value=10写入Store Buffer,继续执行后面的修改isFinsh数据。而如果isFinsh正好在缓存中是独占状态,也就是其他CPUB一定是失效状态,这就不需要通知了,可以直接修改。于是isFinsh顺利的写入了高速缓存。
接着CPUB执行isFinsh的读取,此时读到的值是true,而value值因为CPUB还没响应之前的Read Invalidate消息,从而导致value值还是3 。

硬件设计师给开发者提供了内存屏障(memory-barrier)指令,我们只需要使用内存屏障将代码改造一下,在 value = 10 后面加上 smp_mb(),就能消除 store buffer 的引入带来的影响。

  • 等 store buffer 生效

就是内存屏障后续的写必须等待 store buffer 里面的值都收到了对应的响应消息,都被写到缓存行里面。

  • 进 store buffer 排队

就是内存屏障后续的写直接写到 store buffer 排队,等 store buffer 前面的写全部被写到缓存行。

Invalidate Queue


store buffer 容量非常小,如果在其他 CPU 繁忙的时候响应消息的速度变慢,store buffer 会很容易地被填满,会直接的影响 CPU 的运行效率。

有了 invalidate queue 之后,CPU 在收到 invalidate 消息时,可以先不讲对应的缓存行失效,而是将消息放入 invalidate queue,立即返回 Invalidate Acknowledge 消息,然后在要对外发送 invalidate 消息时,先检查 invalidate queue 中有无该缓存行的 Invalidate 消息,如果有的话这个时候才处理 Invalidate 消息。

invalidate queue和store buffer类似,也会造成全局顺序问题,这里就不展开叙述了。

内存屏障


Linux操作系统面向cpu抽象出了自己的一套内存屏障函数,它们分别是:

smp_rmb(): 在invalid queue的数据被刷完之后再执行屏障后的读操作。
smp_wmb(): 在store buffer的数据被刷完之后再执行屏障后的写操作。
smp_mb(): 同时具有读屏障和写屏障功能。

voliate解决可见性问题


最后来说说voliate,它使用了lock前缀指令,这个指令和MESI没关系,但它能达到内存屏障的效果。

Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令

上一篇:
私有音乐云
下一篇:
Thread.sleep(0)的奇怪现象
本文目录
本文目录