前言
大家好哇,我又来了。
最近在工作中接触到了一个开源的内存队列框架--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的几个特点:
- 无锁队列:disruptor本质上就是无锁内存队列,
- 性能非常好:主要是基于无锁以及CPU缓存优化。
- 通用性好:适用于多种场景。
与常见内存队列比较
实现并发的解决方式
ArrayBlockingQueue使用ReentrantLockLinkedBlockingQueue使用ReentrantLockConcurrentLinkedQueue使用CASdisruptor使用环形缓冲区(Ring Buffer)的无锁并发数据结构(多生产者获取序号时也是通过CAS),并且基于硬件特性(解决伪共享问题。
速度比较
以下是官方的(disruptor和ArrayBlockingQueue的比较),相同硬件设备上的多场景比较:(Ps:这里解释一下,P代表生产者,C代表消费者,如第一行就是1个生产者,3个消费者)
可以看出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资源重要时,可以使用此策略。 |
官网调用图如下:
解释一下调用图:
-
生产者(producer)向RingBuffer中加入事件,生产方只维护一个代表生产的最后一个元素的序号。代表生产的最后一个元素的序号。每次向Disruptor发布一个元素都调用Sequenced.next()来获取下个位置的写入权。。
-
消费者(EventHandler)从RingBuffer中读取事件,但是读取之前会通过
SequenceBarrier检查事件是否准备好,如果事件已准备好,消费者调用get()方法从RingBuffer中读取事件,并通过自己的Sequence更新处理进度 -
SequenceBarrier,引用
Sequencer的实例来跟踪生产者的序列号,检查事件是否已经发布(根据序列号),判断消费者是否可以安全地读取事件,假如消费者的序列号对应的事件还没有生产者生产出来,那么SequenceBarrier就会执行等待策略Wait Strategy -
消费者之间可以有依赖关系。例如:
JournalConsumer完成后,ReplicationConsumer才能处理事件。ApplicationConsumer可能依赖于前两个消费者的结果。
这种依赖关系通过
SequenceBarrier和Sequence协调。
说明:
在单生产者模式(SINGLE)由于不存在并发写入,则不需要解决同步问题。在多生产者模式(MULTI)就需要借助JDK中基于CAS(Compare And Swap/Set)实现的API来保证线程安全。
高性能原理
环形数组队列
这里指的就是ringBuffer的实现。
环形数组队列的优势:
- 使用数组队列:相比于链表,通过下标索引访问更快。
- 环形结构:避免生产,消费速度导致的队头,队尾的竞争。
- 有界无需扩容:一般设置队列长度为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)。
除此以外,disruptor还有很多地方使用了填充方式解决伪共享,比如RingBuffer中。
基本原理是:当一个对象被加载到缓存行时,默认情况下,这个对象的成员变量会按它们在内存中的排列顺序(通常是 Java 类中定义的顺序)填充到缓存行中。假设对象的字段比较小,它们就会紧密地放在缓存行的相邻位置。所以,如果是要填充字节解决伪共享问题,填充的字节变量必须在目标变量前后。
预分配内存
环形队列存放的是Event对象,而且是在Disruptor创建的时候调用EventFactory创建并一次将队列填满。Event保存生产者生产的数据,消费也是通过Event获取,后续生产则只需要替换掉Event中的属性值。这种方式避免了重复创建对象,降低JVM的GC产频率。
cas代替锁
锁非常昂贵,因为它们在竞争时需要仲裁。这种仲裁是通过到操作系统内核的上下文切换来实现的,该内核将挂起等待锁的线程,直到它被释放。系统提供的原子操作CAS(Compare And Swap/Set)是很好的锁替代方案,Disruptor中同步就是使用的这种。
比如多生产者模式中com.lmax.disruptor.MultiProducerSequencer就是用了Java里sun.misc.Unsafe类基于CAS实现的API。
旧的disruptor版本:直接使用的unsafe类
等待策略com.lmax.disruptor.BlockingWaitStrategy使用了基于CAS实现的ReentrantLock。
新版本:使用varhandle实现
VarHandle 是 Java 9 引入的一个类,它是对 Java 内存模型(Java Memory Model,JMM)的扩展,用于简化和强化对共享变量的低级别访问操作。它是 java.lang.invoke 包的一部分,主要用于提供更灵活且高效的原子操作,而不需要使用 sun.misc.Unsafe。VarHandle 作为一个高层次的 API,提供了一些与并发编程相关的功能,尤其是在多线程环境下,它允许更精细控制对共享变量的访问和修改。
其他问题
多生产者如何保证生产者之间不会相互覆盖?
每个线程获取不同的一段数组空间,然后通过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。
生产者在环形数组中追上了消费者怎么办?
从gatingSequences中取得最小的序号,生产者最多能写到这个序号的后一位。通俗来讲就是申请的序号不能大于最小消费者序号一圈【申请到最大序列号-buffersize 要小于/等于 最小消费的序列号】的时候, 才能申请到当前写的序号,第一点中的代码已经解释了。
基本使用
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!
代码说明
MyEvent:表示生产者和消费者之间传递的数据。MyEventHandler:处理MyEvent事件的消费者。onEvent方法会在 RingBuffer 中有新事件时被调用。MyProducer:生产者,负责向 RingBuffer 发布事件。DisruptorDemo:配置和启动 Disruptor,包括事件工厂、RingBuffer、事件处理器和生产者。
如果你想要学习更多如何通过disruptor进行开发以及一些demo,这位大佬写的很好,可以参考一下: 单机最快的队列Disruptor解析和使用