LMAX Disruptor:高性能替代有界队列的方法,用于在并发线程之间交换数据
转载自: https://lmax-exchange.github.io/disruptor/disruptor.html,使用 chatgpt 翻译
Martin Thompson、Dave Farley、Michael Barker、Patricia Gee、Andrew Stewart 版本 4.0.0-SNAPSHOT,2011年5月
摘要
LMAX成立的目的是创建一个非常高性能的金融交易所。为实现这一目标,我们评估了设计这种系统的多种方法,但在开始测量时,我们遇到了传统方法的一些根本限制。
许多应用程序依赖于队列来在处理阶段之间交换数据。我们的性能测试表明,使用这种方式的队列时,延迟成本与IO操作(基于RAID或SSD的磁盘系统)的成本相同数量级,极其缓慢。如果在端到端操作中有多个队列,这将增加整体延迟数百微秒。显然有优化的空间。
进一步的调查和对计算机科学的关注使我们意识到,传统方法中固有的关注点混合(例如队列和处理节点)导致多线程实现中的竞争,这表明可能存在更好的方法。
考虑现代CPU的工作方式,我们喜欢称之为“mechanical sympathy”,使用良好的设计实践,强调分离关注点,我们提出了一个我们称之为“Disruptor”的数据结构和使用模式。
测试表明,对于三阶段流水线,使用Disruptor的平均延迟比等效队列方法低了3个数量级。此外,Disruptor处理相同配置的吞吐量约为等效方法的8倍。
这些性能改进代表了并发编程思想的一个重大转变。这种新模式是任何需要高吞吐量和低延迟的异步事件处理架构的理想基础。
在LMAX,我们基于这个模式构建了订单匹配引擎、实时风险管理以及高可用性的内存事务处理系统,取得了巨大成功。我们所知,这些系统每个都建立了新的性能标准,无人能及。
但是,这不是一个仅在金融行业有关的专业解决方案。Disruptor是一个通用机制,以一种最大化性能且易于实现的方式解决了并发编程中的复杂问题。尽管其中一些概念可能看起来不寻常,但根据我们的经验,构建按照这种模式的系统比可比机制要简单得多。
Disruptor具有明显较少的写入竞争、更低的并发开销以及比可比方法更加友好的缓存,所有这些都导致了更高的吞吐量、更少的抖动和更低的延迟。在中等时钟速率的处理器上,我们看到每秒超过2500万条消息和低于50纳秒的延迟。这个性能与我们见过的任何其他实现相比都有显著提高。这非常接近现代处理器在核心之间交换数据的理论极限。
1. 概述
Disruptor是我们在LMAX努力构建世界上性能最高的金融交易所的成果。早期的设计侧重于从SEDA [Staged Event-Driven Architecture]和Actors [Actor model]派生的架构,使用流水线来提高吞吐量。在对各种实现进行分析之后,明显地发现,在流水线中的阶段之间事件排队占主导地位,并增加了成本。我们发现,队列还引入了延迟和高水平的抖动。我们投入了大量精力开发具有更好性能的新队列实现。然而,明显地,作为一种基本数据结构,队列由于生产者、消费者和它们的数据存储的设计关注点混合而受到限制。Disruptor是我们努力工作的结果,旨在构建一个干净地分离这些关注点的并发结构。
2. 并发的复杂性
在本文档的上下文中,以及在计算机科学一般情况下,"并发" 不仅表示两个或多个任务并行执行,还表示它们争夺资源的访问权限。争夺的资源可以是数据库、文件、套接字,甚至是内存中的某个位置。
代码的并发执行涉及两个方面,即互斥性和更改的可见性。互斥性是管理对某些资源的争夺更新的问题。更改的可见性是控制何时将这些更改对其他线程可见的问题。如果您可以消除对争夺更新的需求,就可以避免对互斥性的需求。如果您的算法可以保证任何给定的资源只由一个线程修改,那么互斥性是不必要的。读取和写入操作要求将所有更改对其他线程可见。然而,只有争夺写操作需要更改的互斥性。
在任何并发环境中,最昂贵的操作是争夺的写访问。要让多个线程写入相同的资源,需要复杂且昂贵的协调。通常,这是通过采用某种形式的锁定策略来实现的。
2.1. 锁的成本
锁提供互斥性,并确保更改的可见性以有序的方式发生。锁非常昂贵,因为在争夺时需要仲裁。这通过将上下文切换到操作系统内核来实现,该内核将挂起等待锁的线程,直到锁被释放。在这种上下文切换期间,除了将控制权交给操作系统并让其在拥有控制权时可能决定执行其他维护任务外,执行上下文还可能会丢失以前缓存的数据和指令。这可能会对现代处理器的性能产生严重影响。可以使用快速用户模式锁,但仅当不争夺时才能真正受益。
我们将通过一个简单的演示来说明锁的成本。这个实验的重点是调用一个函数,该函数在循环中将一个64位计数器增加500百万次。如果使用Java编写,单个线程可以在2.4GHz Intel Westmere EP上只需300毫秒就可以执行此操作。语言对这个实验不重要,结果在所有使用相同基本原语的语言中都会类似。
一旦引入锁来提供互斥性,即使当锁尚未争夺时,成本也会显著上升。当两个或更多线程开始争夺时,成本会再次增加数个数量级。这个简单实验的结果如下表所示:
方法 | 时间(毫秒) |
---|---|
单线程 | 300 |
单线程使用锁 | 10,000 |
两个线程使用锁 | 224,000 |
单线程使用CAS(比较与交换) | 5,700 |
两个线程使用CAS | 30,000 |
单线程使用volatile写入 | 4,700 |
2.2. "CAS" 的成本
在更新内存时,使用锁的更高效替代方法可以用于更新目标是单个字的情况。这些替代方法基于现代处理器中实现的原子或互锁定的指令。这些通常称为CAS(比较与交换)操作,例如x86上的“lock cmpxchg”。CAS操作是一种特殊的机器码指令,允许以原子操作的方式设置内存中的一个字。对于“递增计数器实验”,每个线程可以在循环中自旋读取计数器,然后尝试将其原子地设置为其新递增的值。旧值和新值作为参数提供给此指令。如果在执行操作时计数器的值与提供的期望值匹配,那么计数器将使用新值更新。另一方面,如果值不符合期望值,CAS操作将失败。然后,尝试执行更改的线程将重试,从该值重新读取计数器,依此类推,直到更改成功。与锁相比,这种CAS方法要高效得多,因为它不需要将上下文切换到内核进行仲裁。但是,CAS操作并非没有成本。处理器必须锁定其指令流水线以确保原子性,并使用内存屏障使更改对其他线程可见。在Java中,可以使用java.util.concurrent.Atomic*
类来执行CAS操作。
如果程序的关键部分比简单的递增计数器复杂,可能需要使用多个CAS操作来协调争夺。使用锁编写并发程序是困难的;使用CAS操作和内存屏障编写无锁算法要复杂得多,并且很难证明它们是正确的。
理想的算法将是只有一个线程拥有对单一资源的所有写入权限,而其他线程只能读取结果。要在多处理器环境中读取结果,需要使用内存屏障使更改对在其他处理器上运行的线程可见。
2.3. 内存屏障
现代处理器执行指令的顺序和在内存和执行单元之间的数据加载和存储的顺序是无序的,这是出于性能原因。处理器只需保证程序逻辑产生相同的结果,而不考虑执行顺序。对于单线程程序,这不是问题。但是,在多线程共享状态时,重要的是所有内存更改都按照所需的顺序出现,以确保数据交换成功。处理器使用内存屏障来指示代码的哪些部分内存更新的顺序很重要。它们是硬件在线程之间实现顺序和更改可见性的手段。编译器可以放置补充的软件屏障以确保编译代码的顺序,这些软件内存屏障是处理器自身使用的硬件屏障之外的内容。
现代CPU比当前一代内存系统快得多。为了弥合这一差距,CPU使用复杂的缓存系统,这些系统实际上是没有链接的快速硬件散列表。这些缓存通过消息传递协议与其他处理器缓存系统保持一致。此外,处理器还具有“存储缓冲区”以将写入卸载到这些缓存中,并具有“失效队列”,以便缓存一致性协议可以在写入即将发生时快速确认失效消息,以提高效率。
这对于数据意味着任何值的最新版本在写入后的任何阶段都可能位于寄存器、存储缓冲区、多个缓存层或主内存中。如果线程要共享这个值,就需要以有序的方式进行可见性,这是通过协调缓存一致性消息的交换来实现的。及时生成这些消息可以通过内存屏障来控制。
读取内存屏障通过标记在执行它的CPU上的无效队列中的某个点来对CPU上的加载指令进行排序,以便在读屏障之前有关的更改进入其缓存。这为写操作在读屏障之前有序提供了一致的视图。
写入内存屏障通过标记执行它的CPU上的存储缓冲区中的某个点来对CPU上的存储指令进行排序,从而通过其缓存刷新写入。这个屏障为写入屏障之前的存储操作提供了一个有序的视图。
全内存屏障同时对加载和存储进行排序,但仅在执行它的CPU上进行排序。
一些CPU除了这三个基本操作外,还有更多的变体,但这三个足以理解涉及的复杂性。在Java内存模型 Java Memory Model 中,volatile字段的读取和写入实现了读取和写入屏障。这在Java 5发布时的Java内存模型中是明确规定的。
2.4. 缓存行
现代处理器中缓存的使用方式对于成功的高性能操作非常重要。这些处理器在缓存中保存的数据和指令上以极高效率运行,但是,相对而言,在发生缓存未命中时效率会显著下降。
我们的硬件不会以字节或字为单位移动内存。出于效率考虑,缓存组织成通常为32-256字节的缓存行,最常见的缓存行为64字节。这是缓存一致性协议操作的粒度级别。这意味着如果两个变量位于相同的缓存行中,并且它们由不同的线程写入,那么它们会像单个变量一样引发写入争用的问题。这是一个称为“虚假共享”的概念。为了实现高性能,很重要确保独立但同时写入的变量在争用时不共享相同的缓存行。
在以可预测的方式访问内存时,CPU可以通过预测哪些内存可能会被下一次访问并将其预取到缓存中来隐藏访问主内存的延迟成本。只有在处理器可以检测到具有可预测“跨度”的访问模式时,这才有效,例如通过可预测的“步幅”遍历内存。当遍历数组的内容时,步幅是可预测的,因此内存将以缓存行的形式预取,从而最大化访问的效率。步幅通常必须小于2048字节以内任何方向才能被处理器注意到。然而,像链表和树这样的数据结构往往在内存中分布广泛,没有可预测的访问步幅。内存中没有一致的模式会限制系统预取缓存行的能力,导致主内存访问的效率可能低于两个数量级。
2.5. 队列的问题
队列通常使用链表或数组作为元素的底层存储方式。如果允许内存中的队列无限制增长,那么对于许多问题类别,当生产者超过消费者时,队列可以无限制增长,直到耗尽内存而导致灾难性故障。这种情况发生在生产者的速度超过了消费者的速度。无界队列在生产者有保证不会超越消费者且内存是宝贵资源的系统中可以有用,但如果这个假设不成立并且队列无限制增长
,就会有风险。为了避免这种灾难性后果,通常会限制队列的大小(有界队列)。保持队列有界要求它要么是数组支持的,要么要积极跟踪大小。
队列实现通常在头部、尾部和大小变量上存在写入争用。在使用时,由于消费者和生产者之间的速度差异,队列通常始终接近满或接近空。它们极少在生产和消费速率平衡的中间地带操作。始终处于满或空的状态导致高水平的争用和/或昂贵的缓存一致性。问题在于,即使使用不同的并发对象(如锁或CAS变量)将头部和尾部机制分开,它们通常仍然占据相同的缓存行。
管理生产者声明队列头部、消费者声明队列尾部以及在两者之间存储节点的问题使得并发实现的设计非常复杂,超出了对于除了单一生产者-单一消费者实现以外的任何情况都很复杂的大粒度队列上使用单一锁。在Java中使用队列存在另一个问题,因为它们是垃圾的重要来源。首先,需要分配对象并将其放置在队列中。其次,如果是链表支持的,还必须分配表示链表节点的对象。当不再引用它们时,需要回收用于支持队列实现的所有这些对象。
2.6. 管道和图形
对于许多问题类别,将多个处理阶段连接成管道是有意义的。这样的管道通常具有并行路径,并且组织成图形式的拓扑结构。每个阶段之间的连接通常由队列实现,每个阶段都有自己的线程。
这种方法不便宜 - 在每个阶段,我们都必须承担入队和出队工作单元的成本。当路径必须分叉时,目标数量会增加这个成本,当必须在这种分叉之后重新连接时,会产生不可避免的争用成本。
如果可以在不引入队列的成本的情况下表示依赖关系图,那将是理想的。
3. LMAX Disruptor的设计
在尝试解决上述问题时,通过对我们认为在队列中混淆在一起的关注点进行严格分离,一个设计 emerged 出来。这种方法结合了确保任何数据只能由一个线程拥有写访问权限的重点,从而消除了写冲突。这个设计被称为 "Disruptor"。之所以这样命名,是因为它在处理依赖图的元素时与Java 7中引入的 "Phasers" 的概念 [Phasers] 相似,Phasers 用于支持 Fork-Join。
LMAX Disruptor 的设计旨在解决上述所有问题,以最大程度地提高内存分配的效率,并以对现代硬件性能最优化的方式运行。
在 Disruptor 机制的核心是一个预先分配的有界数据结构,即环形缓冲区。数据通过一个或多个生产者添加到环形缓冲区,然后由一个或多个消费者处理。
3.1. 内存分配
在启动时,环形缓冲区的所有内存都是预分配的。环形缓冲区可以存储指向条目的指针数组,也可以存储表示条目的结构体数组。由于Java语言的限制,条目与环形缓冲区关联为指向对象的指针。这些条目通常不是传递数据本身,而是用于容纳数据的容器。这些条目的预分配消除了在支持垃圾回收的语言中的问题,因为这些条目将被重复使用,并在Disruptor实例的生存期内保持活动状态。这些条目的内存是在同一时间分配的,很可能在主内存中以连续的方式布局,从而支持高速缓存跨越。John Rose提出了在Java语言中引入“值类型”[5]的提案,它允许数组的元组,就像其他语言如C一样,从而确保内存将被连续分配并避免指针间接引用。
在开发Java等管理运行时环境中的低延迟系统时,垃圾收集可能会成为问题。分配的内存越多,垃圾收集器的负担就越重。垃圾收集器在对象的生命周期非常短暂或实际上是永恒的情况下效果最佳。环形缓冲区中的条目的预分配意味着就垃圾收集器而言它是永生的,因此对垃圾收集器几乎没有负担。
在重负载的队列系统下,可能会出现排队问题,这可能导致处理速度降低,并导致分配的对象存活时间超过应有的时间,从而被推升到年轻一代与分代垃圾收集器。这有两个影响:首先,对象必须在代之间进行复制,这会引起延迟抖动;其次,这些对象必须从旧代中收集,这通常是一项更昂贵的操作,并增加了在碎片化的内存空间需要压缩时引发“停止世界”暂停的可能性。在大内存堆中,这可能导致每GB的持续时间暂停几秒钟。
3.2. 分离关注点
我们发现以下关注点在所有队列实现中混淆在一起,以至于这些不同行为的集合往往定义了队列实现的接口:
- 存储正在交换的项目
- 协调生产者声明下一个要交换的序列
- 协调通知消费者新项目可用
在使用垃圾收集的语言设计金融交易所时,过多的内存分配可能会成为问题。因此,正如我们所描述的链表支持的队列不是一个好方法。如果可以预先分配用于处理阶段之间数据交换的整个存储空间,就可以将垃圾收集最小化。此外,如果可以在统一块中执行此分配,那么对该数据的遍历将以对现代处理器使用的缓存策略非常友好的方式完成。满足此要求的数据结构是具有所有插槽预填充的数组。在创建环形缓冲区时,Disruptor使用抽象工厂模式预先分配条目。当生产者声明一个条目时,生产者可以将其数据复制到预分配的结构中。
在大多数处理器上,计算环中的序列号(确定环中的插槽)的余数的成本非常高。通过使环的大小成为2的幂,可以大大降低这个成本。可以使用大小减一的位掩码来有效地执行余数运算。
正如我们之前所描述的,有界队列在队列的头部和尾部存在争用。环形缓冲区数据结构不受此争用和并发原语的影响,因为这些关注点已经被拆分为生产者和消费者屏障,必须通过这些屏障访问环形缓冲区。这些屏障的逻辑如下所述。
在Disruptor的大多数常见用法中,通常只有一个生产者。典型的生产者包括文件读取器或网络监听器。在存在单个生产者的情况下,序列/条目分配不会产生争用。在更不寻常的情况下,存在多个生产者时,生产者将争相声明环形缓冲区中的下一个条目。可以通过对该插槽的序列号执行简单的CAS操作来管理对下一个可用条目的声明争用。
一旦生产者将相关数据复制到声明的条目中,它可以通过更新表示供消费者使用的最新条目的环形缓冲区上的光标来将更改提交。生产者可以通过在其他生产者达到其自己的提交序列之前进行简单的自旋,而无需使用CAS来完成此操作。然后,该生产者可以前进光标,表示下一个可用条目供消费者使用。生产者可以通过在写入环形缓冲区之前将消费者序列的简单读取操作来跟踪消费者的序
列,以避免环绕环。
消费者在等待环形缓冲区中的特定序列可用之前会等待。在等待时,可以采用各种策略。如果CPU资源宝贵,它们可以等待锁内的条件变量,该条件变量由生产者发出信号。这显然是争用点,只有在CPU资源比延迟或吞吐量更重要时才应使用。消费者还可以循环检查表示环形缓冲区中当前可用序列的光标。这可以在使用或不使用线程让渡的情况下进行。如果不使用锁和条件变量,这将非常高效,因为我们已经将生产者和消费者之间的争用依赖关系拆分了。无锁多生产者-多消费者队列确实存在,但它们需要在头部、尾部和大小计数器上执行多个CAS操作。Disruptor不受此CAS争用的影响。
3.3. 序列
序列是Disruptor中管理并发的核心概念。每个生产者和消费者都基于严格的序列概念与环形缓冲区进行交互。生产者在声明环中的条目时会声明下一个可用序列。在只有一个生产者的情况下,下一个可用插槽的这个序列可以是一个简单的计数器,而在有多个生产者的情况下,可以使用CAS操作来更新的原子计数器。一旦声明了序列值,环形缓冲区中的这个条目现在可以被声明的生产者写入。当生产者完成更新条目后,它可以通过更新表示环形缓冲区上最新可供消费者使用的光标的单独计数器来提交更改。环形缓冲区的光标可以通过生产者使用内存屏障进行忙旋转来读取和写入,而无需执行CAS操作,如下所示:
long expectedSequence = claimedSequence – 1;
while (cursor != expectedSequence)
{
// 忙旋转
}
cursor = claimedSequence;
消费者等待特定序列变得可用,通过使用内存屏障来读取光标。一旦光标已经更新,内存屏障确保了环形缓冲区中条目的更改对等待光标前进的消费者是可见的。
每个消费者都包含自己的序列,它们在处理来自环形缓冲区的条目时更新。这些消费者序列允许生产者跟踪消费者以防止环绕环。消费者序列还允许消费者以有序的方式在相同条目上协调工作。
在只有一个生产者的情况下,无论消费者图的复杂性如何,都不需要锁或CAS操作。可以仅通过上述序列上的内存屏障来实现整个并发协调。
3.4. 批处理效应
当消费者在等待环形缓冲区中的光标序列前进时,会出现一个有趣的机会,这在队列中是不可能的。如果消费者发现环形缓冲区的光标自上次检查以来已经前进了多个步骤,它可以处理直到该序列而不涉及并发机制。这会导致滞后的消费者在生产者突然超前时迅速恢复步伐,从而平衡系统。这种批处理类型会增加吞吐量,同时减少和平滑延迟。根据我们的观察,这种效应导致延迟几乎保持恒定,无论负载如何,直到内存子系统饱和为止,然后延迟的特性是线性的,遵循Little's Law[6]。这与我们观察到的队列随着负载增加而产生的延迟的“J”曲线效应非常不同。
3.5. 依赖关系图
队列表示生产者和消费者之间的简单一步管道依赖关系。如果消费者形成链式或图形结构的依赖关系,则在图形的各个阶段之间需要队列。这会在依赖阶段的图形中多次产生固定成本的队列成本。在设计LMAX金融交易所时,我们的分析表明,采用基于队列的方法会导致排队成本主导了处理交易的总执行成本。
由于Disruptor模式将生产者和消费者关注点分离开来,因此可以在核心使用单个环形缓冲区来表示消费者之间的复杂依赖关系图,从而大大减少了执行的固定成本,从而提高了吞吐量,同时降低了延迟。
可以使用单个环形缓冲区来存储具有复杂结构的条目,表示在一个一致的地方进行整个工作流程的状态。必须在设计此类结构时格外小心,以确保由独立的消费者编写的状态不会导致缓存行的虚假共享。
3.6. Disruptor 类图
Disruptor 框架中的核心关系如下类图所示。此类图省略了可用于简化编程模型的便利类。在构建依赖图之后,编程模型变得简单。生产者通过 ProducerBarrier 依次声明条目,将其更改写入已声明的条目,然后通过 ProducerBarrier 提交该条目,使其可供消费。作为消费者,唯一需要做的是提供一个 BatchHandler 实现,当新条目可用时,会收到回调。这个结果导向的编程模型是基于事件的,与 Actor 模型有许多相似之处。
将通常在队列实现中混合的关注点分离开来允许更灵活的设计。RingBuffer 位于 Disruptor 模式的核心,提供无争用的数据交换存储。并发关注点已分离出来,供生产者和消费者与 RingBuffer 进行交互。ProducerBarrier 管理与在环形缓冲区中声明插槽相关的任何并发关注点,同时跟踪依赖的消费者以防止环形缓冲区的包装。ConsumerBarrier 在新条目可用时通知消费者,消费者可以构建成一个依赖图,代表处理管道中的多个阶段。
3.7. 代码示例
下面的代码是一个使用便捷接口 BatchHandler
实现单个生产者和单个消费者的示例。消费者在单独的线程上运行,接收可用的条目。
// 可以由消费者实现的回调处理程序
final BatchHandler<ValueEntry> batchHandler = new BatchHandler<ValueEntry>()
{
public void onAvailable(final ValueEntry entry) throws Exception
{
// 处理新的可用条目。
}
public void onEndOfBatch() throws Exception
{
// 如果需要,用于将结果刷新到IO设备的有用操作。
}
public void onCompletion()
{
// 在关闭之前执行任何必要的清理工作
}
};
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);
// 每个消费者可以在单独的线程上运行
EXECUTOR.submit(batchConsumer);
// 生产者按顺序声明条目
ValueEntry entry = producerBarrier.nextEntry();
// 将数据复制到条目容器中
// 使条目对消费者可用
producerBarrier.commit(entry);
4. 吞吐量性能测试
作为参考,我们选择了Doug Lea的出色的 java.util.concurrent.ArrayBlockingQueue
[ArrayBlockingQueue],根据我们的测试,它在所有基于有界队列的性能方面表现最佳。测试以阻塞式编程风格进行,以与Disruptor相匹配。以下详细描述的测试用例可在Disruptor开源项目中找到。
运行这些测试需要一个能够并行执行至少4个线程的系统。
图1. 单播:1P – 1C
图2. 三级管道:1P – 3C
图3. 顺序器:3P – 1C
图4. 多播:1P – 3C
图5. 钻石:1P – 3C
对于上述配置,对于数据流的每个弧,都应用了 ArrayBlockingQueue
,与Disruptor的屏障配置进行了比较。以下表格显示了在使用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进行处理500百万条消息时,每秒操作的性能结果。性能结果可能在不同的JVM执行中变化很大,下面的数据不是我们观察到的最高性能。
Nehalem 2.8Ghz – Windows 7 SP1 64位 | Sandy Bridge 2.2Ghz – Linux 2.6.38 64位 | |||
---|---|---|---|---|
ABQ | Disruptor | ABQ | Disruptor | |
单播:1P – 1C | 5,339,256 | 25,998,336 | 4,057,453 | 22,381,378 |
管道:1P – 3C | 2,128,918 | 16,806,157 | 2,006,903 | 15,857,913 |
顺序器:3P – 1C | 5,539,531 | 13,403,268 | 2,056,118 | 14,540,519 |
多播:1P – 3C | 1,077,384 | 9,377,871 | 260,733 | 10,860,121 |
钻石:1P – 3C | 2,113,941 | 16,143,613 | 2,082,725 | 15,295,197 |
5. 延迟性能测试
为了测量延迟,我们采用了三级管道,并在不饱和条件下生成事件。这是通过在注入事件后等待1微秒然后再注入下一个事件,并重复5000万次来实现的。要以这种精度计时,需要使用CPU的时间戳计数器。我们选择了具有不变TSC的CPU,因为旧处理器受电源节省和休眠状态导致的频率变化而受影响。英特尔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的平均延迟为32,757纳秒。分析显示,锁和通过条件变量进行信号传递是ArrayBlockingQueue的延迟的主要原因。
数组阻塞队列(纳秒) | Disruptor(纳秒) | |
---|---|---|
最小延迟 | 145 | 29 |
平均延迟 | 32,757 | 52 |
99% 观察值小于 | 2,097,152 | 128 |
99.99% 观察值小于 | 4,194,304 | 8,192 |
最大延迟 | 5,069,086 | 175,567 |
6. 结论
Disruptor是提高吞吐量、降低并发执行上下文之间延迟并确保可预测延迟的重大进步,这在许多应用程序中是一个重要考虑因素。我们的测试表明,它在线程之间交换数据方面表现优越于可比较的方法。我们认为这是用于这种数据交换的性能最高的机制。通过专注于清晰分离涉及跨线程数据交换的问题,通过消除写入竞争、最小化读取
竞争,并确保代码与现代处理器使用的缓存良好配合,我们已经创建了一种在任何应用程序中交换数据的高效机制。
批处理效果使消费者能够处理达到给定阈值的条目,而不会出现任何争用,这引入了高性能系统中的新特性。对于大多数系统,随着负载和争用的增加,延迟呈指数增加,形成“J”曲线特性。在Disruptor上负载增加时,延迟几乎保持不变,直到内存子系统饱和。
我们认为Disruptor为高性能计算建立了新的基准,并非常适合继续利用处理器和计算机设计的当前趋势。
查看本文的原始PDF 链接。