Disruptor队列解决高并发线程共享

前言:

前面写的文章用CSDN的代码编辑器编辑后,虽然美感增加不少,但个人感觉阅读效果不是很好(主要是有滑动条),所以以后的文字描述部分改用文本编辑,增强可读性。

最近公司项目中有涉及内存缓存队列的应用场景,解决方案使用的是Disruptor框架,可能以前只是熟悉BlockQueque相关的队列并没有真正思考过是否可以用其他的方案进行替代,公司既然摒弃了JDK原生的BlockQueue必然有其道理,所以最近学习了Disruptor框架,所以稍微总结下。

1、首先第一个问题:什么是Disruptor?

在介绍之前,首先抛出几个Disruptor相关的概念及其所代表的含义:

1、RingBuffer:环形缓冲区通常被认为是Disruptor的主要实现,当前版本即3.0版本之后,RingBuffer仅负责存储和更新通过Disruptor的数据(Event)。

2、Sequence:Disruptor使用Sequences作为识别特定组件所在位置的方法。每个消费者(EventProcessor)都像Disruptor本身一样维护一个Sequence。大多数并发代码依赖于这些Sequence值的变化或者叫移动,因此Sequence支持AtomicLong的许多当前功能。事实上,唯一真正的区别是Sequence包含额外的功能,以防止序列和其他值之间的错误共享。

3、Sequencer:Sequencer是Disruptor的核心API。该接口的2个实现类(SingleProducer,MultiProducer)实现了所有并发算法,用于在生产者和消费者之间快速,正确地传递数据。

4、Sequence Barrier:序列屏障由Sequencer 产生,包含对Sequencer 中主要发布的sequence和任何依赖性消费者的序列的引用。它包含确定是否有任何可供消费者处理的事件的逻辑。

5、WaitStrategy:等待策略确定消费者如何等待生产者将事件放入Disruptor。

6、Event:从生产者传递给消费者的数据单位,完全由用户定义其类型。

7:、EventProcessor:用于处理来自Disruptor的事件的主事件循环,并具有消费者序列的所有权。有一个名为 BatchEventProcessor表示,它包含事件循环的有效实现,并将回调到使用的提供的EventHandler接口实现。

8、EventHandler:由用户实现并代表Disruptor的使用者的接口,用户客户端实现消息的处理机制,由客户端具体实现。

9、Producer:生产者,产生消息,并将消息发布到RingBuffer内存队列中。

tips:这里留一个小小的疑问,为什么消息的生产者为Producer,而事件的消费者不命名为Consumer呢?

以上是部分Disruptor涉及的核心概念的介绍。

首先Disruptor是为了解决高并发缓存的队列,为线程间通讯提供高效的性能。

这里涉及到多线程的概念:

在多个线程对于一个共享变量或者在操作系统中叫临界区的共享产生了数据的不一致性,这是为什么呢?

如果从JVM的角度去谈的话,这里涉及到JMM相关的知识,下面是JMM相关的图解:

  

JMM模型示意图

在JVM中,每个线程在虚拟机栈中是相互隔离的,所以线程间是通过JMM机制进行线程间的通讯的,这样会出现一个问题,

也就是当线程将数据加载到线程的本地内存时,此时对于共享内存或者叫主内存中的变量改变对于当前线程是不可见的。

简单给个场景:

当线程1从主内存中load value进入到本地内存,并准备更新时,恰巧线程1的cpu时间片轮转完毕。

当线程2获取cpu调度时,从主内存中load value进入本地内存时,并将value的值更新后,重新store入共享内存,但

线程1并不知道,线程2更改的value的值,线程1获取cpu调用后,将自己更改的值存入主内存中,这样在获取到存入阶段

线程2对主内存中vlaue值的任何操作,当然主要是更新操作,是对线程1不可见的。

Java提供了很多机制去保证数据的一致性。

1、synchronized关键字,众所周知,synchronized是并发编程中重量级锁的实现,其实现细节不具体介绍,但其对于并发的处理效率是很低的,当并发量上来时,很显然每个线程的QOS必然会出现异常,线程队列处理状态下,每个请求的延迟是不平均的,排队处理的方式显然增加了平均延迟。

2、有人会说那我使用volatile+Lock的机制不行吗?对于无锁编程或者叫轻量级锁的CAS操作,实际上已经能够满足大多说并发要求,对于高并发环境不考虑QOS状态的情况下,个人也认为是一种很好的策略,但如果系统需要承受更高的TPS并且在要求每个QPS低延迟的情况下,例如2ms以下的低延迟,显然无锁编程仍然不能够很好的解决问题。

这时候有没有想到CocurrentMap的实现呢?分段,似乎是能够降低单位并发,不错很好的设计实现。但降低并发,在tps量高的情况下似乎也不是很适用,因为每个Segement当然1.8改版使用CAS了的并发量依然很大,但是似乎是一个很好的解决方案,比如我们每个位置只处理少量请求,貌似每个位置处理少量并发请求,但实际上增加了对cpu性能的需求,8,16核,超线程,似乎当并发量骤增也不是很好的处理方式,由于公司目前衔接的有其他业务,平常QPS在1万左右,高峰3万左右,还算正常,当然主要是对TPS即吞吐量有硬性要求,因为多部门数据统一处理在高并发情况下的TPS不能过低,有人可能会考虑横向扩展,暴力增加机器数,对于公司来说肯定是不划算的,并且对于运维方面也会增加相关的开销。

上面是基于并发的考量,下面简单谈谈对于基于队列对数据的处理的考量及消息可靠性方面的考量。

先谈谈对于队列数据处理的考量

首先我们知道,对于计算机操作系统中cpu调度资源的过程如下,ProcessOn作图工具,大家有兴趣的可以试试,部分功能需要收费,免费功能基本够用,下面看图。

CPU数据加载示意图

而计算操作系统在cache实现上的细节如下图:

cpu内存示意图

图画的有点糙,将就着看下,基本意思到位就行,下面是百度的缓存效率图,可能数据更加直观一点:

从CPU到大约需要的 CPU 周期大约需要的时间
主存 约60-80纳秒
QPI 总线传输,
(between sockets, not drawn)
 约20ns
L3 cache约40-45 cycles,约15ns
L2 cache约10 cycles,约3ns
L1 cache约3-4 cycles,约1ns
寄存器1 cycle

首先cpu执行调度资源首先会从高速缓存L1中去加载,当然也是速度最快的,L1中加载不到再去L2缓存中加载,L2加载不到再从L3中去加载,当然看高速缓存的数量了,这里只列到3级缓存,为什么缓存快快,当然这里涉及寄存器及cpu加载的相关知识,不作深入解释,一般离cpu越近相对来说其速度越快,但貌似代价更高,有兴趣去看计算机操作系统

缓存虽然高效,缓存相对来说存储量不大,而且缓存并不是实时都存在的,比如基于很多LRU等缓存策略不多讲。

一般大多数核心数据都是在内存中存储的,每次调用需要将内存中的数据加载到CPU中,如果缓存中有相关数据命中首先从缓存中加载。

tips:有没有发现,在我们代码宏观世界中也一直遵循着类似的条约,为什么,伪微观(找不出一个相对应的词)计算机操作系统实现上,设计时使用的如此的结构,宏观上也必然有相应的体现,由小到大,想起了计算机操作系统中的多路复用,许多年前都已经提出来了,只是近些年貌似更火了,什么epoll、select等在宏观代码世界中去实现,最基本的实现还是计算机操作系统的支持,显然软件的发展离不开操作系统的进步,操作系统的进步,又离不开硬件的支持,软件只是硬件的设计的一种体现。

对于一些不经常使用的数据,或者需要做灾备或数据共享,更多的会持久化到磁盘中,当然从内存到磁盘的过程中避免不了IO操作,对于IO操作,其性能是非常低的,并且影响机器性能,一般都是起定时任务(单独分配线程)处理,保证不干扰基础业务,对于低延时任务来说,IO操作无异于低延迟杀手。讲了这么多,其实就一点,如果想实现高吞吐量,数据最好放到内存或者缓存中,提高数据的读取效率,减少IO操作。对于其他的一些解决方案如redis等,也是基于内存存储的,但其会产生网络时延,同样会增加时延,基于业务可以考虑使用。ehcache及memorycache等本地内存缓存也是很高效的,但并不提供队列消息处理能力。 

下面基于上面的分析来看看Disruptor如何解决这些问题的:

首先看看传统队列

标准队列示意图

队列的目的就是为生产者和消费者提供一个地方存放要交互的数据,帮助缓冲它们之间传递的消息。这意味着缓冲常常是满的(生产者比消费者快)或者空的(消费者比生产者快)。生产者和消费者能够步调一致的情况非常少见。队列需要保存一个关于大小的变量,以便区分队列是空还是满。否则,它需要根据队列中的元素的内容来判断,这样的话,消费一个节点后需要做一次写入来清除标记,或者标记节点已经被消费过了。无论采用何种方式实现,在头、尾和大小变量上总是会有很多竞争,或者如果消费操作移除元素时需要使用一个写操作,那元素本身也包含竞争。传统队列中,生产者生产消息的时候,只能逐个元素的添加,如果不逐个添加,对于尾指针位置无法确认,即时使用CAS减少锁的代价,也避免不了性能上的瓶颈。

Disruptor相对于传统队列方式的优点:

1、没有竞争即没有锁

2、所有访问者都记录自己的序号的实现方式,允许多个生产者与多个消费者共享相同的数据结构。

3、在每个对象中都能跟踪序列号(ring buffer, claim strategy,生产者和消费者),加上神奇的缓存行填充,就意味着没有伪共享和非预期的竞争。

下面再简单介绍下RingBuffer核心实现,来看看队列的实现细节,ProcessorOn无法处理环形图,只好用画图板简单画下。

首先可以看到RingBuffer顾名思义,其为环形队列,有点像一致性Hash算法中的闭环,但完全不一样。

 

RingBuffer结构示意图

 

底层的话是一个固定大小的数组结构,相比于队列来说,其只有一个下标指针cursor,如果槽的个数是2的N次方更有利于基于二进制的计算机进行计算。如果看过HashMap源码应该知道,HashMap定位元素槽时使用了一种巧妙的方式,hash&(length-1)。

RingBuffer底层存储结构源码

这里的RingBuffer同样是相同的计算方式,sequence&(length-1),当然你可以进行取模操作,但取模操作在寄存器中的计算,需要多次的迭代加操作进行的,所以相对于计算速度来说,对于计算机进行位运算效率绝对是高于取模操作的,尤其是对于高并发状况下的计算,能够节省很多单位cpu开销,所以不多讲,有兴趣自己去看。

我们知道一般实现线性存储有两种实现方式,一种是基于连续内存分配的HashTable,另一种是基于随机内存分配的迭代指针。

为什么RingBuffer选用数组作为存储结构,而不选用链表存储?

对计算机操作系统有一定了解的,就会明白,实际上,这里RingBuffer选用数组去作为存储结果,是依赖于连续分配内存的预读策略,也就是内存加载时,会将部分连续内存地址预先加载到高速缓存中,即认为你可能会使用,上面我们分析了操作系统中的cpu操作数据的流程,可以看出这种设计是为了不用反复从内存中加载,链表的内存分配是碎片化的所以其存储地址不是连续的,导致每次都会cpu都会重新计算下一个链表位置的地址,并从内存中加载相关的数据,数据量小的情况下并不能看出性能的优劣,但是当数据量大的情况下,这种极小的消耗,会对整体的运行效率产生影响。

难道RingBuffer就因为选用数组就对性能产生了这么大的影响,当然不是,我们上面谈到了锁及并发情况下的代价或者叫开销,

下面我们看看RingBuffer是怎么解决这个问题的。

在介绍之前我们首先介绍一个概念,伪共享的概念。

内存以高速缓存行的形式存储在高速缓存系统中。高速缓存行是2的N次方个连续字节,其大小通常为32-256。最常见的缓存行大小为64字节。伪共享是一个术语,适用于线程在修改共享同一缓存行的独立变量时无意中影响彼此的性能。在高速缓存行上写入争用是实现SMP系统中并行执行线程的可伸缩性的最大限制因素。这是百度的伪共享术语介绍,本人水平有限,搬来用用。

如果对于volatile有一定深入研究,我们很显然可以清楚的理解上面copy过来的对伪共享的解释,简单说明下,深入的自己去了解。volatile是java中的关键字,用于并发时候解决共享内存一致性,JVM层面的相关知识自己去了解下。下面讲下在计算机操作系统层面的实现细节,首先我们知道对于锁来说是关中断实现,锁定bus消息总线实现,而对于共享内存,计算机使用的是缓存行,共享变量的多个线程,共享相同的缓存行。而要实现线程数量的线性可伸缩性,我们必须确保没有两个线程写入同一个变量或缓存行。而当使用volatile的时候,我们读取直接共享变量从主内存或者叫共享内存中读取变量的值,其本质是使计算机缓存行失效。

不用质疑,此缓存行也是彼缓存行。下面也是百度copy过来的一张关于缓存行的一张图,考虑是不是该买个VPN账户了。

ç¼å­line.png

上图说明了伪共享的问题。在核心1上运行的线程想要更​​新变量X,而核心2上的线程想要更​​新变量Y。这两个热变量位于同一缓存行中。每个线程都将竞争缓存行的所有权,以便他们可以更新它。如果核心1获得所有权,那么缓存子系统将需要使核心2的相应缓存行无效。当Core 2获得所有权并执行其更新时,将告知核心1使其缓存行的副本无效。这将通过L3缓存进行交互,极大地影响性能。如果竞争核心在不同的套接字上并且还必须跨越套接字互连,则缓存行问题将进一步加剧。

对于Hotspot JVM,所有对象都有一个2个字的header。首先是“mark”word,不熟悉的去看jvm,其由用于散列码的24位和用于诸如锁定状态的标志的8位组成,或者它可以被交换用于锁定对象。第二个是对象类的引用。数组有一个额外的word,用于表示数组的大小。为了提高性能,每个对象都与8字节的粒度边界对齐。因此,根据大小(以字节为单位)将对象字段从声明顺序重新排序为以下顺序: 

 如果多个线程操作不同的成员变量, 但是这些变量存储在同一个缓存行,如果有处理器更新了缓存行的数据并刷新到主存之后,根据缓存一致性原则,其他处理器将失效该缓存行(I状态)导致缓存未命中,需要重新去内存中读取最新数据,这就是伪共享问题。特别是不同的线程操作同一个缓存行,需要发出RFO(Request for Owner)信号锁定缓存行,保证写操作的原子性,此时其他线程不能操作这个缓存行,这将对效率有极大的影响。

  1. doubles (8) and longs (8)
  2. ints (4) and floats (4)
  3. shorts (2) and chars (2)
  4. booleans (1) and bytes (1)
  5. references (4/8)
  6. <repeat for sub-class fields>

为了避免避免经常执行写操作的变量因为在同一个缓存行而导致的伪共享问题,常用的解决方式就是缓存行填充,或者称为缓存行对齐。

 下面是缓存行实现,另外缓存行填充有一个前提同时分配的对象往往位于同一位置。

public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
private volatile long cursor = INITIAL_CURSOR_VALUE;
public long p8, p9, p10, p11, p12, p13, p14; // cache line padding

 如果有不同的消费者往不同的字段写入,你需要确保各个字段间不会出现伪共享。

为什么会解释缓存行呢,很显然在单一volatile下,并不能保证所有字段不在同一个缓存行,为了避免字段间的伪共享,缓存行填充是有必要的,也能够减少相应的开销。

tips:当多个线程同时对共享的缓存行进行写操作的时候,因为缓存系统自身的缓存一致性原则,会引发伪共享问题,解决的常用办法是将共享变量根据缓存行大小进行补充对齐,使其加载到缓存时能够独享缓存行,避免与其他共享变量存储在同一个缓存行。下面是java实现的缓存行填充实践。

/**
 * 数组保存了VolatileLongPadding,其中数组中一个long类型保存数组长度,算上
 * 自身long类型value,需要再填充6个long类型,就能将数组中的对象填充满一个 缓存行。
 * 注意:这里使用继承的方式实现缓存行对齐,因为Java编译器会优化无效的字段。
 */
class VolatileLongPadding {
    // 如果不需要填充,只需要注释掉这段代码即可
    public volatile long p1, p2, p3, p4, p5, p6; 
}

class VolatileLong extends VolatileLongPadding {
    //实际操作的值
    public volatile long value = 0L;
}
public class FalseSharing implements Runnable {
    //线程个数
    public static int NUM_THREADS = 4; // change
    //循环修改数组中数据的次数
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    //数组下标
    private final int arrayIndex;
    //操作的数组
    private static VolatileLong[] longs;

    private static final CountDownLatch cdl = new CountDownLatch(NUM_THREADS);

    public FalseSharing(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    public static void main(final String[] args) throws Exception {
        Thread.sleep(10000);
        System.out.println("starting....");
        //初始化数组
        longs = new VolatileLong[NUM_THREADS];
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new VolatileLong();
        }
        final long start = System.nanoTime();
        runTest();
        System.out.println("duration = " + (System.nanoTime() - start));
    }

    private static void runTest() throws InterruptedException {
        //初始化线程组
        Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseSharing(i));
        }
        //开始运行所有线程
        for (Thread t : threads) {
            t.start();
        }
        //主线程阻塞直到所有子线程结束
        cdl.await();
    }

    @Override
    public void run() {
        //多线程情况下持续修改数组中某一个volatile值
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = i;
        }
        cdl.countDown();
    }
}
使用缓存行填充,耗时(ns):
8856562470   (8.8s)
去掉缓存行填充,耗时(ns):
31879117768  (31.88s)

对比可以发现耗时相差一个量级,而且随着线程数越大,循环次数越大,这个耗时的量级差别越大。

 

讲了这么多,就是为了说明RingBuffer实现上同样也是使用了缓存行填充,保证了数组中的数据没有伪共享的存在,总结似乎有点短,但RingBuffer缓存行核心知识就这点,看下RingBuffer源码。

可以看出RingBuffer除了一个long类型的cursor索引指针,p1->p7都为缓存行填充。

(时间不足,预留位置有时间再写)

所以选用了无锁编程的Disruptor用于构建高并发、低延迟业务处理及高TPS缓存队列。

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值