`
m635674608
  • 浏览: 5043141 次
  • 性别: Icon_minigender_1
  • 来自: 南京
社区版块
存档分类
最新评论

disruptor:CAS实现高效(伪)无锁阻塞队列实践

    博客分类:
  • java
 
阅读更多

在多线程开发中,我们常常遇到这样一种场景:一些线程接受用户请求,另外一些线程处理这些请求,之所以把接受请求和处理请求的逻辑分开,一方面是出于资源调度的考虑(用户请求也许很多,但这些请求涉及的资源很少),另一方面也可能是异步响应的需求。这种场景存在于NIO的通信框架,存在于 Tomcat的回调处理框架,存在于日志系统的异步flush,存在于各种类型的线程池中。总之,这种典型的生产者消费者场景比比皆是,而生产者消费者模式的核心就是阻塞队列。由于阻塞队列会涉及大量的锁竞争和线程阻塞,都是非常耗费CPU的操作,因此阻塞队列的性能好坏能够在很大程度上决定上层应用的性能瓶颈。

不同语言中,都会有(较为)官方的阻塞队列实现,JAVA中用BlockingQueue这个接口来描述阻塞队列,有数组实现的有界阻塞队列为 ArrayBlockingQueue,用链表实现的无界阻塞队列为LinkedBlockingQueue,除此之外还有优先级阻塞队列 PriorityBlockingQueue,这些阻塞队列除了自身特有逻辑外,都采用基于锁的悲观并发控制,即标准的生产者消费者模型(见操作系统教材)。这种模型由于锁冲突严重,已经被证明是一种低效的实现。那么它有优化空间吗?优化空间能有多少?

最近很火的Disruptor给了我们一个答案,在使用了Ringbuffer,内存屏障,乐观并发控制等众多优化手段后,Disrupter的阻塞队列与传统的阻塞队列相比有超过10倍的吞吐率。

遗憾的是,Disruptor主要应用于订阅模式,即一条消息被所有消费者消费,另外Disruptor支持构建具有复杂依赖关系的消费者,Storm底层便利用Disruptor来构建本地任务拓扑。而语言自带的阻塞队列一般是一个消息被单个消费者消费,这种场景更加通用。

介绍Disruptor不在本文范围内,有机会的话会另开一篇博文做Disruptor的源码解析,本文想做的是,利用Disruptor中RingBuffer和乐观并发控制的设计思想,实现一个单消息单消费的通用阻塞队列。

RingBuffer实现

RingBuffer是一个首尾相连的环形数组,所谓首尾相连,是指当RingBuffer上的指针越过数组是上界后,继续从数组头开始遍历。因此,RingBuffer中至少有一个指针,来表示RingBuffer中的操作位置。另外,指针的自增操作需要做并发控制,Disruptor和本文的 OptimizedQueue都使用CAS的乐观并发控制来保证指针自增的原子性,关于乐观并发控制之后会着重介绍。

Disruptor中的RingBuffer上只有一个指针,表示当前RingBuffer上消息写到了哪里,此外,每个消费者会维护一个 sequence表示自己在RingBuffer上读到哪里,从这个角度讲,Disruptor中的RingBuffer上实际有消费者数+1个指针。由于我们要实现的是一个单消息单消费的阻塞队列,只要维护一个读指针(对应消费者)和一个写指针(对应生产者)即可,无论哪个指针,每次读写操作后都自增一次,一旦越界,即从数组头开始继续读写。如图1所示:

ringbuffer

图1:RingBuffer结构

RingBuffer的元素个数最好设置为2的整数次幂,这样可以通过与运算获取指针的真实位置(有人问到溢出问题,解释见评论),Disruptor正是这样做的。RingBuffer的实现摘要如下:

 

同是数组,我们会发现实际上ArrayBlockingQueue也用的是RingBuffer,从源码中可以看到 ArrayBlockingQueue包含一个putIndex和一个takeIndex,分别对应写指针和读指针,每次读写后Index自增,且判断是否等于数组长度,等于的话归0:

那么ArrayBlockingQueue中的RingBuffer和我们这里说的RingBuffer有区别吗?

区别很大。

RingBuffer中之所以分离读指针和写指针,为的是使生产者和消费者互不干扰,两者可以同时操作指针,达到完全并发执行。然而 ArrayBlockingQueue中对所有的读写操作都进行了加锁互斥,也就是说同一时刻只能有一个生产者或消费者操作,这其实和用一个指针没什么区别,没有发挥RingBuffer的真正优势,同时这也是ArrayBlockingQueue本身的一个瓶颈。

那么为什么ArrayBlockingQueue中要对所有的操作都做互斥?这个问题一言两语说不清楚,留给读者自己推敲,一言以蔽之:在基于锁的悲观并发控制下,在阻塞队列中互斥所有操作是最正确的做法。

既然基于锁的悲观并发控制需要互斥所有操作,RingBuffer无法发挥其“读写分离”的优势,那么怎样才能不互斥读写操作呢?

这就要说到CAS与乐观并发控制了。

CAS与乐观并发控制

悲观并发控制,是用锁把可能出现并发操作的临界区保护起来,以杜绝临界区内的并发,将并发错误防范于未然,正如其名,是在“悲观”地认为会发生错误而设计的并发控制策略。

乐观并发控制则相反:在临界区的执行过程中,乐观地认为不会发生并发冲突,没有并发错误自然无需加锁。OK,不加锁性能自然好多了,那如果真地发生并发冲突了呢?首先我们需要随时知道并发冲突发生了没有,也就是需要一种并发冲突检测机制,冲突检测可以在执行临界区之前,也可以在执行临界区之后。 OK,假设我们现在有一种冲突检测机制,接下来怎么办呢?自然是需要一种冲突解决机制了,不同的冲突检测机制对应不同的冲突解决机制,这要视场景和情况而定。

说的这么玄乎,举个例子:

上述代码为JDK中AtomicInteger的原子自增操作,可以看到它依赖的是CAS,即compareAndSet操作来检测自增操作是否有并发冲突,当没有并发冲突时,compareAndSet比较当前值等于自增前的值,并成功将自增后的值赋给自己,若有并发冲突发生,也就是有其他线程已经更改了当前值,compareAndSet会返回false,并重新执行自增操作

上述案例中,CAS是冲突检测机制,重新执行自增操作是冲突解决机制。CAS的冲突检测发生在临界区执行之前(这里的临界区是给自己赋值)。

顺带一提,上述的循环操作是不是有点像spinlock?其实spinlock也是一种乐观并发控制。而Mutex,在JAVA中又叫Monitor这种包含阻塞逻辑的锁才真正对应悲观并发控制。

我这里也有一个冲突检测发生在临界区执行之后的案例:著名的MySQL高可用解决方案Galera是通过对比WriteSet中主键的值来检测是否有不同Master的事务对同一行数据进行了操作,这种检测是发生在事务commit前,同步WriteSet后,这时事务中所有的操作都已执行完毕,若检测到事务冲突,直接进行回滚。这里检测writeset主键值冲突是冲突检测机制,事务回滚是冲突解决机制。

由此可见,虽然悲观并发控制都是基于锁的,但乐观并发控制的方式就多种多样了,需要根据场景对症下药。

我们先来看看怎样通过CAS来检测队列中的并发冲突。

Entry

图2. RingBuffer的Entry结构

如图2所示,Entry即RingBuffer中的元素,这里为每个Entry定义一个frontDoor和backDoor,即前门和后门,frontDoor和backDoor各为一个AtomicReference(对象的原子引用,对这个类不熟的Google之),对生产者A,当它要往一个Entry中写消息时,需要先调用frontDoor的CAS(null, A)来检测是否能进入前门,若返回true,表示A获得了写Entry的权利,在A写完Entry后,会将frontDoor的值重置为null。

在A写Entry的这段时间,frontDoor的值为A,其他生产者也想写这个Entry时,会在CAS frontDoor时检测到并发冲突。同理,对消费者B,当它想要消费Entry中的消息时,要通过backDoor.CAS(null, B)来检测是否有读的并发冲突。

前门和后门的AtomicReference就像两个单人通道,保证了一个Entry在一个时间点上只会有一个生产者和消费者来读写消息。

这里有个疑问,CAS实现指针自增可以保证多个生产者或多个消费消费者不会进入一个entry,那这个前门后门的设计是为了什么?我们假设这样一个场景:有entry为10的ringbuffer,2个生产者,生产者1获取entry1消息的速度非常慢(假设该线程因为不明原因卡住了),生产者2同时会快速消费掉entry2-10,绕了一圈后进入entry1获取消息,这时候entry1中就会有2个生产者,也就是说指针上的CAS并不能严格保证一个entry上不会有多个生产者或多个消费者并发访问,但是有了frontDoor这个屏障,在生产者2进入entry1后,发现entry1的前门中已经有了生产者1,即可立即发现冲突。

冲突检测机制有了,还需要一个冲突解决机制,很容易想到,无论对生产者还是消费者,检测到冲突后只要对下一个指针上的Entry执行操作即可。当然对下一个Entry也要做冲突检测。

目前为止,一切看起来很美好,我们用CAS来检测生产者与生产者,消费者与消费者之间的并发冲突,通过指针+1来解决冲突。我们是不是遗漏了什么?

  • 当生产者A进入frontDoor后,如果发现Entry中已经有了消息怎么办?相应的,若消费者B进入backDoor后,发现Entry中还没有消息怎么办?
  • AS实现指针+1解决了生产者与生产者之间,消费者与消费者之间的并发冲突问题,同一个Entry上的生产者和消费者就不会出现并发冲突吗?

先回答第一个问题,我们知道,任何有界队列都可能出现队列被写满的情况,队列被写满后生产者在写消息时被阻塞,直到队列被消费一点后才被唤醒,把消息塞进队列。相应的,在队列为空时消费者会阻塞,直到队列中有消息后才会被唤醒,来消费消息。这种生产者满则阻塞和消费者空则阻塞的模式正是阻塞队列的名称由来。

当生产者A进入frontDoor后,发现Entry中已经有了消息,生产者满则阻塞,相应的,若消费者B进入backDoor后,发现Entry 中还没有消息,消费者空则阻塞。传统的队列中,生产者消费者阻塞都发生在队列全体满或者空的状态后,而我们这种队列的阻塞则是针对每个Entry而言的。

稍作斟酌,将阻塞逻辑细化到每个Entry中,会不会使阻塞频繁发生呢?倘若如此,这种优化就很难笃定地说是一种优化了。这里RingBuffer 的作用再次凸显出来,在RingBuffer中读写就像赛跑,只有生产者们超越消费者们一圈,才可能出现上述的生产者满则阻塞,而这时也正是队列被写满的情况,相应的,只有消费者们追上生产者们,才可能出现消费者空则阻塞,这时也正是队列为空的情况。所以不会使阻塞频繁发生。

对第二个问题,同一个Entry上的生产者和消费者实际上是通过Entry上的消息进行线程同步,当生产者A写完消息后,通知消费者B获取消息,或者消费者B消费完消息后,通知生产者A可以写新的消息。两者之间通过wait和notify原语做线程同步,因此不会有并发冲突。

写到这里,我们已经实现了一个基于RingBuffer和CAS乐观并发控制的无锁阻塞队列,接下来我们来聊聊这个队列的实现细节。

两阶段发布

上文提到,无论是生产者还是消费者,在操作完消息后,都需要通知Entry的“另一头”,Disruptor中管这种行为叫做“两阶段发布”,例如对生产者来说,第一次发布是将消息赋给Entry中的item,第二次发布是通知backDoor中的消费者(如果有)消息到了,如图3所示:

OptimizedQueue两阶段发布

图3. 两阶段发布流程

需要特别留意的是,如果我们不在item上做任何互斥,可能出现“干等”的情况,例如当生产者A发现item不为空,进入阻塞之前,消费者B取走了消息,并向生产者A发出通知,由于这时候A还没有进入阻塞,导致通知失效,A会永无止境地等下去,而且Entry中的消息也会一直为空,新进来的消费者也会一直等下去,进入活锁。

生产者A             消费者B

check item != null?

              check item != null?

              take(item), item = null

              notify producer wakeup

wait consumer notify….

为了避免活锁,我们这里引入JAVA中常用的一个编程技术:双重检查锁定,我们知道Object的wait和notify需要写在synchronized中,双重检查锁定就是除了在一开始判断item是否为null外,在synchonized内部再判断一次。

下面给出Entry的完整实现,其中publish(T event)为发布消息,take(T event)为取走消息,由于Entry是OptimizedQueue的内部类,类型参数T无需再声明。

 

 

通过synchronized内部的while判断,保障了生产者和消费者都不会死等,活锁的问题是解决了,但我们也发现我们的代码中再次引入了 “锁”(虽然没有while循环也无法消除synchronized,但纯粹为了线程同步而加的synchronized严格意义上讲不算锁,如果 JAVA能从native层保障wait和notify的原子性,完全可以做到消除synchronized)。所以严格地说,OptimizedQueue没有消除锁,而是将整个队列上的一把大锁细化到了每个Entry中,即所谓的“锁细化”,锁细化在很大程度上降低了锁冲突概率,并且把加锁的时间控制到了最少,通过上一篇synchronized原理深探,可以知道synchornized的代价在这里非常小(基本不会生成Monitor)。

下面贴出OptimizedQueue的完整实现:

 

使用场景

本文中实现的OptimizedQueue的使用场景有如下限制:

      1. OptimizedQueue类似于JDK中的ArrayBlockingQueue,是有界数组,当队列中挤压的消息数大于数组长度时,生产者再想塞入消息会阻塞。

2. OptimizedQueue的参数为RingBuffer长度的自然对数,例如当参数为10时,RingBuffer长度为1024,这样设计是为了用更高效的方式获取真实指针位置。

3. OptimizedQueue的生产者和消费者的个数都必须小于RingBuffer长度,试想,倘若生产者的个数大于RingBuffer长度,可能出现某个生产者因为RingBuffer中所有frontDoor都被占用而一直找不到槽位,以至于进入死循环。消费者亦然。

OptimizedQueue的实际限制其实只有3,而在大多数应用场景中,都能保障3的条件,因为线程是非常消耗资源的对象,相对的 RingBuffer的Entry只会消耗内存中非常少量的空间,在使用OptimizedQueue需要保证RingBuffer长度大于生产者消费者上限。

阻塞队列一个典型的应用是线程池,例如在网易分布式数据库DDB中,一个简单的select语句在创建完执行计划后,可能要将SQL下发给多个 mysql节点,这里就需要线程池来完成多个mysql节点上的查询,而单个client连接数有1024的最大限制,这种情况下只要保证 RingBuffer长度大于1024,并且连接池中的线程个数设上限为1023,即可用OptimizedQueue构建线程池。

Benchmark

最后我们来看看OptimizedQueue在性能上究竟比JDK的自带阻塞队列高出多少。

为了衡量队列性能,设计了一个简单的生产者消费者场景,生产者初始化一个整数2000,并从1一直累加到2000,然后将2000放入队列,生产者从队列中取出2000,也同样从1累加到2000,累加完后吞吐量+1,这个场景本身没什么逻辑,只是为了测量性能而设计。这里就不再贴代码了,附件中的源码中包含完整的benchmark实现。

用于对比的是OptimizedQueue和JDK中的ArrayBlockingQueue,因为ArrayBlockingQueue也是数组实现,而且相比无界队列LinkedBlockingQueue性能更好。性能指标为队列在各种生产者和消费者个数下的吞吐率(实际上是单位时间内生产者完成的计算量)。

测试分别在两个机器上进行,第一个是公司的办公机,性能较差,第二个是我自己的笔记本,因为比较新,性能好。

办公机的性能测试结果如下:

办公机测试

配置烂,用eclipse写代码没法网页听歌,干什么都卡的一B

我的笔记本的测试结果如下:

笔记本测试

i7 2.9GHZ 双核,平时用起来很流畅

在我的办公机上,两种队列性能差距非常明显,尤其是10个生产者和1个消费者的场景下,OptimizedQueue与ArrayBlockingQueue有将近60倍的性能差距。相对的,在我的笔记本上,两者差距也存在,但不那么明显了,两者差距大小应该和业务场景,机器配置甚至CPU架构都相关。

值得关注的是表格中标红的数据,用办公机在1个生产者和10个消费者的测试场景下,ArrayBlockingQueue的吞吐率反而高出很多,而在接下来的测试中,TPS直接降到了个位数,而在我自己性能更好的笔记本中,同等场景下的吞吐率也才只有31W。因只能用“异常”来形容这组数据了。 ArrayBlockingQueue是用ReentrantLock来实现的,大概是恰巧踩到了什么优化点。当然我们不能期待现实环境中总能遇到这样的情况。另外,这几组数据中,使用ArrayBlockingQueue在生产者个数大于消费者个数时性能较差,各种原因值得玩味。

 

http://www.majin163.com/2014/03/24/cas_queue/

http://maoyidao.iteye.com/blog/1663193

分享到:
评论

相关推荐

    无锁队列

    无锁队列是一种高效、线程安全的数据结构,它在多线程环境下广泛应用于高性能并发编程,例如在Java中,著名的Disruptor框架就利用了无锁队列的设计。无锁队列的核心思想是利用原子操作(如CAS,Compare and Swap)来...

    Disruptor:一种高性能的、在并发线程间数据交换领域用于替换有界限队列的方案.pdf

    传统的并发解决方案常常依赖于队列来传递数据,但在LMAX公司的实践中,他们发现即使是最高效的队列,其延迟也与磁盘I/O操作相当,这对于需要高速交易的金融交易平台来说是不可接受的。深入研究后,团队意识到队列的...

    无锁队列Disruptor超详细教程

    无锁队列Disruptor是LMAX公司为解决内存队列延迟问题而设计的一种高性能队列,它在Java领域被誉为最高性能的队列。与Kafka等服务间消息队列不同,Disruptor主要用于线程间的消息传递。由于其卓越的性能,基于...

    Disruptor:一种高性能的、在并发线程间数据交换领域用于替换有界限队列的方案

    Disruptor是一种高性能的并发数据交换框架,由Martin Thompson、Dave Farley、Michael Barker、Patricia Gee和Andrew Stewart共同开发,主要用于替代传统的有界队列。这个框架的诞生源于LMAX公司在构建高性能金融...

    Disruptor框架:无锁并发性能的革命

    Disruptor是一个高性能的无锁并发框架,最初由LMAX开发。与传统的基于锁的并发模型相比,Disruptor通过创新的设计大幅提升了数据处理的效率和吞吐量。其核心特性包括环形数组结构、元素位置定位、无锁设计、和多种...

    disruptor:LMAX干扰器的C++实现

    无锁队列是Disruptor实现中的另一大亮点。C++版的实现借鉴了其他项目中的无锁队列算法,如Michael-Scott队列或者Harris队列。这些队列算法通过原子操作和条件变量保证了在并发环境下的正确性和高效性,避免了传统锁...

    串行io disruptor

    总的来说,Disruptor的出现挑战了传统并发编程的思维方式,提供了一种更高效的数据交换策略。它在金融交易、实时系统、大数据处理等领域有着广泛的应用潜力,对于追求高性能、低延迟的系统设计有着深远的影响。

    mpscilq:Java MPSC 无锁侵入式链接队列

    本篇文章将详细介绍`mpscilq`,这是一个基于Java实现的MPSC无锁队列。 首先,我们来理解一下“多生产者单消费者”模型。在这种模型中,多个生产者线程可以同时向队列添加元素,而只有一个消费者线程从队列中取出...

    disruptor 实例

    本文将深入探讨Disruptor的核心原理,并通过一个实际的例子——testMyDisruptor,来展示如何在应用中有效利用Disruptor实现高效的缓冲队列。 首先,我们要理解Disruptor的核心设计理念。传统的并发编程往往依赖于锁...

    netty结合disruptor队列实现即时通信

    netty结合disruptor队列实现即时通信1、简介使用disruptor改造netty通讯,使提高吞吐率,主要是提供disruptor如何与netty整合的思路2、软件架构spring-boot2.7.3 + netty4.1.36.Final + disruptor + jdk1.83、源码...

    tiny_disruptor:简化的[disruptor](https

    通过tiny_disruptor项目,开发者可以学习如何构建高效的并发系统,如何减少锁的使用以降低上下文切换的开销,以及如何利用无锁数据结构来提高程序的执行效率。 在实际应用中,tiny_disruptor可能会提供以下关键特性...

    disruptor:Disruptor BlockingQueue

    Conversant Disruptor 是这种环形缓冲区中性能最高的实现,因为它几乎没有开销,并且采用了特别简单的设计。 2017 Conversant Disruptor - 仍然是世界上最快的入门运行 maven build 来构建和使用包。 $ mvn -U ...

    LMAX-Disruptor框架jar包

    Disruptor框架是由LMAX公司开发的一款高效的无锁内存队列。使用无锁的方式实现了一个环形队列。据官方描述,其性能要比BlockingQueue至少高一个数量级。根据GitHub上的最新版本源码打出的包,希望对大家有帮助。

    Disruptor 一种可替代有界队列完成并发线程间数据交换高性能解决方案.docx

    Disruptor 是一个高性能的并发编程框架,由 LMAX 公司开发,旨在解决线程间数据交换的...尽管随着Disruptor版本的迭代,实现细节有所变化,但其核心设计理念依然保持不变,对开发者来说仍然具有很高的学习和实践价值。

    disruptor-3.3.0-API文档-中文版.zip

    赠送jar包:disruptor-3.3.0.jar; 赠送原API文档:disruptor-3.3.0-javadoc.jar; 赠送源代码:disruptor-3.3.0-sources.jar; 赠送Maven依赖信息文件:disruptor-3.3.0.pom; 包含翻译后的API文档:disruptor-...

    Java工具:高性能并发工具Disruptor简单使用

    学习Disruptor的源码"study-disruptor"可以帮助我们深入理解其内部机制,例如如何实现无锁操作,如何优化内存访问,以及如何通过事件处理器链来并行处理事件。通过对这些概念和技术的理解,我们可以更好地利用...

    Disruptor C++版(仅支持单生产者)

    无锁队列是Disruptor中的关键数据结构,通过使用CAS(Compare and Swap)操作,能够在不使用锁的情况下保证数据的一致性和完整性。这种方法减少了上下文切换和竞态条件,从而提升了并发性能。 在Windows环境下,...

    disruptor-3.3.0-API文档-中英对照版.zip

    赠送jar包:disruptor-3.3.0.jar; 赠送原API文档:disruptor-3.3.0-javadoc.jar; 赠送源代码:disruptor-3.3.0-sources.jar; 赠送Maven依赖信息文件:disruptor-3.3.0.pom; 包含翻译后的API文档:disruptor-...

    SourceAnalysis_Disruptor:Disruptor原始码解析-源码解析

    《Disruptor原始码解析-源码解析》 Disruptor是英国LMAX公司开发的一款高性能、低延迟的并发框架,它在处理高并发场景...在实际项目中,结合Disruptor的设计理念和实践经验,我们能够构建出更加高效、可靠的并发系统。

Global site tag (gtag.js) - Google Analytics