webIM系列之六——LMAX Disruptor:多线程数据交换高性能有界队列替代选择

理论

LMAX成立是为了创建一个高性能的金融交易所。作为我们完成这一目标的工作的一部分,我们已经评估了设计这样一个系统的几种方法,但是当我们开始衡量这些方法时,我们遇到了传统方法的一些基本限制。

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

对计算机科学的进一步研究和关注使我们意识到,传统方法中固有的关注点(例如队列和处理节点)的合并导致多线程实现中的争用,这表明可能有更好的方法。

想想现代cpu是如何工作的,我们称之为“机械同情”(Martin Thompson 很喜欢用 Mechanical Sympathy 这个短语,这个短语源于赛车驾驶,它反映了驾驶员对于汽车有一种天生的感觉,所以他们对于如何最佳的驾御它非常有感觉),利用良好的设计实践,专注于梳理关注点,我们提出了一种数据结构和使用模式,我们称之为“Disruptor”。

测试表明,使用Disruptor进行三级管道的平均延迟比同等的基于队列的方法低3个数量级。此外,在相同的配置下,Disruptor处理的吞吐量大约增加了8倍。

这些性能改进代表了围绕并发编程思想的一个步骤的改变。对于任何需要高吞吐量和低延迟的异步事件处理体系结构来说,这种新模式都是理想的基础。

在LMAX,我们建立了订单匹配引擎、实时风险管理和高可用内存事务处理系统,所有这些都在这个模式上取得了巨大成功。这些系统中的每一个都设定了新的性能标准,据我们所知,这些标准是无与伦比的。

然而,这不是一个只适用于金融行业的专业解决方案。Disruptor是一种通用机制,它以一种最大化性能的方式解决并发编程中的复杂问题,而且实现起来很简单。尽管有些概念看起来不太寻常,但根据我们的经验,按照这种模式构建的系统比类似机制的实现要简单得多。

Disruptor有更少的写争用,更低的并发开销,比类似的方法对缓存更友好,所有这些都导致了更大的吞吐量,更少的抖动和更低的延迟。在中等时钟速率的处理器上,我们看到每秒有超过2500万条消息,延迟低于50纳秒。与我们所见过的任何其他实现相比,这一性能是一个显著的改进。这非常接近现代处理器在核心之间交换数据的理论极限。

1.概述

Disruptor是我们努力在LMAX建立世界上性能最高的金融交易所的结果。早期的设计集中于从SEDA(Staged Event Driven Architecture)和Actors派生的架构,使用管道实现吞吐量。在分析了各种实现之后,很明显,管道中各个阶段之间的事件排队是成本的主要因素。我们发现队列还会引入延迟和高水平的抖动。我们在开发具有更好性能的新队列实现上花费了大量精力。然而,显而易见的是,由于生产者、消费者及其数据存储的设计关注点的合并,队列作为一种基本数据结构受到了限制。Disruptor是我们构建并行结构的结果,它可以清晰地分离这些关注点。

2.并发性的复杂性

在本文的上下文中,以及一般的计算机科学中,并发性不仅意味着两个或多个任务并行发生,而且还意味着它们争夺对资源的访问。争用的资源可以是数据库、文件、套接字,甚至是内存中的某个位置。

代码的并发执行涉及到两件事:互斥和更改的可见性。互斥是关于管理对某些资源的争用更新。更改的可见性是指控制这些更改何时对其他线程可见。如果可以消除争用更新的需要,就可以避免互斥。如果您的算法可以保证任何给定的资源只被一个线程修改,那么互斥就没有必要了。读和写操作要求所有更改对其他线程可见。然而,只有争用写操作需要互斥的更改。

在任何并发环境中,开销最大的操作是争用写访问。让多个线程写入同一个资源需要复杂而昂贵的协调。这通常是通过使用某种锁定策略来实现的。

2.1锁的成本

锁提供互斥,并确保更改以有序的方式发生。锁非常昂贵,因为在争用时需要仲裁。这个仲裁是通过到操作系统内核的上下文切换来实现的,这个操作系统内核将挂起等待锁释放的线程。在这样的上下文切换期间,以及向操作系统释放控制权时,操作系统可能决定在拥有控制权时执行其他内务管理任务,执行上下文可能会丢失之前缓存的数据和指令。这可能对现代处理器产生严重的性能影响。可以使用快速用户模式锁,但只有在没有竞争时才有真正的好处。

我们将用一个简单的演示来说明锁的成本。这个实验的重点是调用一个在循环中使64位计数器递增5亿次的函数。如果用Java编写,在2.4Ghz Intel Westmere EP上的单个线程只需300毫秒即可执行。对于这个实验来说,语言并不重要,具有相同基本元素的所有语言的结果都是相似的。

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

Method Time(ms)
Single thread 300
Single thread with lock 10,000
Two threads with lock 224,000
Single thread with CAS 5,700
Two threads with CAS 30,000
Single thread with volatile write 4,700

2.2CAS的成本

当更新的目标是单个单词时,可以使用比使用锁更有效的替代方法来更新内存。这些选择是基于现代处理器中实现的原子或互锁指令。这些通常被称为CAS(比较和交换)操作,例如在x86上的“lock cmpxchg”。CAS操作是一种特殊的机器码指令,它允许将内存中的单词有条件地设置为原子操作。对于“递增计数器实验”,每个线程都可以在循环中读取计数器,然后尝试原子化地将其设置为新的递增值。旧值和新值将作为参数提供给该指令。如果在执行操作时,计数器的值与提供的期望值匹配,则计数器将使用新值更新。另一方面,如果值不像预期的那样,CAS操作将失败。然后由试图执行更改的线程重试,重新读取该值的计数器,以此类推,直到更改成功。这种CAS方法比锁有效得多,因为它不需要切换到内核进行仲裁。然而,CAS操作不是免费的。处理器必须锁定其指令管道以确保原子性,并使用内存屏障使更改对其他线程可见。通过使用*Java .util.concurrent.Atomic**包中的类,可以在Java中获得CAS操作能力。

如果程序的关键部分比简单的计数器增量更复杂,那么可能需要使用复杂的状态机使用多个CAS操作来编排争用。使用锁开发并发程序是困难的;使用CAS操作和内存屏障开发无锁算法要复杂许多倍,而且很难证明它们是正确的。

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

2.3内存屏障

现代处理器出于性能原因,会打乱指令的执行顺序,在内存和执行单元之间打乱数据的加载和存储顺序。处理器只需要保证无论执行顺序如何,程序逻辑都产生相同的结果。这不是单线程程序的问题。然而,当线程处于共享状态时,为了使数据交换成功,所有内存更改必须在所需的点按顺序出现。内存屏障被处理器用来指示那些重要的内存更新顺序的代码段。它们是在线程之间实现硬件排序和更改可见性的方法。编译器可以安装免费的软件屏障来确保编译代码的顺序,这样的软件内存屏障是处理器本身使用的硬件屏障之外的。

现代的cpu现在比当前的内存系统快得多。为了桥接这种划分,cpu使用复杂的缓存系统,这些系统是有效快速的硬件哈希表,没有链接。这些缓存通过消息传递协议与其他处理器缓存系统保持一致。此外,处理器有“store buffers”来将写操作卸载到这些缓存中,还有“invalidate queues”,这样当写操作即将发生时,缓存一致性协议可以快速确认无效消息以提高效率。

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

读内存屏障通过在invalidate queue中标记一个点来执行进入CPU缓存的更改,从而在CPU上执行加载指令。这使它对写操作的顺序在读屏障之前保持一致。

写内存屏障通过在存储缓冲区中标记一个点来在执行CPU上执行存储指令,从而通过它的缓存刷新写入。这个障碍为写入障碍之前发生的存储操作提供了一个有序的视图。

一个完整的内存屏障命令加载和存储,但只在执行它的CPU上。

有些cpu除了这三个原语之外还有更多的变体,但这三个变体足以理解所涉及的复杂性。在Java内存模型中,volatile字段的读和写分别实现了读和写屏障。这在Java 5发行版中定义的Java内存模型中是明确的。

2.4缓存行

现代处理器中缓存的使用方式对于成功的高性能操作非常重要。这样的处理器在处理缓存中的数据和指令时效率非常高,但相对而言,当缓存丢失时效率就会非常低。

我们的硬件不会以字节或字的形式移动内存。为了提高效率,缓存被组织成缓存行,通常大小为32-256字节,最常见的缓存行为64字节。这是缓存一致性协议所操作的粒度级别。这意味着,如果两个变量位于同一缓存行中,并且它们被不同的线程写入,那么它们就会像单个变量一样出现相同的写争用问题。这是一个被称为“伪共享”的概念。因此,对于高性能来说,如果要最小化争用,确保独立但并发写的变量不共享相同的缓存行是很重要的。

当以一种可预测的方式访问内存时,cpu能够通过预测下一个可能被访问的内存并在后台预取到缓存中来隐藏访问主存的延迟成本。这只有在处理器能够检测到一种访问模式(比如具有可预测“步幅”的行走记忆)时才有效。当迭代一个数组的内容时,步幅是可预测的,因此内存将在缓存线中预取,最大限度地提高访问的效率。在任何一个方向上,stride通常都必须小于2048字节才能被处理器注意到。然而,像链表和树这样的数据结构的节点更广泛地分布在内存中,访问的步数无法预测。内存中缺乏一致的模式限制了系统预取缓存线的能力,导致主存访问效率降低了2个数量级以上。

2.5队列的问题

队列通常使用链表或数组作为元素的基础存储。如果允许一个内存中的队列是无界的,那么对于许多类型的问题,它就会无限制地增长,直到耗尽内存而达到灾难性故障的地步。当生产者的生产速度超过消费者时,就会出现这种情况。在保证生产者的速度不会超过消费者且内存是一种宝贵资源的系统中,无界队列非常有用,但如果这种假设不正确且队列无限制增长,则总是存在风险。为了避免这种灾难性的结果,队列通常在大小上受到限制(有界限)。保持队列有界要求它要么是数组支持的,要么是主动跟踪队列的大小。

队列实现倾向于在头、尾和大小变量上有写争用。在使用时,由于消费者和生产者的速度不同,队列通常接近满或接近空。它们很少在一个生产和消费速度相等的平衡的中间地带运作。这种总是满的或总是空的倾向会导致高水平的争用和/或昂贵的缓存一致性。问题是,即使使用不同的并发对象(如锁或CAS变量)分隔头部和尾部机制,它们通常占用相同的缓存行。

管理生产者声明队列头部,消费者声明队列尾部,以及中间节点的存储,使得并发实现的设计非常复杂,无法在队列上使用单个大粒度锁。用于put和take操作的整个队列上的大粒度锁实现起来很简单,但这是吞吐量的一个重大瓶颈。如果并发关注在队列的语义中被分开,那么除了单一生产者-单一消费者实现之外,其他任何实现都将变得非常复杂。

在Java中,队列的使用还有一个更深层次的问题,因为它们是重要的垃圾来源。首先,必须分配对象并将其放入队列中。其次,如果支持链表,则必须分配表示链表节点的对象。当不再引用时,为支持队列实现而分配的所有这些对象都需要重新回收。

2.6管道和图

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

这种方法并不便宜——在每个阶段,我们都必须付出加入排队和退出排队工作单元的代价。当路径必须分叉时,目标的数量将乘以此代价,当路径必须在这样的分叉后重新加入时,将导致不可避免的争用代价。

如果可以表示依赖关系图,而不产生在阶段之间放置队列的成本,那将是理想的。

3.Disruptor的设计

在试图解决上述问题时,通过对我们所看到的合并在队列中的问题进行严格分离的设计出现了。这种方法结合了一个重点,即确保任何数据都只属于一个用于写访问的线程,从而消除了写争用。这个设计被称为“Disruptor”。之所以这样命名,是因为它在处理依赖关系图方面与Java 7中为支持Fork-Join而引入的“Phasers”概念有相似之处。

LMAX disruptor旨在解决上述所有问题,试图最大化内存分配效率,并以缓存友好的方式操作,以便在现代硬件上实现最佳性能。

Disruptor的核心机制是一个以环形缓冲区形式预先分配的有界数据结构。数据通过一个或多个生产者添加到循环缓冲区,并由一个或多个消费者进行处理。

3.1内存分配

所有用于ring-buffer(循环缓冲区)的内存都是在启动时预先分配的。循环缓冲区既可以存储指向entries的指针数组,也可以存储表示entries的结构数组。Java语言的局限性意味着条目作为指向对象的指针与循环缓冲区相关联。这些条目通常不是传递的数据本身,而是它的容器。这种对条目的预分配消除了支持垃圾收集的语言中的问题,因为条目将被重用,并在Disruptor实例的持续时间内存在。这些条目的内存是同时分配的,而且很可能会在主存中连续排列,因此支持跨缓存。John Rose曾提议在Java语言中引入“值类型”,这将允许元组数组,就像C等其他语言一样,从而确保连续分配内存并避免指针的间接指向。

在Java这种运行时环境中开发低延迟系统时,垃圾收集可能会出现问题。分配的内存越多,给垃圾收集器的负担就越大。当对象非常短命或有效地永久存在时,垃圾收集器工作得最好。在循环缓冲区中预分配条目意味着,就垃圾收集器而言,它是不朽的,因此负担很小。

在负载较大的情况下,基于队列的系统可以进行备份,这可能会导致处理速率的降低,并导致分配的对象存活的时间比它们应该存活的时间更长,因此在使用分代垃圾收集器的年轻代之后被提升。这有两个含义:首先,对象必须在代之间复制,这会导致延迟抖动;其次,必须从老一代中收集这些对象,这通常是一个开销更大的操作,并增加了在需要压缩碎片内存空间时导致的“stop the world”暂停的可能性。在大内存堆中,这可能会导致每GB内存停顿时间更长。

3.2消除担忧

我们认为,以下问题在所有队列实现中都是混杂的,在某种程度上,这种不同行为的集合倾向于定义队列实现的接口:

  • 储存要交换的条目(item)

  • 协调生产者声明下一个交换条目的序号

  • 协调通知消费者有条目可被消费

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

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

正如我们前面所描述的,有界队列在队列的头部和尾部都有争用。 环形缓冲区数据结构不受此争用和并发原语的影响,因为这些问题已被梳理到必须访问环形缓冲区的生产者和消费者壁垒中。 这些屏障的逻辑描述如下。

在Disruptor的大多数常见用法中,通常只有一个生产者。典型的生产者是文件读取器或网络侦听器。在只有一个生产者的情况下,没有序列/条目分配的争用。在有多个生产者的更不寻常的用法中,生产者将竞相在环形缓冲区中声明下一个条目。声明下一个可用条目时的争用可以通过对该槽的序列号进行简单的CAS操作来管理。

一旦生产者将相关数据复制到声明的条目中,它就可以通过提交序列将其公开给消费者。这可以在没有CAS的情况下通过简单的忙碌旋转完成,直到其他生产者在自己的提交中达到这个顺序。然后,这个生产者可以向前移动光标,表示下一个可供消费的条目。生产者可以通过在消费者写入环缓冲区之前跟踪消费者序列作为简单的读操作来避免包装环。

消费者在读取条目之前,要等待一个序列在循环缓冲区中变得可用。在等待的过程中可以采用各种策略。如果CPU资源是宝贵的,它们可以等待一个由生产者发出信号的锁中的条件变量。这显然是一个争用点,仅在CPU资源比延迟或吞吐量更重要时使用。使用者还可以循环检查表示循环缓冲区中当前可用序列的游标。这可以通过以CPU资源换取延迟来完成,也可以不使用线程yield。这可以很好地扩展,因为如果不使用锁和条件变量,我们就打破了生产者和消费者之间的争用依赖关系。无锁的多生产者多消费者队列确实存在,但它们需要在头、尾、大小计数器上进行多个CAS操作。Disruptor不会遭受此CAS争用。

3.3排序

排序是如何在Disruptor中管理并发性的核心概念。每个生产者和消费者都有一个严格的顺序概念,用于它如何与循环缓冲区交互。生产者在声明环中的一个条目时,按顺序声明下一个槽。下一个可用槽的序列在只有一个生产者的情况下可以是一个简单的计数器,在有多个生产者的情况下可以是使用CAS操作更新的原子计数器。一旦声明了序列值,声明生产者就可以将循环缓冲区中的该条目写入。当生产者完成更新条目时,它可以通过更新一个单独的计数器来提交更改,该计数器表示消费者可用的最新条目的环缓冲区上的游标。生产者可以使用内存屏障在繁忙的旋转中读取和写入环形缓冲区游标,而不需要如下所示的CAS操作。

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

cursor = claimedSequence;
复制代码

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

每个消费者都包含自己的序列,它们在处理来自循环缓冲区的条目时更新这些序列。这些消费者序列允许生产者跟踪消费者,以防止环被包装。消费者序列还允许消费者以有序的方式协调同一条目上的工作。

在只有一个生产者的情况下,无论消费者图的复杂性如何,都不需要锁或CAS操作。只要在所讨论的序列上设置内存屏障,就可以实现整个并发协调。

3.4批处理效果

当消费者在循环缓冲区中等待一个前进的游标序列时,一个有趣的机会出现了,而这是队列不可能的。如果消费者发现自上次检查以来,环形缓冲区游标已经前进了许多步,那么它可以在不涉及并发机制的情况下处理该序列。这导致当生产者突然领先时,落后的消费者迅速恢复与生产者的步伐,从而平衡系统。这种类型的批处理增加了吞吐量,同时减少并平滑了延迟。根据我们的观察,无论负载如何,这种效应都会导致接近常数的延迟时间,直到内存子系统饱和为止,然后这个配置文件将遵循Little定律。这与我们观察到的随着负载增加的队列对延迟的“J”曲线效应非常不同。

3.5依赖图

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

因为生产者和消费者的关注点在Disruptor模式中是分开的,所以在核心中只使用一个环形缓冲区的情况下,就可以表示消费者之间的依赖关系的复杂图。这大大降低了执行的固定成本,从而提高了吞吐量,同时减少了延迟。

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

3.6Disruptor类图

在下面的类图中描述了Disruptor框架中的核心关系。此图省略了可用于简化编程模型的便利类。建立依赖图后,编程模型简单。生产者通过一个ProducerBarrier依次声明条目,将他们的更改写入声明的条目中,然后通过ProducerBarrier将该条目提交回来,使其可用于消费。作为一个消费者,我们需要做的就是提供一个BatchHandler实现,当有新条目可用时接收回调。生成的编程模型是基于事件的,与Actor模型有很多相似之处。

将通常合并在队列实现中的关注点分离,可以实现更灵活的设计。RingBuffer存在于Disruptor模式的核心,为数据交换提供存储,而无需争用。对于与RingBuffer交互的生产者和消费者,并发问题是分开的。ProducerBarrier管理与环形缓冲区中声明槽相关的任何并发问题,同时跟踪依赖消费者以防止环形被包装。当有新条目可用时,ConsumerBarrier会通知消费者,消费者可以被构造成表示处理管道中多个阶段的依赖关系图。

Disruptor_Class_Diagram.png

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作为参考。根据我们的测试,ArrayBlockingQueue在所有有界队列中具有最高的性能。测试以块编程风格进行,以匹配Disruptor。下面详细介绍的测试用例可在Disruptor开源项目中获得。

unicast1p1c.png

Figure 1. Unicast: 1P – 1C

threestep1p3c.png

Figure 2. Three Step Pipeline: 1P – 3C

sequencer3p1c.png

Figure 3. Sequencer: 3P – 1C

multicast1p3c.png

Figure 4. Multicast: 1P – 3C

diamond1p3c.png

Figure 5. Diamond: 1P – 3C

对于上述配置,与使用Disruptor的barrier配置相比,对于每个数据流弧线应用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以及在处理5亿条消息时采用3次最佳运行的性能结果。结果可能会因不同的JVM执行而有很大的差异,下面的数字并不是我们观察到的最高的。

throughput.jpg

5.延迟性能测试

为了测量延迟,我们使用三个阶段的管道,并在低于饱和状态下生成事件。这是通过在注入一个事件后等待1微秒,然后再注入下一个事件并重复5000万次来实现的。为了在这个精度级别计时,必须使用CPU的时间戳计数器。我们之所以选择具有不变TSC的cpu,是因为较老的处理器会因为节能和睡眠状态而改变频率。Intel Nehalem和以后的处理器使用不变的TSC,可以被运行在Ubuntu 11.04上的最新Oracle jvm访问。此测试没有使用CPU绑定。为了进行比较,我们再次使用ArrayBlockingQueue。我们本可以使用ConcurrentLinkedQueueviii,它可能会得到更好的结果,但我们希望使用有限制的队列实现,以确保生产者不会通过产生回压而超过消费者。下面是2.2Ghz Core i7-2720QM在Ubuntu 11.04上运行Java 1.6.0_25 64位时的结果。Disruptor每跳的平均延迟为52纳秒,而ArrayBlockingQueue为32,757纳秒。分析显示,使用锁和通过条件变量发送信号是ArrayBlockingQueue延迟的主要原因。

latency_test.jpg

6.结论

Disruptor在提高吞吐量、减少并发执行上下文之间的延迟和确保可预测的延迟方面迈出了重要的一步,这在许多应用程序中都是一个重要的考虑因素。我们的测试表明,在线程之间交换数据时,它的性能优于类似的方法。我们认为这是这种数据交换的最高性能机制。通过专注于在跨线程数据交换中所涉及的关注点的清晰分离,通过消除写争用,最小化读争用,并确保代码与现代处理器所使用的缓存良好地工作,我们已经创建了一种用于任何应用程序中线程之间交换数据的高效机制。

批处理允许消费者在没有任何争用的情况下处理给定阈值的条目,这在高性能系统中引入了一个新特性。对于大多数系统,随着负载和争用的增加,延迟呈指数级增长,即特征的“J”曲线。当Disruptor上的负载增加时,延迟几乎保持不变,直到内存子系统出现饱和。

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

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享