【计组】理解Disruptor--《计算机组成原理》(十五)

Disruptor 的开发语言,并不是很多人心目中最容易做到性能极限的 C/C++,而是性能受限于 JVM 的 Java。其实只要通晓硬件层面的原理,即使是像 Java 这样的高级语言,也能够把 CPU 的性能发挥到极限。

一、Padding Cache Line,体验高速缓存的威力

Disruptor 里面一段神奇的代码。这段代码里,Disruptor 在 RingBufferPad 这个类里面定义了 p1,p2 一直到 p7 这样 7 个 long 类型的变量。这些变量没有实际意义,只是帮助我们进行缓存行填充(Padding Cache Line),使得我们能够尽可能地用上 CPU 高速缓存(CPU Cache)。

abstract class RingBufferPad
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

CPU Cache 装载内存里面的数据,不是一个一个字段加载的,而是加载一整个缓存行。举个例子,如果我们定义了一个长度为 64 的 long 类型的数组。那么数据从内存加载到 CPU Cache 里面的时候,会一次性加载固定长度的一个缓存行。

我们现在的 64 位 Intel CPU 的计算机,缓存行通常是 64 个字节(Bytes)。一个 long 类型的数据需要 8 个字节,所以一下子会加载 8 个 long 类型的数据。也就是说,一次加载数组里面连续的 8 个数值。这样的加载方式可以加快遍历数组元素时的速度。因为后面连续 7 次的数据访问都会命中缓存,不需要重新从内存里面去读取数据。

但是,不使用数组,而是使用单独的变量的时候,这里就会出现问题了。在 Disruptor 的 RingBuffer(环形缓冲区)的代码里面,定义了一个 RingBufferFields 类,里面有 indexMask 和其他几个变量,用来存放 RingBuffer 的内部状态信息。

CPU 在加载数据的时候,自然也会把这个数据从内存加载到高速缓存里面来。不过,这个时候,高速缓存里面除了这个数据,还会加载这个数据前后定义的其他变量。问题就来了,Disruptor 是一个多线程的服务器框架,在这个数据前后定义的其他变量,可能会被多个不同的线程去更新数据、读取数据。这些写入以及读取的请求,会来自于不同的 CPU Core。于是,为了保证数据的同步更新,我们不得不把 CPU Cache 里面的数据,重新写回到内存里面去或者重新从内存里面加载数据。

CPU Cache 的写回和加载,都不是以一个变量作为单位的。这些动作都是以整个 Cache Line 作为单位的。所以,当 INITIAL_CURSOR_VALUE 前后的那些变量被写回到内存的时候,这个字段自己也写回到了内存,这个常量的缓存也就失效了。要再次读取这个值的时候,还需重新从内存读取。这也就意味着,读取速度大大变慢了。

面临这样一个情况,Disruptor 里有一种神奇的代码技巧,就是缓存行填充。Disruptor 在 RingBufferFields 里面定义的变量的前后,分别定义了 7 个 long 类型的变量。前面的 7 个来自继承的 RingBufferPad 类,后面的 7 个则是直接定义在 RingBuffer 类里面。这 14 个变量没有任何实际的用途。我们既不会去读他们,也不会去写他们。

......

abstract class RingBufferPad
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}
  

abstract class RingBufferFields extends RingBufferPad
{
    ......    
    private final long indexMask;
    private final Object[] entries;
    protected final int bufferSize;
    protected final Sequencer sequencer;
    ......    
}

public final class RingBuffer extends RingBufferFields implements Cursored, EventSequencer, EventSink
{
    ......    
    protected long p1, p2, p3, p4, p5, p6, p7;
    ......
}

【计组】理解Disruptor--《计算机组成原理》(十五)_第1张图片

 RingBufferFields 里面定义的这些变量都是 final 的,第一次写入之后不会再进行修改。所以,一旦它被加载到 CPU Cache 之后,只要被频繁地读取访问,就不会再被换出 Cache 了。这也就意味着,对于这个值的读取速度,会是一直是 CPU Cache 的访问速度,而不是内存的访问速度。

二、使用 RingBuffer,利用缓存和分支预测

利用 CPU Cache 性能的思路,贯穿了整个 Disruptor。Disruptor 整个框架,其实就是一个高速的生产者 - 消费者模型(Producer-Consumer)下的队列。生产者不停地往队列里面生产新的需要处理的任务,而消费者不停地从队列里面处理掉这些任务。

实现一个队列,最合适的数据结构应该是链表。只要维护好链表的头和尾,就能很容易实现一个队列。生产者只要不断地往链表的尾部不断插入新的节点,而消费者只需要不断从头部取出最老的节点进行处理就好了。实际上,Java 自己的基础库里面就有 LinkedBlockingQueue 这样的队列库,可以直接用在生产者 - 消费者模式上。

不过,Disruptor 里面并没有用 LinkedBlockingQueue,而是使用了一个 RingBuffer 这样的数据结构,这个 RingBuffer 的底层实现是一个固定长度的数组。比起链表形式的实现,数组的数据在内存里面会存在空间局部性。

数组的连续多个元素会一并加载到 CPU Cache 里面来,所以访问遍历的速度会更快。而链表里面各个节点的数据,多半不会出现在相邻的内存空间,自然也就享受不到整个 Cache Line 加载后数据连续从高速缓存里面被访问到的优势。

除此之外,数据的遍历访问还有一个很大的优势,就是 CPU 层面的分支预测会很准确。可以更有效地利用 CPU 里面的多级流水线,使程序就会跑得更快。

三、无锁的 RingBuffer

(一)缓慢的锁

Disruptor 作为一个高性能的生产者 - 消费者队列系统,一个核心的设计就是通过 RingBuffer 实现一个无锁队列。

Java 里面的基础库里像 LinkedBlockingQueue 这样的队列库比起 Disruptor 里用的 RingBuffer 要慢上很多。慢的第一个原因我们说过,因为链表的数据在内存里面的布局对于高速缓存并不友好,而 RingBuffer 所使用的数组则不然。

另外一个重要的因素是 LinkedBlockingQueue 对锁的依赖较强。在生产者 - 消费者模式里,我们可能有多个消费者,同样也可能有多个生产者。多个生产者都要往队列的尾指针里面添加新的任务,就会产生多个线程的竞争。于是,在做这个事情的时候,生产者就需要拿到对于队列尾部的锁。同样地,在多个消费者去消费队列头的时候,也就产生竞争。同样消费者也要拿到锁。

就算只有一个生产者,一个消费者,也是需要锁的。一般来说,在生产者 - 消费者模式下,消费者要比生产者快。不然的话,队列会产生积压,队列里面的任务会越堆越多。任务不能及时完成,内存也会放不下。虽然生产者 - 消费者模型下,有一个队列来作为缓冲区,但是大部分情况下,这个缓冲区里面是空的。也就是说,即使只有一个生产者和一个消费者者,这个生产者指向的队列尾和消费者指向的队列头是同一个节点。因此,生产者和消费者之间一样会产生锁竞争。

在 LinkedBlockingQueue 上,这个锁机制是通过 Java 基础库 ReentrantLock 来实现的。这个锁是一个用 Java 在 JVM 上直接实现的加锁机制,锁机制需要由 JVM 来进行裁决。锁的争夺,会把没有拿到锁的线程挂起等待,也就需要经过一次上下文切换(Context Switch)。

上下文切换的过程,需要把当前执行线程的寄存器等的信息,保存到线程栈里面。而这个过程也必然意味着,已经加载到高速缓存里面的指令或者数据,又回到了主内存里面,会进一步拖慢性能。

加锁和不加锁自增到5亿性能对比:

package com.xuwenhao.perf.jmm;


import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;


public class LockBenchmark{


    public static void runIncrement()
    {
        long counter = 0;
        long max = 500000000L;
        long start = System.currentTimeMillis();
        while (counter < max) {
            counter++;
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end-start) + "ms without lock");
    }


    public static void runIncrementWithLock()
    {
        Lock lock = new ReentrantLock();
        long counter = 0;
        long max = 500000000L;
        long start = System.currentTimeMillis();
        while (counter < max) {
            if (lock.tryLock()){
                counter++;
                lock.unlock();
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end-start) + "ms with lock");
    }


    public static void main(String[] args) {
        runIncrement();
        runIncrementWithLock();

Time spent is 207ms without lock
Time spent is 9603ms with lock

(二)无锁的 RingBuffer

加锁很慢,所以 Disruptor 的解决方案就是“无锁”。这个“无锁”指的是没有操作系统层面的锁。实际上,Disruptor 还是利用了一个 CPU 硬件支持的指令,称之为 CAS(Compare And Swap,比较和交换)。在 Intel CPU 里面,这个对应的指令就是 cmpxchg。

Disruptor 的 RingBuffer 是这么设计的:和直接在链表的头和尾加锁不同,Disruptor 的 RingBuffer 创建了一个 Sequence 对象,用来指向当前的 RingBuffer 的头和尾。这个头和尾的标识不是通过指针来实现的,而是通过一个序号。

【计组】理解Disruptor--《计算机组成原理》(十五)_第2张图片

在这个 RingBuffer 当中,进行生产者和消费者之间的资源协调,采用的是对比序号的方式。当生产者想要往队列里加入新数据的时候,它会把当前的生产者的 Sequence 的序号,加上需要加入的新数据的数量,然后和实际的消费者所在的位置进行对比,看看队列里是不是有足够的空间加入这些数据,而不会覆盖掉消费者还没有处理完的数据。

在 Sequence 的代码里面,就是通过 compareAndSet 这个方法,并且最终调用到了 UNSAFE.compareAndSwapLong,也就是直接使用了 CAS 指令。

 public boolean compareAndSet(final long expectedValue, final long newValue)
      {
          return UNSAFE.compareAndSwapLong(this, VALUE_OFFSET, expectedValue, newValue);
      }


public long addAndGet(final long increment)
    {
        long currentValue;
        long newValue;


        do
        {
            currentValue = get();
            newValue = currentValue + increment;
        }
        while (!compareAndSet(currentValue, newValue));


        return newValue;

这个 CAS 指令,也就是比较和交换的操作,并不是基础库里的一个函数。它也不是操作系统里面实现的一个系统调用,而是一个 CPU 硬件支持的机器指令。在我们服务器所使用的 Intel CPU 上,就是 cmpxchg 这个指令。

compxchg [ax] (隐式参数,EAX累加器), [bx] (源操作数地址), [cx] (目标操作数地址)

cmpxchg 指令,一共有三个操作数,第一个操作数不在指令里面出现,是一个隐式的操作数,也就是 EAX 累加寄存器里面的值。第二个操作数就是源操作数,并且指令会对比这个操作数和上面的累加寄存器里面的值。如果值是相同的,那一方面,CPU 会把 ZF(也就是条件码寄存器里面零标志位的值)设置为 1,然后再把第三个操作数(也就是目标操作数),设置到源操作数的地址上。如果不相等的话,就会把源操作数里面的值,设置到累加器寄存器里面。

单个指令是原子的,这也就意味着在使用 CAS 操作的时候,不再需要单独进行加锁,直接调用就可以了。没有了锁,CPU 这部高速跑车就像在赛道上行驶,不会遇到需要上下文切换这样的红灯而停下来。虽然会遇到像 CAS 这样复杂的机器指令,就好像赛道上会有 U 型弯一样,不过不用完全停下来等待, CPU 运行起来仍然会快很多。

那么,CAS 操作到底会有多快呢?我们还是用一段 Java 代码来看一下。

package com.xuwenhao.perf.jmm;


import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;


public class LockBenchmark {


    public static void runIncrementAtomic()
    {
        AtomicLong counter = new AtomicLong(0);
        long max = 500000000L;
        long start = System.currentTimeMillis();
        while (counter.incrementAndGet() < max) {
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end-start) + "ms with cas");
    }


    public static void main(String[] args) {
        runIncrementAtomic();
    }

 和上面的 counter 自增一样,只不过这一次,自增采用了 AtomicLong 这个 Java 类。里面的 incrementAndGet 最终到了 CPU 指令层面,在实现的时候用的就是 CAS 操作。可以看到,它所花费的时间,虽然要比没有任何锁的操作慢上一个数量级,但是比起使用 ReentrantLock 这样的操作系统锁的机制,还是减少了一半以上的时间。

当想要追求最极致的性能的时候,我们会从应用层、贯穿到操作系统,乃至最后的 CPU 硬件,搞清楚从高级语言到系统调用,乃至最后的汇编指令,这整个过程是怎么执行代码的。而这个,也是学习组成原理的意义所在。

【推荐阅读】Disruptor 官方文档,里面不仅包含了怎么用好 Disruptor,也包含了整个 Disruptor 框架的设计思路,是一份很好的阅读学习材料。另外,Disruptor 的官方文档里,还有很多文章、演讲,详细介绍了这个框架,很值得深入去看一看。Disruptor 的源代码其实并不复杂,很适合用来学习怎么阅读开源框架代码。

课程链接:深入浅出计算机组成原理_组成原理_计算机基础-极客时间 

你可能感兴趣的:(jvm)