线程基础:多任务处理——MESI协议以及带来的问题:伪共享

时间:2022-06-19 23:30:52

1、概述
 本文和后续文章将着眼CPU的工作原理阐述伪共享的解决方法和volatile关键字的应用。

2、复习CPU工作原理
2.1、CPU工作原理
要清楚理解本文后续内容,就需要首先重新概述一下JVM的内存工作原理。当然JVM的内存模型是一个可以专门作为另一个专题的较复杂知识点,所以这里我们只描述对下文介绍的伪共享、volatile关键字相关联的一些要点。这里我们不讨论JVM的内存模型,因为本专题之前的内容有过相关讨论(本专题后续还会讨论),也因为JVM内存模型的操作最终会转换成如下图所示的在内存、寄存器、CPU内核中的操作过程。

线程基础:多任务处理——MESI协议以及带来的问题:伪共享

如上图所示,当一个JVM线程进入“运行”状态后(这个状态的实际切换由操作系统进行控制),这个线程使用的变量(实际上存储的可能是某个变量实际的值,也可能是某个对象的内存地址)将基于缓存行的概念被换入CPU缓存(既L1、L2缓存)。通常情况下CPU在工作中将优先尝试命中L1、L2缓存中的数据,如果没有命中才会到主存中重新读取最新的数据,这是因为从L1、L2缓存中读取数据的时间远远小于从主存中读取数据的时间,且由于边际效应的原因,往往L1、L2中的数据命中率都很高(参见下表)

线程基础:多任务处理——MESI协议以及带来的问题:伪共享
(上表中时间的单位是纳秒。1秒=1000000000纳秒,也就是说1纳米极其短暂,短到光在1纳秒的时间内只能前进30厘米)。

请注意:每一个CPU物理内核都有其独立使用的L1、    L2缓存,一些高级的CPU中又存在可供多核共享的L3缓存,以便MESI的工作过程中能在L3中进行数据可见性读取。另外请注意,当CPU内核对数据进行修改时,通常来说被修改的数据不会立即回存到主存中(但最终会回写到主存中)。

那么当某一个数据(对象)A在多个处于“运行”状态的线程中进行读写共享时(例如ThreadA、ThreadB和ThreadC),就可能出现多种问题:首先是多个线程可能在多个独立的CPU内核中“同时”修改数据A,导致系统不知应该以哪个数据为准;又或者由于ThreadA进行数据A的修改后没有即时写会内存ThreadB和ThreadC也没有即时拿到新的数据A,导致ThreadB和ThreadC对于修改后的数据不可见。

2.2、MESI 协议及 RFO 请求
为了解决这个问题,CPU工程师设计了一套数据状态的记录和更新协议——MESI(中文名:CPU缓存一致性协议)。这个规则实际上由四种数据状态的描述构成,如下图所示:

线程基础:多任务处理——MESI协议以及带来的问题:伪共享

(图片摘自网络)其中:

M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
I(无效,Invalid):缓存行失效, 不能使用。
这里请注意一个关键点:CPU对于缓存状态的记录是以“缓存行”为单位。举个例子,一个CPU独立使用的一级缓存的大小为32KB,如果按照标准的一个“缓存行”为64byte计算,这个一级缓存最大容纳的“缓存行”为512行。一个缓存行中可能存储了多个变量值(例如一个64byte的缓存行理论上可以存储64 / 8 = 8个long型变量的值),那么只要这8个long型变量的任何一个的值发生了变化,都会导致该“缓存行”的状态发生变化(造成的其中一种后果请参见本文后续2.3小节描述的内容)。

CPU从本地寄存器读取数据:从本地寄存器读取数据时,可能遇到缓存行分别为M、E、S、I四种状态,实际上处理“I”状态以外的其它状态在进行读本地寄存器操作是,其状态标记都不会发生任何变化。而读取状态为“I”的缓存行时,由于缓存行已经失效,所以最终会在主存上读取数据并重新加载。

如果CPU中的寄存器控制器发现当前已经有其它寄存器拥有了该数据,则读取后将缓存行状态置为“S”,否则将该缓存行状态置为“E”。

CPU从远程寄存器读取数据:什么时候CPU会从远程寄存器上读取数据呢?就是上一步进行“I”状态缓存行读取时,如果寄存器控制器发现本数据已经存在于其它寄存器中的时候,就会发起远程读。在进行远程读操作时,远程缓存行可能处于“S”状态或者“E”状态,但无论处于哪种状态,在远程读操作成功后本地缓存行和所有远程缓存行的状态都将变成“S”状态。

CPU进行本地寄存器写操作:当CPU进行本地寄存器上指定缓存行的写操作时,这个缓存行可能处于M、E、S、I的任何状态。但是由于是进行了本地写操作,所以无论处于什么状态,操作成功后本地缓存行的最终状态都是“M”(这个情况是很好理解的)。我们先来讨论两种比较简单的情况,既是操作前这个缓存行的状态为“M”或者为“E”,这种状态下,由于不涉及其它寄存器的状态变化,所以只需要直接更改数据后,将状态变为“M”即可;接着讨论一种较复杂的情况,既是操作前缓存行的状态为“S”,这种情况下,说明在其它寄存器中也同时存在相同数据,这时需要首先将本地寄存器中的缓存行状态更改为“E”,并将其它寄存器中相同缓存行状态更改为“I”,再进行操作,且在操作后将本地寄存器的状态更改为“M”;最后说一说操作前本地缓存行状态为“I”的情况,这种情况下,说明缓存行已经过期,那么首先需要通过寄存器控制器重新读取数据,那么读取后的缓存行状态可能为“E”也可能为"S",当读取成功后,再视情况执行写操作,最终将该缓存行的状态更改为“M”。

CPU进行远程寄存器写操作:这里要明确一个概念,从上文已经描述的三个操作来看,CPU都是将操作数据通过寄存器控制器防止到本地寄存器中,再进行读/写操作。而寄存器控制器可能读取的是主存信息,也可能读取的是另外某个远程寄存器上读取。所以按照这样的描述就不应该有远程写的概念,那么这里的远程写又是什么意思呢?

实际上这里说的远程写,并不是真正意义上的直接将数据写到远程寄存器,而是说本地寄存器通过寄存器控制器读取了远程寄存器的数据后并不用于本地读操作,而是用于本地写的操作。也就是上文所述第“3”小点中,本地指定缓存行状态为“I”,且寄出器控制器从其它寄存器读取缓存行到本地缓存行的情况。

这种情况下,本地缓存行将会通过寄存器控制器向远程拥有相同缓存行的寄存器发送一个RFO请求(Request For Owner),要求其它所有寄存器将指定缓存行变更为“I”状态(实际上需要其它远程寄存器变更缓存行状态的需求,都会发送RFO请求)。

2.3、MESI 协议存在的问题
上述内容就是MESI状态变化的主要过程,请注意这里提到的RFO请求过程放在计算机的整个计算过程中来看,市是极为短暂的,但如果放在寄存器工作环境下来看,则是比较耗费时间的(单位在100纳秒以上)。在高并发情况下MESI协议还存在一些问题:

由于寄存器中处于“M”状态的数据不会立即更新到主存(虽然最终会写入主存),那么就导致在其它寄存器中的相同数据会出现短暂的数值差异。这个短暂的时间真的是非常短——一个纳秒级别的时间,但是在高并发情况下,依然会出现偶发性问题。也就是说在某一个变量值被多个线程共享的情况下,当某一个线程改变了这个变量的值,由于MESI协议的固有问题,另一个线程在很短暂的时间内是无法知晓值的变化的(但最终会知晓)。

要解决这个问题,其中一种方式就是使用锁(java中的synchronized方式或者lock锁的方式都行),但是这种解决方式又比较“重量级”,因为实际上这种场景下我们并不需要保证操作的原子性,所以还有一种更“轻量级”的解决方法,就是使用volatile关键字(这是volatile关键字的主存一致性场景,将在后面一篇文章中专门介绍)。

上文已经提到MESI协议的标记单位是“缓存行”,以一个一级缓存总容量为32Kbyte的寄存器来说,如果每一个缓存行定义的大小为64byte,那么整个寄存器就有512个“缓存行”。如果进行对象地址的换算,一个支持64位寻址长度计算机系统中,可以使用8个byte指向一个明确的内存地址起始位,也就是一个缓存行理论上最多可以存储8个对象的内存起始位置;如果再进行长整型换算(长整型为64位,也就是8个byte),那么可以得到一个缓存行可以存储8个长整型的数值。

设想一下这样的一个使用场景,某一“缓存行”中的多个变量(姑且认为就是长整型变量)被多个线程共享,其中线程1对变量A的值进行了修改,这时即使在另一个CPU内核工作的线程2没有对变量B进行修改,后者的“缓存行”也会被标记为“I”,当线程2要对变量B的值进行修改时,就必须使用RFO请求,到前者的寄存器上调取“缓存行”,并将前者寄存器“缓存行”的状态更改为“I”。这就导致了线程A和线程B虽然没有共享某一个数值对象,但是也出现了关联的状态强占的“活锁”情况。

3、伪共享及解决方法
上文2.3小节提到的多个CPU内核抢占同一缓存行上的不相关变量所引起的“活锁”情况,称之为伪共享。在高并发情况下,这种MESI协议引起的“活锁”情况反而降低了整个系统的性能。并且由于CPU和寄存器的工作调配并不是由Java程序员直接负责,所以这种伪共享问题很难发现和排查。

3.1、伪共享示例
请看如下代码片段:

package testCoordinate;

/**
 * 伪共享示例
 * yinwenjie
 */
public class FalseSharing1 {
  /**
   * 因为笔者做测试的电脑是8核CPU。
   * 这里我们不考虑多线程的状态切换因素,只考虑多线程在同一时间的MESI状态强占因素
   */
  private static final int CORENUIMBER = 8;
  private static VolatileClass[] volatileObjects = new VolatileClass[CORENUIMBER];
  static {
    // 这里不能使用Arrays.fill工具,原因自己去看
    for(int index = 0 ; index < CORENUIMBER ; index++) {
      volatileObjects[index] = new VolatileClass();
    }
  }
  public static void main(String[] args) throws Exception {
    /*
     * 测试过程为:
     * 1、首先创建和CORENUIMBER数量一致的线程对象和VolatileClass对象。
     * 2、这些线程各自处理各自的对应的VolatileClass对象,
     * 处理过程很简单,就是进行当前currentValue在二进制下的加法运算,当数值超过 达到2^32时终止
     * 3、记录整个过程的完成时间,并进行比较
     * 
     * 我们主要来看,看似多个没有关系的计算过程在不同代码编辑环境下的时间差异
     * 看整个3次的总时间(你也可以根据自己的实际情况进行调整,次数越多平均时间越准确)
     * */
    long totalTimes = 0l;
    int maxTimes = 3;
    for(int times = 0 ; times < maxTimes ; times++) {
      long startTime = System.currentTimeMillis();
      Thread[] testThreads = new Thread[CORENUIMBER];
      for(int index = 0 ; index < CORENUIMBER ; index++) {
        testThreads[index] = new Thread(new Handler(volatileObjects , index));
        testThreads[index].start();
      }
      
      // 等到所有计算线程终止,才继续了
      for(int index = 0 ; index < CORENUIMBER ; index++) {
        testThreads[index].join();
      }
      long endTime = System.currentTimeMillis();
      totalTimes += (endTime - startTime);
      
      System.out.println("执行完第" + times + "次");
    }
    
    System.out.println("time arra = " + (totalTimes / maxTimes));
  }
  /**
   * 该类就是模拟我们在缓存行中需要修改的数据对象
   * 其中有一个long类型的变量,就是用来进行修改的<br/>
   * 为了简单起见,这里就直接关掉了变量的修饰符
   */
  // 屏蔽以下两句注解将得到不一样的工作效率
  @SuppressWarnings("restriction")
  cc
  private static class VolatileClass {
    long currentValue = 0l;
  }
  private static class Handler implements Runnable {
    private int index;
    private VolatileClass[] volatileObjects;
    public Handler(VolatileClass[] volatileObjects , int index) {
      this.index = index;
      this.volatileObjects = volatileObjects;
    }

public void run() {
      Long number = 0l;
      while(number++ < 0xFFFFFFFFL) {
        volatileObjects[index].currentValue = number;
      }
    }
  }
}

以上代码在所描述的工作场景实际上在很多介绍伪共享的文章中都可以找到,笔者只是基于易读的目的出发进行了一些调整:代码中描述了N个线程(例如8个),每个线程持有独立的VolatileClass类的实例(注意,是“独立的”),每一个VolatileClass类的实例示例中只包括了一个长整型变量“currentValue ”,接下来我们让这些线程工作起来,各自对各自持有的currentValue 变量进行累加,直到达到0xFFFFFFFF这个上限值(注意,这里是位运算并不代表32位整形的最小值)。

那么整个代码在运行时就拥有了8个完全独立的currentValue工作在8个独立线程中,但是看似没有关联的8个变量赋值过程,却因为“有没有使用Contended注解”的区别,显示出较大的性能差异。如下表所示:

线程基础:多任务处理——MESI协议以及带来的问题:伪共享
注意,整个JDK使用的版本是JDK 8.0+,因为在不同的低版本的JDK版本下,体现性能差异的方式是不一样的;另外执行时,需要携带JVM参数“-XX:-RestrictContended”,这样Contended注解才能起作用。

3.2、性能差异原因
那么我们基于寄存器对多个currentValue变量在缓存行的存取方式,结合上文提到的MESI协议状态的变化,来解释一下为什么执行结果上会有这样的性能差异:

当没有增加“Contended”注解的时候,由于每个VolatileClass类的实例中只有一个长整型变量“currentValue”,再加上实例对象本身8byte的描述信息,所以总共是16byte,远远没有达到单缓存行64byte的大小限制。

再加上这些VolatileClass类的实例又是使用一个数组进行连续描述的,所以就出现了多个VolatileClass类的实例再计算过程中被放到了一个缓存行上(不一定是上文示例代码中8个VolatileClass对象都被放到了同一缓存行,而是说肯定有多个VolatileClass对象被放在了同一缓存行上)。

这个时候虽然多个线程使用了不同的VolatileClass对象(中的变量),但是都会导致同一缓存行的状态发生了变化,缓存行的无效状态变化将会非常频繁,导致了较高的无效性能消耗。

欢迎工作一到五年的Java工程师朋友们加入Java架构交流:874811168 群内提供免费的Java架构学习资料(里面有高可用、高并发、高性能及分布式、Jvm性能调优、Spring源码,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多个知识点的架构资料)合理利用自己每一分每一秒的时间来学习提升自己,不要再用"没有时间“来掩饰自己思想上的懒惰!趁年轻,使劲拼,给未来的自己一个交代!

3.3、特别说明
当笔者写作本篇文章的时候,查阅网络上的一些资料。但是发现其中一些文章对于伪共享的代码示意存在一些描述不完整的问题。当然这些问题都是可以理解的,因为有的文章发表时间都已经是3、4年前的事情了。很多文章中对于不同JDK处理“伪共享”的机制,没有分别进行说明。

上文已经提到有一种处理“伪共享”的方式,叫做“占位”。这种方式很好理解,就是将一个缓存行使用8个长整型变量全部占满(以单缓存行64byte为单位,其中一个对象的头描述战友8byte,所以实际上只需要7个长整型变量就可以全部占满),虽然这些变量中只有一个长整型在使用,但没有关系,因为保证了所有可能存在伪共享风险的变量肯定在不同的缓存行。如下代码示例:

线程基础:多任务处理——MESI协议以及带来的问题:伪共享
以上代码当然可以达到占位的目的,但实际上只能在JDK 1.7版本之前使用,因为JDK 1.7以及之后的版本会在Java文件的编译期将无用的变量自动忽略掉,这样就导致了设计失效。

而JDK1.8以及以后的版本,提供了一个注解“@sun.misc.Contended”来表示一个类的变量需要启用避免“伪共享”的配置。但是该注解默认情况下只用于JDK的原生包,如果需要在自己的代码中使用该注解,就需要在在启动时程序时携带JVM参数“-XX:-RestrictContended”。

出处:http://mp.toutiao.com/preview_article/?pgc_id=6616230919214744078