Disruptor 是 LMAX 公司开发的高性能队列,用于解决内存队列的延迟问题。
LMAX 基于 Disruptor 打造的系统单线程能支撑每秒 600 万订单,许多著名的开源项目也使用 Disruptor 获取高性能,比如 log4j2 使用 Disruptor 加速异步日志输出,canal 使用 Disruptor 处理数据库 binlog 的解析分发。
内存队列性能
LMAX 在性能测试中发现,内存队列中数据的延迟竟然和 I/O 操作处于同样的数量级,其中最大的性能损耗在于缓存行和锁竞争。
伪共享
计算机的存储单元包括寄存器,高速缓存,内存和磁盘等,越贴近 CPU 性能越高、空间越少、价格越昂贵。
计算机提供了三级的高速缓存:L1、L2、L3,分别代表一级缓存、二级缓存、三级缓存,遵循越靠近CPU的缓存,速度越快,容量也越小的规则,L1 缓存很小很快,紧靠着 CPU 内核;L2 比 L1 空间大但也慢一些,同样是 CPU 独享;L3 更大、更慢,被单个插槽上的所有 CPU 核共享;内存被全部插槽上的所有 CPU 共享。
CPU 运行时会依次去 L1、L2、L3 查找数据,如果在缓存中没有命中,继续向内存和磁盘中去加载。
如果可以提高高速缓存的命中率,尤其是 L1 缓存,可以大大提高运行速度。
但计算机的高速缓存又极其复杂:「它提供了 3 级缓存,且 L1 和 L2 是内核独享,L3 是插槽上的所有的内核独享,内存则被全部插槽上的所有 CPU 共享」。
因此如何保持高速缓存和内存中的数据一致性就是个问题,计算机使用多核 CPU 多级缓存一致性协议 MESI 来保证数据一致性。
因此当高速缓存中的数据被修改后,会导致高速缓存中的数据失效,需要从内存中重新加载。
高速缓存是由 cache line 组成,每个 cache line 为 64 字节,CPU 加载缓存是按照 cache line 进行加载的。
当 cache line 中的数据被 CPU 修改后,整个 cache line 都会失效。
如果多个数据被加载至同一个 cache line 后,任一数据被修改,都会影响 cache line 中的其他数据。
这就是高速缓存中的伪共享问题。
为了解决这个问题,可以对数据进行填充,对齐到 64 字节,独占 cache line。
cache line 对齐是一种空间换时间的解决办法。
锁竞争
线程在竞争锁失败的时候会被挂起,当锁被释放时所有被挂起的线程会再次一起竞争锁。线程被挂起时无法执行任务,同时被挂起线程恢复时再去竞争锁,也只有一个线程能获取锁,其余线程会被继续挂起,出现惊群效应。锁竞争时会造成很大的开销,Disruptor 进行了一个性能测试
测试程序调用一个函数,该函数会对一个 64 位的计数器循环自增 5 亿次。
机器环境:2.4G 6核
运算:64 位的计数器累加 5 亿次
method Time (ms)
Single thread 300
Single thread with CAS 5,700
Single thread with lock 10,000
Single thread with volatile write 4,700
Two threads with CAS 30,000
Two threads with lock 224,000
CAS 操作比单线程无锁慢 1 个数量级
有锁且多线程并发的情况下,速度比单线程无锁慢 3 个数量级
单线程情况下,不加锁 > CAS 操作 > 加锁
多线程情况下,为了保证线程安全,必须使用CAS或锁,CAS的性能超过锁的性能,前者大约是后者的8倍
综上可知,加锁的性能是最差的。
Disruptor
Disruptor 通过环形数组、无锁化设计来突破内存队列性能瓶颈。
环形数组。采用数组而非链表,数组对 CPU 友好,数组中的相邻元素可以一起被加载至缓存中。数组也对垃圾回收更友好。
缓存行对齐。避免伪共享。
无锁设计。生产者或消费者先申请数组中元素位置,申请后直接写入或读取数据。
位运算。数组长度为 2^n。
Disruptor 使用
Disruptor 是一个低延迟、高吞吐的支持并发的环形缓冲区的数据结构。
依赖于底层的环形缓冲区,它可以保证任务的投递顺序和执行顺序一致。Disruptor 支持事件广播,事件可以投递至多个消费者,多个消费者之间还可以构建消费者依赖图。
如下图所示:
图片
image-20211209220518351
第一阶段。存在两个 handler,log-1 和 log-2 的执行顺序无法保证,但是每个事件都会被广播到 log-1 和 log-2,且在 log-1 和 log-2 全部执行完毕后,才会进入下一阶段。
第二阶段。执行 log。
第三阶段。第三阶段使用 worker-pool,事件只会广播到 worker-pool 中的一个。不同的事件在 worker-pool 的执行顺序无法保证有序,但是在向下一阶段输出的时候仍然是有序的。worker-pool 有序输入、有序输出、乱序执行。
第四阶段。在第四阶段可以观察到第三阶段的事件是有序到达第四阶段。
代码如下:
Disruptor
disruptor.setDefaultExceptionHandler(new LongEventExceptionHandler());
EventHandlerGroup
EventHandlerGroup
EventHandlerGroup
thirdStage.then(new ForthStageLogEventHandler());
disruptor.start();
Disruptor 为事件预分配内存以提高性能,通过 EventFactory 可以减少程序运行期间的垃圾回收压力。
public class LongEvent {
private long value;
}
public class LongEventFactory implements EventFactory
@Override
public LongEvent newInstance() {
return new LongEvent();
}
}
EventFactory 作为 Disruptor 的构造器参数,在发布事件时,将发布对象包裹在事件中。
// Disruptor 构造器
Disruptor
// 发布事件至 Disruptor
for (long i = 0L; i < 10; i++) {
disruptor.publishEvent((longEvent, l1, value) -> longEvent.setValue(value), i);
}
生产者-消费者
Disruptor 存在使用场景:生产者-消费者。
Disruptor 提供低延迟、高吞吐的内存队列,它是生产者-消费者模式的一个实现。
能否将 Disruptor 作为线程池使用呢?
有限支持,线程池支持 Callable 任务提交,可以获得返回结果,因此只能使用 worker-pool 模式替代线程池的 #execute(Runnable) 功能,无法替代 #submit(Callable) 功能。
因为 Disruptor 是生产者-消费者模式,用于对生产者、消费者解耦的场景,如果提交任务时需要获取任务的执行结果,建议使用异步编程,使用 Future、回调等功能。
强大的任务编排—消费者依赖图。
借助环形数组,Disruptor 的事件消费天然是顺序的,在此基础上,Disruptor 提供消费者依赖图用于控制消费者的执行顺序,提供 handler 编排功能。
在 java 中 Future 表示任务执行结果,如果要获取,需要使用阻塞方法 #get(),程序必须在 Future 完成之后才能对结果进行后续操作。
Guava 等 java 类库提供为 Future 提供回掉的功能,从而避免了必须等待 Future 执行结束的尴尬,但是如果回掉逻辑复杂则会引入另一个问题:回调地狱,造成程序可读性差的问题,状态难以追踪。
好在 jdk8 提供了 CompletableFuture,支持对 Future 进行逻辑变换,每次变换都产生一个新的 Future 表示原始 Future 经过指定的逻辑变换后产生的异步结果,从而使异步计算可以描述为一系列对不可变值的变换而无需考虑共享内存和锁。
CompletableFuture 支持复杂的异步任务编排。
那么对于 CompletableFuture 和 Disruptor 如何选择呢?
首先仍然是本质的区别,一个是 Future、另一个是生产者-消费者,前者一定会产生一个结果,即使是一个异常,后者用于解耦。对于需要获取执行结果的场景,Disruptor 不支持,需要使用者自行实现。
Disruptor 的消费者依赖图的最大特点是有序,在不同阶段内的执行顺序和生产者的投递顺序是一致的,在阶段内支持两种执行模式,广播模式和 worker-pool 模式。
Disruptor 的消费者可以组装出一个有向无环图。
CompletableFuture 对于任务的顺序执行不如 Disruptor 强大,Disruptor 可以在经过 worker-pool 执行后下一阶段仍然有序,CompletableFuture 对于广播和 worker-pool 的支持需要使用者自行实现。