翻译-LMAX Disruptor:并发线程间数据交换的有界队列的高性能替代方案

首先说下,为什么翻译这篇文档,本人觉得Disruptor框架的实现非常优秀,给我们处理高性能的并发提供了很好的思路,并且其中思想涉及了很多计算机知识,比如内存屏障,CPU流水线,缓存行等等。框架本身在处理并发时,并没有使用大量的锁,也正是巧妙的无锁设计,使得它的性能非常的高,也让Java并发编程不再拘泥于concurrent包下的工具类。本人英文水平有限,可能有些翻译不恰当地方,但是尽量做到不产生歧义,英文好的同学应该直接阅读原文。

[Github仓库]  https://github.com/LMAX-Exchange/disruptor

 [原文地址]  https://lmax-exchange.github.io/disruptor/disruptor.html 

引言

LMAX 的成立是为了创建一个非常高性能的金融交易所。为了实现这一目标,我们评估了设计这个系统的几种方法,但是当我们开始测量这些方法时,我们遇到了传统方法中的一些基本限制。

许多应用程序在运行处理阶段都依赖队列结构交换数据。我们做的性能测试显示,当以这种方式使用队列时,延迟成本与磁盘( RAID 或 SSD 磁盘系统)的 IO 操作成本处于同一数量级—异常的慢。如果端到端操作之间有多个队列的话,这会增加数百微秒的整体延迟。这显然还是有优化的空间。

更进一步的调查以及持续关注聚焦在计算机科学上,让我们意识到:传统方法(比如队列和处理节点)中内在的一些关键点的糅合,会在多线程实现的环境中导致竞争,这也提示我们,可能存在更好的方法去实现它。

考虑到现代 CPU 的工作原理(我们喜欢称之为“机械共鸣”),我们利用良好的设计实践,并重点聚焦在梳理和分离上述传统方法内在的一些关键点,由此提出了一种数据结构和使用模式,我们把它称之为 Disruptor (分裂者,破坏者)。

测试表明, 在平均延迟方面,一个三级管道(pipline)的Disruptor比等效的基于队列的方式低 3 个数量级。另外,相同的配置下,Disruptor的吞吐量处理高出约 8 倍。

上述提到的性能提升,表明在围绕并发编程的思考层面一个阶段性改变。这个新模式是任何需要高吞吐量和低延迟特性的异步事件处理架构的方法基础。

在 LMAX,我们已经基于这种模式下,构建了一个订单匹配引擎、实时风险管理系统和一个高可用的内存交易处理系统,并取得了巨大的成功。其中每一个系统都达到了新的性能标准,就目前我们所知,这些标准都是异常优秀、卓越。

然而,Disruptor不是仅与金融行业相关的专业解决方案。 Disruptor 是一种通用机制,它以最大化性能的方式解决并发编程中的复杂问题,并且实现简单。虽然有些概念可能看起来特别,但我们的经验是,按照这种模式构建的系统比其它类似的机制更易于实现。

与同类其它方法相比,Disruptor 明显具有更少的写竞争、更低的并发开销和更加的缓存友好(cache friendly),所有这些将会产生更高的吞吐量、低延迟上更少的抖动。在中等时钟频率的处理器上,我们已经看到了每秒超过 2500 万条消息和低于 50 纳秒的延迟。相比我们之前见过的任何实现方式,这种方式的性能是一个重大的提升。这非常接近现代处理器内核间交换数据的理论极限。

1. 概览

Disruptor 是我们在LMAX奋力建设世界上最高性能表现的金融交易所的结果。早期的设计侧重于利用管道实现吞吐量的SEDA 和 Actors衍生的架构。 在分析各种实现之后,明显显示:各个阶段之间的事件在管道中排队的成本一直占支配性地位。 我们发现,队列还引发了延迟和高度抖动。 我们花费了大量精力来开发具有更好性能的新队列实现。 然而很明显,由于生产者、消费者及其数据存储这几个关注点上的混杂,将队列作为一种底层数据结构受到了限制。 Disruptor则是我们构建一个并发结构的结果,这个结构能干净地分离上述的几个关注点。

2. 并发的复杂性

在这篇文档,以及通常的计算机科学的语境里,并发不仅意味着两个或多个任务并发的执行,也意味着它们对资源的访问上有竞争。竞争资源可能是数据库、文件、套接字(Socket),或者甚至是内存中的一个地址。

代码并发执行关乎两件事,互斥可见性。互斥是关于管理对某些资源的竞争式更新操作。可见性则是关于控制这些更新对其它线程可见的时机。如果你可以消除竞争式更新的前提条件,则可以避免互斥。如果你的算法可以保证任何给定的资源只被一个线程修改,那么互斥就没有必要。读和写操作都需要所有更新对其它线程可见。不过,只有竞争式写操作需要对更新进行互斥。

在任何并发环境中成本最高的操作是竞争式写访问。让多个线程对同一资源写入需要复杂且昂贵的协调工作。通常,这是通过采用某种锁策略来实现。

2.1 锁的成本

锁提供了互斥,确保了修改的可见性以一种精心安排的方式产生。锁是非常昂贵,因为它们在竞争时候需要仲裁。这种仲裁是通过上下文切换到操作系统内核来实现的,内核将会暂停线程,阻塞等待在一个锁上,直到锁被释放。就在这个上下文切换,以及将控制权释放给操作系统的过程中,并且即便操作系统得到控制权有可能决定执行其他内务处理任务,执行上下文就会丢失之前缓存的数据和指令。在现代处理器上这会产生严重的性能影响。虽然可以使用“快速用户模式锁”(Fast user mode locks),但这只有在没有竞争时才有一些实际的效益。

我们将通过一个简单的演示来说明锁的成本。本实验的关注点是调用一个函数,该函数将一个 64 位的计数器循环递增 5 亿次。如果用 Java 编写,这个函数可以在 2.4Ghz Intel Westmere EP 处理器上由单个线程在 300 毫秒内执行完成。语言本身对这个实验来说并不重要,所有具有相同基础原语的语言,结果都是相似的。

一旦引入锁来提供互斥,即使锁尚未竞争,成本也会显著增加。当两个或更多线程开始竞争时,成本会再以数量级的增加。这个简单实验的结果如下表所示:

表1:竞争成本比较

方法时间 (ms)
单线程300
单线程,使用锁10,000
两个线程,使用锁224,000
单线程,使用CAS5,700
两个线程,使用CAS30,000
单线程,使用Volatile写4,700

2.2 CAS的成本

当更新的目标是单个字(word)时,可以采用比锁更有效的替代方法来更新内存。这些替代方法是基于现代处理器中实现的原子或互锁指令。通常是大家熟知的 CAS(比较交换)操作,例如x86 上的“lok cmpxchg”。 CAS 操作是一种特殊的机器代码指令,它允许将内存中的一个字(word)有条件地以原子性方式被赋值。对于“递增计数器实验”,每个线程可以自旋式循环读取计数器值,然后尝试原子性的设置为新的递增值。旧值和新值作为参数提供给该指令。如果在执行操作时计数器的值与提供的预期值匹配,则用新的值来更新计数器的值。相反,如果该值不符合预期,则 CAS 操作将失败。然后由尝试执行更新的线程重试,重新读取计数器的值并递增,依此类推,直到更新成功为止。这种 CAS 方法比锁更加有效,因为它不需要上下文切换到内核进行仲裁。然而,CAS 操作也并不是没有成本。处理器必须锁定其指令管道以确保原子性并使用内存屏障使更新操作对其它线程是可见的。在 Java 里面,可以使用 java.util.concurrent.Atomic* 类进行 CAS 操作。

如果程序的临界区部分比简单自增计数器更复杂,则可能需要一个使用了多个 CAS 操作的复杂状态机来协调竞争。使用锁来开发并发程序很困难;使用 CAS 操作和内存屏障开发无锁算法更要复杂好几倍,而且很难保证正确性。

理想的算法是,只有一个线程拥有对单个资源的所有写入操作,而其他线程则是读取结果。在多处理器环境中读取结果,则需要使用内存屏障来保证运行在其他处理器上的线程可以看到更改。

2.3 内存屏障

现代处理器出于性能原因,会在内存和CPU执行单元之间,乱序地执行指令以及乱序地加载和存储数据。无论最后指令的执行顺序如何,处理器仅仅只需要保证程序的逻辑性,以便产生相同的结果。这对于单线程程序来说,并不是问题。但是,当线程间共享状态时,所有内存的更改都要在所需的时候按顺序出现,以便数据交换成功,顺序性就变的很重要。处理器使用内存屏障来标明那些内存更新顺序很重要的代码部分。它们是实现硬件排序和线程之间可见性的方法。编译器可以额外得插入软件屏障来确保编译之后代码的顺序,这种软件内存屏障是除了处理器本身使用的硬件屏障的另外一种屏障方式。

现代 CPU 要比当前一代内存系统快得多。为了桥接这之间的差距,CPU 使用复杂的缓存系统,这些系统实际上是一些无关联的快速硬件哈希表。这些缓存通过消息传递协议与其他处理器的缓存系统保持一致。此外,处理器具有“存储缓冲区(store buffer)”来先卸载(offload。注:翻译成”承载“可能更合适)对这些缓存的写入(译注:处理器在写操作时,不会立马将新值写入高速缓存cache,而是先写入存储缓冲区),以及“无效队列(invalidate queue。注:或者叫失效队列)”,以便缓存一致性协议在写入即将发生时候可以快速确认无效的消息以提高效率。

这对数据来说,意味着任意一个最新的值在被写入之后的任何阶段,都可以位于寄存器中、位于存储缓冲区中、位于多级缓存中某一级中,或者位于主内存中。如果线程间要共享此值,则需要以有序的方式使其可见,这是通过“缓存一致性消息”的协调式交换来实现的。这些消息的及时生成,就是通过内存屏障来控制的。

读内存屏障会插入加载(load)指令,让CPU通过执行这些指令,在“无效队列”中标记一个点,以便能让更新后的值加载到CPU的缓存中。这使得CPU对于在读屏障之前写操作保持着一致性。

写屏障会插入存储(store)指令,让CPU通过执行这些指令,在“存储缓冲区”中标记一个点,从而将缓冲区的数据刷出到CPU的缓存中。这个屏障提供了这样一个规则,所有的存储(store)操作都会在之前发生(happen before),在什么之前发生?在写屏障之前。

一个完整的内存屏障会对加载(load)和存储(store)都进行排序,但这仅在执行它的 CPU 上排序。

除了上述三个原语之外,一些 CPU 还有更多变体形式,但这三个足以理解我们所涉及东西的复杂性。在 Java 内存模型中,对 volatile 字段的读和写分别实现了读屏障和写屏障。这在 Java 5 版本中定义的 Java 内存模型里面有明确说明。

2.4 缓存行(cache lines)

在现代处理器中高速缓存的使用对于有效的高性能操作非常重要。这类处理器在处理已经保存在高速缓存的数据和指令时非常高效,当然了,在发生高速缓存未命中时效率就会非常低。

我们的硬件是不会以字节(bytes)或字(words)为单位来移动存储。为了提高效率,缓存会被组织成缓存行(cache lines)的形式,缓存行大小通常为 32-256 个字节,最常见的缓存行为 64 字节,这是缓存一致性协议操作的粒度级别。这就意味着,如果两个变量在同一个缓存行中,并且由不同的线程写入,那么它们会出现与单个变量相同的写入竞争问题。这就是“伪共享”(false sharing)概念。因此为了高性能,并且假设要最小化竞争,那么确保独立性,以及并发写入的变量不共享相同的缓存行就变得很重要了。

当以可预测的方式访问内存时,CPU 能够通过预测接下来很可能访问哪些内存地址,并在后台将其预取到高速缓存中,以此来降低访问主内存的延迟成本。这仅在处理器可以探测到比如像可预测“步幅”式地访问内存,类似的这种访问模式下才会有效。当迭代数组的内容时,步幅是可预测的,因此将内存的数据预取到缓存行中,从而最大限度地提高访问效率。处理器能够感知到在任一方向上的步幅通常必须小于 2048 字节。然而,像链表和树这样的数据结构往往有多个更广泛分布在内存里的节点,它们就没有可预测的访问步幅。由于在内存中缺少一种统一的模式,因此就限制了系统能够预取缓存行的能力,也就导致主内存访问效率降低 2 个数量级以上。

2.5 队列的问题

队列(Queues)通常使用链表或数组作为元素的底层存储。如果一个内存中队列是无界的,那么相对于多种不同类型的问题来说,它可以不受约束地增长,直到耗尽内存从而导致灾难性故障。这种问题发生在生产者(producer)速度超过消费者(consumer)的情况下。在保证生产者速度不会超过消费者并且内存是宝贵资源的系统中,无界队列是很有用的。但是如果这个假设不成立并且队列无限地增长,那么就总会存在风险。为了避免这种灾难性的后果,队列通常在大小上会受到限制(有界)。保持一个队列有界就需要有数组支持的,或是主动跟踪队列的大小。

队列的实现往往会在头部、尾部和大小变量上有写竞争。在使用时候,由于消费者和生产者之间的速度差异,队列在通常情况下总是接近满的或接近空的。队列很少在生产速率和消费速率几乎匹配的状态下,处于平衡的中间区域。这种倾向于总是满的或总是空的状态,导致了高度的竞争,亦或者昂贵的缓存一致性。然而问题是,即使使用了不同的并发对象(例如锁或 CAS 变量)将队列头部和尾部结构分离,它们通常也会占用相同的缓存行。

我们的关注点是要很好的管理拥有队列头部的生产者,拥有尾部的消费者以及中间存储数据的节点,仅通过一个在队列上粗粒度的锁来设计并发实现是非常困难的。大粒度锁在整个队列上只用于存( put) 和取 (take)操作是很容易实现的,但也就意味着吞吐量的巨大瓶颈。如果将队列语义中的上述关注点分离开,那么除了单个生产者 – 单个消费者的实现之外,其余的实现变得非常复杂。

在 Java 中,队列的使用还有一个问题,那就是它们是“垃圾”的重要来源。首先,必须分配对象并将其放入队列中;其次,如果是链表结构,则必须分配代表链表节点(Nodes)的对象。当不再被引用时,所有这些被分配以支撑队列实现的对象都需要重新声明。

2.6 管道和图

对于许多不同类型的问题,将几个不同的处理阶段用管道(Piplines)连接起来都是具有意义的。 这样的管道通常具有并行的路径,被组织成类似图的拓扑结构。 每个阶段之间的链接通常由队列实现,并且每个阶段都有自己的线程。

这种实现方法成本不低——在每个阶段,我们都必须承担入队和出队工作单元的成本。 当路径必须分叉时,成本将会以阶段数量的倍数增加,并且在这个分叉后又必须重新汇入到主线时,会产生不可避免的争用成本。

如果依赖关系图能够被清晰的表达展现,并且不用承担各个阶段之间的排队的成本,那这个方案将是非常理想的。

3. LMAX Disruptor的设计

在试图解决上述问题的同时,通过仔细地分离上述在队列中混乱的几个关注点,一种设计方法便慢慢浮现。 这个方法包含了一个关键点,它确保任何数据只能由一个线程所有进行写访问,从而消除了写竞争。 这种设计就是“Disruptor”。 之所以这样命名,是因为它和Java7引入支持Fork-Join的”Phasers”中处理依赖关系图有着相似的概念。

LMAX Disruptor设计旨在解决上述所有问题,并试图最大限度地提高内存分配的效率,以缓存友好的方式运行,以便在现代硬件上能有最佳的表现。

Disruptor的核心机制是一个预先被分配好的有界数据结构—环形缓冲区(Ring Buffer)。 数据通过一个或多个生产者添加到环形缓冲区,由一个或多个消费者处理。

3.1 内存分配

环形缓冲区的所有内存都在启动时就已预先分配好。环形缓冲区可以存储指向数据的指针数组或表示存储数据本身的结构数组。 根据Java 语言的规定限制,环形缓冲区中的条目就是指向对象的指针。这些条目中的每一个通常不是传递数据本身,而是数据的包装。条目的这种预分配机制消除了支持垃圾回收语言中的问题,因为条目将被重复使用并在 Disruptor 实例的整个生命周期中存活。由于这些条目的内存是同时分配的,因此很可能会在主内存中连续分布,从而支持缓存步幅。 John Rose 提议将“值类型”引入 Java 语言,这就允许元组(tuples)数组,就像其他(如C语言)语言一样,从而确保内存连续分配并避免了指针间接性。

在 Java 等托管的运行时环境中开发低延迟系统时,垃圾回收可能会出现问题。分配的内存越多,垃圾收集器的负担就越大。当对象存活非常短暂或相对有效地一直存活时,垃圾收集器会尽最大的努力进行工作。环形缓冲区中对条目的预分配便意味着,对于垃圾收集器而言它就是一直存活对象,因此也就负担很小。

基于队列的重负载系统可以备份,这会导致处理速度的降低,并导致分配的对象比应该的时间更长,从而在分代垃圾收集器中将其提升到年轻代之外。这有两个含义:首先,必须在各代之间复制对象,这会导致延迟抖动;其次,这些对象必须从老年代收集,这通常是一个更昂贵的操作,当碎片内存空间需要压缩时,增加了导致“Stop the world”暂停的可能性。在大内存的堆中,这可能会导致持续数秒/GB的暂停。

3.2 分离几个核心关注点

我们看到以下的几个关注点在所有以队列实现的方法中都被混杂在一起,以至于以下列出的不同行为倾向于定义基于队列实现的接口:

  1. 可交换性条目的存储
  2. 协调生产者,用来获取下一个条目可用于交换的序列号
  3. 协调并通知消费者,有新的条目可以被消费

当使用垃圾回收的语言来设计金融交易所时,过多的内存分配可能会出现问题。因此,正如我们所描述的,链表支持的队列也不是一个好方法。如果用于处理阶段之间的交换数据可以预先分配存储空间,则可以最大限度地减少垃圾回收。此外,如果这种分配可以在一个统一的内存块(uniform chunk)中执行,那么对于现代处理器来说,对该数据的遍历就可以使用一种缓存策略非常友好的方式来完成。满足此要求的数据结构是所有槽位(slot)都已预填充的数组。在创建环形缓冲区时,Disruptor利用抽象工厂模式来预先分配好所有的条目。当一个条目被声明时,生产者便可以将其数据复制到预先分配的这个结构中。

在大多数处理器上,对于序列号的余数计算成本都非常高,这个计算决定了数据在环中的槽位。通过让环大小为 2 的幂可以大大降低此成本。可以使用环大小减一的位掩码来有效地执行余数运算。

正如我们之前描述的,有界队列在队列的头部和尾部都会有竞争。环形缓冲区数据结构不受这种竞争和并发原语的影响,因为这些问题已被分离到生产者和消费者屏障(barriers)中,必须通过这些屏障来访问环形缓冲区。这些屏障的逻辑描述如下。

在 Disruptor 的最常见用法中,通常只有一个生产者。典型的生产者是文件读取一方或者网络监听器。在只有一个生产者的情况下,不会出现对序列/条目分配的竞争。在多个生产者的特殊用法中,所有生产者将以相互竞争方式,来声明环形缓冲区中的下一个条目。这可以通过对该槽位的序列号执行简单的 CAS 操作,来管理对下一个可用条目的竞争。

一旦生产者将相关数据复制到声明的条目中,它就可以通过提交序列号方式,将数据公开给消费者。这可以不用CAS,而是通过简单的自旋,直到其他生产者在他们自己的提交中达到这个序列。然后该生产者可以使游标前进一格,以表示下一个可供消费的条目。生产者在写入数据的之前,可以通过简单的读取操作来跟踪消费者的序号,从而避免环里的数据被覆盖。

消费者在读取条目之前,会等待该环型缓冲区中的相应的消费序列号变为可用。等待时有各种策略可以使用。如果 CPU 资源很宝贵,则可以阻塞在一个锁的信号量上,再等待由生产者发出的信号通知激活。这显然是一个锁竞争的点,仅当 CPU 资源比延迟或吞吐量更重要时才使用。消费者还可以通过不断循环检查方式,来检查环形缓冲区中代表目前可用序号的游标。这可以通过让出(with yield)或者不让出(without yield)线程CPU资源方式,来权衡CPU 资源和延迟。如果我们不使用锁和条件变量,我们就打破了生产者和消费者之间的竞争依赖,这就变得可以很好地扩展。其它无锁的多生产者—多消费者的队列模式也确实存在,但它们需要在头、尾、大小计数器上进行多个 CAS 操作。 而Disruptor就没有这种CAS竞争。

3.3 序列

对于Disruptor如何管理并发来说,序列是一个核心的概念。每个生产者和消费者都按照严格的顺序概念来确定它如何与环形缓冲区交互。 生产者在声明环中的条目时,按顺序声明下一个槽位。 下一个可用槽位的序列在只有一个生产者的情况下可以是一个简单的计数器,或者,在多个生产者的情况下使用 CAS 操作来更新的原子计数器。 一旦声明了一个序列值,环形缓冲区中的这个条目现在就可以被声明的生产者写入。 当生产者完成对条目的更新后,它可以通过更新一个单独的计数器来提交更改,该计数器代表着,对于消费者来说最新的可用条目的游标。 这个环形缓冲区游标,生产者可以通过使用内存屏障在自旋中读取和写入,而无需 CAS 操作,如下所示:

long expectedSequence = claimedSequence – 1;
while (cursor != expectedSequence)
{
 // busy spin
}

cursor = claimedSequence;

消费者通过使用内存屏障读取游标,来等待给定的序列变为可用状态。 一旦游标被更新,内存屏障可以确保环形缓冲区中条目的更改,对所有等待游标前进的消费者可见。

每个消费者都包含他们自己的序列,当它们处理来自环形缓冲区的条目时,他们会更新这些序列。 这些消费者序列允许生产者跟踪消费者序列以防止环数据覆盖。 消费者序列还允许消费者以有序的方式对同一个条目的协调工作。

在只有一个生产者的情况下,无论消费者图谱多复杂,都不需要锁或 CAS 操作。 整个并发协调可以上述的内存屏障来实现。

3.4 批处理的作用

当消费者在环形缓冲区中等待前进的游标序列时,会出现一个有趣的时机,而这在队列是不可能的。 如果消费者发现环形缓冲区游标自上次检查以来已经前进了好几步,则它可以在不涉及并发机制的情况下处理该序列。 这就导致落后的消费者在生产者领先时迅速跟上生产者的步伐,从而平衡整个系统。 这种类型的批处理增加了吞吐量,同时减少延迟,并使得延迟更加平滑。 根据我们的观察,无论负载如何,这种效应都会导致延迟时间接近恒定值,直到内存子系统达到饱和,然后这个曲线会按照利特尔定律呈线性状态。 这与我们在负载增加时,观察到的队列延迟的“J”曲线效应是非常不同的。

3.5 依赖图

一个队列代表着生产者和消费者之间一步简单的管道依赖。如果消费者形成链或图状结构的依赖关系,则图的每个阶段之间都需要队列。这会在依赖阶段的图中产生基于队列的多次固定成本。在设计 LMAX 金融交易所时,我们的分析表明,采用基于队列的方法会导致排队成本在处理交易的总执行成本中占主导地位。

由于生产者和消费者关注点被 Disruptor 模式分离,因此可以在核心仅使用单个环形缓冲区的情况下表示消费者之间的复杂依赖关系图。这就会大大降低执行的固定成本,从而增加了吞吐量,同时又降低了延迟。

单个环形缓冲区可用于在一个内聚性的地方存储具有复杂结构的条目,这个条目代表整个工作流程。在设计这种结构时必须小心,以便独立消费者写入的状态不会导致缓存行的“伪共享”。

3.6 Disruptor类图

Disruptor 框架中的核心关系如下图所示。该图省略了一些可用于简化编程模型的方便类。构建依赖图后,编程模型就很简单。生产者通过 ProducerBarrier 按顺序声明条目,将他们的修改写入声明的条目,然后通过 ProducerBarrier 将该条目提交回去,使它们可供消费。作为消费者,所有需要做的就是提供一个 BatchHandler 实现,该实现在新条目变为可用时接收回调。这个最终的编程模型是基于事件的,它与 Actor 模型有很多相似之处。

通过分离队列实现中经常混淆的关注点,就会带来了更灵活的设计。一个 RingBuffer 存在于 Disruptor 模式的核心位置,为无竞争式的数据交换提供存储。对于和 RingBuffer 交互的生产者和消费者来说,并发关注点就是分离它们。 ProducerBarrier 管理与在环形缓冲区中声明插槽相关的任何并发问题,同时跟踪依附着的消费者,以防止环覆盖。当有新条目可用时,ConsumerBarrier 会通知消费者,同时消费者可以构建在依赖关系图中,来表示处理管道中的多个阶段。

3.7 代码示例

下方的代码示例,是一个单个消费者和单个生产者的例子,使用了便利的接口BatchHandler来实现一个消费者。消费者运行在一个独立的线程中,来接收可用的条目,

// Callback handler which can be implemented by consumers
final BatchHandler<ValueEntry> batchHandler = new BatchHandler<ValueEntry>()
{
public void onAvailable(final ValueEntry entry) throws Exception
{
// process a new entry as it becomes available.
}

   public void onEndOfBatch() throws Exception
  {
       // useful for flushing results to an IO device if necessary.
  }

   public void onCompletion()
  {
       // do any necessary clean up before shutdown
  }
};

RingBuffer<ValueEntry> ringBuffer =
   new RingBuffer<ValueEntry>(ValueEntry.ENTRY_FACTORY, SIZE,
                              ClaimStrategy.Option.SINGLE_THREADED,
                              WaitStrategy.Option.YIELDING);
ConsumerBarrier<ValueEntry> consumerBarrier = ringBuffer.createConsumerBarrier();
BatchConsumer<ValueEntry> batchConsumer =
   new BatchConsumer<ValueEntry>(consumerBarrier, batchHandler);
ProducerBarrier<ValueEntry> producerBarrier = ringBuffer.createProducerBarrier(batchConsumer);

// Each consumer can run on a separate thread
EXECUTOR.submit(batchConsumer);

// Producers claim entries in sequence
ValueEntry entry = producerBarrier.nextEntry();

// copy data into the entry container

// make the entry available to consumers
producerBarrier.commit(entry);

4. 吞吐量性能测试

我们选择 Doug Lea 的优秀的 java.util.concurrent.ArrayBlockingQueue作为参考,根据我们的测试,它在所有的有界队列中性能是最高的。 这些测试以阻塞编程方式进行,以匹配Disruptor。 下面详述的测试用例可以在 Disruptor 开源项目中找到。

对于上述配置,与 Disruptor 的屏障配置相比,每个弧形的数据流都应用到了ArrayBlockingQueue。 下表显示了性能表现结果,配置使用 Java 1.6.0_25, 64 位 Sun JVM、Windows 7、Intel Core i7 860 @ 2.8 GHz 无 HT 和 Intel Core i7-2720QM、Ubuntu 11.04 并充分利用 3个核心来处理 5 亿条消息。 结果在不同的 JVM 执行中可能会有很大差异,下面的数字并不是我们观察到的最高数字。

Nehalem 2.8Ghz – Windows 7 SP1 64-bitSandy Bridge 2.2Ghz – Linux 2.6.38 64-bit
ABQDisruptorABQDisruptor
Unicast: 1P – 1C5,339,25625,998,3364,057,45322,381,378
Pipeline: 1P – 3C2,128,91816,806,1572,006,90315,857,913
Sequencer: 3P – 1C5,539,53113,403,2682,056,11814,540,519
Multicast: 1P – 3C1,077,3849,377,871260,73310,860,121
Diamond: 1P – 3C2,113,94116,143,6132,082,72515,295,197

5. 延迟性能测试

为了测量延迟,我们采用三级管道并在低于饱和度的情况下生成事件。这是通过在插入事件后等待 1 微秒,然后再注入下一个,并重复 5000 万次的方式来实现的。要以这种精度水平计时,必须使用来自 CPU 的时间戳计数器。我们选择了具有不变 TSC 的 CPU,因为老的处理器会因省电和睡眠状态而受到频率变化的困扰。 Intel Nehalem 和更高版本的处理器使用不变的 TSC,可以通过运行在 Ubuntu 11.04 上的最新 Oracle JVM 来访问它。这次测试没有使用 CPU 绑定。为了进行比较,我们再次使用 ArrayBlockingQueue。我们本可以使用 ConcurrentLinkedQueueviii ,这可能会带来更好的结果,但我们希望使用有界队列实现,通过产生背压来确保生产者不会来超越消费者。以下结果产生自运行在Ubuntu 11.04 Java 1.6.0_25 64 位上的 2.2Ghz Core i7-2720QM。 Disruptor 的平均每跳延迟为 52 纳秒,对比于 ArrayBlockingQueue的32757 纳秒。分析显示,锁的使用和使用条件变量的信号是 ArrayBlockingQueue 延迟的主要原因。

Array Blocking Queue (ns)Disruptor (ns)
最小延迟14529
平均延迟32,75752
99%分位2,097,152128
99.99%分位4,194,3048,192
最大延迟5,069,086175,567

6. 总结

对于提高吞吐量、减少并发执行上下文间的延迟,以及保证可预测性的延迟来说,Disruptor 是重要的“向前一小步”,是许多应用程序中一个重要考虑因素。我们的测试表明,它在线程间交换数据方面的性能优于同类方法。我们相信对于此类的数据交换而言,这是最高的性能机制。通过专注于将跨线程数据交换中所涉及的问题进行清晰分离,通过消除写入竞争,最小化读取竞争并确保代码与现代处理器所采用的缓存相配合,我们创造了一种用于任何应用的线程间交换数据的高效机制。

批处理的效果是,它允许消费者在没有任何争用的情况下的处理速度达到一个特定的阈值,在高性能系统中,这是一个新的特性。对于大多数系统,随着负载和竞争的增加,延迟呈指数增长,即特征“J”曲线。随着 Disruptor 上负载的增加,延迟几乎保持不变,直到内存子系统达到饱和。

我们相信 Disruptor 为高性能计算建立了新的基准,并且非常适合继续充分利用当前计算机设计和处理器中的发展趋势。