一篇文章带你搞定高性能的生产者-消费者模式:无锁的实现

用BlockigQueue队列实现生产者和消费者是一个不错的选择。它可以很自然地实现作为生产者和消费者的内存缓冲区。但是BlockigQueue队列并不是一个高性能的实现,它完全使用锁和阻塞等待来实现线程间的同步。在高并发场合,它的性能并不是特别的优越。就像之前我已经提过的:ConcurrentLinkedQueue是一个高性能的队列,但是BlockingQueue队列只是为了方便数据共享。

ConcurrentLinkedQueue队列的秘诀就在于大量使用了无锁的CAS操作。同理,如果我们使用CAS来实现生产者-消费者模式,也同样可以获得可观的性能提升。不过正如大家所见,使用CAS进行编程是非常困难的,但有一个好消息是,目前有一个现成的Disruptor框架,它已经帮助我们实现了这一个功能。

一、无锁的缓存框架:Disruptor

Disruptor框架是由LMAX公司开发的一款高效的无锁内存队列。它使用无锁的方式实现了一个环形队列(RingBuffer),非常适合实现生产者-消费者模式,比如事件和消息的发布。Disruptor框架别出心裁地使用了环形队列来代替普通线形队列,这个环形队列内部实现为一个普通的数组。对于一般的队列,势必要提供队列同步head和尾部tail两个指针,用于出队和入队,这样无疑增加了线程协作的复杂度。但如果队列是环形的,则只需要对外提供一个当前位置cursor,利用这个指针既可以进行入队操作,也可以进行出队操作。由于环形队列的缘故,队列的总大小必须事先指定,不能动态扩展。为了能够快速从一个序列(sequence)对应到数组的实际位置(每次有元素入队,序列就加1),Disruptor框架要求我们必须将数组的大小设置为2的整数次方。这样通过sequence &(queueSize-1)就能立即定位到实际的元素位置index,这比取余(%)操作快得多。

如果大家不理解上面的sequence &(queueSize-1),那么我在这里再简单说明一下。如果queueSize是2的整数次幂,则这个数字的二进制表示必然是10、100、1000、10000等形式。因此,queueSize-1的二进制则是一个全1的数字。因此它可以将sequence限定在queueSize-1范围内,并且不会有任何一位是浪费的。

图5.3显示了RingBuffer的结构。生产者向缓冲区中写入数据,而消费者从中读取数据。生产者写入数据时,使用CAS操作,消费者读取数据时,为了防止多个消费者处理同一个数据,也使用CAS操作进行数据保护。

在这里插入图片描述

二、用Disruptor框架实现生产者-消费者模式的案例

现在我们已经了解了Disruptor框架的基本实现。本节将展示一下Disruptor框架的基本使用和API,这里使用的版本是disruptor-3.3.2,不同版本的Disruptor框架可能会有细微的差别,请大家留意。

这里,我们的生产者不断产生整数,消费者读取生产者的数据,并计算其平方。

首先,我们还是需要一个代表数据的PCData对象。

在这里插入图片描述
消费者实现为WorkHandler接口,它来自Disruptor框架。

在这里插入图片描述
消费者的作用是读取数据进行处理。这里,数据的读取已经由Disruptor框架进行封装了,onEvent()方法为框架的回调方法。因此,这里只需要简单地进行数据处理即可。

还需要一个产生PCData对象的工厂类。它会在Disruptor框架系统初始化时,构造所有的缓冲区中的对象实例(之前说过Disruptor框架会预先分配空间)。

在这里插入图片描述
接着,让我们来看一下生产者,它比前面几个类稍微复杂一点。

在这里插入图片描述
生产者需要一个RingBuffer的引用,也就是环形缓冲区。它有一个重要的方法pushData()将产生的数据推入缓冲区。方法pushData()接收一个ByteBuffer对象。在ByteBuffer对象中可以用来包装任何数据类型。这里用来存储long整数,pushData()方法的功能就是将传入的ByteBuffer对象中的数据提取出来,并装载到环形缓冲区中。

上述第12行代码通过next()方法得到下一个可用的序列号。通过序列号,取得下一个空闲可用的PCData对象,并且将PCData对象的数据设为期望值,这个值最终会传递给消费者。最后,在第21行进行数据发布,只有发布后的数据才会真正被消费者看见。

至此,我们的生产者、消费者和数据都已经准备就绪,只差一个统筹规划的主函数将所有的内容整合起来。

在这里插入图片描述
上述代码第6行,设置缓冲区大小为1024。显然是2的整数次幂—一个合理的大小。第7~12创建了disruptor对象。它封装了整个disruptor库的使用,提供了一些便捷的API。第13~17行设置了用于处理数据的消费者,这里设置了4个消费者实例。系统会把每一个消费者实例映射到一个线程中,也就是这里提供了4个消费者线程。第18行启动并初始化disruptor系统。在第23~29行中由一个生产者不断地向缓冲区中存入数据。

系统执行后,你就可以得到类似如下的输出:

在这里插入图片描述
生产者和消费者正常工作。根据Disruptor框架的官方报告,Disruptor框架的性能要比BlockingQueue队列至少高一个数量级以上。如此诱人的性能,当然值得我们尝试!

三、提高消费者的响应时间:选择合适的策略

当有新数据在Disruptor框架的环形缓冲区中产生时,消费者如何知道这些新产生的数据呢?或者说,消费者如何监控缓冲区中的信息呢?为此,Disruptor框架提供了几种策略,这些策略由WaitStrategy接口进行封装,主要有以下几种实现。

  • BlockingWaitStrategy:这是默认的策略。使用BlockingWaitStrategy和使用BlockingQueue是非常类似的,它们都使用锁和条件(Condition)进行数据的监控和线程的唤醒。因为涉及线程的切换,BlockingWaitStrategy策略最节省CPU,但是在高并发下它是性能表现最糟糕的一种等待策略。
  • SleepingWaitStrategy:这个策略对CPU的消耗与BlockingWaitStrategy类似。它会在循环中不断等待数据。它会先进行自旋等待,如果不成功,则使用Thread.yield()方法方法让出CPU,并最终使用LockSupport.parkNanos(1)进行线程休眠,以确保不占用太多的CPU数据。因此,这个策略对于数据处理可能会产生比较高的平均延时。它比较适合对延时要求不是特别高的场合,好处是它对生产者线程的影响最小。典型的应用场景是异步日志。
  • YieldingWaitStrategy:这个策略用于低延时的场合。消费者线程会不断循环监控缓冲区的变化,在循环内部,它会使用Thread.yield()方法让出CPU给别的线程执行时间。如果你需要一个高性能的系统,并且对延时有较为严格的要求,则可以考虑这种策略。使用这种策略时,相当于消费者线程变成了一个内部执行了Thread.yield()方法的死循环。因此,你最好有多于消费者线程数量的逻辑CPU数量(这里的逻辑CPU指的是“双核四线程”中的那个四线程,否则,整个应用程序恐怕都会受到影响)。
  • BusySpinWaitStrategy:这个是最疯狂的等待策略了。它就是一个死循环!消费者线程会尽最大努力疯狂监控缓冲区的变化。因此,它会吃掉所有的CPU资源。只有对延迟非常苛刻的场合可以考虑使用它(或者说,你的系统真的非常繁忙)。因为使用它等于开启了一个死循环监控,所以你的物理CPU数量必须要大于消费者的线程数。注意,我这里说的是物理CPU,如果你在一个物理核上使用超线程技术模拟两个逻辑核,另外一个逻辑核显然会受到这种超密集计算的影响而不能正常工作。
  • 在上面的例子中,使用的是BlockingWaitStrategy(第11行)。读者可以替换这个实现,体验一下不同等待策略的不同效果。

四、CPU Cache的优化:解决伪共享问题

除使用CAS和提供了各种不同的等待策略来提高系统的吞吐量之外,Disruptor框架大有将优化进行到底的气势,它甚至尝试解决CPU缓存的伪共享问题。

什么是伪共享问题呢?我们知道,为了提高CPU的速度,CPU有一个高速缓存Cache。在高速缓存中,读写数据的最小单位为缓存行(Cache Line),它是从主存(Memory)复制到缓存(Cache)的最小单位,一般为32字节到128字节。

当两个变量存放在一个缓存行时,在多线程访问中,可能会影响彼此的性能。在图5.4中,假设变量X和Y在同一个缓存行,运行在CPU1上的线程更新了变量X,那么CPU2上的缓存行就会失效,同一行的变量Y即使没有修改也会变成无效,导致Cache无法命中。接着,如果在CPU2上的线程更新了变量Y,则导致CPU1上的缓存行失效(此时,同一行的变量X变得无法访问)。这种情况反复发生,无疑是一个潜在的性能杀手。如果CPU经常不能命中缓存,那么系统的吞吐量就会急剧下降。

为了避免这种情况发生,一种可行的做法就是在变量X的前后空间都先占据一定的位置(把它叫作padding,用来填充用的)。这样,当内存被读入缓存时,这个缓存行中,只有变量X一个变量实际是有效的,因此就不会发生多个线程同时修改缓存行中不同变量而导致变量全体失效的情况,如图5.5所示

在这里插入图片描述
在这里插入图片描述
为了实现这个目的,我们可以这么做:

在这里插入图片描述
在这里插入图片描述
这里使用了两个线程,因为我的计算机是双核的,大家可以根据自己的硬件配置修改参数NUM_THREADS(第2行)。我们准备一个数组longs(第6行),数组元素个数和线程数量一致。每个线程都会访问自己对应的longs中的元素(从第14行、第27行和第42行都可以看到这一点)。

最后,最关键的一点就是VolatileLong。在第48行,准备了7个long型变量用来填充缓存。实际上,只有VolatileLong.value是会被使用的。而p1、p2等仅仅用于将数组中第一个VolatileLong.value和第二个VolatileLong.value分开,防止它们进入同一个缓存行。

这里使用JDK7 64位的Java虚拟机执行上述程序,输出如下:

在这里插入图片描述
这说明系统花费了5秒完成所有的操作。如果我注释第48行,也就是允许系统中两个VolatileLong.value放置在同一个缓存行中,程序输出如下:

在这里插入图片描述
很明显,第48行的填充对系统的性能是非常有帮助的。

注意:由于各个JDK版本内部实现不一致,在某些JDK版本中(比如JDK 8),会自动优化不使用字段。这将直接导致这种padding的伪共享解决方案失效。更多详细内容大家可以参考第6章中有关LongAddr的介绍。

Disruptor框架充分考虑了这个问题,它的核心组件Sequence会被频繁访问(每次入队,它都会被加1),其基本结构如下:

在这里插入图片描述
虽然在Sequence中,主要使用的只有value,但是通过LhsPadding和RhsPadding在这个value的前后安置了一些占位空间,使得value可以无冲突的存在于缓存中。

此外,对于Disruptor框架的环形缓冲区RingBuffer,它内部的数组是通过以下语句构造的:

在这里插入图片描述
注意:实际产生的数组大小是缓冲区实际大小再加上两倍的BUFFER_PAD。这就相当于在这个数组的头部和尾部各增加了BUFFER_PAD个填充,使得整个数组被载入Cache时不会因为受到其他变量的影响而失效。

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值