java-5.1 disruptor

340 阅读9分钟

需求

Disruptor是开源的并发框架,是一种高效的“生产者-消费者”模型。最早由LMAX(一种新型零售金融交易平台)提出并使用,能够在无锁的情况下实现队列的并发操作,并号称能够在一个线程里每秒处理6百万笔订单。性能远高于传统的BlockingQueue。

Disruptor应运而生的优势: (1) 使用环形数组队列,访问比链表快,位运算定位快 (2) 队列中存储的对象预先建立,减少频繁创建或释放对象开销,避免垃圾回收 (3) 生产者两阶段提交发布事件,第一阶段占空位使用CAS,第二阶段填数据并通知消费者 (4) 使用缓存区填充解决”伪共享”。

设计

架构

image.png

可以看到有3个事件处理程序(JournalConsumer、ReplicationConsumer和ApplicationConsumer)正在侦听Disruptor,其中每个事件处理程序将接收Disruptor中可用的所有消息(顺序相同)。

ApplicationConsumer依赖于JournalConsumer和ReplicationConsumer。这意味着JournalConsumer和ReplicationConsumer可以彼此并行地自由运行。从ApplicationConsumer的SequenceBarrier到JournalConsumer和ReplicationConsumer序列的连接可以看出依赖关系。还值得注意的是,排序器与下游用户的关系。它的角色之一是确保发布不会封装循环缓冲区。为了做到这一点,任何下游使用者的序列都不能低于环缓冲区的序列,也不能小于环缓冲区的大小。然而,使用依赖关系图可以进行有趣的优化。因为ApplicationConsumer的序列被保证小于或等于JournalConsumer和ReplicationConsumer(这就是依赖关系所确保的),所以排序器只需要查看ApplicationConsumer的序列。从更一般的意义上说,排序器只需要知道依赖关系树中的叶节点的消费者的序列。

(1)消费者序号数值必须小于生产者序号数值;

(2)消费者序号数值必须小于其前置(依赖关系)消费者的序号数值;

(3)生产者序号数值不能大于消费者中最小的序号数值,以避免生产者速度过快,将还未来得及消费的消息覆盖。

概念

image.png

  • Disruptor:Disruptor的入口,主要封装了环形队列RingBuffer、消费者集合ConsumerRepository的引用;主要提供了获取环形队列、添加消费者、生产者向RingBuffer中添加事件的操作; 
  • RingBuffer:Disruptor中队列具体的实现,底层封装了Object[]数组;在初始化时,会使用Event事件对数组进行填充,填充的大小就是bufferSize设置的值;此外,该对象内部还维护了Sequencer(序列生产器)具体的实现; 
  • Sequencer:序列生产器,分别有MultiProducerSequencer(多生产者序列生产器) 和 SingleProducerSequencer(单生产者序列生产器)两个实现类;
  • Sequence:序列对象,内部维护了一个long型的value,这个序列指向了RingBuffer中Object[]数组具体的角标。生产者和消费者各自维护自己的Sequence;但都是指向RingBuffer的Object[]数组;
  • WaitStrategy:等待策略。当没有可消费的事件时,消费者根据特定的策略进行等待;当没有可生产的地方时,生产者根据特定的策略进行等待; 
  • Event:事件对象,就是我们Ringbuffer中存在的数据,在Disruptor中用Event来定义数据;
  • EventProcessor:有两个有操作逻辑的实现类,BatchEventProcessor与WorkProcessor ;
  • EventHandler:事件处理器,由用户自定义实现,也就是最终的事件消费者,需要实现EventHandler接口;

流程

一个生产者生产过程

生产者单线程写数据的流程比较简单:

image.png

申请写入m个元素; 若是有m个元素可以入,则返回最大的序列号。这儿主要判断是否会覆盖未读的元素; 若是返回的正确,则生产者开始写入元素。

多个生产者生产过程

多个生产者写入的时候:

申请写入m个元素;

若是有m个元素可以写入,则返回最大的序列号。每个生产者会被分配一段独享的空间;

生产者写入元素,写入元素的同时设置available Buffer里面相应的位置,以标记自己哪些位置是已经写入成功的。

如下图所示,Writer1和Writer2两个线程写入数组,都申请可写的数组空间。Writer1被分配了下标3到下表5的空间,Writer2被分配了下标6到下标9的空间。

image.png Writer1写入下标3位置的元素,同时把available Buffer相应位置置位,标记已经写入成功,往后移一位,开始写下标4位置的元素。Writer2同样的方式。最终都写入完成。

多个生产者的消费者消费过程

生产者多线程写入的情况会复杂很多:

申请读取到序号n;

若writer cursor >= n,这时仍然无法确定连续可读的最大下标。从reader cursor开始读取available Buffer,一直查到第一个不可用的元素,然后返回最大连续可读元素的位置;

消费者读取元素。

如下图所示,读线程读到下标为2的元素,三个线程Writer1/Writer2/Writer3正在向RingBuffer相应位置写数据,写线程被分配到的最大元素下标是11。

读线程申请读取到下标从3到11的元素,判断writer cursor>=11。然后开始读取availableBuffer,从3开始,往后读取,发现下标为7的元素没有生产成功,于是WaitFor(11)返回6。

然后,消费者读取下标从3到6共计4个元素。

image.png

wait策略

  1. 阻塞策略 BlockingWaitStrategy(加锁):默认策略,没有获取到任务的情况下线程会进入等待状态。cpu 消耗少,但是延迟高。

  2. 阻塞限时策略 TimeoutBlockingWaitStrategy (加锁,有超时限制) :相对于BlockingWaitStrategy来说,设置了等待时间,超过后抛异常。

  3. 自旋策略 BusySpinWaitStrategy (自旋) :线程一直自旋等待。cpu 占用高,延迟低.

  4. Yield 策略 YieldingWaitStrategy (自旋+yield+自旋) :尝试自旋 100 次,然后调用 Thread.yield() 让出 cpu。cpu 占用高,延迟低。

  5. 睡眠策略 SleepingWaitStrategy (自旋+yield+sleep) :尝试自旋 100次,然后调用 Thread.yield() 100 次,如果经过这两百次的操作还未获取到任务,尝试前睡眠一个纳秒级别的时间再尝试。

底层原理

数据结构层面

环形数组 ringbuffer

为了避免垃圾回收,采用数组而非链表。同时,数组对处理器的缓存机制更加友好。 数组长度2^n,通过位运算,加快定位的速度。下标采取递增的形式。不用担心index溢出的问题。index是long类型,即使100万QPS的处理速度,也需要30万年才能用完。

并发层面

单线程写

Disruptor的RingBuffer,之所以可以做到完全无锁,也是因为”单线程写“,这是所有”前提的前提“。离开了这个前提条件,没有任何技术可以做到完全无锁

内存屏障

编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。 内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。

这意味着如果你对一个volatile字段进行写操作,你必须知道: 1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。 2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。

RingBuffer的指针(cursor)属于一个神奇的volatile变量,同时也是我们能够不用锁操作就能实现Disruptor的原因之一。

生产两阶段提交

多个生产者的情况下,会遇到“如何防止多个线程重复写同一个元素”的问题。Disruptor的解决方法是,每个线程获取不同的一段数组空间进行操作。这个通过CAS很容易达到。只需要在分配元素的时候,通过CAS判断一下这段空间是否已经分配出去即可。

但是会遇到一个新问题:如何防止读取的时候,读到还未写的元素。Disruptor在多个生产者的情况下,引入了一个与Ring Buffer大小相同的buffer:available Buffer。当某个位置写入成功的时候,便把availble Buffer相应的位置置位,标记为写入成功。读取的时候,会遍历available Buffer,来判断元素是否已经就绪。

内存层面

预分配内存

队列中存储的对象预先建立,减少频繁创建或释放对象开销,避免垃圾回收

缓存行填充

这种无法充分使用缓存行特性的现象,称为伪共享。 对于伪共享,一般的解决方案是,增大数组元素的间隔使得由不同线程存取的元素位于不同的缓存行上,以空间换时间。

场景

  1. 多个消费者, 每个消费者都有机会消费相同数据(重复消费同一个数据)。每一种消费者一个实例实现EventHandler消费数据,使用handleEventsWith方法

    Disruptor.handleEventWith(new C11EventHandler(),new C11EventHandler(),...).then(...);

  2. 多个消费者,每个消费者消费不同数据。每个消费者实现WorkHandler接口,使handleEventsWithWorkerPool方法

    Disruptor.handleEventWithWorkerPool(new C11EventHandler(),new C11EventHandler(),...)
    

image.png

参考