Disruptor并发框架介绍和原理解析

背景

        Disruptor是国外的公司LMAX开发的一个高性能队列,其目的是为了解决内存队列的延迟问题。基于Disruptor开发的系统单线程能支撑每秒600万的订单量。这里所说的队列是系统内部的内存队列,而不是Kafka、RabbitMQ这样的分布式队列。

1. Java内置队列

        介绍Disruptor之前,我们先来看一看常用的线程安全的内置队列有什么问题。Java的内置队列如下表所示。

队列

有界性

数据结构

ArrayBlockingQueue

有界

加锁

数组

LinkedBlockingQueue

有界/无界

加锁

链表

ConcurrentLinkedQueue

无界

无锁

链表

LinkedTransferQueue

无界

无锁

链表

PriorityBlockingQueue

无界

加锁

DelayQueue

无界

加锁

        队列的底层数据结构一般分成三种:数组、链表和堆。其中,堆这里是为了实现带有优先级特性的队列,暂且不考虑。

        在稳定性和性能要求特别高的系统中,为了防止生产者速度过快,导致内存溢出,只能选择有界队列;同时,为了减少Java的垃圾回收对系统性能的影响,会尽量选择array/heap格式的数据结构。这样筛选下来,符合条件的队列就只有ArrayBlockingQueue。

但是ArrayBlockingQueue有两个问题严重影响了性能。

  1. 通过加锁的方式保证线程安全。
  2. 存在伪共享问题。

其中对于影响性能的两种方式:加锁相信大家已经很熟悉了,下面章节对伪共享进行介绍。

2. 伪共享问题

2.1. CPU内存

        上图是计算机的基本结构。L1、L2、L3分别表示一级缓存、二级缓存、三级缓存,越靠近CPU的缓存,速度越快,容量也越小。所以L1缓存很小但很快,并且紧靠着在使用它的CPU内核;L2大一些,也慢一些,并且仍然只能被一个单独的CPU核使用;L3更大、更慢,并且被单个插槽上的所有CPU核共享;最后是主存,由全部插槽上的所有CPU核共享。

        当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要尽量确保数据在L1缓存中。

2.2. 缓存行

        Cache是由很多个cache line组成的。每个cache line通常是64字节,CPU每次从主存中拉取数据时,会把相邻的数据也存入同一个cache line。

        在访问一个long数组的时候,如果数组中的一个值被加载到缓存中,它会自动加载另外7个。因此你能非常快的遍历这个数组。事实上,你可以非常快速的遍历在连续内存块中分配的任意数据结构。

2.3. 伪共享

以ArrayBlockingQueue队列为例,其中最核心的三个成员变量为:

  • putIndex:入队下标。
  • takeIndex:出队下标。
  • count:队列中元素的数量。

        这三个变量很容易放到一个缓存行中,但是之间修改没有太多的关联。所以每次修改,都会使之前缓存的数据失效,从而不能完全达到共享的效果。

        如上图所示,当生产者线程和消费者线程同时操作ArrayBlockingQueue时,两个线程会加载这3个变量到自己的L1缓存中。假设Producer Thread put一个元素到ArrayBlockingQueue时,putIndex会修改,根据MESI协议,那么变量对应的其他所有缓存行都会失效。因为putIndex 和 takeIndex位于同一缓存行。所以consumer Thread 中的L1缓存行也会失效。 当consumer Thread需要消费后需要修改takeIndex时,则需要到主内存中去拿数据。

        这种无法充分使用缓存行特性的现象,称为伪共享。

3. Disruptor高性能原理

3.1. 环形数组

环形数组结构是整个Disruptor的核心所在。

        首先因为是数组,所以要比链表快,而且根据我们对上面缓存行的解释知道,数组中的一个元素加载,相邻的数组元素也是会被预加载的,因此在这样的结构中,cpu无需时不时去主存加载数组中的下一个元素。

        数组会预先分配内存,使得数组对象一直存在(除非程序终止)。这就意味着不需要花大量的时间用于垃圾回收。此外,不像链表那样,需要为每一个添加到其上面的对象创造节点对象—对应的,当删除节点时,需要执行相应的内存清理操作。环形数组中的元素采用覆盖方式,避免了jvm的GC。

        其次结构作为环形,数组的大小为2的n次方,这样元素定位可以通过位运算效率会更高,这个跟一致性哈希中的环形策略有点像。在disruptor中,这个牛逼的环形结构就是RingBuffer,既然是数组,那么就有大小,而且这个大小必须是2的n次方。

3.2. 无锁设计

        先来看一下Disruptor 的RingBuffer中哪些地方需要用到锁。

        相较于ArrayBlockingQueue队列中的三个变量,putIndex,takeIndex,count。 RingBuffer中存在线程竞争的只有数组的角下表 Sequence。这样线程访问之间的冲突也就更少了。然而Disruptor中并没有使用锁,那他是如何保证线程安全的。

        我们都知道线程安全的三要素分别是:原子性、有序性和可见性。

原子性:在代码中Sequence的数据类型是AtomicLong类型。所以Disruptor是通过CAS的方式来保证原子性的。

什么是CAS(CSDN:CAS机制实现原理分析icon-default.png?t=N2N8https://blog.csdn.net/Ginny97/article/details/127141551

        CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。如果不匹配,则会从内存总拿到最新的值重新进行计算,直到V = A 为止。

有序性、可见性:在Disruptor中,在Sequence之前用volatile进行修饰的。

        对Volatile关键字修饰的变量,执行写操作的时候,Jvm会发送一条lock前缀指令给CPU,CPU在操作完这个变量后会立即把这个值写会主存中,同时有MESI缓存一致性协议,所以各个CPU都会访问总线,感知自己本地高速缓存区中的数据是否被其他线程修改过。如果发现本地缓存的数据被其他线程修改,那么本地高速缓存区与主存中不一致的数据当做过期的值并清理掉。然后再从主存内中重新加载最新的数据。这样就是实现了可见性。

        在对Volatile关键字修饰的变量,执行操作读写操作的时候会插入内存屏障,这样可以禁止CPU指令重排。内存屏障遵循的就是happens-before原则表示的是前一个操作的结果对于后续操作是可见的。

  • StoreStore:每个Volatile写操作前加上StoreStore屏障,禁止上面的普通写和它重排;
  • StoreLoad:每个Volatile写操作后面加StoreLoad屏障,禁止跟下面的Volatile读和它重排;
  • LoadLoad:每个Volatile读操作前面加LoadLoad屏障,禁止跟上面的普通读和Volatile读重排;
  • LoadStore:每个Volatile读操作后面加LoadStore屏障,禁止禁止下面的普通写和Volatile读重排;

详情参考:CSDN:volatile实现原理icon-default.png?t=N2N8https://blog.csdn.net/u013291050/article/details/117335477

3.3. 缓存行填充

        从上面介绍的缓存行可以知道,每个cache line通常是64字节。Long数据类型是8字节。所以在前面补充7个long ,在后面补充7个long,就可以保证sequence无论如何加载,都会独占一个缓存行。这样就避免了伪共享的问题。采取的就是空间换时间的方式。

class LhsPaddingl {
    //缓存行补齐提升cache缓存命中概率
    protected long p1, p2, p3, p4, p5, p6, p7;
}

class Value extends LhsPadding{
    protected volatile long value;
}
    
class RhsPadding extends Value {
    /缓存行补产提升cache缓存命中率
    protected long p9, p10, p11, p12, p13, p14, p15;
}
    
public class Sequence extends RhsPadding{
    ...
}

        在jdk1.8中,有专门的注解@Contended来避免伪共享,更优雅地解决问题。

3.4. 批量操作

        Ring Buffer的核心操作是生产和消费,如果能减少这两个操作的次数,性能必然相应地提高。Disruptor中使用成批操作来减少生产和消费的次数,下面具体说一下Disruptor的生产和消费过程中如何体现Batch的。向RingBuffer生产东西的时候,需要经过2个阶段:阶段一为申请空间,申请后生产者获得了一个指针范围[low,high],然后再对缓冲区中[low,high]这段的所有对象进行setValue,阶段2为发布(像这样ringBuffer.publish(low,high);)。阶段1结束后,其他生产者再申请的话,会得到另一段缓冲区。阶段2结束后,之前申请的这一段数据就可以被消费者读到。

        从RingBuffer消费东西的时候也需要两个阶段,阶段一为指定消费者的消费区间[A,B],判断A到B不是一段可以连续消费的区间,如果不是则返回最大可消费连续区间进行消费。 阶段二 对[A,B]区间中的值进行消费。

        这样可以减少生产和消费时同步带来的性能损失。

4. 如何实现生产和消费

        在Disruptor中生产者分为单生产者和多生产者,而消费者并没有区分。单生产者情况下,就是普通的生产者向RingBuffer中放置数据,消费者获取最大可消费的位置,并进行消费。

        多生产者时候,又多出了一个跟RingBuffer同样大小的Buffer,称为AvailableBuffer。在多生产者中,每个生产者首先通过CAS竞争获取可以写的空间,然后再进行慢慢往里放数据,放完数据后就将AvailableBuffer中对应的位置置位。如果正好这个时候消费者要消费数据,那么每个消费者都需要获取最大可消费的下标,这个下标是在AvailableBuffer进行获取得到的最长连续的序列下标。

4.1. 多生产者 - 写数据

        如下图所示,Writer1和Writer2两个线程写入数组,都申请可写的数组空间。Writer1被分配了下标3到下表5的空间,Writer2被分配了下标6到下标9的空间。

        Writer1写入下标3位置的元素,同时把available Buffer相应位置置位,标记已经写入成功,往后移一位,开始写下标4位置的元素。Writer2同样的方式。最终都写入完成。

4.2. 多生产者 - 读数据

        如下图所示,读线程读到下标为2的元素,三个线程Writer1/Writer2/Writer3正在向RingBuffer相应位置写数据,写线程被分配到的最大元素下标是11。

        读线程申请读取到下标从3到11的元素,判断writer cursor>=11。然后开始读取availableBuffer,从3开始,往后读取,发现下标为7的元素没有生产成功,于是WaitFor(11)返回6。

        然后,消费者读取下标从3到6共计4个元素。

5. 代码运行对比

Disruptor和ArrayBlockingQueue进行对比:

数据量(生产者线程数为1)

Disruptor

ArrayBlockingQueue

差距时间

100

106ms

5ms

101ms

10000

201ms

98ms

103ms

1000000

6427ms

7844ms

-1417ms

10000000

64255ms

80032ms

-15777ms

我们可以看到随着数据量的增加,Disruptor的效率逐渐超越了ArrayBlockingQueue,并且越拉越大。

数据量/ 生产者线程数

Disruptor

ArrayBlockingQueue

差距时间

1000000 / 1

6401ms

7854ms

-1453ms

1000000 / 10

6317ms

7709ms

-1392ms

1000000 / 50

6209ms

8061ms

-1852ms

1000000 / 100

6169ms

8072ms

-1903ms

我们可以看到随着线程数量的增加,ArrayBlockingQueue锁竞争越严重,效率也就越低。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值