Disruptor是一个用Java语言开发的,充分利用CPU和告诉缓存的硬件特性的高性能系统
充分利用高速缓存
Padding Cache Line,体验高速缓存的威力
我们先来看看 Disruptor 里面一段神奇的代码。这段代码里,Disruptor 在RingBufferPad 这个类里面定义了 p1,p2 一直到 p7 这样 7 个 long 类型的变量。
abstract class RingBufferPad
{
protected long p1, p2, p3, p4, p5, p6, p7;
}
我在看到这段代码的第一反应是,变量名取得不规范,p1-p7 这样的变量名没有明确的意义啊。不过,当我深入了解了 Disruptor 的设计和源代码,才发现这些变量名取得恰如其分。因为这些变量就是没有实际意义,只是帮助我们进行缓存行填充(padding cache line),使得我们能够尽可能的用上CPU高速缓存(CPU Cache)。那么缓存行填充这个黑科技到底是什么样的呢?我们接着往下看。
如下图,内存的访问速度是远远慢于CPU的,想要追求极致性能,需要我们尽可能的多从CPU Cache里面拿数据,而不是从内存里面拿数据。
CPU Cache装载内存里面的数据,不是一个一个字段加载的,而是加载一整个缓存行。举个例子,如果我们定义了一个长度为64的long 类型的数组,那么数据从内存加载到CPU Cache里面的时候,不是一个一个数组元素加载的,而是一次性加载固定长度的一个欢唱。
64位的Intel CPU的计算机,缓存行通常是64个字节(bytes)。一个long类型的数据需要8个字节,所以我们一下子会加载8个long类型的数据。也就是说,一次加载数组里面连续的8个数值。这样的加载方式使得我们遍历数组元素的时候会很快。因为后面连续7次的数据访问都会命中缓存,不需要重新从内存里面去读取数据。
但是,在我们不是使用数组,而是使用单独的变量的时候,这里就会出现问题了。在Disruptor的RingBuffer(环形缓冲区)的代码里面,定义了一个单独的long类型的变量。这个变量叫做INITIAL_CURSOR_VALUE ,用来存放RingBuffer起始的元素位置。
CPU在加载数据的时候,自然也会把这个数据从内存加载到高速缓存里面来。不过,这个时候,高速缓存里面除了这个数据,还会加载这个数据前后定义的其他变量。这个时候,问题就来了,Disruptor是一个多线程的服务器框架,在这个数据前后定义的其他变量,可能会被多个不同的线程去更新数据、读取数据。这些写入以及读取的请求,会来自于不同的CPU Core。于是,为了保证数据的同步更新,我们不得不把CPU Cache里面的数据,重新写回内存里面去杭州重新从内存里面加载数据。
而这些CPU Cache的写回和加载,都不是以一个变量作为单位的。这些动作都是以整个Cache Line作为单位的。所以,当 INITIAL_CURSOR_VALUE 前后的那些变量被写回到内存的时候,这个字段自己也写回到了内存,这个常量的缓存也就失效了。当我们要再次读取这个值的时候,要再重新从内存中读取。这就意味着,读取速度大大变慢了。
面临这样一个情况,Disruptor里发明了一个神奇的代码技巧,这个技巧就是缓存行填充。Disruptor在INITIAL_CURSOR_VALUE 的前后,分布定义了7的long类型的变量。前面的7个来自继承的RingBufferPad 类,后面的7个则是直接定义在RingBuffer类里面。这14个变量没有任何实际的用途我们既不会去读它们,也不会去写它们
......
abstract class RingBufferPad
{
protected long p1, p2, p3, p4, p5, p6, p7;
}
abstract class RingBufferFields<E> extends RingBufferPad
{
......
}
public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventS
{
public static final long INITIAL_CURSOR_VALUE = Sequence.INITIAL_VALUE;
protected long p1, p2, p3, p4, p5, p6, p7;
......
而INITIAL_CURSOR_VALUE 又是一个常量,也不会进行修改。所以,一旦它被加载到了CPU Cache之后,只要被频繁的读取访问,就不会被换出Cache了。这也就意味着,对于这个值的读取速度,会一直是CPU Cache的访问速度,而不是内存的访问速度
使用RingBuffer,利用缓存和分支预测
这个利用 CPU Cache 的性能的思路,贯穿了整个 Disruptor。Disruptor整个框架,其实就是一个高速的生产者-消费者模型下的队列。生产者不停的往队列里面生成新的需要处理的任务,而消费者不停地从队列里面处理掉这些任务。
如果要实现一个队列,最合适的数据结构就是链表。我们只要维护好链表都头和尾,就很容易就实现一个队列。生产者只要不停的往队列外部插入新的节点,而消费者只需要不断的从头部取出最老的节点进行处理就好。
Java自己的基础库里面就有LinkedBlockingQueue这样的队列库,可以直接用在生产者-消费者模式上。
不过,Disruptor里面并没有用LinkedBlockQueue,而是使用了一个RingBuffer这样的数据结构,这个RingBuffer的底层实现则是一个固定长度的数组。比起链表形式的实现,数组的数据在内存里面会存在空间局部性。
数组的连续多个元素会一并加载到CPU Cache里面来,所以访问遍历的速度会更快。而链表里面各个节点的数据,多半不会出现在相邻的内存空间,自然也就享受不到整个Cache Line加载后数据连续从高速缓存里面被访问到的优势。
另外,数据的遍历访问还有一个很大的优势,就是CPU层面的分支预测会很准确。这就可以使得我们更有效地利用了 CPU 里面的多级流水线,我们的程序就会跑得更快
小结
-
CPU 从内存加载数据到 CPU Cache 里面的时候,不是一个变量一个变量加载的,而是加载固定长度的 Cache Line。如果是加载数组里面的数据,那么 CPU 就会加载到数组里面连续的多个数据。所以,数组的遍历很容易享受到 CPU Cache 带来的红利。
-
对于类里面定义的单独的变量,就不容易享受到 CPU Cache 红利了。因为这些字段虽然在内存层面会分配到一起,但是实际应用的时候往往没有什么关联。于是,就会出现多个CPU Core 访问的情况下,数据频繁在 CPU Cache 和内存里面来来回回的情况。而Disruptor 很取巧地在需要频繁高速访问的常量INITIAL_CURSOR_VALUE 前后,各定义了 7 个没有任何作用和读写请求的 long 类型的变量。
-
这样,无论在内存的什么位置上,这个 INITIAL_CURSOR_VALUE 所在的 Cache Line 都不会有任何写更新的请求。我们就可以始终在 Cache Line 里面读到它的值,而不需要从内存里面去读取数据,也就大大加速了 Disruptor 的性能。
-
这样的思路,其实渗透在 Disruptor 这个开源框架的方方面面。作为一个生产者 - 消费者模型,Disruptor 并没有选择使用链表来实现一个队列,而是使用了 RingBuffer。RingBuffer 底层的数据结构则是一个固定长度的数组。这个数组不仅让我们更容易用好CPU Cache,对 CPU 执行过程中的分支预测也非常有利。更准确的分支预测,可以使得我们更好地利用好 CPU 的流水线,让代码跑得更快。
无锁
利用 CPU 高速缓存,只是 Disruptor“快”的一个因素,Disruptor 快的另一个因素,也就是“无锁”,而尽可能发挥 CPU 本身的高速处理性能。
缓慢的锁
Disruptor 作为一个高性能的生产者 - 消费者队列系统,一个核心的设计就是通过RingBuffer 实现一个无锁队列。
Java 里面的基础库里,就有像 LinkedBlockingQueue 这样的队列库。但是,这个队列库比起 Disruptor 里用的 RingBuffer 要慢上很多。
- 慢的第一个原因是因为链表的数据在内存里面的布局对于高速缓存并不友好,而 RingBuffer 所使用的数组充分利用了高速缓存。
- LinkedBlockingQueue 慢,有另外一个重要的因素,那就是它对于锁的依赖。
- 在生产者 -消费者模式里,我们可能有多个消费者,同样也可能有多个生产者。多个生产者都要往队列的尾指针里面添加新的任务,就会产生多个线程的竞争。于是,在做这个事情的时候,生产者就需要拿到对于队列尾部的锁。同样地,在多个消费者去消费队列头的时候,也就产生竞争。同样消费者也要拿到锁。
- 那只有一个生产者,或者一个消费者,我们是不是就没有这个锁竞争的问题了呢?很遗憾,答案还是否定的。一般来说,在生产者 - 消费者模式下,消费者要比生产者快。不然的话,队列会产生积压,队列里面的任务会越堆越多。一方面,越来越多的任务没有能够及时完成;另一方面,内存也会放不下。虽然生产者-消费者模型下,我们都有一个队列再来作为缓冲区,但是大部分情况下,这个缓冲区里面是空的。也就是说,即使只有一个生产者和一个消费者者,这个生产者指向的队列尾和消费者指向的队列头是同一个节点。于是,这两个生产者和消费者之间一样会产生锁竞争。
- 在 LinkedBlockingQueue 上,这个锁机制是通过 synchronized 这个 Java 关键字来实现的。一般情况下,这个锁最终会对应的操作系统层面的加锁机制,这个锁机制需要由操作系统的内核来进行裁决。这个裁决,也需要一次上下文切换,把没有拿到锁的线程挂起等待。
- 上下文切换的过程,需要把当前指向线程的寄存器等信息,保存到线程栈里面。而这个过程也必然意味着,已经加载到高速缓存里面的指令或者数据,又回到了主内存里面,会进一步拖慢我们的性能。
无锁的RingBuffer
加锁很慢,所以 Disruptor 的解决方案就是“无锁”。这个无锁指的是没有操作系统层面的锁。实际上,Disruptor 还是利用了一个CPU硬件支持的指令,叫做CAS(Compare And Swap,比较和交换)。在 Intel CPU 里面,这个对应的指令就是cmpxchg
Disruptor 的 RingBuffer 是这么设计的,它和直接在链表的头和尾加锁不同,Disruptor 的RingBuffer创建了一个Sequence对象,用来指向当前的RingBuffer的头和尾。这个头和尾的表示,不是通过一个指针来实现的,而是通过一个序号。这也是为什么对应源码里面的类名叫 Sequence。
在RingBuffer当做,进行生产者和消费者之间的资源协调,采用的是对比序号的方法。当生产者想要往队列里面加入新数据的时候,它会把当前的生产者的 Sequence 的序号,加上需要加入的新数据的数量,然后和实际的消费者所在的位置进行对比。看看队列里是不是有足够的空间加入这些数据,而不会覆盖消费者还没有处理完的数据。
没有了锁,CPU这辆高速跑车就像在赛道上行驶,不会遇到需要上下文切换这样的红灯而停下来。虽然会遇到像CAS这样复杂的机器指令,就好像赛道上会有U型瓦一样,不过不用完全停下来等待,我们 CPU 运行起来仍然会快很多。
小结
- Java 基础库里面的 BlockingQueue,都需要通过显式的加锁来保障生产者之间,消费者之间,以及生产者和消费者之间,不会发生锁冲突的问题
- 但是,加锁会大大拖慢我们的性能。在获取锁的过程中,CPU没有去执行计算的相关指令,而是等待操作系统进行锁竞争的裁决。而那些没有拿到锁而被挂起来等待的线程,则需要进行上下文切换。这个上下文切换,会把挂起线程的寄存器里的数据放到线程的程序栈里面去。这也意味着,加载到高速缓存里面的数据也失效了,程序就变得更慢了。
- Disruptor 里的 RingBuffer 采用了一个无锁的解决方案,通过 CAS 这样的操作,去进行序号的自增和对比,使得 CPU 不需要获取操作系统的锁。而是能够继续顺序地执行 CPU指令。没有上下文切换,没有操作系统锁,自然程序就跑的块了。不过因为采用了CAS这样的忙等待(busy-wait)的方式,会使得我们的CPU始终满负荷运转,消耗更多的点,算是一个小小的缺点。
- 程序里面的 CAS 调用,映射到我们的 CPU 硬件层面,就是一个机器指令,这个指令就是cmpxchg。可