剖析Disruptor为什么会这么快?神奇的缓存行填充

本文介绍了CPU缓存的工作原理及如何避免伪共享带来的性能损失,强调了数据结构布局的重要性。

计算机入门

我喜欢在LMAX工作的原因之一是,在这里工作让我明白从大学和A Level Computing所学的东西实际上还是有意义的。做为一个开发者你可以逃避不去了解CPU,数据结构或者大O符号 —— 而我用了10年的职业生涯来忘记这些东西。但是现在看来,如果你知道这些知识并应用它,你能写出一些非常巧妙和非常快速的代码。

因此,对在学校学过的人是种复习,对未学过的人是个简单介绍。但是请注意,这篇文章包含了大量的过度简化。

CPU是你机器的心脏,最终由它来执行所有运算和程序。主内存(RAM)是你的数据(包括代码行)存放的地方。本文将忽略硬件驱动和网络之类的东西,因为Disruptor的目标是尽可能多的在内存中运行。

CPU和主内存之间有好几层缓存,因为即使直接访问主内存也是非常慢的。如果你正在多次对一块数据做相同的运算,那么在执行运算的时候把它加载到离CPU很近的地方就有意义了(比如一个循环计数-你不想每次循环都跑到主内存去取这个数据来增长它吧)。

越靠近CPU的缓存越快也越小。所以L1缓存很小但很快(译注:L1表示一级缓存),并且紧靠着在使用它的CPU内核。L2大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一块主存,由全部插槽上的所有 CPU 核共享。

当CPU执行运算的时候,它先去L1查找所需的数据,再去L2,然后是L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要确保数据在L1缓存中。

Martin和Mike的 QCon presentation演讲中给出了一些缓存未命中的消耗数据:

从CPU到大约需要的 CPU 周期大约需要的时间
主存 约60-80纳秒
QPI 总线传输
(between sockets, not drawn)
 约20ns
L3 cache约40-45 cycles,约15ns
L2 cache约10 cycles,约3ns
L1 cache约3-4 cycles,约1ns
寄存器1 cycle 

如果你的目标是让端到端的延迟只有 10毫秒,而其中花80纳秒去主存拿一些未命中数据的过程将占很重的一块。

缓存行

现在需要注意一件有趣的事情,数据在缓存中不是以独立的项来存储的,如不是一个单独的变量,也不是一个单独的指针。缓存是由缓存行组成的,通常是64字节(译注:这篇文章发表时常用处理器的缓存行是64字节的,比较旧的处理器缓存行是32字节),并且它有效地引用主内存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。

(为了简化,我将忽略多级缓存)

非常奇妙的是如果你访问一个long数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个。因此你能非常快地遍历这个数组。事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。我在第一篇关于ring buffer的文章中顺便提到过这个,它解释了我们的ring buffer使用数组的原因。

因此如果你数据结构中的项在内存中不是彼此相邻的(链表,我正在关注你呢),你将得不到免费缓存加载所带来的优势。并且在这些数据结构中的每一个项都可能会出现缓存未命中。

不过,所有这种免费加载有一个弊端。设想你的long类型的数据不是数组的一部分。设想它只是一个单独的变量。让我们称它为head,这么称呼它其实没有什么原因。然后再设想在你的类中有另一个变量紧挨着它。让我们直接称它为tail。现在,当你加载head到缓存的时候,你也免费加载了tail

听想来不错。直到你意识到tail正在被你的生产者写入,而head正在被你的消费者写入。这两个变量实际上并不是密切相关的,而事实上却要被两个不同内核中运行的线程所使用。

设想你的消费者更新了head的值。缓存中的值和内存中的值都被更新了,而其他所有存储head的缓存行都会都会失效,因为其它缓存中head不是最新值了。请记住我们必须以整个缓存行作为单位来处理(译注:这是CPU的实现所规定的,详细可参见深入分析Volatile的实现原理),不能只把head标记为无效。

现在如果一些正在其他内核中运行的进程只是想读tail的值,整个缓存行需要从主内存重新读取。那么一个和你的消费者无关的线程读一个和head无关的值,它被缓存未命中给拖慢了。

当然如果两个独立的线程同时写两个不同的值会更糟。因为每次线程对缓存行进行写操作时,每个内核都要把另一个内核上的缓存块无效掉并重新读取里面的数据。你基本上是遇到两个线程之间的写冲突了,尽管它们写入的是不同的变量。

这叫作“伪共享”(译注:可以理解为错误的共享),因为每次你访问head你也会得到tail,而且每次你访问tail,你也会得到head。这一切都在后台发生,并且没有任何编译警告会告诉你,你正在写一个并发访问效率很低的代码。

解决方案-神奇的缓存行填充

你会看到Disruptor消除这个问题,至少对于缓存行大小是64字节或更少的处理器架构来说是这样的(译注:有可能处理器的缓存行是128字节,那么使用64字节填充还是会存在伪共享问题),通过增加补全来确保ring buffer的序列号不会和其他东西同时存在于一个缓存行中。

1 public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
2     private volatile long cursor = INITIAL_CURSOR_VALUE;
3     public long p8, p9, p10, p11, p12, p13, p14; // cache line padding


因此没有伪共享,就没有和其它任何变量的意外冲突,没有不必要的缓存未命中。

在你的Entry类中也值得这样做,如果你有不同的消费者往不同的字段写入,你需要确保各个字段间不会出现伪共享。

修改:Martin写了一个从技术上来说更准确更详细的关于伪共享的文章,并且发布了性能测试结果。

<think>好的,用户的问题是关于如何利用Disruptor的背压机制来控制生产者和消费者的速率,防止内存溢出。首先,我需要回忆一下Disruptor的基本原理和背压机制的相关知识。Disruptor是一个高性能的线程间消息传递库,它使用环形缓冲区和预分配内存来减少垃圾回收,从而提高性能。背压机制在这里的作用是当消费者处理速度跟不上生产者时,能够通知生产者调整速度,避免缓冲区溢出导致内存问题。 接下来,我需要考虑用户的具体需求。他们可能在使用Disruptor时遇到了生产者速度过,导致消费者无法及时处理,进而出现内存溢出。用户希望了解如何配置背压机制来控制生产者的速率,确保系统稳定运行。 首先,我应该解释Disruptor背压机制的基本原理。比如,环形缓冲区的容量是固定的,当生产者试图向已满的缓冲区写入数据时,需要有策略来处理这种情况,比如阻塞生产者或抛出异常。同时,消费者的处理速度需要被监控,以便动态调整生产者的速率。 然后,我需要提供具体的实现方法。可能包括设置等待策略,比如使用BlockingWaitStrategy让生产者在缓冲区满时阻塞;或者使用超时策略,避免无限期等待。另外,通过监控消费者的处理进度,比如使用SequenceBarrier来跟踪消费者的位置,生产者可以根据消费者的进度动态调整发布速度。 接下来,结合用户提供的引用内容,特别是引用[2]提到的使用固定大小的缓冲区和堆外内存,可以建议用户配置Disruptor时使用这些策略,防止过多的数据拉入堆内存,导致GC问题。同时,引用[4]提到如果没有正确的确认机制,消息可能会丢失,所以在设计背压时可能需要考虑消息的确认机制,确保数据不丢失。 另外,用户提到的引用[5]中的依赖注入框架如Guice或Dagger,可能在系统设计中有用,但当前问题主要集中在背压机制本身,可能不需要深入讨论这部分,除非用户有进一步的集成需求。 还需要注意用户可能对代码示例感兴趣,所以给出一个简单的Disruptor配置示例,展示如何设置缓冲区和等待策略,以及生产者和消费者的协作方式。例如,使用RingBuffer的剩余容量检查来控制生产者的发布速度,或者结合Phaser进行协调。 最后,需要提醒用户综合策略,比如合理设置缓冲区大小、选择适当的等待策略、监控消费者延迟、使用批处理等,这些都是防止内存溢出的关键。同时,结合性能测试和动态调整,确保系统在高负载下稳定运行。 总结来说,回答的结构应该是先解释背压机制的原理,然后分步骤说明实现方法,提供代码示例,最后给出注意事项和综合建议,确保用户能够全面理解并应用这些策略。</think>### Disruptor背压机制实现生产者速率控制 Disruptor通过环形缓冲区(RingBuffer)和序列号跟踪机制实现背压控制。其核心是当消费者处理速度不足时,通过阻塞等待策略限制生产者写入速度,避免内存溢出。具体实现步骤如下: 1. **缓冲区容量预分配** ```java // 创建固定大小的环形缓冲区(必须为2的幂) int bufferSize = 1024; RingBuffer<Event> ringBuffer = RingBuffer.create( ProducerType.MULTI, Event::new, bufferSize, new BlockingWaitStrategy() // 背压关键策略 ); ``` 2. **消费者进度跟踪** ```java SequenceBarrier barrier = ringBuffer.newBarrier(); Sequence consumerSequence = new Sequence(); // 消费者定期更新处理进度 long processedSequence = consumerSequence.get(); ``` 3. **生产者发布控制** ```java // 获取下一个可发布序号 long sequence = ringBuffer.next(); try { Event event = ringBuffer.get(sequence); // 填充数据 } finally { // 根据消费者进度决定是否发布 if (sequence - consumerSequence.get() < bufferSize) { ringBuffer.publish(sequence); } else { LockSupport.parkNanos(1); // 背压生效 } } ``` 4. **等待策略选择(背压行为控制)** - `BlockingWaitStrategy`: 通过LockSupport.parkNanos(1)实现CPU友好型背压 - `TimeoutBlockingWaitStrategy`: 设置最大阻塞时间(推荐设置10-100ms) - `YieldingWaitStrategy`: 适用低延迟系统,通过Thread.yield()让步CPU 5. **消费者延迟监控** ```java // 计算未处理消息量 long producerSequence = ringBuffer.getCursor(); long lag = producerSequence - consumerSequence.get(); if (lag > bufferSize * 0.8) { // 触发背压警报 } ``` ### 关键配置建议(根据引用[2]优化) 1. **堆外内存分配** ```java // 使用DirectByteBuffer避免GC压力 RingBuffer<ByteBuffer> ringBuffer = RingBuffer.create( ProducerType.MULTI, () -> ByteBuffer.allocateDirect(1024), bufferSize, new YieldingWaitStrategy() ); ``` 2. **批处理模式** ```java // 批量获取可发布序号 long hi = ringBuffer.next(10); // 批量申请10个序号 long lo = hi - 9; for (long seq = lo; seq <= hi; seq++) { // 填充数据 } ringBuffer.publish(lo, hi); // 批量发布 ``` 3. **动态速率调整** ```java // 根据消费者延迟调整生产速率 double maxLagRatio = 0.7; int batchSize = 10; while (true) { long currentLag = ringBuffer.getCursor() - consumerSequence.get(); if (currentLag < bufferSize * maxLagRatio) { long hi = ringBuffer.next(batchSize); // 生产批次数据 } else { Thread.sleep(adjustmentInterval); // 动态休眠 } } ``` ### 异常处理要点(根据引用[4]改进) 1. 使用`tryPublishEvent()`替代直接发布 ```java if (!ringBuffer.tryPublishEvent(translator, arg)) { metrics.markBackpressure(); // 记录背压事件 handleBackpressure(arg); // 执行降级策略 } ``` 2. 设置发布超时机制 ```java long timeout = 100; // 毫秒 long start = System.currentTimeMillis(); while (!ringBuffer.tryPublish(sequence)) { if (System.currentTimeMillis() - start > timeout) { throw new TimeoutException("发布超时"); } LockSupport.parkNanos(1_000); } ``` ### 监控指标建议 1. 生产者发布速率:`events/second` 2. 消费者处理延迟:`max(ringBuffer cursor - consumer sequence)` 3. 背压触发次数:`backpressure_events_total` 4. 缓冲区使用率:`(cursor - min(consumer sequences)) / bufferSize` ### 注意事项 1. 缓冲区容量应设置为消费者最大处理能力的2-3倍(根据引用[2]建议) 2. 避免使用无界队列,严格遵循固定缓冲区原则 3. 消费者线程数建议与CPU核心数保持1:1关系 4. 当持续触发背压时,需要分析是消费者性能问题还是生产过剩 ### 性能优化技巧 1. **序列号缓存行填充**(防止伪共享) ```java class Sequence extends RhsPadding { // 继承带padding的类 } abstract class RhsPadding { protected long p1, p2, p3, p4, p5, p6, p7; protected volatile long value; protected long p9, p10, p11, p12, p13, p14, p15; } ``` 2. **批量事件处理** ```java BatchEventProcessor<Event> processor = new BatchEventProcessor<>( ringBuffer, barrier, (events, sequence) -> { for (Event event : events) { // 批量处理 } } ); ``` 3. **多消费者协调** ```java WorkerPool<Event> workerPool = new WorkerPool<>( ringBuffer, ringBuffer.newBarrier(), new FatalExceptionHandler(), new EventHandler[] {handler1, handler2} ); ``` ### 综合建议(根据引用[2][3][4]) 1. 采用`生产者→RingBuffer→消费者`的单向数据流设计 2. 使用内存屏障(Memory Barrier)保证可见性[^3] 3. 对关键操作添加volatile修饰或使用Unsafe保证原子性 4. 重要数据写入需等待消费者确认(类似Kafka的acks配置[^4])
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值