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

在多线程开发中,我们常常遇到这样一种场景:一些线程接受用户请求,另外一些线程处理这些请求,之所以把接受请求和处理请求的逻辑分开,一方面是出 于资源调度的考虑(用户请求也许很多,但这些请求涉及的资源很少),另一方面也可能是异步响应的需求。这种场景存在于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/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值