12、Disruptor知识点总结

348 阅读7分钟

1、disruptor是什么?

Disruptor是一个开源框架,研发的初衷是为了解决高并发下队列锁的问题,最早由LMAX提出并使用,能够在无锁的情况下实现队列的并发操作,并号称能够在一个线程里每秒处理6百万笔订单。

基本概念

RingBuffer——Disruptor底层数据结构实现,核心类,是线程间交换数据的中转地;

Sequencer——序号管理器,生产同步的实现者,负责消费者/生产者各自序号、序号栅栏的管理和协调,Sequencer有单生产者,多生产者两种不同的模式,里面实现了各种同步的算法;

Sequence——序号,声明一个序号,用于跟踪ringbuffer中任务的变化和消费者的消费情况,disruptor里面大部分的并发代码都是通过对Sequence的值同步修改实现的,而非锁,这是disruptor高性能的一个主要原因;

SequenceBarrier——序号栅栏,管理和协调生产者的游标序号和各个消费者的序号,确保生产者不会覆盖消费者未来得及处理的消息,确保存在依赖的消费者之间能够按照正确的顺序处理。

EventProcessor——事件处理器,监听RingBuffer的事件,并消费可用事件,从RingBuffer读取的事件会交由实际的生产者实现类来消费;它会一直侦听下一个可用的序号,直到该序号对应的事件已经准备好。

EventHandler——业务处理器,是实际消费者的接口,完成具体的业务逻辑实现,第三方实现该接口;代表着消费者。 Producer——生产者接口,第三方线程充当该角色,producer向RingBuffer写入事件。

Wait Strategy:Wait Strategy决定了一个消费者怎么等待生产者将事件(Event)放入Disruptor中。

等待策略

「BlockingWaitStrategy」
Disruptor的默认策略是BlockingWaitStrategy。在BlockingWaitStrategy内部是使用锁和condition来控制线程的唤醒。BlockingWaitStrategy是最低效的策略,但其对CPU的消耗最小并且在各种不同部署环境中能提供更加一致的性能表现。

「SleepingWaitStrategy」
SleepingWaitStrategy 的性能表现跟 BlockingWaitStrategy 差不多,对CPU的消耗也类似,但其对生产者线程的影响最小,通过使用LockSupport.parkNanos(1)来实现循环等待。

「YieldingWaitStrategy」
YieldingWaitStrategy是可以使用在低延迟系统的策略之一。YieldingWaitStrategy将自旋以等待序列增加到适当的值。在循环体内,将调用Thread.yield()以允许其他排队的线程运行。在要求极高性能且事件处理线数小于CPU逻辑核心数的场景中,推荐使用此策略;例如,CPU开启超线程的特性。

「BusySpinWaitStrategy」
性能最好,适合用于低延迟的系统。在要求极高性能且事件处理线程数小于CPU逻辑核心数的场景中,推荐使用此策略;一直自旋。

2、disruptor的优点?为什么这么快?

Disruptor应运而生的优势:

1、环形数组结构
为了避免垃圾回收,采用数组而非链表。同时,数组对处理器的缓存机制更加友好。使用缓存区填充解决”伪共享”。

2、无锁设计:
每个生产者或者消费者线程,会先申请可以操作的元素在数组中的位置,申请到之后,直接在该位置写入或者读取数据,整个过程通过原子变量CAS,保证操作的线程安全。

3、元素位置定位
数组长度2^n,通过位运算,加快定位的速度。下标采取递增的形式。不用担心index溢出的问题。index是long类型,即使100万QPS的处理速度,也需要30万年才能用完。

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

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

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

如何防止读取的时候,读到还未写的元素

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

3、什么是缓存的伪共享?

伪共享的非标准定义为:缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存,二级缓存,部分高端CPU还具有三级缓存。每一级缓存中所储存的全部数据都是下一级缓存的一部分,越靠近CPU的缓存越快也越小。所以L1缓存很小但很快(译注:L1表示一级缓存),并且紧靠着在使用它的CPU内核。L2大一些,也慢一些,并且仍然只能被一个单独的CPU核使用。L3在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一块主存,由全部插槽上的所有CPU核共享。拥有三级缓存的的CPU,到三级缓存时能够达到95%的命中率,只有不到5%的数据需要从内存中查询。

一个运行在处理器 core1上的线程想要更新变量X的值,同时另外一个运行在处理器core2上的线程想要更新变量Y的值。但是,这两个频繁改动的变量都处于同一条缓存行。两个线程就会轮番发送RFO(Request For Owner)消息,占得此缓存行的拥有权。当core1取得了拥有权开始更新X,则core2对应的缓存行需要设为I状态。当core2取得了拥有权开始更新Y,则core1对应的缓存行需要设为I状态(失效态)。轮番夺取拥有权不但带来大量的RFO消息,而且如果某个线程需要读此行数据时L1和L2缓存上都是失效数据,只有L3缓存上是同步好的数据。从前一篇我们知道,读L3的数据非常影响性能。更坏的情况是跨槽读取,L3都要miss,只能从内存上加载。

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

伪共享在多核编程中很容易发生,而且非常隐蔽。例如,在JDK的LinkedBlockingQueue中,存在指向队列头的引用head和指向队列尾的引用tail。而这种队列经常在异步编程中使用,这两个引用的值经常的被不同的线程修改,但它们却很可能在同一个缓存行,于是就产生了伪共享。线程越多,核越多,对性能产生的负面效果就越大。

备注:在jdk1.8中,有专门的注解@Contended来避免伪共享,更优雅地解决问题。

4、MESI协议

1、M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
2、E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
3、S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
4、I(无效,Invalid):缓存行失效, 不能使用。