云时代架构阅读笔记九——Disruptor无锁框架为啥这么快

原文连接:https://mp.weixin.qq.com/s/YTZEwp_7x10hIDZCItwp8w

首先介绍几个概念

1.1 CPU缓存

在现代计算机当中,CPU是大脑,最终都是由它来执行所有的运算。而内存(RAM)则是血液,存放着运行的数据;但是,由于CPU和内存之间的工作频率不同,CPU如果直接去访问内存的话,系统性能将会受到很大的影响,所以在CPU和内存之间加入了三级缓存,分别是L1、L2、L3。

当CPU执行运算时,它首先会去L1缓存中查找数据,找到则返回;如果L1中不存在,则去L2中查找,找到即返回;如果L2中不存在,则去L3中查找,查到即返回。如果三级缓存中都不存在,最终会去内存中查找。对于CPU来说,走得越远,就越消耗时间,拖累性能。

在三级缓存中,越靠近CPU的缓存,速度越快,容量也越小,所以L1缓存是最快的,当然制作的成本也是最高的,其次是L2、L3。

CPU频率,就是CPU运算时的工作的频率(1秒内发生的同步脉冲数)的简称,单位是Hz。主频由过去MHZ发展到了当前的GHZ(1GHZ=10^3MHZ=10^6KHZ= 10^9HZ)。

内存频率和CPU频率一样,习惯上被用来表示内存的速度,内存频率是以MHz(兆赫)为单位来计量的。目前较为主流的内存频率1066MHz、1333MHz、1600MHz的DDR3内存,2133MHz、2400MHz、2666MHz、2800MHz、3000MHz、3200MHz的DDR4内存。

1.2 缓存行

当数据被加载到三级缓存中,它是以缓存行的形式存在的,不是一个单独的项,也不是单独的指针。

在CPU缓存中,数据是以缓存行(cache line)为单位进行存储的,每个缓存行的大小一般为32—256个字节,常用CPU中缓存行的大小是64字节;CPU每次从内存中读取数据的时候,会将相邻的数据也一并读取到缓存中,填充整个缓存行;

可想而知,当我们遍历数组的时候,CPU遍历第一个元素时,与之相邻的元素也会被加载到了缓存中,对于后续的遍历来说,CPU在缓存中找到了对应的数据,不需要再去内存中查找,效率得到了巨大的提升;

但是,在多线程环境中,也会出现伪共享的情况,造成程序性能的降低,堪称无形的性能杀手;

1.2.1 缓存命中

代码示例:

测试代码:

public class CacheHit {

    //二维数组:
    private static long[][] longs;

    //一维数组长度:
    private static int length = 1024*1024;

    public static void main(String [] args) throws InterruptedException {
        //创建二维数组,并赋值:
        longs = new long[length][];
        for(int x = 0 ;x < length;x++){
            longs[x] = new long[6];
            for(int y = 0 ;y<6;y++){
                longs[x][y] = 1L;
            }
        }
        cacheHit();
         cacheMiss();
    }
    //缓存命中:
    private static void cacheHit() {
        long sum = 0L;
        long start = System.nanoTime();
        for(int x=0; x < length; x++){
            for(int y=0;y<6;y++){
                sum += longs[x][y];
            }
        }
        System.out.println("命中耗时:"+(System.nanoTime() - start));
    }
    //缓存未命中:
    private static void cacheMiss() {
        long sum = 0L;
        long start = System.nanoTime();
        for(int x=0;x < 6;x++){
            for(int y=0;y < length;y++){
                sum += longs[y][x];
            }
        }
        System.out.println("未命中耗时:"+(System.nanoTime() - start));
    }
}

测试结果:

未命中耗时:43684518
命中耗时:19244507

在Java中,一个long类型是8字节,而一个缓存行是64字节,因此一个缓存行可以存放8个long类型。但是,在内存中的布局中,对象不仅包含了实例数据(long类型变量),还包含了对象头。对象头在32位系统上占用8字节,而64位系统上占用16字节。

所以,在上面的例子中,笔者向二维数组中填充了6个元素,占用了48字节。

在cacheHit()的例子中,当第一次遍历的时候,获取longs[0][0],而longs[0][0]—longs[0][5]也同时被加载到了缓存行中,接下来获取longs[0][1],已存在缓存行中,直接从缓存中获取数据,不用再去内存中查找,以此类推;

在cacheMiss()的例子中,当第一次遍历的时候,也是获取longs[0][0]的数据,longs[0][0]—longs[0][5]也被加载到了缓存行中,接下来获取long[1][0],不存在缓存行中,去内存中查找,以此类推;

以上的例子可以充分说明缓存在命中和未命中的情况下,性能之间的差距。

1.2.2 伪共享

由于CPU加载机制,某个数据被加载的同时,其相邻的数据也会被加载到CPU当中。在得到CPU免费加载的同时,也产生了不好的情况;俗话说得好,凡事都有利有弊。

在我们的java程序中,当多个线程修改两个独立变量的时候,如果这两个变量存在于一个缓存行中,那么就有很大的概率产生伪共享。

这是为什么呢?

现如今,CPU都是多核处理器,一般为2核或者4核,当我们程序运行时,启动了多个线程。例如:核心1启动了1个线程,核心2启动了1个线程,这2个线程分别要修改不同的变量,其中核心1的线程要修改x变量,而核心2的线程要修改y变量,但是x、y变量在内存中是相邻的数据,他们被加载到了同一个缓存行当中,核心1的缓存行有x、y,核心2的缓存行也有x、y。

那么,只要有一个核心中的线程修改了变量,另一个核心的缓存行就会失效,导致数据需要被重新到内存中读取,无意中影响了系统的性能,这就是伪共享。

cpu的伪共享问题本质是:几个在内存中相邻的数据,被CPU的不同核心加载在同一个缓存行当中,数据被修改后,由于数据存在同一个缓存行当中,进而导致缓存行失效,引起缓存命中降低。

1.2.3 MESI协议

多核理器中,每个核心都有自己的cache,内存中的数据可以同时处于不同的cache中,若各个核心独立修改自己的cache,就会出现不一致问题。为了解决一致性问题,MESI协议被引入。

MESI(Modified Exclusive Shared Or Invalid)是一种广泛使用的支持写回策略的缓存一致性协议,该协议最早被应用在Intel奔腾系列的CPU中。

其实,MESI协议就是规定了缓存行的4种状态,以及这4种状态之间的流转,以来保证不同核心中缓存的一致;每种状态在缓存行中用2个bit位来进行描述,分别是修改态(M)、独享态(E)、共享态(S)、无效态(I);

在CPU中,每个核心不但控制着自己缓存行的读写操作,而且还监听这其他核心中缓存行的读写操作;每个缓存行的状态受到本核心和其他核心的双重影响;

下面,我们就阐述下这4中状态的流转:

(1)I--本地读请求:CPU读取变量x,如果其他核中的缓存有变量x,且缓存行的状态为M,则将该核心的变量x更新到内存,本核心的再从内存中读取取数据,加载到缓存行中,两个核心的缓存行状态都变成S;如果其他核心的缓存行状态为S或者E,本核心从内存中杜取数据,之后所有核心中的包含变量x的缓存行状态都变成S。

(2)I--本地读请求:CPU读取变量x,如果其他核中的缓存没有变量x,则本核心从内存中读取变量x,存入本核心的缓存行当中,该缓存行状态变成E;

(3)I--本地写请求:CPU读取写入变量x,如果其他核中没有此变量,则从内存中读取,在本核心中修改,此缓存行状态变为M;如果其他缓存行中有变量x,并且状态为M,则需要先将其他核心中的变量x写回内存,本核心再从内存中读取;如果其他缓存行中有变量x,并且状态为E/S,则将其他核心中的缓存行状态置为I,本核心在从内存中读取变量x,之后将本核心的缓存行置为M;

注意,一个缓存除在Invalid状态外都可以满足CPU的读请求,一个invalid的缓存行必须从主存中读取(变成S或者 E状态)来满足该CPU的读请求。

(4)S--远程写请求:多个核心共享变量X,其他核心将变量x修改,本核心中的缓存行不能使用,状态变为I;

(5)S--本地读请求:多个核心共享变量X,本核心读取本缓存中的变量x,状态不变;

(6)S--远程读请求:多个核心共享变量X,其他核心要读取变量X,从主内存中读取变量x,状态置为S,本核心状态S不变;

(7)S--本地写请求:多个核心共享变量X,本核心修改本缓存行中的变量x,必须先将其他核心中所拥有变量x的缓存行状态变成I,本核心缓存行状态置为M;该操作通常使用RequestFor Ownership (RFO)广播的方式来完成;

(8)E--远程读请求:只有本核心拥有变量x,其他核心也要读取变量x,从内存中读取变量x,并将所有拥有变量x的缓存行置为S状态;

(9)E--本地读请求:只有本核心拥有变量x,本核心需要读取变量x,读取本地缓存行中的变量x即可,状态不变依旧为E;

(10)E--远程写请求:只有本核心拥有变量x,其他核心需要修改变量x,其他核心从内存中读取变量x,进行修改,状态变成M,而本核心中缓存行变为状态I;

(11)E--本地写请求:只有本核心拥有变量x,本核心修改本缓存行中的变量x,状态置为M;

(12)M--本地写请求:只有本核心中拥有变量x,本核心进行修改x操作,缓存行状态不变;

(13)M--本地读请求:只有本核心中拥有变量x,本核心进行读取x操作,缓存行状态不变;

(14)M--远程读请求:只有本核心中拥有变量x,其他核心需要读取变量x,先将本核心中的变量x写回到内存中,在将本缓存行状态置为S,其他核心拥有变量x的缓存行状态也变为S;

(15)M--远程写请求:只有本核心中拥有变量x,其他和核心需要修改变量x,先将本核心中的变量x写回内存,再将本核心中缓存行置为I。其他核心的在从缓存行中读取变量x,修改后置为M;

1.3 CAS

前2节,我们已经讲了缓存行、伪共享的知识,本节来阐述Disruptor中另一个知识点—-CAS;那么,CAS是什么呢?

在Java中,多线程之间如何保证数据的一致性?想必大部分都会异口同声地说出锁—-synchronized锁。在JDK1.5之前,的确是使用synchronized锁来保证数据的一致性。但是,synchronized锁是一种比较重的锁,俗称悲观锁。在较多线程的竞争下,加锁、释放锁会对系统性能产生很大的影响,而且一个线程持有锁,会导致其他线程的挂起,直至锁的释放。

那么,有没有比较轻的锁呢,答案是有的!与之相对应的是乐观锁!乐观锁虽然名称中带有锁,但实际在代码中是不加锁的,乐观锁大多实现体现在数据库sql层面,通常是的做法是:为数据增加一个版本标识,在表中增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

update XXX_TABLE SET MONEY = 100 AND VERSION = 11 WHERE ID = 1 AND VERSION = 10;

这就是乐观锁!

上面说到了数据库层面的乐观锁,那么代码层面有没有类似的实现?答案是,有的!那就是我们本小节的主角—CAS;

CAS是一个CPU级别的指令,翻译为Compare And Swap比较并交换;

CAS是对内存中共享数据操作的一种指令,该指令就是用乐观锁实现的方式,对共享数据做原子的读写操作。原子本意是“不能被进一步分割的最小粒子”,而原子操作意为”不可被中断的一个或一系列操作”。原子变量能够保证原子性的操作,意思是某个任务在执行过程中,要么全部成功,要么全部失败回滚,恢复到执行之前的初态,不存在初态和成功之间的中间状态。

CAS有3个操作数,内存中的值V,预期内存中的值A,要修改成的值B。当内存值V和预期值相同时,就将内存值V修改为B,否则什么都不做。

1.4 Disruptor中的运用

上面,说了分别说了CAS、缓存行、伪共享。接下来,就来看看再Disruptor中是如何使用的!

在多生产者的环境下,更新下一个可用的序列号地方,我们使用CAS(Compare And Swap)操作。

猜你喜欢

转载自www.cnblogs.com/DaisyYuanyq/p/11043117.html