LMAX的破坏者模式如何工作?


205

我试图了解破坏者的模式。我已经看过InfoQ视频,并试图阅读他们的论文。我知道其中涉及一个环形缓冲区,它被初始化为一个非常大的数组,以利用缓存的局部性,消除新内存的分配。

听起来好像有一个或多个原子整数可以跟踪位置。每个“事件”似乎都有一个唯一的ID,并且可以通过找到相对于环大小等的模数来找到它在环中的位置。

不幸的是,我对它的工作方式没有直观的认识。我完成了许多交易应用程序,研究了参与者模型,研究了SEDA等。

他们在演讲中提到,这种模式基本上就是路由器的工作方式。但是我也没有找到关于路由器工作方式的任何好的描述。

是否有一些更好的解释的好指针?

Answers:


210

Google Code项目确实引用了有关环形缓冲区实现的技术论文,但是对于想要了解其工作原理的人来说,这有点干,学术性强。但是,有些博客文章已经开始以更易读的方式解释内部原理。有对环形缓冲区解释,它是破坏者模式的核心,对消费者壁垒描述(与从破坏者中读取有关的部分)以及有关处理多个可用生产者的一些信息

Disruptor的最简单描述是:这是一种以可能的最有效方式在线程之间发送消息的方式。它可以用作队列的替代方法,但它也与SEDA和Actors共享许多功能。

与队列相比:

Disruptor提供了将消息传递到另一个线程的功能,可以在需要时将其唤醒(类似于BlockingQueue)。但是,存在3个明显的差异。

  1. Disruptor的用户通过扩展Entry类并提供工厂进行预分配来定义如何存储消息。这允许内存重用(复制),或者Entry可能包含对另一个对象的引用。
  2. 将消息放入Disruptor是一个两阶段的过程,首先在环形缓冲区中声明一个插槽,该插槽为用户提供了可以填充适当数据的条目。然后必须提交该条目,此2阶段方法对于允许灵活使用上述内存是必需的。正是提交使消息对使用者线程可见。
  3. 消费者有责任跟踪已从环形缓冲区使用的消息。将责任从环形缓冲区本身移开有助于减少写争用的数量,因为每个线程都维护自己的计数器。

与演员相比

Actor模型比大多数其他编程模型更接近Disruptor,特别是如果您使用提供的BatchConsumer / BatchHandler类。这些类隐藏了维护消耗的序列号的所有复杂性,并在发生重要事件时提供了一组简单的回调。但是,有一些细微的差异。

  1. Disruptor使用1个线程-1个消费者模型,其中Actor使用N:M模型,即,您可以根据需要拥有任意数量的actor,它们将分布在固定数量的线程中(每个内核通常为1个)。
  2. BatchHandler接口提供了一个额外的(也是非常重要的)回调onEndOfBatch()。这允许速度较慢的使用者,例如那些执行I / O来将事件批处理在一起以提高吞吐量的使用者。可以在其他Actor框架中进行批处理,但是由于几乎所有其他框架都不在批处理结束时提供回调,因此您需要使用超时来确定批处理结束,从而导致延迟时间长。

与SEDA相比

LMAX构建了Disruptor模式来取代基于SEDA的方法。

  1. 与SEDA相比,它提供的主要改进是并行工作的能力。为此,Disruptor支持将相同的消息(以相同的顺序)多播到多个使用者。这避免了在管道中需要派生阶段。
  2. 我们还允许消费者等待其他消费者的结果,而不必在他们之间放置另一个排队阶段。使用者可以简单地查看它所依赖的使用者的序列号。这避免了在管道中加入阶段的需要。

与内存屏障相比

考虑它的另一种方法是将其作为结构化的有序内存屏障。生产者壁垒形成写壁垒,而消费者壁垒是读壁垒。


1
谢谢迈克尔。您的文章和所提供的链接帮助我更好地了解其工作原理。剩下的,我想我只需要让它沉下去即可
。– Shahbaz

我仍然有疑问:(1)“提交”如何工作?(2)当环形缓冲区已满时,生产者如何检测到所有使用者都看过数据,以便生产者可以重用条目?
Qwertie 2012年

@Qwertie,可能值得发布一个新问题。
Michael Barker

1
与SEDA比较下的最后一个要点(第2个)的第一句不应该阅读“我们也允许消费者等待其他消费者的结果而不得不在他们之间进行另一个排队阶段”,而不是阅读“我们也允许消费者等待其他消费者的结果不必在他们之间放置另一个排队阶段”(即,“有”应替换为“无”)?
runeks

@runeks,是的。
Michael Barker

135

首先,我们想了解它提供的编程模型。

有一个或多个作家。有一个或多个读者。有一行条目,从旧到新(从左到右如图)完全排序。作家可以在右边添加新条目。每个阅读器从左到右顺序读取条目。显然,读者无法阅读过去的作家。

没有条目删除的概念。我使用“阅读器”而不是“消费者”来避免条目图像被消耗。但是,我们知道最后一个阅读器左侧的条目将变得无用。

通常,读者可以同时独立阅读。但是,我们可以在读者之间声明依赖关系。读者依赖性可以是任意的非循环图。如果读者B依赖读者A,则读者B无法阅读过去的读者A。

出现读者依赖性是因为读者A可以注释条目,而读者B依赖于该注释。例如,A对条目进行一些计算,并将结果存储a在条目中的字段中。然后,A继续前进,现在B可以读取该条目,并a存储A 的值。如果读者C不依赖于A,则C不应尝试阅读a

这确实是一个有趣的编程模型。无论性能如何,单独的模型都可以使许多应用程序受益。

当然,LMAX的主要目标是性能。它使用预分配的条目环。环足够大,但有界,因此不会加载超出设计容量的系统。如果铃声已满,作家将等到最慢的读者前进并腾出空间。

入口对象是预先分配的,并且可以永久使用,以减少垃圾收集成本。我们不是插入新的条目对象,也不是删除旧的条目对象,而是由作者要求一个预先存在的条目,填充其字段并通知读者。这种明显的两相作用实际上只是原子作用

setNewEntry(EntryPopulator);

interface EntryPopulator{ void populate(Entry existingEntry); }

预分配条目还意味着相邻条目(很有可能)位于相邻的存储单元中,并且由于读取器按顺序读取条目,因此对于利用CPU缓存很重要。

为了避免锁定,CAS甚至内存障碍,我们付出了很多努力(例如,如果只有一个写入器,则使用非易失性序列变量)

对于阅读者的开发人员:不同注释的阅读者应写到不同的字段,以避免写争用。(实际上,它们应该写入不同的缓存行。)带注释的阅读器不应触摸其他非依赖性阅读器可能会读取的任何内容。这就是为什么我说这些读者注释条目而不是修改条目。


2
对我来说还好。我喜欢使用“注释”一词。
Michael Barker

21
+1是试图描述破坏者模式实际工作方式的唯一答案,正如OP所要求的那样。
G-Wiz

1
如果铃声已满,作家将等到最慢的读者前进并腾出空间。-带有深FIFO队列的问题之一是使它们在负载下太容易被填满,因为它们直到填充塞满并且等待时间已经很高时才真正尝试反压。
bestsss

1
@irreputable您还可以为作者端写类似的说明吗?
布奇(Buchi)

我喜欢它,但是我发现这“一个作家要求一个预先存在的条目,填充其字段,并通知读者。这种明显的两阶段动作实际上只是一个原子动作”,令人困惑并且可能是错误的吗?没有“通知”对吗?同样不是原子的,这仅仅是一个有效/可见的写,对吗?好的答案只是模棱两可的语言?
HaveAGuess 2014年


17

实际上,出于好奇,我花了一些时间研究实际的来源,其背后的想法很简单。在撰写本文时,最新版本是3.2.1。

有一个存储预分配事件的缓冲区,该事件将保存数据以供使用者读取。

缓冲区由其长度的标志数组(整数数组)支持,该数组描述缓冲区插槽的可用性(有关详细信息,请参见进一步)。就像java#AtomicIntegerArray一样访问该数组,因此出于本次展示的目的,您还可以假定它是一个数组。

可以有任何数量的生产者。当生产者想要写入缓冲区时,会生成一个长整数(如调用AtomicLong#getAndIncrement时,Disruptor实际上使用其自己的实现,但其工作方式相同)。我们称此生成的很长的生产者ID。以类似的方式,当使用者结束从缓冲区读取插槽时,会生成一个ConsumerCallId。访问最新的ConsumerCallId。

(如果有很多消费者,则选择ID最低的呼叫。)

然后将这些id进行比较,如果两者之间的差异小于缓冲区,则允许生产者进行写操作。

(如果producerCallId大于最近的consumerCallId + bufferSize,则表示缓冲区已满,并且生产者被迫进行总线等待,直到找到可用位置为止。)

然后根据生产者的callId(即ducducerCallId模缓冲区大小)为生产者分配缓冲区中的插槽,但是由于bufferSize始终为2的幂(在创建缓冲区时强制执行限制),因此实际使用的操作是producerCallId&(bufferSize-1 ))。然后可以随意在该插槽中修改事件。

(实际算法稍微复杂一些,其中包括出于优化目的在单独的原子引用中缓存最近的consumerId。)

修改事件后,更改将“发布”。发布时,标志数组中的相应插槽将填充有更新的标志。标志值是循环的编号(producerCallId除以bufferSize(同样,因为bufferSize为2的幂,所以实际操作为右移)。

以类似的方式,可以有任何数量的消费者。每当消费者想要访问缓冲区时,都会生成一个ConsumerCallId(取决于将消费者添加到干扰器的方式,id生成中使用的原子可以为每个共享或分离)。然后将此consumerCallId与最新的producentCallId进行比较,如果两者中的较小者,则允许阅读器进行处理。

(类似地,如果producerCallId甚至是consumerCallerId,这意味着缓冲区是空的,并且消费者被迫等待。等待的方式由创建干扰源期间的WaitStrategy定义。)

对于个人消费者(具有自己的ID生成器的消费者),接下来要检查的是批量消费的能力。依次检查缓冲区中的时隙,即从一个对应于ConsumerCallId(以与生产者相同的方式确定索引)到一个与最近的producerCallId对应的时隙。

通过将标记数组中写入的标记值与为ConsumerCallId生成的标记值进行比较,来循环检查它们。如果标志匹配,则表示填充插槽的生产者已提交更改。如果不是,则循环中断,并返回最高提交的changeId。从ConsumerCallId到changeId中收到的时隙可以批量使用。

如果一组使用者一起阅读(具有共享ID生成器的使用者),则每个使用者仅使用一个callId,并且仅检查并返回该单个callId的插槽。


7

这篇文章

破坏者模式是一个批处理队列,由填充有预先分配的传输对象的环形阵列(即环形缓冲区)支持,该环形对象使用内存屏障通过序列同步生产者和消费者。

内存障碍有点难以解释,在我看来,Trisha的博客对此帖子进行了最佳尝试:http : //mechanitis.blogspot.com/2011/08/dissecting-disruptor-why-its-so-fast。 html

但是,如果您不想深入了解底层细节,则可以知道Java中的内存屏障是通过volatile关键字或通过实现的java.util.concurrent.AtomicLong。破坏者模式序列为AtomicLongs,并通过内存屏障而非锁在生产者和消费者之间来回通信。

我觉得更容易理解通过代码的一个概念,所以下面的代码是一个简单的HelloWorldCoralQueue,这是与我关联CoralBlocks做了破坏者模式实现。在下面的代码中,您可以看到干扰模式如何实现批处理,以及环形缓冲区(即环形数组)如何实现两个线程之间的无垃圾通信:

package com.coralblocks.coralqueue.sample.queue;

import com.coralblocks.coralqueue.AtomicQueue;
import com.coralblocks.coralqueue.Queue;
import com.coralblocks.coralqueue.util.MutableLong;

public class Sample {

    public static void main(String[] args) throws InterruptedException {

        final Queue<MutableLong> queue = new AtomicQueue<MutableLong>(1024, MutableLong.class);

        Thread consumer = new Thread() {

            @Override
            public void run() {

                boolean running = true;

                while(running) {
                    long avail;
                    while((avail = queue.availableToPoll()) == 0); // busy spin
                    for(int i = 0; i < avail; i++) {
                        MutableLong ml = queue.poll();
                        if (ml.get() == -1) {
                            running = false;
                        } else {
                            System.out.println(ml.get());
                        }
                    }
                    queue.donePolling();
                }
            }

        };

        consumer.start();

        MutableLong ml;

        for(int i = 0; i < 10; i++) {
            while((ml = queue.nextToDispatch()) == null); // busy spin
            ml.set(System.nanoTime());
            queue.flush();
        }

        // send a message to stop consumer...
        while((ml = queue.nextToDispatch()) == null); // busy spin
        ml.set(-1);
        queue.flush();

        consumer.join(); // wait for the consumer thread to die...
    }
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.