LMAX Disruptor: High performance alternative to bounded queues for exchanging data between concurrent threads
《LMAX Disruptor:用于并发线程间交换数据的有界队列的高性能替代方案》
作者 Martin Thompson
Dave Farley
Michael Barker
Patricia Gee
Andrew Stewart
版本 Version 4.0.0-SNAPSHOT,May 2011
摘要
LMAX的成立旨在是要创建一个非常高性能的线上金融交易所。而作为我们实现这一目标的工作的一部分,我们评估了设计这样一个系统的几种方法,但是当我们开始评估和测试这些方法时,我们遇到了一些传统方法的基本限制和瓶颈。
许多应用系统要依赖队列在处理阶段之间交换数据。我们的性能测试表明,以这种方式使用队列时,延迟成本与磁盘IO操作(基于RAID或SSD的磁盘系统)的成本基本同一个数量级——非常慢。如果在一个端到端操作中有多个队列,这将使总延迟增加数百微秒。这显然是还有优化空间的。
进一步的研究和对计算机科学原理的关注使我们认识到,传统方法(如队列和处理节点)中固有的因素的共同作用( the conflation of concerns inherent in conventional approaches)会导致多线程实现中的争用,这表明可能有更好的方法。
考虑到现代cpu是如何工作的,我们喜欢称之为“机械共鸣”,软件与底层硬件的机制呼应上了,使用良好的设计实践,并将重点放在解决导致多线程争用的因素上(a strong focus on teasing apart the concerns),我们提出了一种新的数据结构和设计模式,这就是我们的Disruptor框架。
测试表明,对于一个有3个阶段的业务处理流程pipeline,使用Disruptor方案的平均延迟比对应的基于队列的方案低3个数量级。此外,对于相同的配置,Disruptor处理的吞吐量大约是原来的8倍。
这些性能改进代表了并发编程思想的一个阶段性变化。这种新模式是任何需要高吞吐量和低延迟的异步事件处理架构的理想基础。
在LMAX,我们已经构建了一个订单匹配引擎、实时风险管理和一个高度可用的内存事务处理系统,所有这些都基于了这种模式并取得了巨大的成功。这些系统中的每一个都制定了新的性能标准,而据我们所知,这些标准是无与伦比的。
我们的基于Disruptor的方案并不是一个仅与金融行业相关的特定专业领域内的解决方案。Disruptor是一种通用机制,它以一种性能最大化的方式解决并发编程中的复杂问题,并且易于实现。使用的过程中,尽管这个框架的一些个设计上的概念、可能一开始理解起来不习惯,但根据我们的经验,一旦熟悉、按照这种模式构建的系统要比类似的机制简单得多。
与同类方案相比,Disruptor具有显著的更少的写争用、更低的并发开销、对CPU的缓存机制更友好,所有这些都使得其在更低的延迟下产生更大的吞吐量和更少性能上的抖动。在中等时钟频率的处理器上,我们已经看到每秒处理超过2500万条消息,延迟低于50纳秒。与我们所看到的任何其他实现相比,此性能是一个显著的改进。这已经非常接近现代处理器在内核之间交换数据的硬件上的理论极限了。
1. 概述
Disruptor是我们努力在LMAX公司建立世界上性能最高的在线金融交易所这一工作目标下的成果。早期的设计侧重于从SEDA[1]和Actors[2]派生的架构,它们使用pipeline模型来实现吞吐量。在分析了各种实现之后,很明显,pipeline模型中各阶段之间的事件排队是主要的成本。我们发现队列还引入了延迟和很高的性能抖动(性能很不稳定、每次测试结果差异很大)。我们花了大量精力开发性能更好的新队列实现。然而,显而易见的是,队列作为一种基本的数据结构,由于生产者、消费者和他们的数据存储的设计关注点的合并(the conflation of design concerns)而受到限制。Disruptor是我们构建一个干净地分离这些关注点的并发数据结构的成果。
2. 并发的复杂性
在本文以及在一般的计算机科学理论中,并发不仅意味着两个以上任务同时并行发生,而且意味着它们在访问资源时相互竞争。争用的资源可以是数据库、文件、socket,甚至是内存中的一个位置。
代码的并发执行涉及两件事:互斥和内存可见性。互斥是关于如何管理保证某些资源的独占式使用。内存可见性是关于控制内存更改何时对其他线程可见。如果你可以避免多线程竞争的去更新共享资源,那么就可以避免互斥。如果您的算法可以保证任何给定的资源只被一个线程修改,那么互斥是不必要的。读写操作要求所有更改对其他线程可见。但是,只有争用的写操作需要对更改进行互斥。
在任何并发环境中,最昂贵的操作是争用写访问。要让多个线程写入同一资源,需要复杂而昂贵的协调。通常,这是通过采用某种锁策略来实现的。
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 2线程加锁 | 224,000 |
Single thread with CAS 单线程用CAS | 5,700 |
Two threads with CAS 2线程用CAS | 30,000 |
Single thread with volatile write 单线程写volatile变量 | 4,700 |
2.2 CAS的开销
当更新的目标是单个内存变量时,可以采用比使用锁更有效的替代方法来更新内存。这些替代方案基于现代处理器中实现的原子指令或互锁指令。这些操作通常称为CAS(比较和交换)操作,例如x86上的“lock cmpxchg”。CAS操作是一种特殊的机器代码指令,它允许将内存中的字有条件地设置为原子操作。比如对于前面的“递增计数器实验”例子,每个线程都可以在一个循环中自旋,读取计数器,然后尝试以原子方式将其设置为新的递增值。旧值和新值作为本指令的参数提供。如果在执行操作时,计数器的值与提供的预期值匹配,则计数器将用新值更新。另一方面,如果该值不符合预期,则CAS操作将失败。然后由尝试执行更改计数器的线程重试,重新读取从该值递增的计数器,依此类推,直到更改成功。这种CAS方法比锁更有效,因为它不需要上下文切换到内核态进行仲裁。但是CAS操作也并不是完全没有开销的。处理器必须锁定其指令管道以确保原子性,并使用内存屏障使更改对其他线程可见。通过使用Java.util.concurrent.Atomic*类,可以在Java中使用CAS操作。
笔者注:CAS无需线程进行上下文切换到内核态去执行,在用户态执行了CPU的原语指令cmpxchg,这里可能有疑问,指令分为特权指令和非特权指令,用户态是可以执行非特权指令的。所以这里纠正一个错误理解就是并不是发生了系统调用就一定是内核态,内核态和用户态是用CPU中的程序状态字寄存器PSW,当该寄存器值为0时表示CPU处于用户态、为1时表示处于核心态(此刻执行的线程处于内核态)。
https://blog.csdn.net/qq_19018277/article/details/98225102 《非特权指令、特权指令,用户态、核心态》
如果程序的关键部分比计数器的简单增量更复杂,则可能需要使用多个CAS操作的复杂状态机来编排争用。使用锁开发并发程序是困难的;而使用CAS操作和内存屏障开发无锁算法要更加复杂多倍,而且难于测试和证明正确性。
理想的算法是只有一个线程负责对单个资源的所有写入操作,而其他线程则读取结果。而说到在多处理器环境中读取结果,需要内存屏障,以确保在其他处理器上运行的线程可以立即看到更改。
2.3 内存屏障
出于提升性能的原因,现代处理器执行指令、以及内存和执行单元之间数据的加载和存储都是不保证顺序的。不管实际的执行顺序如何,处理器只需保证与程序逻辑的顺序产生相同的结果即可。这在单线程的程序中不是一个问题。但是,当线程共享状态时,为了确保数据交换的成功与正确,在需要的时候、内存的改变能够以正确的顺序显式是非常重要的。处理器使用内存屏障来指示内存更新顺序很重要的代码部分。它们是在线程之间实现硬件排序和更改可见性的方法。编译器可以设置免费的软件屏障,以确保编译代码的顺序,也就是除了处理器本身使用的硬件屏障之外,还存在这样的软件内存屏障。
现代的CPU现在比当前一代的内存系统快得多。为了弥合这一鸿沟,CPU使用复杂的高速缓存系统,这些系统是有效的快速硬件哈希表,无需链接。这些缓存通过消息传递协议与其他处理器缓存系统保持一致。此外,处理器还具有“存储缓冲区”(store buffer/load buffer,比L1缓存更靠近CPU,跟寄存器同一个级别,用来当作CPU与高速缓存之间的缓冲。毕竟高速缓存由于一致性的问题也会阻塞)来缓冲对这些缓存的写入,以及作为“失效队列”,以便缓存一致性协议能够在即将发生写入时快速确认失效消息,以提高效率。
这对数据意味着,任何值的最新版本在被写入后的任何阶段都可以位于寄存器、存储缓冲区、L1/L2/L3缓存之一或主内存中。如果线程要共享此值,则需要以有序的方式使其可见,这是通过协调缓存一致性消息的交换来实现的。这些信息的及时产生可以通过内存屏障来控制。
A read memory barrier orders load instructions on the CPU that executes it by marking a point in the invalidate queue for changes coming into its cache. This gives it a consistent view of the world for write operations ordered before the read barrier.
A write barrier orders store instructions on the CPU that executes it by marking a point in the store buffer, thus flushing writes out via its cache. This barrier gives an ordered view to the world of what store operations happen before the write barrier.
A full memory barrier orders both loads and stores but only on the CPU that executes it.
除了这三个原语之外,有些CPU还有更多的变体,但这三个原语足以理解所涉及内容的复杂性。在Java内存模型中,volatile字段的读写分别实现了读写屏障。这在Java内存模型中是明确的[3]正如Java5发行版所定义的那样。
理解这节需要了解内存屏障的底层实现原理和CPU缓存一致性协议相关的知识。https://blog.csdn.net/m0_37561834/article/details/78457078
2.4 缓存行
高速缓存在现代处理器中的使用方式对于成功的高性能操作具有极其重要的意义。这样的处理器在处理高速缓存中的数据和指令时非常高效,但在发生高速缓存丢失时,效率相对非常低。
我们的硬件不会以byte或word(字,1字=2byte)的形式移动内存。为了提高效率,缓存被组织成大小通常为32-256字节的缓存行cacheline,最常见的cacheline是64字节。这是缓存一致性协议MESI操作的粒度级别。这意味着,如果两个变量位于同一个cacheline中,并且它们是由不同的线程写入的,那么它们将出现与单个变量相同的写争用问题。这是一个被称为“伪共享”的概念。为了获得高性能、尽量最小化争用,确保独立但同时写入的变量不共享同一cacheline是非常重要的。
当以可预测的方式访问内存时,cpu能够通过预测下一个可能访问的内存并在后台将其预取到缓存中来减少下一次访问主内存的延迟成本。只有当处理器能够预测一个固定内存访问的模式、比如以可预测的“步幅”遍历一块内存,这种方法才有效。当迭代数组的内容时,步长是可预测的,因此内存将被预读到cacheline,从而最大限度地提高访问效率。步长通常必须小于2048字节,处理器才能注意到。然而,像链表和树这样的数据结构往往有节点,这些节点在内存中分布更广,没有可预测的访问步长。内存中缺少一致的模式限制了系统预取cacheline的能力,从而导致只能直接访问主存——其性能较高速缓存要降低2个数量级以上。
笔者注:其实第2节都是在讲理论储备,2.4概括来说就是一句话,为了有效利用CPU的高速缓存机制,我们应该尽量用顺序存储结构,比如数组
2.5 队列带来的问题
队列通常使用链表或数组作为元素的底层存储。如果允许内存中的队列是无界的,那么对于许多类的问题,它可以不受约束地增长,直到耗尽内存而达到灾难性的后果,当生产者超过消费者时就会发生这种情况。无界队列在可以在生产者可以保证不超过消费者的系统中使用,因为内存是一种宝贵的资源,但是如果这种假设不成立,而队列增长没有限制,那么总是有风险的。为了避免这种灾难性的结果,队列的大小通常要受到限制(有界)。要使队列保持有界,就需要对其底层选择数组结构或主动跟踪其大小。
队列的实现往往要在head、tail和size变量上有写争用。在使用时,由于消费者和生产者之间的速度差异,队列通常总是接近于满或接近于空。它们很少在生产和消费速率均衡的中间地带运作。这种总是满的或总是空的倾向会导致高级别的争用、和/或昂贵的缓存一致性。问题在于,即使head和tail使用不同的并发对象(如锁或CAS变量)来进行读写锁分离,它们通常也占用相同的cacheline。
管理生产者申请队列的head,消费者申请队列的tail,以及中间节点的存储,这些问题使得并发实现的设计非常复杂,除了在队列上使用一个粗粒度的锁之外,还难以管理。对于put和take操作,使用整个队列上的粗粒度锁实现起来很简单,但对吞吐量来说是一个很大的瓶颈。如果并发关注点在队列的语义中被分离开来,那么对于除单个生产者-单个消费者之外的任何场景,实现都变得非常复杂。
在Java中,队列的使用还有一个问题,因为它们会产生较多的垃圾对象。因为,必须在队列中分配和放置对象。其次,如果支持链表,则必须分配表示链表节点的对象。当不再被引用时,需要重新声明为支持队列实现而分配的所有的这些对象。
笔者注:这节大意是推荐使用有界队列以避免内存耗尽问题,队列的head、tail、size等变量通常会占用相同的cacheline从而引发伪共享问题,这样即便是尝试使用读写锁分离的策略,由于无法有效利用高速缓存机制,性能也很难达到最优。队列中锁的粒度比较粗,带来吞吐量上的性能影响。Java实现的队列容易产生较多垃圾对象。
2.6 Pipelines and Graphs
对于许多类型的问题,都可以将几个处理阶段连接到一个管道pipeline中。有时候几个这样的管道又具有并行路径,可以被组织成图形拓扑状的执行流程。而每个阶段之间的连接通常由队列实现,每个阶段都有自己的处理线程。
上述所说的这种方案的开销并不小——在每一个阶段,我们都必须承担工作单元入队和出队的成本。当路径必须分叉fork时,目标的数量将此成本相乘,当路径必须在分叉之后重新连接时,将不可避免地产生争用成本。
如果我们的流程模型可以即表示stage之间的依赖关系图,而不需要在阶段之间放置队列,这将是非常理想的方案。
3. LMAX Disruptor的设计
在试图解决上述问题的同时,通过严格分离在队列中被合并的关注点,出现了一种设计。这种方法结合了一个重点,即确保任何数据只能由一个线程拥有以进行写访问,从而消除写争用。这种设计被称为“Disruptor”。它之所以这样命名,是因为它在处理与Java7中的“Phasers”[4]概念的图状依赖关系时具有相似的点,而Java7是为了支持Fork-Join而引入的。
LMAX Disruptor旨在解决上述所有问题,试图最大限度地提高内存分配效率,并以缓存友好的方式运行,以便在现代硬件上以最佳方式运行。Disruptor机制的核心是一个以环形缓冲区的形式预先分配的有界数据结构。数据通过一个或多个生产者添加到环形缓冲区,并由一个或多个消费者进行处理。
3.1 内存分配
启动时,将预先分配环形缓冲区的所有内存。环形缓冲区可以存储指向entry的指针数组,也可以存储表示entry的结构数组。Java语言的局限性意味着entry作为指向对象的指针与环形缓冲区相关联。这些entry中的每一个通常不是传递的数据本身,而是它的容器。这种entry的预分配消除了支持垃圾回收的语言中的问题,因为entry将被重用,并在整个Disruptor实例存活期间都有效。这些entry的内存是同时分配的,很可能会在主内存中连续布局,因此支持高速缓存预读。John Rose提议在Java语言中引入“值类型”[5],这将允许元组数组,就像C等其他语言一样,从而确保连续分配内存并避免指针间接寻址。
在像Java这样的托管运行时环境中开发低延迟系统时,垃圾收集机制可能会带来问题。分配的内存越多,给垃圾收集器带来的负担就越大。当对象的寿命很短或实际上是常驻的时候,垃圾收集器工作得最好。在环形缓冲区中预先分配entry意味着它对于垃圾收集器来说是常驻内存的,因此带来很少的负担。
在重负载情况下,基于队列的系统会发生堵塞,这会导致处理速度的降低,并导致分配的对象比原本正常情况下存活的时间更长,因此会被通过分代垃圾收集器提升到年轻一代之外。这有两个含义:第一,对象必须在几代之间复制,这会导致延迟抖动;第二,这些对象必须从老年代收集,这通常是一个更昂贵的操作,并且增加了“STW”暂停的可能性,当碎片化的内存空间需要压缩时,就会出现这种暂停。在大内存堆中,这可能会导致每GB的暂停时间长达数秒。
3.2 Teasing Apart the Concerns
我们看到,在所有队列实现中,以下问题被合并在一起,以至于这些不同行为的集合倾向于定义队列实现的接口:
队列中用于交换的元素的存储
协调生产者获得下一个序列号用于入队
协调通知消费者有新元素可用
当用使用Java这种带垃圾收集机制的语言开发在线金融交易所系统时,过多的内存分配可能是有问题的。因此,正如我们所描述的,用基于链表的队列做数据结构不是一个好方法。如果可以预先分配用于各个处理阶段stage之间做数据交换的整个存储,则可以最小化垃圾收集的开销。此外,如果这个分配可以在一个统一的内存块中执行,那么数据的遍历将以一种对现代处理器所采用的高速缓存策略非常友好的方式来完成。满足此要求的数据结构是一个预先填充了所有slot的数组。在创建RingBuffer时,Disruptor使用抽象工厂模式来预分配entry。这样生产者在数据入队而索要entry时,可以将其数据复制到预先分配的结构中。
在大多数处理器上,序列号的余数计算成本非常高,而序列号%ringBufferSize得到的余数=元素在环中的插槽的下标。通过使环的大小为2的幂,可以大大降低成本。可以使用大小为负1的位掩码来有效地执行余数运算。
正如我们前面所描述的,有界队列在队列的head和tail都会发生争用。而环形缓冲区数据结构不受这种争用和并发原语的影响,因为这些问题已经被梳理成生产者和消费者的屏障barrier,必须通过这些barrier来访问环形缓冲区。这些barrier的逻辑如下所述。
在Disruptor最常见的用法中,通常只有一个生产者。典型的生产者是file reader或网络listener。在只有一个生产者的情况下,equence/entry的分配没有争用。在有多个生产者的更不寻常的用法中,生产者将互相竞争以获得环形缓冲区中的下一个entry。索要下一个可用entry的争用可以通过对该slot的sequence number执行简单的CAS操作来管理。
一旦生产者将相关数据复制到申请到的entry中,它就可以通过提交序列将其公开给消费者。这可以在没有CAS的情况下通过一个简单的忙旋转来完成,直到其他生产者在他们自己的提交中达到这个序列。然后,该生产者可以前进光标,指示下一个可用的消费条目。生产者可以通过跟踪消费者序列来避免覆盖环(避免覆盖了还未读取的slot),这是在其写入环缓冲区之前的要做的一个简单的读取操作。
消费者在读取entry之前,等待entry的序列在环形缓冲区中变为可用。等待时可以采用各种策略。如果CPU资源非常宝贵,那么它们可以在等待一个锁中的Condition.awaite(),等待由生产者对Condition.signal()。这显然是一个争用点,仅在CPU资源比延迟或吞吐量更重要时使用。消费者还可以循环的检查环形缓冲区中代表当前可用序列号的游标。这可以在有或没有线程yield的情况下完成,以CPU资源换取低延迟。如果不使用lock和condition变量,这将有很好的伸缩性,因为我们已经打破了生产者和消费者之间的竞争依赖关系。无锁多生产者-多消费者队列确实存在,但它们需要在head、tail、size等计数器上执行多个CAS操作。Disruptor避免了这种CAS争用。
这节介绍了大量的使用RingBuffer这个环形数组实现无锁队列的精巧算法和设计,由于翻译过程中的信息不对称和表达不到位,还是要配合源码才能理解很多细节。此外,这篇论文是按照当时早期的Disruptor版本写的,跟最新的Disruptor会有一些不同,比如RingBuffer的ReadBarrier和WriteBarrier我记得后来的版本中好像就去掉了,我们注意理解论文中的思想就好。 可以参考美团技术团队的blog,https://tech.meituan.com/2016/11/18/disruptor.html
3.3 Sequencing
顺序序列是Disruptor中如何管理并发的核心概念。每个生产者和消费者都有一个严格的序列概念,用于如何与RingBuffer交互。生产者在环中申请entry时,按顺序申请下一个slot。在只有一个生产者的情况下、下一个可用slot的序列下标可以是一个简单计数器,或者在多个生产者的情况下,可以是使用CAS操作更新的原子计数器。一旦序列值被申请了,环形缓冲区中的这个entry现在就可以被申请它的生产者写入。当生产者完成更新entry时,它可以通过更新一个单独的计数器来提交更改,该计数器表示环形缓冲区上的游标,以获取消费者可用的最新entry。环形缓冲区游标可以由生产者使用内存屏障在繁忙的旋转中读写,而不需要CAS操作。
long expectedSequence = claimedSequence – 1;
while (cursor != expectedSequence)
{
// busy spin
}
cursor = claimedSequence;
消费者通过使用内存屏障来读取游标,等待给定的序列下标对应的entry变为可用。一旦游标被更新,内存屏障确保等待游标前进的消费者可以立刻看到环形缓冲区中entry的变化。
每个消费者都包含自己的序列sequence,在处理来自环形缓冲区的entry时更新这些序列。这些消费者序列允许生产者跟踪消费者,以防止消费者在环形缓冲上被生产者套圈、这样生产者写入就覆盖了还未被消费者读取的数据了。消费者序列还允许消费者以有序的方式协调同一entry上的工作
在只有一个生产者的情况下,不管消费者的复杂性如何,都不需要锁或CAS操作。在所讨论的序列上,只需设置内存障碍,就可以实现整个并发协调。
3.4 Batching Effect
当消费者在环形缓冲区中等待前进的游标序列时,一个在队列中不可能出现有趣的现象出现了。如果消费者发现环形缓冲区游标自上次检查以来已前进了许多步,则它可以处理该序列,而无需涉及并发机制。这导致落后的消费者很快就追赶上了生产者的步伐,从而平衡系统。这种类型的批处理可以提高吞吐量,同时减少和平滑延迟。根据我们的观察,这种效应可使得延迟时间在任何负载下接近恒定,直到内存子系统饱和,延迟曲线是线性的,遵循利特尔定律[6]。这与我们以往观察到的队列在负载增加时的延迟的“J”形曲线效果非常不同。
环形数组中生产者指针如果已经领先很多,消费者是可以不用任何锁等并发机制直接把自己指针与生产者指针之间的数据都拿出来的,与之相对的是队列则是要一个一个的从head里取出数据、且这中间还要用到锁、产生了延迟,很容易想到当负载增加、队列中出现了堆积,这种延迟会成倍的增加。从由数据结构本身产生的延迟的角度来看,无疑是RingBuffer要比一般的队列要少的多。
3.5. Dependency Graphs
队列表示生产者和消费者之间简单的一步管道依赖关系。如果消费者形成了一个链式或类似于图的依赖结构,那么在图的每个阶段之间都需要队列。在相依阶段图中,这会导致多次队列的固定成本。在设计lmax在线金融交易所系统时,我们的观测与分析表明,如采用基于队列的方案,在最终总的整体的事务处理执行的成本中,队列成本占比最高。
因为生产者和消费者的关注点(concerns)是用Disruptor模型分开的,所以可以表示消费者之间的复杂依赖关系图,而只需在核心部分使用一个环形缓冲区。这将大大降低执行的固定成本,从而在减少延迟的同时提高吞吐量。
可以使用单个环形缓冲区来存储具有复杂结构的entry,在一个内聚的地方的表示整个工作流。在设计这种结构时必须小心,要确保彼此独立的几个消费者写状态不会导致cacheline的伪共享。
3.6. Disruptor的类结构图
Disruptor框架中的核心类如图。此图省略了可用于简化编程模型的工具类。在建立了依赖图之后,编程模型变得简单。生产者通过ProducerBarrier
按顺序申请entry,将其更改写入申请到的entry,然后通过ProducerBarrier
提交该entry,使其可供消费。作为消费者,我们所需要做的就是提供一个BatchHandler
实现,当一个新entry可用时接收回调。整个编程模型是基于事件驱动的,与Actor模型有很多相似之处。
分离通常在队列实现中合并的关注点可以实现更灵活的设计。Disruptor框架的核心是一个RingBuffer
,它为数据交换提供存储,而不存在争用。对于与RingBuffer
交互的生产者和消费者,并发关注点被分离出来。ProducerBarrier
管理与在环缓冲区中索要slot相关的任何并发问题,同时跟踪相关的消费者以防止覆盖未读的slot。当新entry可用时,ConsumerBarrier
通知消费者,消费者可以被构造成表示处理管道中多个阶段的依赖关系图状流程。
3.7. 代码例子
下面的代码是单个生产者、以及单个的使用convenience接口BatchHandler
实现的消费者的示例。消费者在一个单独的线程上运行,接收可用的entry。
// 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. Throughput Performance Testing
5. Latency Performance Testing
6. 总结
在提高吞吐量、减少并发执行上下文之间的延迟和确保可预测的延迟方面,Disruptor是向前迈出的重要一步,这在许多应用程序中是一个重要的考虑因素。我们的测试表明,它在线程间交换数据方面的性能超过了类似的方案。我们相信这是这种数据交换的最高性能机制。通过将跨线程数据交换中涉及的关注点完全分离,消除写争用,最小化读争用,并确保代码与现代处理器使用的Cache特性良好配合,我们创建了一种高效的机制,可用于在任何应用中的线程之间交换数据。
批处理效应允许消费者在没有任何争用的情况下处理高达给定阈值的entry,这在高性能系统中引入了一个新特性。对于大多数系统,随着负载和争用的增加,延迟呈指数增长,即特征“J”曲线。而当Disruptor上的负载增加时,在内存子系统达到饱和之前,延迟几乎保持不变。
我们相信Disruptor为高性能计算建立了一个新的基准,并且它的设计非常能够继续利用当前趋势下处理器和计算机设计的优势。
后记
查看百度空间残留的个人日记得知,笔者早在2012年2月1日就动了研究Disruptor原理的念头,并打算在后端开发中使用它,前后花了1个月左右时间尝试翻译和理解它的官方论文和指南、以及开发者的各篇blog,到当年3月20日认识到自己知识储备不足功力不够,遂暂时放弃。2016年差不多到了能够基本的简单进行使用的程度。2020年疫情期间,着手把Disruptor应用在了笔者负责的一个实时竞价系统上去,用来取代队列。只可惜公司拉跨,没有机会把这个成果推到线上并在高负载场景下进行验证。2021年的今天,念念不忘,再次翻译理解Disruptor的官方论文,经过了将近十年的理论认知与实践经验的逐渐提高,理解起来变得容易了许多。笔者愚钝,且又经历种种生活不易,光阴十载、仍未成神。但古人云,朝闻道、夕死可矣。每个人都有着他自己的朝圣路,不是么。