Disruptor 学习笔记 汇总

 

 

一 简介
Disruptor是一个性能极强的异步消息处理框架,可以认为它是线程间通信高效低延时的内存消息组件,它最大的特点是高性能,其LMAX架构可以获得每秒6百万订单,用1微秒的延迟获得吞吐量为100K+。实际上它是拿内存换取处理高性能的低延迟的处理熟速度,
(实际上这个框架在log4j,以及activeMQ源码扩展中有使用)

 

一、Disruptor基本原理

在多线程开发中,我们常常遇到这样一种场景:一些线程接受用户请求,另外一些线程处理这些请求。比如日志处理中的日志输入和告警。这种典型的生产者消费者场景十分常见,而生产者消费者模式的核心就是阻塞队列。由于阻塞队列会涉及大量的锁竞争和线程阻塞,都是非常耗费CPU的操作,因此阻塞队列的性能好坏能够在很大程度上决定上层应用的性能瓶颈。

JAVA中用BlockingQueue这个接口来描述阻塞队列,有数组实现的有界阻塞队列为 ArrayBlockingQueue,用链表实现的无界阻塞队列为LinkedBlockingQueue,除此之外还有优先级阻塞队列 PriorityBlockingQueue,这些阻塞队列除了自身特有逻辑外,都采用基于悲观锁的并发控制。这样的并发机制会有严重的锁冲突,大大影响并发性能。Disruptor满足了我们的要求。

锁是用来做并发最简单的方式,当然其代价也是最高的。内核态的锁的时候需要操作系统进行一次上下文切换,等待锁的线程会被挂起直至锁释放。

使用了Ringbuffer,内存屏障,乐观并发控制等众多优化手段后,Disrupter的阻塞队列与传统的阻塞队列相比有超过10倍的吞吐率。

Disruptor的主要设计思想是无锁的高并发,在设计上采用内存屏障的机制和CAS操作实现此思想。主流的并发程序 都离不开锁对资源的管控,或者尽量避开锁的使用。

其主要的实现原理总结有如下三点:

  • 采用消费者-生产者模型进行读写的分离。

  • 用循环缓存(实际是一个循环队列)实现了数据的暂存和读写速度的匹配。

  • 用内存屏障加序列号的方式实现了无锁的并发机制。

为什么Disruptor的速度这么快?

我们知道Disruptor速度很快,但是为什么呢?

Disruptor没有使用很影响性能锁 。取而代之的是,在需要确保操作是线程安全的(特别是,在多生产者的环境下,更新下一个可用的序列号)地方,我们使用CAS(Compare And Swap/Set)操作。这是一个CPU级别的指令,它的工作方式有点像乐观锁——CPU去更新一个值,但如果想改的值不是原来的值,操作就失败,反之则去更新这个值。

说句题外话,Java中AtomicInteger也使用了CAS操作来保证原子性。在并发控制中,CAS操作是十分重要的。

CAS操作是CPU级别的指令,在Java中CAS操作在Unsafe类中(Unsafe,见名知意,这个类是不安全的,不建议在实际开发的时候使用)。关于CAS操作的原理网上有很多,在此不过多说明了。

另一个重要的因素是Disruptor消除了伪共享。 下面引用网上的一段话,来解释下什么是伪共享。

缓存系统中是以缓存行(cache line)为单位存储的。缓存行是 2 的整数幂个连续字节,一般为 32-256 个字节。最常见的缓存行大小是 64个字节。

当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。缓存行上的写竞争是运行在 SMP 系统中并行线程实现可伸缩性最重要的限制因素。有人将伪共享描述成无声的性能杀手,因为从代码中很难看清楚是否会出现伪共享。

伪共享

伪共享的问题也是基于CPU级别的机制引起的。正常情况下现在的大多数机器的内存分级机制如下图所示:

一般内存会再分为如上图所示的3层,多核CPU,每个CPU核心都有自己的私有内存空间L1,L1上层会有一个比较大的L2内存空间,L2上层有一个同一个插槽的CPU共享的L3内存空间,再上层就是所有插槽的CPU共享的主内存空间。每个CPU核心存取离自己越近的内存空间速度越快,越往上速度越慢。所以为了提高性能,就需要尽量将数据存放在L1区域,而尽量避免到主内存中取数据。

缓存系统是以缓存行为单位存储的,每个缓存行一般64字节,为了提高数据访问性能,CPU在拉起一个变量的值到L1空间时,会将相邻一共达到64字节的变量一起拉到L1空间中。这种情况正常是没有问题的,因为CPU访问相邻变量会变得很快。但是有一个问题,就是如果相邻的变量对该CPU而言是不相干的,也就是说CPU1只关心变量1,CPU2只关心变量2,但是CPU1和CPU2在分别拉取变量1和变量2的都将这两个变量拉到自己的L1空间。此时如果CPU1修改了变量1的值,CPU为了保障修改的值被其他CPU看到,基于内存屏障的机制,会将修改的变量立即刷新到主内存中。而此时CPU2在获取变量2的值时,却不得不到主内存中获取。CPU2修改变量2的值时,也会影响CPU1对变量1的访问。这就是伪共享。

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

 

第三个原因是Disruptor采用了RingBuffer。Ring Buffer 是一个数组,它比链表要快,而且有一个容易预测的访问模式,数据可以在硬件层面预加载到高速缓存,极大的提高了数据访问的速度。

RingBuffer可以预先分配内存,并且保持数组元素永远有效。这意味着内存垃圾收集(GC)在这种情况下几乎什么也不用做。

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

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

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

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

 

 

 

 

二、Disuptor基本概念

  • RingBuffer:Ringbuffer是一个环形缓冲区,是Disruptor的核心。

  • Sequencer:序号管理器,使消费者和生产者之前快速正确地传输数据。

  • Sequence:RingBuffer中每个数据的序号,用于跟踪ringbuffer中任务的变化和消费者的消费情况。

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

  • Event:生产者和消费者之间进行交换的数据称为事件。

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

  • EventHandler:业务处理器,是实际消费者的接口,完成具体的业务逻辑实现,第三方实现该接口;代表着消费者。

  • Producer:生产者接口,第三方线程充当该角色,producer向RingBuffer写入事件。

 

2.生产者如何向队列中插入元素?

生产者插入元素分为两个步骤,第一步申请一个空的slot, 每个slot只会被一个生产者占用,申请到空的slot的生产者将新元素的数据拷贝到该slot;第二步是发布,发布之后,新元素才能为消费者所见。如果只有一个生产者,第一步申请操作无需同步即可完成。如果有多个生产者,那么会有一个变量:claimSequence来记录申请位置,申请操作需要通过CAS来同步,例如图二中,如果两个生产者都想申请第19号slot, 则它们会同时执行CAS(&claimSequence, 18, 19),执行成功的人得到该slot,另一个则需要继续申请下一个可用的slot。在disruptor中,发布成功的顺序与申请的顺序是严格保持一致的,在实现上,发布事件实际上就是修改cursor的值,操作等价于CAS(&cursor, myslot-1, myslot),从此操作也可以看出,发布执行成功的顺序必定是slot, slot +1, slot +2 ....严格有序的。

另外,为了防止生产者生产过快,在环形队列中覆盖消费者的数据,生产者要对消费者的消费情况进行跟踪,实现上就是去读取一下每个消费者当前的消费位置。例如一个环形队列的大小是10,有两个消费者的分别消费到第105和106号元素,那么生产者生产的新元素是不能超过114的。

 RingBuffer当前的队尾位置序号为18.生产者提出申请。 

 

  图3. 生产者申请得到第19号位置,并且19号位置是独占的,可以写入生产元素。此时19号元素对消费者是不可见的。

 

  图4,生产者成功写入19号位置后,将cursor修改为19,从而完成发布,之后消费者可以消费19号元素。

4.批量

如果消费者发现cursor相比其最后的一次消费位置前进了不止一个位置,它就可以选择批量消费这区段的元素,而不是一次一个的向前推进。这种做法在提高吞吐量的同时还可以使系统的延迟更加平滑。

3.消费者如何获知有新的元素进来了?

消费者需要等待有新元素进入方能继续消费,也就是说cursor大于自己当前的消费位置。等待策略有多种。可以选择sleep wait, busy spin等等,在使用disruptor时,可以根据场景选择不同的等待策略。

.依赖图

前面也提过,在传统的系统中,通常使用队列来表示多个处理流程之间的依赖,并且一步依赖就需要多添加一个队列。在Disruptor中,由于生产者和消费者是分开考虑和控制的,因此有可能能够通过一个核心的环形队列来表示全部的依赖关系,可以大大提高吞吐,降低延迟。当然,要达到这个目的,还需要用户细心地去设计。下面举一个简单的例子来说明如何使用disruptor来表示依赖关系。

第三部分 结束语

disruptor本身是用java写的,但是笔者认为在C++中更能体现其优点,自己也山寨了一个c++版本。在一个生产者和一个消费者的场景中测试表明,无锁队列相比有锁队列,qps有大约10倍的提升,latency更是有几百倍的提升。

不管怎么样,现在大家都渐渐都这么一个意识了:锁是性能杀手。所以这些无锁的数据结构和算法,可以尝试借鉴来使用在合适的场景中。

 

内存屏障
一个CPU指令,确保一些特定操作的执行顺序,影响一些数据的可见性。编译器和CPU在保证输出结果一样的情况下为了优化性能会对指令进行重排序,插入一个内存屏障,就等于把指令分为屏障上和屏障下两个部分,上面的必须先于后面的执行
内存屏障还可以强制更新不同CPU的缓存
在Java中,关键词Volatile实现了这个内存屏障的功能,如果你使用了这个字段,JMM(Java内存模型)会在这个字段的写操作后面加一个写屏障指令,在读操作前面加一个读屏障指令
你猜猜这说明了什么?这说明你volatile字段一旦完成写入,任何线程都会得到最新值,而在你写入前(读),确保看到的数据肯定是最新的。
RingBuffer的指针cursor就是一个volatile变量
Ringbuffer是一个环状数组,数组中每一位是一个Entry,每个Entry有它的sequence,当生产者对ringbuffer调用commit,会将cursor更新为该sequence,由于内存屏障的存在,其他所有线程CPU缓存的cursor都会更新为最新的数据(或者缓存失效),这样一来,消费者们就会获得最新的cursor
消费者获得最新的cursor,可以进行消费动作,值得一提的是,如果这个时候有多个消费者,为了防止多个消费者对一个Entry进行操作,可以把消费者分为下游消费者和上游消费者,下游通过内存屏障跟踪上游消费者的操作,当上游执行完commit后,下游能看到缓存的变化。(话说这个是不是有点像Zookeeper分布式锁的单机版?下游节点监控上游节点)
内存屏障作为CPU指令,没有锁那样大的开销,当然,由于它导致CPU和编译器不能重排序,会导致没法高效利用CPU,而且刷新缓存也会有开销
volatile字段每次读写的开支都会比较大,因此有时候会获取一批Entry,全部执行完以后才会修改cursor字段,消费者和生产者都可以这样

一般来说我们并不会考虑堆,因为堆在实现带有优先级的队列更好。

从性能上来说,无锁时的QPS一般来说优于加锁,而ConcurrentLinkedQueue的无锁其实是通过原子变量进行compare and swap(以下简称为CAS,由CPU保证原子性)这种不加锁的方式来实现的。

但无锁的结构都是无界的,为了系统的稳定,我们需要防止生产者速度过快导致内存溢出,我们需要使队列有界;同时,为了减少Java的垃圾回收对系统性能的影响,会尽量选择array/heap(因为使用这两种结构,数据在内存中存储的地址连续)。

这样筛选下来,ArrayBlockingQueue可能相对而言更加合适,但它依旧存在性能问题——加锁、伪共享。

+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

   除此之外,在 Disruptor 中,生产者线程通过 publishEvent() 发布 Event 的时候,并不是创建一个新的 Event,而是通过 event.set() 方法修改 Event, 也就是说 RingBuffer 创建的 Event 是可以循环利用的,这样还能避免频繁创建、删除 Event 导致的频繁 GC 问题。

在 Sequence 的代码里面,就是通过 compareAndSet 这个方法,并且最终调用到了 UNSAFE.compareAndSwapLong,也就是直接使用了 CAS 指令

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值