写点什么

【优化技术专题】「线程间的高性能消息框架」深入浅出 Disruptor 的使用和原理

发布于: 4 小时前
【优化技术专题】「线程间的高性能消息框架」深入浅出Disruptor的使用和原理

前提概要

简单回顾 jdk 里的队列

阻塞队列:

ArrayBlockingQueue 主要通过:数组(Object[])+ 计数器(count)+ ReetrantLock 的 Condition (notEmpty:非空、notFull:非饱和)进行阻塞。

入队操作:
  • 操作不阻塞:

  • add:添加失败,则会直接进行返回。

  • offer:添加失败后(满了)直接抛出异常,注意:offer(E o, long timeout, TimeUnit unit):可以设定等待的时间,如果在指定的时间内,还不能往队列中加入 BlockingQueue,则返回失败。

  • 操作阻塞:

  • put:满了,通过 Condition:notFull.await()阻塞当前数据信息,当出队和删除元素时唤醒 put 操作。

出队操作:
  • 操作不阻塞:

  • poll:当空时直接返回 null。poll(long timeout, TimeUnit unit):从 BlockingQueue 取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间,超时还没有数据可取,返回失败。

  • remove:删除元素情况相关元素信息控制,InterruptException 异常

  • 操作阻塞:

  • take:当空时,notEmpty.await()(当有元素入队时唤醒)。

  • drainTo():一次性从 BlockingQueue 获取所有可用的数据对象(还可以指定获取数据的个数),通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁。


与 ArrayBlockingQueue 相对的是 LinkedBlockingQueue:Node 实现、加锁(读锁、写锁分离)、可选的有界队列。需要考虑实际使用中的内存问题,防止溢出。

实际应用

线程池队列

Excutors 默认是使用 LinkedBlockingQueue,但是在实际应用中,更应该手动创建线程池使用有界队列,防止生产者生产过快,导致内存溢出。

延迟队列(ScheduleService 也是采用了延时队列哦!):

DelayQueue : PriorityQueue (优先级队列) + Lock.condition (延迟等待) + leader (避免不必要的空等待)。


主要方法:


  • getDelay() 延迟时间。

  • compareTo() 通过该方法比较从 PriorityQueue 里取值。


入队:


与 BlockingQueue 很相似,add、put、offer:入队时会将换唤醒等待中的线程,进行一次出队处理。


出队:


  • 如果队列里无数据,元素入队时会被唤醒。

  • 如果队列里有数据,会阻塞至时间满足。

  • take-阻塞:

  • poll-满足队列有数据并且 delay 时间小于 0 时候会取出元素,否则立即返回 null 可能会抢占成为 leader


应用场景:


  • 延时任务:设置任务延迟多久执行;需要设置过期值的处理,例如缓存过期。

  • 实现方式:每次 getDelay() 方法提供一个缓存创建时间与当前时间的差值,出队时 compareTo() 方法取差值最小的。每次入队时都会重新取出队列里差值最小的值进行处理。

  • 使用队列更多的是像生产者、消费者这种场景,这种场景大多数情况又对处理速度有着要求,所以我们会使用多线程技术。

  • 使用多线程就可能会出现并发,为了避免出错,我们会选择线程安全的队列。

  • ArrayBlockingQueue、LinkedBlockingQueue 或者是 ConcurrentLinkedQueue。前俩者是通过加锁取实现,后面一种是通过 cas 去实现线程安全。

  • 要考虑到生产者过快可能造出的内存溢出的问题,所以看起来 ArrayBlockingQueue 是最符合要求的。


但是因为加锁效率又会变慢,所以就引出了:Disruptor 服务框架 !



Disruptor 简介介绍

  • Disruptor 的源码 Git 仓库地址:https://github.com/LMAX-Exchange/disruptor

  • Disruptor 的概念定义:异步体系的线程间的高性能消息框架

  • Disruptor 的核心思想:把多线程并发写的线程安全问题转化为线程本地写,即:不需要做同步,不许要进行加锁操作。

Disruptor 优点介绍

  • 非常轻量,但性能却非常强悍,得益于其优秀的设计和对计算机底层原理的运用

  • 单线程每秒能处理超 600W 的数据(Disruptor 能在 1 秒内将 600W 数据发送给消费者,现在的硬件水平会远远在这个水平之上了!)

  • 基于事件驱动模型,不用消费者主动拉取消息

  • 比 JDK 的 ArrayBlockingQueue 性能高一个数量级

为什么这么快
  • 无锁序号栅栏

  • 缓存行填充,消除伪共享

  • 内存预分配

  • 环形队列 RingBuffer

Disruptor 核心概念

  • RingBuffer(环形队列):基于数组的内存级别缓存,是创建 sequencer(序号)与定义 WaitStrategy(拒绝策略)的入口。

  • Disruptor(总体执行入口):对 RingBuffer 的封装,持有 RingBuffer、消费者线程池 Executor、消费之集合 ConsumerRepository 等引用。

  • Sequence(序号分配器):对 RingBuffer 中的元素进行序号标记,通过顺序递增的方式来管理进行交换的数据(事件/Event),一个 Sequence 可以跟踪标识某个事件的处理进度,同时还能消除伪共享。

  • Sequencer(数据传输器):Sequencer 里面包含了 Sequence,是 Disruptor 的核心,Sequencer 有两个实现类:SingleProducerSequencer(单生产者实现)、MultiProducerSequencer(多生产者实现),Sequencer 主要作用是实现生产者和消费者之间快速、正确传递数据的并发算法

  • SequenceBarrier(消费者屏障):用于控制 RingBuffer 的 Producer 和 Consumer 之间的平衡关系,并且决定了 Consumer 是否还有可处理的事件的逻辑。

  • WaitStrategy(消费者等待策略):决定了消费者如何等待生产者将 Event 生产进 Disruptor,WaitStrategy 有多种实现策略,分别是:

  • BlockingWaitStrategy(最稳定的策略):阻塞方式,效率较低,但对 cpu 消耗小,内部使用的是典型的锁和条件变量机制(java 的 ReentrantLock),来处理线程的唤醒,这个策略是 disruptor 等待策略中最慢的一种,但是是最保守使用消耗 cpu 的一种用法,并且在不同的部署环境下最能保持性能一致。 但是,随着我们可以根据部署的服务环境优化出额外的性能。

  • BusySpinWaitStrategy(性能最好的策略):自旋方式,无锁,BusySpinWaitStrategy 是性能最高的等待策略,但是受部署环境的制约依赖也越强。 仅当 event 处理线程数少于物理核心数的时候才应该采用这种等待策略。 例如,超线程不可开启。

  • LiteBlockingWaitStrategy(几乎不用,最接近原生的策略机制):BlockingWaitStrategy 的变体版本,目前感觉不建议使用

  • LiteTimeoutBlockingWaitStrategy:LiteBlockingWaitStrategy 的超时版本

  • PhasedBackoffWaitStrategy(最低 CPU 配置的策略):自旋 + yield + 自定义策略,当吞吐量和低延迟不如 CPU 资源重要,CPU 资源紧缺,可以使用此策略。

  • SleepingWaitStrategy:自旋休眠方式(无锁),性能和 BlockingWaitStrategy 差不多,但是这个对生产者线程影响最小,它使用一个简单的 loop 繁忙等待循环,但是在循环体中间它调用了 LockSupport.parkNanos(1)

  • 一般情况在 linux 系统这样会使得线程停顿大约 60 微秒。不过这样做的好处是,生产者线程不需要额外的累加计数器,也不需要产生条件变量信号量开销。

  • 负面影响是,在生产者线程与消费者线程之间传递 event 数据的延迟变高。所以 SleepingWaitStrategy 适合在不需要低延迟, 但需要很低的生产者线程影响的情形。一个典型的案例是异步日志记录功能。

  • TimeoutBlockingWaitStrategy:BlockingWaitStrategy 的超时阻塞方式

  • YieldingWaitStrategy(充分进行实现 CPU 吞吐性能策略):自旋线程切换竞争方式(Thread.yield()),最快的方式,适用于低延时的系统,在要求极高性能且事件处理线数小于 CPU 逻辑核心数的场景中推荐使用此策略,它会充分使用压榨 cpu 来达到降低延迟的目标。

  • 通过不断的循环等待 sequence 去递增到合适的值。 在循环体内,调用 Thread.yield()来允许其他的排队线程执行。 这是一种在需要极高性能并且 event handler 线程数少于 cpu 逻辑内核数的时候推荐使用的策略。

  • 这里说一下 YieldingWaitStrategy 使用要小心,不是特别要求性能的情况下,要谨慎使用,否则会引起服务起 cpu 飙升的情况,因为他的内部实现是在线程做 100 次递减然后 Thread.yield(),可能会压榨 cpu 性能来换取速度。


注意:超线程是 intel 研发的一种 cpu 技术,可以使得一个核心提供两个逻辑线程,比如 4 核心超线程后有 8 个线程。




  • Event:从生产者到消费者过程中所处理的数据单元,Event 由使用者自定义。

  • EventHandler:由用户自定义实现,就是我们写消费者逻辑的地方,代表了 Disruptor 中的一个消费者的接口。

  • EventProcessor:这是个事件处理器接口,实现了 Runnable,处理主要事件循环,处理 Event,拥有消费者的 Sequence,这个接口有 2 个重要实现:

  • WorkProcessor:多线程处理实现,在多生产者多消费者模式下,确保每个 sequence 只被一个 processor 消费,在同一个 WorkPool 中,确保多个 WorkProcessor 不会消费同样的 sequence

  • BatchEventProcessor:单线程批量处理实现,包含了 Event loop 有效的实现,并回调到了一个 EventHandler 接口的实现对象,这接口实际上是通过重写 run 方法,轮询获取数据对象,并把数据经过等待策略交给消费者去处理。

Disruptor 整体架构

接下来我们来看一下 Disruptor 是如何做到无阻塞、多生产、多消费的。



  • 构建 Disruptor 的各个参数以及 ringBuffer 的构造:

  • EventFactory:创建事件(任务)的工厂类。

  • ringBufferSize:容器的长度。

  • Executor:消费者线程池,执行任务的线程。

  • ProductType:生产者类型:单生产者、多生产者。

  • WaitStrategy:等待策略。

  • RingBuffer:存放数据的容器。

  • EventHandler:事件处理器。

Disruptor 使用方式

maven 依赖:
<dependency>    <groupId>com.lmax</groupId>    <artifactId>disruptor</artifactId>    <version>3.4.2</version></dependency>
复制代码
生产单消费简单模式案例:

Event 数据模型


import lombok.Data;@Datapublic class SampleEventModel {    private String data;}
复制代码


Event 事件模型 Factory 工厂类


import com.lmax.disruptor.EventFactory;/** * 消息对象生产工厂 */public class SampleEventModelFactory implements EventFactory<SampleEventModel> {    @Override    public SampleEventModel newInstance() {        //返回空的消息对象数据Event        return new SampleEventModel();    }}
复制代码


EventHandler 处理器操作


import com.lmax.disruptor.EventHandler;/** * 消息事件处理器 */public class SampleEventHandler implements EventHandler<SampleEventModel> {    /**     * 事件驱动模式     */    @Override    public void onEvent(SampleEventModel event, long sequence, boolean endOfBatch) throws Exception {        // do ...        System.out.println("消费者消费处理数据:" + event.getData());    }}
复制代码


EventProducer 工厂生产者服务处理器操作


import com.lmax.disruptor.RingBuffer;/** * 消息发送 */public class SampleEventProducer {    private RingBuffer<SampleEventModel> ringBuffer;    public SampleEventProducer(RingBuffer<SampleEventModel> ringBuffer) {        this.ringBuffer = ringBuffer;    }    /**     * 发布数据信息     * @param data     */    public void publish(String data){        //从ringBuffer获取可用sequence序号        long sequence = ringBuffer.next();        try {            //根据sequence获取sequence对应的Event       //这个Event是一个没有赋值具体数据的对象            TestEvent testEvent = ringBuffer.get(sequence);            testEvent.setData(data);        } finally {            //提交发布            ringBuffer.publish(sequence);        }    }}
复制代码


EventProducer 工厂生产者服务处理器操作


public class TestMain {    public static void main(String[] args) {        SampleEventModelFactory eventFactory = new SampleEventModelFactory();        int ringBufferSize = 1024 * 1024;    //这个线程池最好自定义        ExecutorService executor = Executors.newCachedThreadPool();        //实例化disruptor        Disruptor<SampleEventModel> disruptor = new Disruptor<SampleEventModel>(                eventFactory,                   //消息工厂                ringBufferSize,                 //ringBuffer容器最大容量长度                executor,                       //线程池,最好自定义一个                ProducerType.SINGLE,            //单生产者模式                new BlockingWaitStrategy()      //等待策略        );        //添加消费者监听 把TestEventHandler绑定到disruptor        disruptor.handleEventsWith(new SampleEventHandler());        //启动disruptor        disruptor.start();        //获取实际存储数据的容器RingBuffer        RingBuffer<SampleEventModel> ringBuffer = disruptor.getRingBuffer();        //生产发送数据        SampleEventProducer producer = new SampleEventProducer(ringBuffer);        for(long i = 0; i < 100; i ++){            producer.publish(i);        }        disruptor.shutdown();        executor.shutdown();    }}
复制代码

Disruptor 的原理分析

  • 使用循环数组代替队列生产者消费者模型自然是离不开队列的,使用预先填充数据的方式来避免 GC;

  • 使用 CPU 缓存行填充的方式来避免极端情况下的数据争用导致的性能下降;

  • 多线程编程中尽量避免锁争用的编码技巧。


Disruptor 为我们提供了一个思路和实践,基本的循环数组实现,定义一个数组,长度为 2 的次方幂。


循环数组

  • 设定一个数字标志表示当前的可用的位置(可以从 0 开始)。

  • 头标志位表示下一个可以插入的位置。

  • 头标志位不能大于尾标志位一个数组长度(因为这样就插入的位置和读取的位置就重叠了会导致数据丢失)。

  • 尾标志位表示下一个可以读取的位置。

  • 尾标志位不能等于头标志位(因为这样读取的数据实际上是上一轮的旧数据) 预先填充提高性能,我们知道在 java 中如果创造大量的对象使用后弃用,JVM 会在适当的时候进行 GC 操作。

  • 当这个数字标志不断增长到大于数组长度时进行与数组长度的并运算,得到的新数字依然在数组的长度范围内,就又可以插入。

  • 这样就好像一直插入直到数组末尾又再次从头开始,故而称之为循环数组。 一般的循环数组有头尾两个标志位。这点和队列很像。

循环数组(初始化数据信息)

在循环数组中,可以事先在数组中填充好数据。一旦有新数据的产生,要做的就是修改数组中某一位中的一些属性值。这样可以避免频繁创建数据和弃用数据导致的 GC。


这点比起队列是要好的。 只保留一个标志位,多线程在队列也好,循环数组也好,必然存在对标志位的竞争。无论是使用锁来避免竞争,还是使用 CAS 来进行无锁算法。


只要争用的情况存在,并且线程较多,都会出现对资源的不断消耗。争用的对象越多,争用中消耗掉的资源也就越多。


为了避免这样的情况,减少争用的资源就是一个手段。比如在循环数组中只保留一个标志位,也就是下一个可以写入数据位置的标志位。而尾部标志位则在各个消费者线程中保存(具体的编程手法后续细讲)。

循环数组在单线程

  • 循环数组在单线程中的使用,如果确定只有一个生产者,也就是说只有一个写线程。则在循环数组中的使用会更加简化。

  • 具体来说单线程更新数组上的标志位,那这种情况,标志位就无需采用 CAS 写的方式来确定下一个可写入的位置,直接就是在单线程内进行普通的更新即可。

循环数组在多线程

  • 循环数组在多线程中的使用,如果存在多个生产者,则可写入的标志位需要用 CAS 算法来进行争夺,避免锁的使用。

  • 多个线程通过 CAS 得到唯一的不冲突的下一个可写序号,由于需要获得序号后才能进行写入,而写入完成才可以让消费者线程进行消费。

  • 所以才获得序号后,完成写入前,必须有一种方式让消费者检测是否完成。

  • 以避免消费者拿到还未填入输入的数组位。 为了达到这个目标,存在简单—效率低和复杂—效率高两种方式。


简单但是可能效率低的方式使用两个标志位。


  • prePut:表示下一个可以供生产者放入的位置;

  • 多个生产者通过 CAS 获得 prePut 的不同的值,在获得的序号并且完成数据写入后,将 put 的值以 CAS 方式递增(比如获得的序号是 7,只有 put 是 6 的时候才允许设置成功),称之为发布。

  • 这种方式存在一个缺点,如果多个线程并发写入,获取 prePut 的值不会堵塞,假设其中一个生产者在写入数据的时候稍慢,则其他的线程写入完毕也无法完成发布。就会导致循环等待,浪费了 CPU 性能。

  • put:表示最后一个生产者已经放入的位置。

  • 复杂但是可能效率高的方式,在上面的方式中,主要的争夺环节集中在多线程发布中,序号大的线程发布需要等到序号小的线程发布完成后才能发布。那我们的优化的点也在这个地方。

  • 这样就可以避免发布的争夺。 但是又来带来一个问题,用什么数字来表示是否已经发布完成?如果只是 0 和 1,那么写过 1 轮以后,标志数组位上就都是 1 了。又无法区分。

  • 所以标志数组上的数字应该在循环数组的每一轮循环的值都不同。


比如一开始都是-1,第一轮中是 0 的表示已发布,第二轮中是 0 表示没发布,是 1 的表示已发布。

缓存行填充

要了解缓存行填充消除伪共享,首先要了解什么是系统缓存行:


  • CPU 为了更快的执行代码。于是当从内存中读取数据时,并不是只读自己想要的部分。而是读取足够的字节来填入高速缓存行。根据不同的 CPU ,高速缓存行大小不同。如 X86 是 32BYTES ,而 ALPHA 是 64BYTES 。并且始终在第 32 个字节或第 64 个字节处对齐。这样,当 CPU 访问相邻的数据时,就不必每次都从内存中读取,提高了速度。 因为访问内存要比访问高速缓存用的时间多得多。

  • 这个缓存是 CPU 内部自己的缓存,内部的缓存单位是行,叫做缓存行。在多核环境下会出现 CPU 之间的内存同步问题(比如一个核加载了一份缓存,另外一个核也要用到同一份数据),如果每个核每次需要时都往内存中存取(一个在读缓存,一个在写缓存时,造成数据不一致),这会带来比较大的性能损耗。

  • 数据在缓存中不是以独立的项来存储的,如不是一个单独的变量,也不是一个单独的指针。缓存是由缓存行组成的,通常是 64 字节(译注:这篇文章发表时常用处理器的缓存行是 64 字节的,比较旧的处理器缓存行是 32 字节),并且它有效地引用主内存中的一块地址。一个 Java 的 long 类型是 8 字节,因此在一个缓存行中可以存 8 个 long 类型的变量。

  • 当数组中的一个值被加载到缓存中,它会额外加载另外 7 个。因此你能非常快地遍历这个数组。事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。

  • 因此如果你数据结构中的项在内存中不是彼此相邻的,你将得不到免费缓存加载所带来的优势。并且在这些数据结构中的每一个项都可能会出现缓存未命中。

  • 设想你的 long 类型的数据不是数组的一部分。设想它只是一个单独的变量。让我们称它为 head,这么称呼它其实没有什么原因。然后再设想在你的类中有另一个变量紧挨着它。让我们直接称它为 tail。现在,当你加载 head 到缓存的时候,你也免费加载了 tail

发布于: 4 小时前阅读数: 3
用户头像

🏆 2021年InfoQ写作平台-签约作者 🏆 2020.03.25 加入

👑【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 “任何足够先进的技术都是魔法“

评论

发布
暂无评论
【优化技术专题】「线程间的高性能消息框架」深入浅出Disruptor的使用和原理