【深入理解disruptor】高性能内存队列的实现原理以及应用

819 阅读13分钟

前言

大家好哇,我又来了。

最近在工作中接触到了一个开源的内存队列框架--disruptor,所以闲下来就研究一下。发现这个东西非常适合于生产者消费者模型,所以深入研究一下其原理,此篇算是比较清晰易懂的,很多概念解释的比较详细,也希望你读完此篇能够有所收获。

简介

什么是disruptor

首先介绍一下disruptor(/dɪsˈrʌptɚ/ 迪斯rua普特)

Disruptor它是一个开源的并发框架,英国外汇交易公司LMAX开发的一个高性能队列,号称单线程能支撑每秒600万订单~。

其官方介绍文档:lmax-exchange.github.io/disruptor/

其github地址:github.com/LMAX-Exchan…

文档中有以下描述:

背景

LMAX 致力于成为全球最快的交易平台。为了实现这一目标,我们需要在 Java 平台上采取特别的措施,以实现极低的延迟和高吞吐率。性能测试表明,系统中使用队列在不同阶段之间传递数据会引入延迟,因此我们重点优化了这一部分。

Disruptor 是我们研究和测试的成果。我们发现,CPU 缓存未命中锁机制引发的内核仲裁是高昂的性能开销的主要来源。为了解决这些问题,我们设计了一个对底层硬件“硬件友好”(mechanical sympathy)的框架,它完全无锁

通用性

Disruptor 并不是一个为特定场景(如金融应用)设计的专用解决方案,而是一个通用的并发编程工具,旨在解决复杂的并发编程问题。

使用方式

Disruptor 的工作方式不同于传统的解决方案,因此使用时也需要一些调整。例如,将 Disruptor 应用于你的系统并不只是简单地用它的环形缓冲区替换所有队列,还需要根据具体需求设计使用模式。

可以看出来disruptor的几个特点:

  1. 无锁队列:disruptor本质上就是无锁内存队列,
  2. 性能非常好:主要是基于无锁以及CPU缓存优化。
  3. 通用性好:适用于多种场景。

与常见内存队列比较

实现并发的解决方式

  • ArrayBlockingQueue 使用ReentrantLock
  • LinkedBlockingQueue 使用ReentrantLock
  • ConcurrentLinkedQueue 使用CAS
  • disruptor 使用环形缓冲区(Ring Buffer)的无锁并发数据结构(多生产者获取序号时也是通过CAS),并且基于硬件特性(解决伪共享问题。

速度比较

以下是官方的(disruptor和ArrayBlockingQueue的比较),相同硬件设备上的多场景比较:(Ps:这里解释一下,P代表生产者,C代表消费者,如第一行就是1个生产者,3个消费者)

image-20241122144412601.png

可以看出disruptor是非常快的,基本在所有场景上都比ArrayBlockingQueue多出一个数量级。

disruptor的组件

在解释disruptor为什么这么快之前,我们需要先介绍一下其中包含的组件的概念,方便你在后面快速理解其实现原理。

Ring Buffer(环形缓冲区):环形缓冲区通常被视为 Disruptor 的核心组件

Sequencer:(序号管理器)。序列器是 Disruptor 的核心部分。这个接口有两个实现(单生产者和多生产者),它实现了所有并发算法,用于在生产者和消费者之间快速、正确地传递数据。

Sequence(序列):Disruptor 使用序列(Sequence)来标识某个特定组件的进度。每个消费者(事件处理器)以及 Disruptor 本身都会维护一个序列。主要是为了解决并发冲突,disruptor里面大部分的并发代码都是通过对Sequence的值同步修改实现的,而非锁,类似于AtomicLong,但是解决了伪共享问题,这是disruptor高性能的一个主要原因

SequenceBarrier(序号栅栏):,管理和协调生产者的游标序号和各个消费者的序号,确保生产者不会覆盖消费者未来得及处理的消息,确保存在依赖的消费者之间能够按照正确的顺序处理

EventProcessor(事件处理器):监听RingBuffer的事件,并消费可用事件,从RingBuffer读取的事件会交由实际的生产者实现类来消费;它会一直侦听下一个可用的序号,直到该序号对应的事件已经准备好。

EventHandler(业务处理器):是实际消费者的接口,完成具体的业务逻辑实现,第三方实现该接口;代表着消费者。

Producer:生产者接口,第三方线程充当该角色,producer向RingBuffer写入事件。

Wait Strategy:等待策略,Wait Strategy决定了一个消费者怎么等待生产者将事件(Event)放入Disruptor中。

8种等待策略

当消费速度大于生产速度情况下,消费者执行的等待策略。

策略类名描述
BlockingWaitStrategy(常用)使用ReentrantLock,失败则进入等待队列等待唤醒重试。当吞吐量和低延迟不如CPU资源重要时使用。
YieldingWaitStrategy(常用)尝试100次,全失败后调用Thread.yield()让出CPU。该策略将使用100%的CPU,如果其他线程请求CPU资源,这种策略更容易让出CPU资源。
SleepingWaitStrategy(常用)尝试200次 。前100次直接重试,后100次每次失败后调用Thread.yield()让出CPU,全失败线程睡眠(默认100纳秒 )。
BusySpinWaitStrategy线程一直自旋等待,比较耗CPU。最好是将线程绑定到特定的CPU核心上使用。
LiteBlockingWaitStrategy与BlockingWaitStrategy类似,区别在增加了原子变量signalNeeded,如果两个线程同时分别访问waitFor()和signalAllWhenBlocking(),可以减少ReentrantLock加锁次数。
LiteTimeoutBlockingWaitStrategy与LiteBlockingWaitStrategy类似,区别在于设置了阻塞时间,超过时间后抛异常。
TimeoutBlockingWaitStrategy与BlockingWaitStrategy类似,区别在于设置了阻塞时间,超过时间后抛异常。
PhasedBackoffWaitStrategy根据时间参数和传入的等待策略来决定使用哪种等待策略。当吞吐量和低延迟不如CPU资源重要时,可以使用此策略。

官网调用图如下:

image-20241122150656258.png 解释一下调用图:

  1. 生产者(producer)向RingBuffer中加入事件,生产方只维护一个代表生产的最后一个元素的序号。代表生产的最后一个元素的序号。每次向Disruptor发布一个元素都调用Sequenced.next()来获取下个位置的写入权。。

  2. 消费者(EventHandler)从RingBuffer中读取事件,但是读取之前会通过 SequenceBarrier 检查事件是否准备好,如果事件已准备好,消费者调用 get() 方法从 RingBuffer 中读取事件,并通过自己的 Sequence 更新处理进度

  3. SequenceBarrier,引用 Sequencer 的实例来跟踪生产者的序列号,检查事件是否已经发布(根据序列号),判断消费者是否可以安全地读取事件,假如消费者的序列号对应的事件还没有生产者生产出来,那么SequenceBarrier就会执行等待策略Wait Strategy

  4. 消费者之间可以有依赖关系。例如:

    • JournalConsumer 完成后,ReplicationConsumer 才能处理事件。
    • ApplicationConsumer 可能依赖于前两个消费者的结果。

    这种依赖关系通过 SequenceBarrierSequence 协调。

说明:

在单生产者模式(SINGLE)由于不存在并发写入,则不需要解决同步问题。在多生产者模式(MULTI)就需要借助JDK中基于CAS(Compare And Swap/Set)实现的API来保证线程安全。

高性能原理

环形数组队列

这里指的就是ringBuffer的实现。

环形数组队列的优势:

  1. 使用数组队列:相比于链表,通过下标索引访问更快。
  2. 环形结构:避免生产,消费速度导致的队头,队尾的竞争。
  3. 有界无需扩容:一般设置队列长度为2的n次幂,有利于二进制计算

计算是通过环形与运算(sequence & indexMask)实现的,indexMask就是环形队列的长度-1。以环形队列长度8为例,第9个元素Sequence为8,8 & 7 = 0,刚好又回到了数组第1个位置

解决伪共享

什么是伪共享问题?

为了提高CPU的速度,Cpu有高速缓存Cache,该缓存最小单位为缓存行CacheLine,他是从主内存复制的Cache的最小单位,通常是64字节。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。如果你访问一个long数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个。因此你能非常快地遍历这个数组。

但是当多个线程共享某份数据时,就会出现伪共享问题。比如一个缓存行同时存在A,B两个变量,线程1只想修改A变量,但是因为在同一个缓存行中,导致A修改后缓存行失效,而线程2想要读取B变量,就只能去内存拿,两个线程相互影响。

disruptor如何解决这个问题:

Sequence中的value前后都用未赋值的byte来独占(以前的版本是使用long占位,现在是byte)。

image-20241122161451798.png

image-20241122162121995.png

除此以外,disruptor还有很多地方使用了填充方式解决伪共享,比如RingBuffer中。

基本原理是:当一个对象被加载到缓存行时,默认情况下,这个对象的成员变量会按它们在内存中的排列顺序(通常是 Java 类中定义的顺序)填充到缓存行中。假设对象的字段比较小,它们就会紧密地放在缓存行的相邻位置。所以,如果是要填充字节解决伪共享问题,填充的字节变量必须在目标变量前后。

预分配内存

环形队列存放的是Event对象,而且是在Disruptor创建的时候调用EventFactory创建并一次将队列填满。Event保存生产者生产的数据,消费也是通过Event获取,后续生产则只需要替换掉Event中的属性值。这种方式避免了重复创建对象,降低JVM的GC产频率。

image-20241122162928709.png

image-20241122163122182.png

cas代替锁

锁非常昂贵,因为它们在竞争时需要仲裁。这种仲裁是通过到操作系统内核的上下文切换来实现的,该内核将挂起等待锁的线程,直到它被释放。系统提供的原子操作CAS(Compare And Swap/Set)是很好的锁替代方案,Disruptor中同步就是使用的这种。

比如多生产者模式中com.lmax.disruptor.MultiProducerSequencer就是用了Java里sun.misc.Unsafe类基于CAS实现的API。

旧的disruptor版本:直接使用的unsafe类

image-20210922160128392

等待策略com.lmax.disruptor.BlockingWaitStrategy使用了基于CAS实现的ReentrantLock。

image-20210922160301925

新版本:使用varhandle实现

image-20241122163955524.png VarHandle 是 Java 9 引入的一个类,它是对 Java 内存模型(Java Memory Model,JMM)的扩展,用于简化和强化对共享变量的低级别访问操作。它是 java.lang.invoke 包的一部分,主要用于提供更灵活且高效的原子操作,而不需要使用 sun.misc.UnsafeVarHandle 作为一个高层次的 API,提供了一些与并发编程相关的功能,尤其是在多线程环境下,它允许更精细控制对共享变量的访问和修改。

其他问题

多生产者如何保证生产者之间不会相互覆盖?

img

每个线程获取不同的一段数组空间,然后通过CAS判断这段空间是否已经分配出去。

接下来我们看下多生产类MultiProducerSequencer中next方法【获取生产序号】

// 消费者上一次消费的最小序号 // 后续第三点会讲到
private final Sequence gatingSequenceCache = new Sequence(Sequencer.INITIAL_CURSOR_VALUE);
// 当前进度的序号
protected final Sequence cursor = new Sequence(Sequencer.INITIAL_CURSOR_VALUE);
// 所有消费者的序号 //后续第三点会讲到
protected volatile Sequence[] gatingSequences = new Sequence[0];
​
 public long next(int n)
    {
        if (n < 1)
        {
            throw new IllegalArgumentException("n must be > 0");
        }
        long current;
        long next;
        do
        {
            // 当前进度的序号,Sequence的value具有可见性,保证多线程间线程之间能感知到可申请的最新值
            current = cursor.get();
            // 要申请的序号空间:最大序列号
            next = current + n;
  
            long wrapPoint = next - bufferSize;
            // 消费者最小序列号
            long cachedGatingSequence = gatingSequenceCache.get();
            // 大于一圈 || 最小消费序列号>当前进度
            if (wrapPoint > cachedGatingSequence || cachedGatingSequence > current)
            {
                long gatingSequence = Util.getMinimumSequence(gatingSequences, current);
                // 说明大于1圈,并没有多余空间可以申请
                if (wrapPoint > gatingSequence)
                {
                    LockSupport.parkNanos(1); // TODO, should we spin based on the wait strategy?
                    continue;
                }
                // 更新最小值到Sequence的value中
                gatingSequenceCache.set(gatingSequence);
            }
            // CAS成功后更新当前Sequence的value
            else if (cursor.compareAndSet(current, next))
            {
                break;
            }
        }
        while (true);
        return next;
    }
​

在多生产者的情况下,消费者怎么知道哪些事件已经被生产者写入,可以消费?

这个前提是多生产者情况下,第一点我们说过每个线程获取不同的一段数组空间,那么现在单单通过序号已经不够用了,MultiProducerSequencer使用了int 数组 【availableBuffer】来标识当前序号是否可用。当生产者成功生产事件后会将availableBuffer中当前序列号置为1标识可以读取。

如此消费者可以读取的的最大序号就是我们availableBuffer中第一个不可用序号-1。

img

生产者在环形数组中追上了消费者怎么办?

从gatingSequences中取得最小的序号,生产者最多能写到这个序号的后一位。通俗来讲就是申请的序号不能大于最小消费者序号一圈【申请到最大序列号-buffersize 要小于/等于 最小消费的序列号】的时候, 才能申请到当前写的序号,第一点中的代码已经解释了。

img

基本使用

1. 创建事件类

首先,定义一个 MyEvent 类,它表示要传递的数据。

public class MyEvent {
    private String message;

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

2. 创建事件处理器

然后,创建一个事件处理器 MyEventHandler,它处理从 RingBuffer 中提取的事件。

import com.lmax.disruptor.EventHandler;

public class MyEventHandler implements EventHandler<MyEvent> {
    @Override
    public void onEvent(MyEvent event, long sequence, boolean endOfBatch) {
        // 处理事件
        System.out.println("Processing event: " + event.getMessage());
    }
}

3. 创建生产者

接着,创建一个生产者 MyProducer,它负责将事件发布到 Disruptor 的 RingBuffer。

public class MyProducer {
    private final RingBuffer<MyEvent> ringBuffer;

    public MyProducer(RingBuffer<MyEvent> ringBuffer) {
        this.ringBuffer = ringBuffer;
    }

    public void publishEvent(String message) {
        // 获取下一个事件序列
        long sequence = ringBuffer.next();
        try {
            MyEvent event = ringBuffer.get(sequence);  // 获取事件对象
            event.setMessage(message);  // 设置事件内容
        } finally {
            ringBuffer.publish(sequence);  // 发布事件
        }
    }
}

4. 配置 Disruptor

接下来,设置 Disruptor。你需要配置 RingBuffer、EventHandler,并启动 Disruptor。

import com.lmax.disruptor.*;

public class DisruptorDemo {
    public static void main(String[] args) throws Exception {
        // 创建一个事件工厂
        EventFactory<MyEvent> factory = MyEvent::new;

        // 配置 RingBuffer
        int bufferSize = 1024; // 必须是2的幂
        Disruptor<MyEvent> disruptor = new Disruptor<>(factory, bufferSize, Executors.defaultThreadFactory());

        // 创建事件处理器
        MyEventHandler handler = new MyEventHandler();

        // 将事件处理器与 Disruptor 连接
        disruptor.handleEventsWith(handler);

        // 启动 Disruptor
        disruptor.start();

        // 获取 RingBuffer
        RingBuffer<MyEvent> ringBuffer = disruptor.getRingBuffer();

        // 创建生产者
        MyProducer producer = new MyProducer(ringBuffer);

        // 发布一些事件
        producer.publishEvent("Hello, Disruptor!");
        producer.publishEvent("Another event!");
        producer.publishEvent("Final event!");

        // 关闭 Disruptor
        disruptor.shutdown();
    }
}

5. 输出示例

当运行该代码时,输出将如下所示:

csharp
Processing event: Hello, Disruptor!
Processing event: Another event!
Processing event: Final event!

代码说明

  1. MyEvent:表示生产者和消费者之间传递的数据。
  2. MyEventHandler:处理 MyEvent 事件的消费者。onEvent 方法会在 RingBuffer 中有新事件时被调用。
  3. MyProducer:生产者,负责向 RingBuffer 发布事件。
  4. DisruptorDemo:配置和启动 Disruptor,包括事件工厂、RingBuffer、事件处理器和生产者。

如果你想要学习更多如何通过disruptor进行开发以及一些demo,这位大佬写的很好,可以参考一下: 单机最快的队列Disruptor解析和使用

参考

官网:lmax-exchange.github.io/disruptor/u…

juejin.cn/post/717496…

juejin.cn/post/721838…

juejin.cn/post/732568…