JAVA并发-Disruptor框架
JAVA并发-Disruptor框架
Disruptor简介
最好的方法去理解Disruptor就是将它和容易理解并且相似的队列,例如BlockingQueue。Disruptor其实就像一个队列一样,用于在不同的线程之间迁移数据,但是Disruptor也实现了一些其他队列没有的特性,如:
- 同一个“事件”可以有多个消费者,消费者之间既可以并行处理,也可以相互依赖形成处理的先后次序(形成一个依赖图);
- 预分配用于存储事件内容的内存空间;
- 针对极高的性能目标而实现的极度优化和无锁的设计;
Disruptor核心架构组件
- Ring Buffer:Ring Buffer在3.0版本以前被认为是Disruptor的核心组件,但是在之后的版本中只是负责存储和更新数据。在一些高级使用案例中用户也能进行自定义
- Sequence:Disruptor使用一组Sequence来作为一个手段来标识特定的组件的处理进度( RingBuffer/Consumer )。每个消费者和Disruptor本身都会维护一个Sequence。虽然一个 AtomicLong 也可以用于标识进度,但定义 Sequence 来负责该问题还有另一个目的,那就是防止不同的 Sequence 之间的CPU缓存伪共享(Flase Sharing)问题。
- Sequencer:Sequencer是Disruptor的真正核心。此接口有两个实现类 SingleProducerSequencer、MultiProducerSequencer ,它们定义在生产者和消费者之间快速、正确地传递数据的并发算法。
- Sequence Barrier:保持Sequencer和Consumer依赖的其它Consumer的 Sequence 的引用。除此之外还定义了决定 Consumer 是否还有可处理的事件的逻辑。
- Wait Strategy:Wait Strategy决定了一个消费者怎么等待生产者将事件(Event)放入Disruptor中。
- Event:从生产者到消费者传递的数据叫做Event。它不是一个被 Disruptor 定义的特定类型,而是由 Disruptor 的使用者定义并指定。
- EventProcessor:持有特定的消费者的Sequence,并且拥有一个主事件循环(main event loop)用于处理Disruptor的事件。其中BatchEventProcessor是其具体实现,实现了事件循环(event loop),并且会回调到实现了EventHandler的已使用过的实例中。
- EventHandler:由用户实现的接口,用于处理事件,是 Consumer 的真正实现
- Producer:生产者,只是泛指调用 Disruptor 发布事件的用户代码,Disruptor 没有定义特定接口或类型。
事件广播(Multicast Events)
这是Disruptor和队列最大的区别。当你有多个消费者监听了一个Disruptor,所有的事件将会被发布到所有的消费者中,相比之下队列的一个事件只能被发到一个消费者中。Disruptor这一特性被用来需要对同一数据进行多个并行操作的情况。如在LMAX系统中有三个操作可以同时进行:日志(将数据持久到日志文件中),复制(将数据发送到其他的机器上,以确保存在数据远程副本),业务逻辑处理。也可以使用WokrerPool来并行处理不同的事件。
消费者依赖关系图(Consumer Dependency Graph)
为了支持真实世界中的业务并行处理流程,Disruptor提供了多个消费者之间的协助功能。回到上面的LMAX的例子,我们可以让日志处理和远程副本赋值先执行完之后再执行业务处理流程,这个功能被称之为gating。gating发生在两种场景中。第一,我们需要确保生产者不要超过消费者。通过调用RingBuffer.addGatingConsumers()增加相关的消费者至Disruptor来完成。第二,就是之前所说的场景,通过构造包含需要必须先完成的消费者的Sequence的SequenceBarrier来实现。
引用上面的例子来说,有三个消费者监听来自RingBuffer的事件。这里有一个依赖关系图。ApplicationConsumer依赖JournalConsumer和ReplicationConsumer。这个意味着JournalConsumer和ReplicationConsumer可以自由的并发运行。依赖关系可以看成是从ApplicationConsumer的SequenceBarrier到JournalConsumer和ReplicationConsumer的Sequence的连接。还有一点值得关注,Sequencer与下游的消费者之间的关系。它的角色是确保发布不会包裹RingBuffer。为此,所有下游消费者的Sequence不能比ring buffer的Sequence小且不能比ring buffer 的大小小。因为ApplicationConsumers的Sequence是确保比JournalConsumer和ReplicationConsumer的Sequence小或等于,所以Sequencer只需要检查ApplicationConsumers的Sequence。在更为普遍的应用场景中,Sequencer只需要意识到消费者树中的叶子节点的的Sequence即可。
事件预分配(Event Preallocation)
Disruptor的一个目标之一是被用在低延迟的环境中。在一个低延迟系统中有必要去减少和降低内存的占用。在基于Java的系统中,需要减少由于GC导致的停顿次数(在低延迟的C/C++系统中,由于内存分配器的争用,大量的内存分配也会导致问题)。
为了满足这点,用户可以在Disruptor中为事件预分配内存。所以EventFactory是用户来提供,并且Disruptor的Ring Buffer每个entry中都会被调用。当将新的数据发布到Disruptor中时,Disruptor的API将会允许用户持有所构造的对象,以便用户可以调用这些对象的方法和更新字段到这些对象中。Disruptor将确保这些操作是线程安全。
可选择的无锁
无锁算法实现的Disruptor的所有内存可见性和正确性都使用内存屏障和CAS操作实现。只仅仅一个场景BlockingWaitStrategy中使用到了lock。而这仅仅是为了使用Condition,以便消费者线程能被park住当在等待一个新的事件到来的时候。许多低延迟系统都使用自旋(busy-wait)来避免使用Condition造成的抖动。但是自旋(busy-wait)的数量变多时将会导致性能的下降,特别是CPU资源严重受限的情况下。例如,在虚拟环境中的Web服务器。
等待策略
BlockingWaitStrategy
Disruptor的默认策略是BlockingWaitStrategy。在BlockingWaitStrategy内部是使用锁和condition来控制线程的唤醒。BlockingWaitStrategy是最低效的策略,但其对CPU的消耗最小并且在各种不同部署环境中能提供更加一致的性能表现
SleepingWaitStrategy
SleepingWaitStrategy 的性能表现跟 BlockingWaitStrategy 差不多,对 CPU 的消耗也类似,但其对生产者线程的影响最小,通过使用LockSupport.parkNanos(1)来实现循环等待。一般来说Linux系统会暂停一个线程约60µs,这样做的好处是,生产线程不需要采取任何其他行动就可以增加适当的计数器,也不需要花费时间信号通知条件变量。但是,在生产者线程和使用者线程之间移动事件的平均延迟会更高。它在不需要低延迟并且对生产线程的影响较小的情况最好。一个常见的用例是异步日志记录。
YieldingWaitStrategy
YieldingWaitStrategy是可以使用在低延迟系统的策略之一。YieldingWaitStrategy将自旋以等待序列增加到适当的值。在循环体内,将调用Thread.yield(),以允许其他排队的线程运行。在要求极高性能且事件处理线数小于 CPU 逻辑核心数的场景中,推荐使用此策略;例如,CPU开启超线程的特性。
BusySpinWaitStrategy
性能最好,适合用于低延迟的系统。在要求极高性能且事件处理线程数小于CPU逻辑核心树的场景中,推荐使用此策略;例如,CPU开启超线程的特性。
写入RingBuffer
ProducerBarriers
Disruptor 代码给 消费者 提供了一些接口和辅助类,但是没有给写入 Ring Buffer 的 生产者 提供接口。这是因为除了你需要知道生产者之外,没有别人需要访问它。尽管如此,Ring Buffer 还是与消费端一样提供了一个 ProducerBarrier 对象,让生产者通过它来写入 Ring Buffer。
写入 Ring Buffer 的过程涉及到两阶段提交 (two-phase commit)。首先,你的生产者需要申请 buffer 里的下一个节点。然后,当生产者向节点写完数据,它将会调用 ProducerBarrier 的 commit 方法。
那么让我们首先来看看第一步。 “给我 Ring Buffer 里的下一个节点”,这句话听起来很简单。的确,从生产者角度来看它很简单:简单地调用 ProducerBarrier 的 nextEntry() 方法,这样会返回给你一个 Entry 对象,这个对象就是 Ring Buffer 的下一个节点。
ProducerBarrier 如何防止 Ring Buffer 重叠
在后台,由 ProducerBarrier 负责所有的交互细节来从 Ring Buffer 中找到下一个节点,然后才允许生产者向它写入数据。
在这幅图中,假设只有一个生产者写入 Ring Buffer。过一会儿再处理多个生产者的复杂问题。
ConsumerTrackingProducerBarrier 对象拥有所有正在访问 Ring Buffer 的 消费者 列表。这看起来有点儿奇怪-我从没有期望 ProducerBarrier 了解任何有关消费端那边的事情。但是等等,这是有原因的。因为我们不想与队列“混为一谈”(队列需要追踪队列的头和尾,它们有时候会指向相同的位置),Disruptor 由消费者负责通知它们处理到了哪个序列号,而不是 Ring Buffer。所以,如果我们想确定我们没有让 Ring Buffer 重叠,需要检查所有的消费者们都读到了哪里。
在上图中,有一个 消费者 顺利的读到了最大序号 12(用红色/粉色高亮)。第二个消费者 有点儿落后——可能它在做 I/O 操作之类的——它停在序号 3。因此消费者 2 在赶上消费者 1 之前要跑完整个 Ring Buffer 一圈的距离。
现在生产者想要写入 Ring Buffer 中序号 3 占据的节点,因为它是 Ring Buffer 当前游标的下一个节点。但是 ProducerBarrier 明白现在不能写入,因为有一个消费者正在占用它。所以,ProducerBarrier 停下来自旋 (spins),等待,直到那个消费者离开。
申请下一个节点
现在可以想像消费者 2 已经处理完了一批节点,并且向前移动了它的序号。可能它挪到了序号 9(因为消费端的批处理方式,现实中我会预计它到达 12,但那样的话这个例子就不够有趣了)。
上图显示了当消费者 2 挪动到序号 9 时发生的情况。在这张图中我已经忽略了ConsumerBarrier,因为它没有参与这个场景。
ProducerBarier 会看到下一个节点——序号 3 那个已经可以用了。它会抢占这个节点上的 Entry(我还没有特别介绍 Entry 对象,基本上它是一个放写入到某个序号的 Ring Buffer 数据的桶),把下一个序号(13)更新成 Entry 的序号,然后把 Entry 返回给生产者。生产者可以接着往 Entry 里写入数据。
提交新的数据
两阶段提交的第二步是——对,提交。
绿色表示最近写入的 Entry,序号是 13 ——厄,抱歉,我也是红绿色盲。但是其他颜色甚至更糟糕。
当生产者结束向 Entry 写入数据后,它会要求 ProducerBarrier 提交。
ProducerBarrier 先等待 Ring Buffer 的游标追上当前的位置(对于单生产者这毫无意义-比如,我们已经知道游标到了 12 ,而且没有其他人正在写入 Ring Buffer)。然后 ProducerBarrier 更新 Ring Buffer 的游标到刚才写入的 Entry 序号-在我们这儿是 13。接下来,ProducerBarrier 会让消费者知道 buffer 中有新东西了。它戳一下 ConsumerBarrier 上的 WaitStrategy 对象说-“喂,醒醒!有事情发生了!”(注意-不同的 WaitStrategy 实现以不同的方式来实现提醒,取决于它是否采用阻塞模式。)
现在消费者 1 可以读 Entry 13 的数据,消费者 2 可以读 Entry 13 以及前面的所有数据,然后它们都过得很 happy。
ProducerBarrier 上的批处理
有趣的是 Disruptor 可以同时在生产者和 消费者 两端实现批处理。还记得伴随着程序运行,消费者 2 最后达到了序号 9 吗?ProducerBarrier 可以在这里做一件很狡猾的事-它知道 Ring Buffer 的大小,也知道最慢的消费者位置。因此它能够发现当前有哪些节点是可用的。
如果 ProducerBarrier 知道 Ring Buffer 的游标指向 12,而最慢的消费者在 9 的位置,它就可以让生产者写入节点 3,4,5,6,7 和 8,中间不需要再次检查消费者的位置。
多个生产者的场景
在上面的图中我稍微撒了个谎。我暗示了 ProducerBarrier 拿到的序号直接来自 Ring Buffer 的游标。然而,如果你看过代码的话,你会发现它是通过 ClaimStrategy 获取的。我省略这个对象是为了简化示意图,在单个生产者的情况下它不是很重要。
在多个生产者的场景下,你还需要其他东西来追踪序号。这个序号是指当前可写入的序号。注意这和“向 Ring Buffer 的游标加 1”不一样-如果你有一个以上的生产者同时在向 Ring Buffer 写入,就有可能出现某些 Entry 正在被生产者写入但还没有提交的情况。
让我们复习一下如何申请写入节点。每个生产者都向 ClaimStrategy 申请下一个可用的节点。生产者 1 拿到序号 13,这和上面单个生产者的情况一样。生产者 2 拿到序号 14,尽管 Ring Buffer的当前游标仅仅指向 12。这是因为 ClaimSequence 不但负责分发序号,而且负责跟踪哪些序号已经被分配。
现在每个生产者都拥有自己的写入节点和一个崭新的序号。
我把生产者 1 和它的写入节点涂上绿色,把生产者 2 和它的写入节点涂上可疑的粉色-看起来像紫色。
现在假设生产者 1 还生活在童话里,因为某些原因没有来得及提交数据。生产者 2 已经准备好提交了,并且向 ProducerBarrier 发出了请求。
就像我们先前在 commit 示意图中看到的一样,ProducerBarrier 只有在 Ring Buffer 游标到达准备提交的节点的前一个节点时它才会提交。在当前情况下,游标必须先到达序号 13 我们才能提交节点 14 的数据。但是我们不能这样做,因为生产者 1 正盯着一些闪闪发光的东西,还没来得及提交。因此 ClaimStrategy 就停在那儿自旋 (spins), 直到 Ring Buffer 游标到达它应该在的位置。
现在生产者 1 从迷糊中清醒过来并且申请提交节点 13 的数据(生产者 1 发出的绿色箭头代表这个请求)。ProducerBarrier 让 ClaimStrategy 先等待 Ring Buffer 的游标到达序号 12,当然现在已经到了。因此 Ring Buffer 移动游标到 13,让 ProducerBarrier 戳一下 WaitStrategy 告诉所有人都知道 Ring Buffer 有更新了。现在 ProducerBarrier 可以完成生产者 2 的请求,让 Ring Buffer 移动游标到 14,并且通知所有人都知道。
你会看到,尽管生产者在不同的时间完成数据写入,但是 Ring Buffer 的内容顺序总是会遵循 nextEntry() 的初始调用顺序。也就是说,如果一个生产者在写入 Ring Buffer 的时候暂停了,只有当它解除暂停后,其他等待中的提交才会立即执行。
呼——。我终于设法讲完了这一切的内容并且一次也没有提到内存屏障(Memory Barrier)。
更新:最近的 RingBuffer 版本去掉了 Producer Barrier。如果在你看的代码里找不到 ProducerBarrier,那就假设当我讲“Producer Barrier”时,我的意思是“Ring Buffer”。
更新2:注意 Disruptor 2.0 版使用了与本文不一样的命名。如果你对类名感到困惑,请阅读我写的Disruptor 2.0更新摘要。
Disruptor类的handleEventsWith,handleEventsWithWorkerPool方法的区别
在disruptor框架调用start方法之前,往往需要将消息的消费者指定给disruptor框架。
常用的方法是:disruptor.handleEventsWith(EventHandler … handlers),将多个EventHandler的实现类传入方法,封装成一个EventHandlerGroup,实现多消费者消费。
disruptor的另一个方法是:disruptor.handleEventsWithWorkerPool(WorkHandler … handlers),将多个WorkHandler的实现类传入方法,封装成一个EventHandlerGroup实现多消费者消费。
两者共同点都是,将多个消费者封装到一起,供框架消费消息。
不同点在于:
- 对于某一条消息m,handleEventsWith方法返回的EventHandlerGroup,Group中的每个消费者都会对m进行消费,各个消费者之间不存在竞争。handleEventsWithWorkerPool方法返回的EventHandlerGroup,Group的消费者对于同一条消息m不重复消费;也就是,如果c0消费了消息m,则c1不再消费消息m。
- 传入的形参不同。对于独立消费的消费者,应当实现EventHandler接口。对于不重复消费的消费者,应当实现WorkHandler接口。
因此,根据消费者集合是否独立消费消息,可以对不同的接口进行实现。也可以对两种接口同时实现,具体消费流程由disruptor的方法调用决定。
清除Ring Buffer中的对象
通过Disruptor传递数据时,对象的生存期可能比预期的更长。为避免发生这种情况,可能需要在处理事件后清除事件。如果只有一个事件处理程序,则需要在处理器中清除对应的对象。如果您有一连串的事件处理程序,则可能需要在该链的末尾放置一个特定的处理程序来处理清除对象。
Copyclass ObjectEvent<T>
{
T val;
void clear()
{
val = null;
}
}
public class ClearingEventHandler<T> implements EventHandler<ObjectEvent<T>>
{
public void onEvent(ObjectEvent<T> event, long sequence, boolean endOfBatch)
{
// Failing to call clear here will result in the
// object associated with the event to live until
// it is overwritten once the ring buffer has wrapped
// around to the beginning.
event.clear();
}
}
public static void main(String[] args)
{
Disruptor<ObjectEvent<String>> disruptor = new Disruptor<>(
() -> ObjectEvent<String>(), bufferSize, DaemonThreadFactory.INSTANCE);
disruptor
.handleEventsWith(new ProcessingEventHandler())
.then(new ClearingObjectHandler());
}
Disruptor 与 RingBuffer 的关系
- Disruptor 的存储部分实现了 RingBuffer。
- Disruptor 提供了方法供 Producer 和 Consumer 线程来通过 ringbuffer 传输数据。
RingBuffer 的本质
- 固定大小的
- 先入先出的 (FIFO)
- Producer-Consumer 模型的
- 循环使用的一段内存
- 由于进程周期内,可不用重新释放和分配空间
ring buffer维护两个指针,“next”和“cursor”,“next”指针指向第一个未填充数据的区块。“cursor”指针指向最后一个填充了数据的区块。在一个空闲的 ring bufer 中,它们是彼此紧邻的。
Disruptor 适用场景
- Producer-Consumer 场景,一生产者多消费者,多生产者多消费者(线程安全)
- 线程之间交换数据
- 轻量化的消息队列
- 对队列性能要求高:Disruptor 的速度比 LinkedBlockingQueue 提高了七倍(无锁设计)
- 同一个“事件”可以有多个消费者,消费者之间既可以并行处理,也可以相互依赖形成处理的先后次序(形成一个依赖图)
- 典型场景:Canal,从一个 mysql 实例读取 binlog,放到 Disruptor,下游可有多个并发消费者
Disruptor 为什么快而且线程安全
简单说:
- 它是数组,所以要比链表快(添加删除更简单,耗费内存更小),且可以利用 CPU 缓存来预加载
- 数组对象本身一直存在,避免了大对象的垃圾回收(当然元素本身还是要回收的)
- 在需要确保线程安全的地方,用 CAS 取代锁。
- 没有竞争 = 没有锁 = 非常快。
- 所有 Consumer 都记录自己的序号(Sequence),允许多个 Producer 与多个 Consumer 共享 ringbuffer。
- 在每个对象中都能跟踪 Sequence(ring buffer,claim Strategy,生产者和消费者),加上 Sequence 的 cache line padding,就意味着没
为伪共享和非预期的竞争。
个人觉得最重要的设计就是:
- 每个 Consumer 持有一个 Sequence,各 Consumer 消费独立。
- Producer 根据所有 Consumer 的 Sequence 位置决定是否能写入到 ringbuffer,以及写入到何位置。
- 各 Producer 在并发写时,通过 CAS 避免锁。(可参考下面的代码分析)
代码分析
伪共享/UNSAFE.putOrderedLong
class LhsPadding {
// 这是伪共享的填充字段
protected long p1, p2, p3, p4, p5, p6, p7;
}
class Value extends LhsPadding {
// 这是真实的序号值,是一个volatile类型
protected volatile long value;
}
class RhsPadding extends Value {
// 这是伪共享的填充字段
protected long p9, p10, p11, p12, p13, p14, p15;
}
// Sequence 本质上是维护一个数字值,保障其高效可见的读写
// zyn 因为继承了 RhsPadding Sequence的字段实际为
// p1, p2, p3, p4, p5, p6, p7, value, p9, p10, p11, p12, p13, p14, p15
// 避免了伪共享
public class Sequence extends RhsPadding {
static final long INITIAL_VALUE = -1L;
private static final Unsafe UNSAFE;
// 反射value字段在类的 offset
// 后方法会基于反射进行赋值
private static final long VALUE_OFFSET;
// 省略非核心代码
...
// 这是一个普通读
public long get() {
return value;
}
public void set(final long value) {
// 这里不能直接用普通的方式吗?可以看到 Sequence.set 并未直接基于 volatile 的赋值
// putOrderedLong 是Store/Store barrier 比 volatile 的 Store/Load barrier 性能消耗更低
// 此处仅仅防止写的顺序重排序,并不会保障立刻可见,用于不需要其他线程立刻可见的场景
UNSAFE.putOrderedLong(this, VALUE_OFFSET, value);
}
public void setVolatile(final long value) {
// 这是采用了 Store/Load barrier
UNSAFE.putLongVolatile(this, VALUE_OFFSET, value);
}
public boolean compareAndSet(final long expectedValue, final long newValue) {
return UNSAFE.compareAndSwapLong(this, VALUE_OFFSET, expectedValue, newValue);
}
// 省略非核心代码
...
}
在执行消费者事件消费记录当前消费者最新消费位置时,并未采用高消耗的 setVolatile 而是 Store/Store barrier 的set方法
因为消费者更新的位置没必要让生产线程立刻可见,等待生产线程失效队列自动更新时可见即可。
// 循环执行
while (nextSequence <= availableSequence) {
event = dataProvider.get(nextSequence);
// 这里是真实调用 EventHandler 的 onEvent 方法
eventHandler.onEvent(event, nextSequence, nextSequence == availableSequence);
nextSequence++;
}// 执行完成设置新的消费序号
// 这用的是写屏障,未使用全能屏障,因为此值无必要让所有其他线程立刻可见
sequence.set(availableSequence);
多生产类MultiProducerSequencer
中next方法【获取生产序号】
// 消费者上一次消费的最小序号 // 后续第二点会讲到
private final Sequence gatingSequenceCache = new Sequence(Sequencer.INITIAL_CURSOR_VALUE);
// 当前进度的序号
protected final Sequence cursor = new Sequence(Sequencer.INITIAL_CURSOR_VALUE);
// 所有消费者的序号 //后续第二点会讲到
protected volatile Sequence[] gatingSequences = new Sequence[0];
public long next(int n)
{
if (n < 1)
{
throw new IllegalArgumentException("n must be > 0");
}
long current;
long next;
do
{
// 当前进度的序号,Sequence的value具有可见性,保证多线程间线程之间能感知到可申请的最新值
current = cursor.get();
// 要申请的序号空间:最大序列号
next = current + n;
long wrapPoint = next - bufferSize;
// 消费者最小序列号
long cachedGatingSequence = gatingSequenceCache.get();
// 大于一圈 || 最小消费序列号>当前进度
if (wrapPoint > cachedGatingSequence || cachedGatingSequence > current)
{
long gatingSequence = Util.getMinimumSequence(gatingSequences, current);
// 说明大于1圈,并没有多余空间可以申请
if (wrapPoint > gatingSequence)
{
LockSupport.parkNanos(1); // TODO, should we spin based on the wait strategy?
continue;
}
// 更新最小值到Sequence的value中
gatingSequenceCache.set(gatingSequence);
}
// CAS成功后更新当前Sequence的value
else if (cursor.compareAndSet(current, next))
{
break;
}
}
while (true);
return next;
}
终结:
多个 Producer 如何协调把数据写入到 ringBuffer
有多个生产者时,多个线程共用一个写指针,此处需要考虑多线程问题,例如两个生产者线程同时写数据,当前写指针=0,运行后其中一个线程应获得缓冲区0号Slot,另一个应该获得1号,写指针=2。对于这种情况,Disruptor使用CAS来保证多线程安全。
Ringbuffer 如何根据各 consumer 消费速度告知各 Producer 现在是否能写入数据
有多个消费者时,(按Disruptor的设计)每个消费者各自控制自己的指针,依次读取每个Slot(也就是每个消费者都会读取到所有的产品),这时只需要保证生产者指针不会超过最慢的消费者(超过最后一个消费者“一圈”)即可,也不需要锁。
参考:
JAVA并发编程 之 LMAX Disruptor使用实例(高效解决生产者与消费者问题)