002-disruptor原理

一、概述

优点

  1. 没有用锁,使用了cpu的CAS,效率大大提高
  2. 没有使用队列来缓冲数据,而是使用了ringbuffer来避免竞争
  3. 每个访问者(包括生产者和消费者)都有自己的sequence来访问,减少竞争
  4. 使用cache line padding 来避免内存伪共享

1.1、无锁

  要想提高内存队列的性能,首先需要解决的就是并发环境下锁的开销问题。JDK内置的内存队列,有的是有锁的,有的是无锁的,其中无锁的都是无界(List)队列,而Disruptor就是要基于环形数组提供一种无锁的有界(Array)队列。

  锁是为了解决多线程并发环境下的冲突问题,为了避免产生脏数据,就需要对临界对象加锁。但是锁是很慢的,在获得锁和释放锁的过程中都会产生性能开销,并且如果锁的使用不当,还会产生死锁。

  锁分为悲观锁和乐观锁。

    悲观锁就是一个线程获得锁后,其他线程都处于等待状态,随着等待线程的增多,系统的响应性就会越来越慢。

    乐观锁不是对临界对象加锁,而是线程在修改变量时,会比较与期望的值是否相同,如果相同则修改,如果不同则根据策略是一直重试或者直接抛出异常。

  Disruptor的解决方案是基于CAS算法实现无锁,JDK内置的ConcurrentLinkedQueue也是基于CAS算法实现的无界队列。CAS是一个CPU级别的指令,工作方式类似于乐观锁——这里需要说下,乐观锁并不是一种算法或者实现,而是一种思想,CAS才是基于乐观锁这个思想形成的算法实现。CAS操作比锁消耗资源少得多,因为不牵涉操作系统,直接在CPU上操作。当然,CAS虽然比锁消耗资源少,但是相对于单线程无锁而言,还是耗性能的。针对这种情况,Disruptor针对对RingBuffer的写入分别支持单线程模式和多线程模式,在写入过程中会出现对共享对象sequence的操作,针对单线程模式,使用Long类型不加锁,对于多线程模式,使用AtomicLong类型的CAS方式。

1.2、伪共享

1.2.1、共享

  在多核计算机中,软件也越来越多的支持多核运行,其实也可以叫做多处理运行。一个处理器对应一个物理插槽。其中一个插槽对应一个L3 Cache,一个槽包含多个cpu。一个cpu包含寄存器、L1 Cache、L2 Cache,如下图所示:

    

  其中越靠近cpu则,速度越快,容量则越小。其中L1和L2是只能给一个cpu进行共享,但是L3是可以给同一个槽内的cpu共享,而主内存,是可以给所有的cpu共享,这就是内存的共享。

  其中cpu执行运算的流程是这样:首先回去L1里面查找对应数据,如果没有则去L2、L3,如果都没有,则就会去主内存中去拿,走的路越长,则耗费时间越久,性能就会越低。

  需要注意的是,当线程之间进行共享数据的,需要将数据写回到主内存中,而另一个线程通过访问主内存获得新的数据。

  有人就会问了,多个线程之间不是会有一些非主内存的缓存进行共享么,那么另外一个线程会不会直接访问到修改之前的内存呢。答案是会的,但是有一点,就是这种数据我们可以通过设置缓存失效测试来进行保证缓存的最新,这个方式其实在cpu这里进行设置的,叫内存屏障(其实就是在cpu这里设置一条指令,这个指令就是禁止cpu重排序,这个屏障之前的不能出现在屏障之后,屏障之后的处理不能出现屏障之前,也就是屏障之后获取到的数据是最新的),对应到应用层面就是一个关键字volatile。

1.2.2、缓存行

  缓存失效其实指的是Cache line的失效,也就是缓存行,Cache是由很多个Cache line 组成的,每个缓存行大小是32~128字节(通常是64字节)。我们这里假设缓存行是64字节,而java的一个Long类型是8字节,这样的话一个缓存行就可以存8个Long类型的变量,如下图所示:

    

 

  cpu 每次从主内存中获取数据的时候都会将相邻的数据存入到同一个缓存行中。假设我们访问一个Long内存对应的数组的时候,如果其中一个被加载到内存中,那么对应的后面的7个数据也会被加载到对应的缓存行中,这样就会非常快的访问数据。

 

 1.2.3、伪共享

  伪共享的问题也是基于CPU级别的机制引起的。 

  缓存的失效其实就是缓存行的失效,缓存行失效的原理是什么,这里又涉及到一个MESI协议(缓存一致性协议),首先我们用Disruptor中很经典的讲解伪共享的图来讲解下:

    

  上图中显示的是一个槽的情况,里面是多个cpu, 如果cpu1上面的线程更新了变量X,根据MESI协议,那么变量X对应的所有缓存行都会失效,这个时候如果cpu2中的线程进行读取变量Y,发现缓存行失效,就会按照缓存查找策略,往上查找,如果cpu1对应的线程更新变量X后又访问了变量X,那么左侧的L1、L2和槽内的L3 缓存行都会得到生效。这个时候cpu2线程可以在L3 Cache 中得到生效的数据,否则的话(即cpu1对应的线程更新X后没有访问X)cpu2的线程就只能从主内存中获取数据,对性能就会造成很大的影响,这就是伪共享。

  表面上 X 和 Y 都是被独立线程操作的,而且两操作之间也没有任何关系。只不过它们共享了一个缓存行,但所有竞争冲突都是来源于共享。

  如果相邻的变量对该CPU而言是不相干的,也就是说CPU1只关心变量1,CPU2只关心变量2,但是CPU1和CPU2在分别拉取变量1和变量2的都将这两个变量拉倒了自己的L1空间。此时如果CPU1修改了变量1的值,CPU为了保障修改的值被其他CPU看到,基于内存屏障的机制,会将修改的变量立即刷新到主内存中。而此时CPU2在获取变量2的值时,却不得不到主内存中获取。CPU2修改变量2的值时,也会影响CPU1对变量1的访问。这就是伪共享。

1.2.4、ArrayBlockingQueue伪共享问题

public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        //获取当前对象锁
        lock.lockInterruptibly();
        try {
            while (count == items.length)
                //阻塞并释放锁,等待notFull.signal()通知
                notFull.await();
            //将数据放入数组
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }
private void enqueue(E x) {
        final Object[] items = this.items;
        //putIndex 就是入队的下标
        items[putIndex] = x;
        if (++putIndex == items.length)
            putIndex = 0;
        count++;
        notEmpty.signal();
    }
public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        //加锁
        lock.lockInterruptibly();
        try {
            while (count == 0)
                //阻塞并释放对象锁,并等待notEmpty.signal()通知
                notEmpty.await();
            //在数据不为空的情况下
            return dequeue();
        } finally {
            lock.unlock();
        }
    }
private E dequeue() {
    final Object[] items = this.items;
    //takeIndex 是出队的下标
    E x = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length)
        takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    notFull.signal();
    return x;
}
View Code

 

其中最核心的三个成员变量为

putIndex:入队下标

takeIndex:出队下标

count:队列中元素的数量

而三个成员的位置如下:

  

 

 

 这三个变量很容易放到同一个缓存行中

1.3、缓存行填充

  缓冲行填充是为了解决伪共享带来的问题。既然每个CPU会将访问的变量相邻的64字节的变量拉倒自己的内存空间,那么可以在该变量上再新建几个空变量满足64个字节不就可以了么。即使出现伪共享,也不会影响其他CPU。所以在Disruptor的RingBuffer源码中可以看到有几个Long类型的变量P1,P2,P3,P4,P5,P6,P7,就是为了填充。

二、高性能原理

队列的两个性能问题:一个是加锁,一个是伪共享,那么disruptor是怎么解决这两个问题的,以及除了解决这两个问题之外,还引入了其他什么先进的东西提升性能的。

这里简单列举下:

  • 引入环形的数组结构:数组元素不会被回收,避免频繁的GC,

  • 无锁的设计:采用CAS无锁方式,保证线程的安全性

  • 属性填充:通过添加额外的无用信息,避免伪共享问题

  • 元素位置的定位:采用跟一致性哈希一样的方式,一个索引,进行自增

2.1、环形Buffer数组【ringbuffer】

  环形Buffer是一个数组,在Disruptor提高性能方面也起着重要作用,具体方面如下。

  1、环形数组,随着你不停的往数组中填充数据,生产者的序号sequence会增长,绕过这个环sequence % ringbuffer.size() 就是该sequence在这个环中的位置
  2、环的大小是2的指数,元素定位可以通过位运算效率会更高
  5、给数组预分配内存,不删除ringBuffer中的数据,元素采用覆盖方式,数组对象可以一直存在,避免jvm的GC
  6、通过sequence访问数组,比链表更快,(在内存上也是连续存储的),这对CPU的缓存也更加友好,在硬件级别上,数组可以被预加载,CPU不用每次去主存储器加载数组中下一个元素。

    

 

  其实质只是一个普通的数组,只是当放置数据填充满队列(即到达2^n-1位置)之后,再填充数据,就会从0开始,覆盖之前的数据,于是就相当于一个环。

2.1.1、数组

  通过数组预分配内存,减少节点操作空间释放和申请的过程,从而减少GC次数。并且由于数据元素内存地址是连续的,基于缓存行的机制,数组中的数据会被预加载到CPU的L1空间中,就无需到主内存中加载下一个元素,从而提高了数据访问性能。

2.1.2、求余操作优化

  在新建Disruptor的实例时,需要设置bufferSize,并且官方说明该值必须是2的N次方,否则会抛出异常。那么为什么会需要2的N次方呢?主要是为了求余的优化。求余操作本身是一个高耗费的操作,但是在Disruptor中,通过位操作来高效实现求余,这需要值是2的N次方才能保证结果的唯一性。

2.1.3、不删除数据

  对数组中数据的删除也是比较消耗性能的,因为涉及到索引的重新排位,而环形Buffer中并不会删除已经被消费的数据,而是等到有新的数据覆盖它们。

2.2、内存屏障

1、一个CPU指令,确保一些特定操作的执行顺序,影响一些数据的可见性。编译器和CPU在保证输出结果一样的情况下为了优化性能会对指令进行重排序,插入一个内存屏障,就等于把指令分为屏障上和屏障下两个部分,上面的必须先于后面的执行

2、内存屏障还可以强制更新不同CPU的缓存

3、在Java中,关键词Volatile实现了这个内存屏障的功能,如果使用了这个字段,JMM(Java内存模型)会在这个字段的写操作后面加一个写屏障指令,在读操作前面加一个读屏障指令

4、说明volatile字段一旦完成写入,任何线程都会得到最新值,而在你写入前(读),确保看到的数据肯定是最新的。

5、RingBuffer的指针cursor就是一个volatile变量

6、Ringbuffer是一个环状数组,数组中每一位是一个Entry,每个Entry有它的sequence,当生产者对ringbuffer调用commit,会将cursor更新为该sequence,由于内存屏障的存在,其他所有线程CPU缓存的cursor都会更新为最新的数据(或者缓存失效),这样一来,消费者们就会获得最新的cursor

7、消费者获得最新的cursor,可以进行消费动作,值得一提的是,如果这个时候有多个消费者,为了防止多个消费者对一个Entry进行操作,可以把消费者分为下游消费者和上游消费者,下游通过内存屏障跟踪上游消费者的操作,当上游执行完commit后,下游能看到缓存的变化。(话说这个是不是有点像Zookeeper分布式锁的单机版?下游节点监控上游节点)

8、内存屏障作为CPU指令,没有锁那样大的开销,当然,由于它导致CPU和编译器不能重排序,会导致没法高效利用CPU,而且刷新缓存也会有开销

9、volatile字段每次读写的开支都会比较大,因此有时候会获取一批Entry,全部执行完以后才会修改cursor字段,消费者和生产者都可以这样

2.3、Consumer 和 Producer

从ringbuffer中读取数据,通过consumerBarrier对象final long avaliableSeq = consumerBarrier.waitfor(nextSequence);consumerBarrier有一个waitStrategy来决定这个waitfor如何等待,拿到数据后,消费者会更新自己的cursor

producer和consumer一样,有一个producerBarrier来提供读写

producer的写入是二阶段提交(two phase commit),先会调用ProducerBarrier 的nextEntry(),然后调用他的commit方法,

ConsumerTrackingProducerBarrier对象维护了所有消费者列表,为了检测每个消费者读到了哪里

生产者

  在Disruptor中生产者分为单生产者和多生产者,而消费者并没有区分。单生产者情况下,就是普通的生产者向RingBuffer中放置数据,消费者获取最大可消费的位置,并进行消费。而多生产者时候,又多出了一个跟RingBuffer同样大小的Buffer,称为AvailableBuffer。在多生产者中,每个生产者首先通过CAS竞争获取可以写的空间,然后再进行慢慢往里放数据,如果正好这个时候消费者要消费数据,那么每个消费者都需要获取最大可消费的下标,这个下标是在AvailableBuffer进行获取得到的最长连续的序列下标。

消费者常见的等待

BusySpinWaitStrategy : 自旋等待,类似Linux Kernel使用的自旋锁。低延迟但同时对CPU资源的占用也多。

BlockingWaitStrategy : 使用锁和条件变量。CPU资源的占用少,延迟大,默认等待策略。

SleepingWaitStrategy : 在多次循环尝试不成功后,选择让出CPU,等待下次调度,多次调度后仍不成功,尝试前睡眠一个纳秒级别的时间再尝试。这种策略平衡了延迟和CPU资源占用,但延迟不均匀。

YieldingWaitStrategy : 在多次循环尝试不成功后,选择让出CPU,等待下次调。平衡了延迟和CPU资源占用,但延迟也比较均匀。

PhasedBackoffWaitStrategy : 上面多种策略的综合,CPU资源的占用少,延迟大

2.4、下标指针

  RingBuffer的指针(Sequence)属于一个volatile变量,同时也是我们能够不用锁操作就能实现Disruptor的原因之一,而且通过缓存行补充,避免伪共享问题。  该所谓指针是通过一直自增的方式来获取下一个可写或者可读数据,该数据是Long类型,不用担心会爆掉。有人计算过: long的范围最大可以达到9223372036854775807,一年365 * 24 * 60 * 60 = 31536000秒,每秒产生1W条数据,也可以使用292年

class LhsPadding{
    //缓存行补齐, 提升cache缓存命中率
    protected long p1, p2, p3, p4, p5, p6, p7;
}

class Value extends LhsPadding{
    protected volatile long value;
}

class RhsPadding extends Value{
    //缓存行补齐, 提升cache缓存命中率
    protected long p9, p10, p11, p12, p13, p14, p15;
}

public class Sequence extends RhsPadding{
    ...
}

 

三、详见问题

1.disruptor应该如何用才能发挥最大功效?

disruptor原本就是事件驱动的设计,其整个架构跟普通的多线程很不一样。比如一种用法,将disruptor作为业务处理,中间带I/O处理,这种玩法比多线程还慢;相反,如果将disruptor做业务处理,需要I/O时采用nio异步调用,不阻塞disruptor消费者线程,等到I/O异步调用回来后在回调方法中将后续处理重新塞到disruptor队列中,可以看出来,这是典型的事件处理架构,确实能在时间上占据优势,加上ringBuffer固有的几项性能优化,能让disruptor发挥最大功效。

2.如果buffer常常是满的怎么办?

一种是把buffer变大,另一种是从源头解决producer和consumer速度差异太大问题,比如试着把producer分流,或者用多个disruptor,使每个disruptor的load变小。

3. 什么时候使用disruptor?

如果对延迟的需求很高,可以考虑使用。

参看:https://www.yuque.com/simonalong/jishu/qhdcb2

 

posted @ 2020-06-28 22:11  bjlhx15  阅读(909)  评论(0)    收藏  举报
Copyright ©2011~2020 JD-李宏旭