张伦聪的技术博客 Research And Development

指令重排与cpu缓存伪共享问题


指令重排

As-if-serial语义

所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。比如,为了保证这一语义,重排序不会发生在有数据依赖的操作之中。

int a = 1;
int b = 2;
int c = a + b;

将上面的代码编译成Java字节码或生成机器指令,可视为展开成了以下几步动作(实际可能会省略或添加某些步骤)。

1.对a赋值1

2.对b赋值2

3.取a的值

4.取b的值

5.将取到两个值相加后存入c

在上面5个动作中,动作1可能会和动作2、4重排序,动作2可能会和动作1、3重排序,动作3可能会和动作2、4重排序,动作4可能会和1、3重排序。但动作1和动作3、5不能重排序。动作2和动作4、5不能重排序。因为它们之间存在数据依赖关系,一旦重排,as-if-serial语义便无法保证。

内存访问重排序与内存可见性

计算机系统中,为了尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能。其模型如下图所示。

在这种模型下会存在一个现象,即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。有的观点会将这种现象也视为重排序的一种,命名为“内存系统重排序”。因为这种内存可见性问题造成的结果就好像是内存访问指令发生了重排序一样。就拿上面的例子来看1,2步可能在不同cpu cache中处理速度可能不同。1.对a赋值1和2.对b赋值2顺序不一定是按照先执行1再执行2的顺序。

内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。对于java内存模型中主内存到私有内存原语read,load,use,asign,store,write的一种规则。

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的.在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

cpu cache的优化,解决伪共享问题

我们知道,为了提高CPU的速度,CPU有一个高速缓存Cache。在高速缓存中,读写数据的最小单位为缓存行(Cache Line),它是从主存(memory) 复制到缓字(Cache) 的最小单位,一般为32字节到128字节。如果两个变量存放在一个缓存行中时,在多线程访问中,可能会相互影响彼此的性能。如图5.4所示,假设X和Y在同一个缓存行。运行在CPU1上的线程更新了X,那么CPU2上的缓存行就会失效,同一行的Y即使没有修改也会变成无效,导致Cache无法命中。接着,如果在CPU2.上的线程更新了Y,则导致CPU1上的缓存行又失效(此时,同一行的X又变得无法访问)。这种情况反反复复发生,无疑是一个潜在的性能杀手。如果CPU经常不能命中缓存,那么系统的吞吐量就会急剧下降。为了使这种情况不发生,一-种可行的做法就是在x变量的前后空间都先占据一定的位置(把它叫做padding吧,用来填充用的)。这样,当内存被读入缓存中时,这个缓存行中,只有x一个变量实际是有效的,因此就不会发生多个线程同时修改缓存行中不同变量而导致变量全体失效的情况,如图5.5 所示。


如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!

¥ 打赏博主

上一篇 kafka基本原理

留言