周末有读者告诉我,在接受字节跳动采访时,被问到:“什么是虚假分享?如何避免虚假分享?”这其实是CPU缓存的问题。图形系统也提到了。今天,我再告诉你。文CPU是如何读写数据的?我们先来了解一下CPU的架构。只有了解了CPU的架构,才能更好的理解CPU是如何读写数据的。现代CPU的架构图如下:可以看到,一个CPU中通常有多个CPU核心。比如上图中的1号和2号CPU核心,每个CPU核心都有自己的L1Cache和L2Cache,而L1Cache通常分为dCache(数据缓存)和iCache(指令缓存),以及L3Cache由多个核心共享,这是典型的CPU缓存层次结构。以上所说的都是CPU内部的Cache。往外看,会有内存和硬盘。这些存储设备共同构成了金字塔存储层次。如下图所示:从上图我们也可以看出,从上到下,存储设备的容量会越大,访问速度越慢。至于各个存储设备的访问延迟,可以看下图中的表格:可以看到CPU访问L1Cache比访问内存快100倍,这就是为什么会有L1~CPU中的L3缓存。就是利用Cache作为CPU和内存之间的缓存层,降低内存访问频率。CPU从内存中读取数据到Cache时,并不是逐字节读取,而是逐条读取数据。这段数据叫做CPULine(缓存线)。所以CPULine是CPU从内存中读取数据到Cache的单位。至于CPULine的大小,可以在Linux系统中通过以下方式查看。可以看到我的服务器L1CacheLine的大小是64字节,也就是说一次加载到L1Cache的数据大小是64字节。那么在加载数组的时候,CPU会将数组中连续的多个数据加载到Cache中,所以我们应该按照物理内存地址分布的顺序来访问元素,这样在访问数组元素的时候,Cache的命中率就会很高。因此,可以降低从内存中读取数据的频率,从而提高程序的性能。但是,当我们不使用数组,而是使用单独的变量时,就会出现Cachefalsesharing的问题。缓存错误共享是性能杀手,我们应该避免它。接下来我们来看看什么是Cache伪共享?以及如何避免这个问题?现在假设有一个双核CPU。这两个CPU内核并行运行两个不同的线程。他们同时从内存中读取了两个不同的数据,分别是long类型的变量A和B。这两个数据的地址在物理内存中是连续的。如果CacheLine的大小是64字节,变量A在CacheLine的开头,那么这两个数据就位于同一个CacheLine中,而由于CPULine是CPU从中读取数据的单位内存到Cache,所以这两个数据会同时读入到两个CPU核各自的Cache中。让我们考虑一个问题。如果不同核的两个线程分别修改不同的数据,比如1号CPU核的线程只修改变量A,或者2号CPU核的线程只修改变量B,会怎样?什么?伪共享问题分析下面结合MESI协议保证多核缓存的一致性来说明整个过程。①.最初,变量A和B都不在缓存中。假设核心1绑定线程A,核心2绑定线程B,线程A只能读写变量A,线程B只能读写变量B。。②.1号核读取变量A,由于CPU从内存中读取数据到Cache单元是CacheLine,而变量A和变量B的数据恰好属于同一个CacheLine,所以A的数据B将被加载。到缓存,并将此缓存行标记为“独占”。③.然后2号核开始从内存中读取变量B,同时也将CacheLine大小的数据读入Cache。该CacheLine中的数据还包括变量A和变量B。此时1号和2号核心的CacheLine状态变为“Shared”状态。④.1号核需要修改变量A,发现CacheLine的状态为“shared”,因此需要通过总线向2号核发送消息,通知2号核将Cache中相应的CacheLine标记为“无效”。”状态,则1号核对应的CacheLine状态变为“Modified”状态,变量A被修改。⑤.之后2号核需要修改变量B,此时2号核的Cache中对应的CacheLine无效。另外,因为1号核心的Cache也有相同的数据,状态为“Modified”,所以必须先将1号核心的Cache对应的CacheLine写回内存,然后2号核会从内存中读取CacheLine大小的数据到Cache中,最后修改变量B到2号核的Cache中。并将状态标记为“已修改”。因此可以发现,如果1号和2号CPU核心连续交替地分别修改变量A和B,会重复④和⑤这两个步骤,Cache不具备缓存的作用,虽然变量A和B其实没有任何关系,但是因为同时属于一个CacheLine,所以这个CacheLine中的任何数据修改后都会相互影响,从而产生了④和⑤两个步骤。因此,当多个线程同时读写同一个CacheLine的不同变量时,CPUCache失效的现象称为FalseSharing。避免falsesharing的方法因此,对于多线程共享的热点数据,也就是经常被修改的数据,应该避免这些数据刚好在同一个CacheLine中,否则会出现falsesharing的问题。接下来我们看看如何在实际项目中避免虚假分享的问题。Linux内核中有一个__cacheline_aligned_in_smp宏定义,用来解决伪共享问题。从上面的宏定义我们可以看出:如果在多核(MP)系统中,宏定义是__cacheline_aligned,也就是CacheLine的大小;如果在单核系统中,宏定义为空;因此,对于同一个CacheLine中的共享数据,如果多核之间的竞争比较严重,为了防止虚假共享,可以使用上面的宏定义,让CacheLine中的变量对齐。例如有如下结构:结构中的两个成员变量a和b在物理内存地址上是连续的,所以它们可能位于同一个CacheLine,如下图所示:因此,为了防止前面提到的解决Cachefalsesharing问题,我们可以使用上面介绍的宏定义,将b的地址设置为CacheLine对齐地址,如下:这样变量a和b就不会在同一个Cache中行,如下图:【外链图片传输失败,源站可能有防盗链机制,建议保存图片直接上传(img-xrJFlr5L-1653486388195)(https:///upload-images.jianshu...)]因此,避免虚假共享Cache的实际情况以上是用空间换时间的思路,浪费一部分Cache空间换取性能的提升。我们来看一个应用层的翻墙方案。有一个Java并发框架Disruptor,使用“字节填充+继承”来避免伪共享的问题。Disruptor中有一个RingBuffer类,经常被多线程使用。代码如下:你可能觉得RingBufferPad类中的7个long类型的名字很奇怪,但实际上,它们虽然看起来没什么用,但对性能是有好处的。阿森松起到了至关重要的作用。我们都知道CPUCache从内存中读取数据的单元就是CPULine。一般64位CPU的CPULine的大小是64字节,一个long类型的数据是8字节,所以CPU会加载8个long类型的数据。根据JVM对象继承关系中的父类成员和子类成员,内存地址连续排列,所以RingBufferPad中的7个long类型数据作为CacheLine的预填充,而这7个RingBuffer中的long类型数据是作为CacheLine后填充的,这14个long变量没有实际用途,更不用说对其进行读写操作了。另外,RingBufferFelds中定义的变量是final修饰的,也就是说在第一次加载后不会被修改,而且由于“前后”填充了7个长变量,无法读写,无论如何都是加载CacheLine,整个CacheLine中没有数据会被更新,所以只要频繁读取和访问数据,就不存在数据被换出Cache的可能,因此false的问题共享不会发生。
