MpmcArrayQueue学习

前言

在netty的NioEventLoop中用到的jcTools下的MPSC队列很有必要学习下。由于MPSC队列是多生产单消费者的,MPMC是多生产者多消费者的,更有利于学习,故此篇以MpmcArrayQueue为基础。在学习之前,先简单回顾一遍队列及相关知识。

队列

队列是一种FIFO的数据结构,使用队列存取数据元素时,数据元素只能从表的一端进入队列,另一端出队列。常见于线程池的等待排队,锁的等待排队。
队列有两种实现方式(堆栈实现不讨论)

  • 顺序存储实现–>数组
  • 链式存储实现–>链表

Queue定义如下:

public interface Queue<E> {
    void enqueue(E e);
    E dequeue();
    int size();
}

定义了三个方法enqueue入队,dequeue出队,size队列大小。
基于数组实现的queue:

public class ArrayQueue1<E> implements Queue<E> {
    private Object[] elements;
    private int size;
    private int index;
    private int capacity;

    public ArrayQueue1(int capacity) {
        this.capacity = capacity;
        elements = new Object[capacity];
    }

    @Override
    public void enqueue(E e) {
        elements[index++] = e;
        size++;
    }

    @Override
    public E dequeue() {
        E e = (E) elements[0];
        moveLeftOneStep();
        index--;
        size--;
        return e;
    }

    //左移一步
    private void moveLeftOneStep() {
        for (int i = 1; i < elements.length - 1; i++) {
            elements[i] = elements[i + 1];
        }
    }

    @Override
    public int size() {
        return this.size;
    }

}

ArrayQueue1中队列通过给定一个容量进行初始化,Object数组用于存元素,index表示元素放到何处了,size表示元素的个数。入队的时候index处放元素,然后index自增1,size加1。出队的时候从头(0号位置)取,取完之后剩余的元素集体左移一步。
注:上述代码省略了边界条件校验等其它必要的校验。

ArrayQueue1的代码存在一个不好的地方:每取一次元素都要移动一次,有N个元素就要移动N-1次。如果用时间复杂度表示就是O(n),这显然是不可接受的。于是,有了第二种的数组实现队列:循环数组队列。

基于循环数组的队列

循环数组是一个如同环的数组,它用两个指针putIndex和takeIndex,移动takeIndex就可以指示出可以取的元素在哪儿,就可以不用再移动元素,如下图。
MpmcArrayQueue学习_第1张图片

这样做有两个问题需要考虑:

  • Q1:如何判断队列满或空?
  • Q2:数组是线性的,有限的,指针如何进行循环?

Q1的答案有3种。

  • A1:预留长度法
  • A2:预留一位法
  • A3:设标志位法

Q2的答案有2种。

  • A1:取余
  • A2:指针到达尾部置0

Q1的解决方法采用A1-预留长度法,入队+1,出队-1,size == capacity为满,size==0为空。Q2的解决方法采用A2来处理。
代码如下:

public class ArrayQueue2<E> implements Queue<E> {
    private Object[] elements;
    private int size;
    private int putIndex;
    private int takeIndex;

    public ArrayQueue2(int capacity) {
        this.elements = new Object[capacity];
    }

    @Override
    public void enqueue(E e) {
        Object[] elements = this.elements;
        if (size == elements.length) 
            return;
        elements[putIndex++] = e;
        if (putIndex == elements.length)
            putIndex = 0;
        size++;
    }

    @Override
    public E dequeue() {
        Object[] elements = this.elements;
        if (size == 0) return null;
        E e = (E) elements[takeIndex];
        elements[takeIndex--] = null;
        if (takeIndex == elements.length)
            takeIndex = 0;
        size--;
        return e;
    }

    @Override
    public int size() {
        return this.size;
    }
}

通过这样的结构和方法实现了一个队列,但是它有问题,它是线程不安全的,也就是存在并发问题。

并发队列

针对并发问题最简单的方法就是加锁,只需要在每个方法上加上synchronized关键字就可以了。
这里选择了lock接口来进行处理,synchronized关键字也是可以的看个人喜好。

public class ArrayQueue4<E> implements Queue<E> {
    Lock lock = new ReentrantLock();
    Condition notFull = lock.newCondition();
    Condition notEmpty = lock.newCondition();
    private Object[] elements;
    private int size;
    private int putIndex;
    private int takeIndex;

    public ArrayQueue4(int capacity) {
        this.elements = new Object[capacity];
    }

    @Override
    public void enqueue(E e) {
        try {
            lock.lock();
            Object[] elements = this.elements;
            while (size == elements.length) {
                //队列满了,生产者等待
                try {
                    notFull.await();
                } catch (InterruptedException ex) {
                    ex.printStackTrace();
                }
            }
            elements[putIndex] = e;
            if (++putIndex == elements.length)
                putIndex = 0;
            size++;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    @Override
    public E dequeue() {
        try {
            lock.lock();
            Object[] elements = this.elements;
            while (size == 0) {
                //空了,消费者等待
                try {
                    notEmpty.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            E e = (E) elements[takeIndex];
            elements[takeIndex] = null;
            if (++takeIndex == elements.length)
                takeIndex = 0;
            size--;
            notFull.signal();
            return e;
        } finally {
            lock.unlock();
        }
    }

    @Override
    public int size() {
        return this.size;
    }

每次操作的时候进行加锁,入队一个元素后进行一次消费者唤醒,出队一个元素后进行一次生产者唤醒,满了生产者等待,空了消费者等待。

到这里就解决了并发的问题,但是带来了另一个问题:性能问题。众所周知,无锁>CAS(CPU级锁)>有锁。无锁是最快的,但它不安全;有锁安全但不快。

无锁队列

前置知识

  • CAS以及ABA问题+自旋
  • volatile语义
  • hash函数

插一句,基于循环数组的无锁队列在Java中是不提供的,比较有名的是Disruptor,而MpmcArrayQueue和Disruptor不一样。在MpmcArrayQueue中用了双指针和双数组。
回到正文要去除锁就面临几个问题:

  • Q1:如何防止多个生产者的重复写和多个消费者的重复读?
  • Q2:如何保证指针的原子性、有序性?
  • Q3:队列满或空了如何判断?
  • Q4:size如何统计?

解决办法很简单,进行3个调整:

  • 指针变为无限大
  • 去掉size变量
  • index改为hash

如下图

MpmcArrayQueue学习_第2张图片
具体的解决方案:
Q1的问题主要在于后面的指针放元素时可能覆盖前面指针的元素,其实就是两个线程拿到的putIndex同时是0,现在改为putIndex原子递增,且putIndex一直增长,也就是每个线程拿到的putIndex必然不一样。 但有另外一个问题hash后映射的下标会一样, 比如:假设在容量为16的数组中,线程A拿到putIndex=0,线程X拿到putIndex=16,线程A放入元素后,线程X放入元素就会覆盖线程A的。消费者同理。这个问题很好解决,只要线程X放入元素时校验队列是不是满了,满就等待或放入失败就可以了。这样问题Q1转化为Q3。
Q2的问题用volatile+cas解决。CAS附带的ABA问题,这里不存在,指针不会变回A。
Q3容量的判断,putIndex==takeIndex为空,putIndex-takeIndex=capacity为满。
Q4的问题用putIndex-takeIndex,putIndex和takeIndex是自增的,且takeIndex一定小于等于putIndex。

这里还存在一个重要的问题:生产者和消费者如何协同工作?
比如:同一下标,线程A放入元素,线程X放入元素,线程B消费,线程C消费。他们之间的执行时间是不确定的。如果A放了,之后B消费了,而此时X拿到的是数组中的元素,不是null,C拿到的是什么?可能是null,可能是元素。总之,这个线程之间的顺序还是混乱的。怎么解决?

有人说是数组加volatile。这个方法实际不可行,比如说有内存浪费,其实是变相的提高了程序的耗时,拉低了性能。

答案是引入一个long数组,sequenceBuffer。
sequenceBuffer数组大小同元素数组,它在putIndex存元素时放putIndex+1,takeIndex消费时放takeIndex+capacity。
举个例子:
假设capacity=16,线程A的putIndex=0,线程X的putIndex=16。
A放入buffer的是1,若是有消费,buffer中是16,若A还没放则是0。
X就好判断了,X拿到的sequence比期待的sequence小,X就自旋等待即可。

public class ArrayQueue<E> implements Queue<E> {
    private Object[] buffer;
    private static final Unsafe UNSAFE = UnsafeAccess.UNSAFE;
    private final long mask;
    protected volatile long putIndex;
    protected volatile long takeIndex;
    protected final static long P_INDEX_OFFSET = UnsafeAccess.fieldOffset(ArrayQueue.class, "putIndex");
    protected final static long C_INDEX_OFFSET = UnsafeAccess.fieldOffset(ArrayQueue.class, "takeIndex");
    protected final long[] sequenceBuffer;

    public ArrayQueue(int capacity) {
        if(capacity<2)
            throw new IllegalArgumentException("容量太小!");
        //把容量变成2的幂次
        int actualCapacity = Pow2.roundToPowerOfTwo(capacity);
        mask = actualCapacity - 1;
        buffer = new Object[actualCapacity];
        sequenceBuffer = new long[actualCapacity];
        for (int i = 0; i < actualCapacity; i++) {
            long offset = UnsafeLongArrayAccess.calcCircularLongElementOffset(i, mask);
            UNSAFE.putOrderedLong(sequenceBuffer, offset, i);
        }
    }


    @Override
    public void enqueue(E e) {
		if (e == null) return ;
        final long mask = this.mask;
        final long capacity = mask + 1;
        final long[] sBuffer = sequenceBuffer;
        long pIndex;
        long seqOffset;
        long seq;//sequence下标
        long cIndex = Long.MIN_VALUE;
        do{
            pIndex=putIndex;
            seqOffset = UnsafeLongArrayAccess.calcCircularLongElementOffset(pIndex, mask);
            seq = UNSAFE.getLongVolatile(sequenceBuffer, seqOffset);
            if (seq < pIndex) {
                if (pIndex - capacity >= cIndex &&
                        pIndex - capacity >= (cIndex = this.takeIndex)) {
                    return ;//可抛异常可自旋
                } else {
                    seq = pIndex + 1;
                }
            }
        }while (seq > pIndex ||
                !UNSAFE.compareAndSwapLong(this, P_INDEX_OFFSET, pIndex, pIndex + 1));
        UNSAFE.putObject(buffer, UnsafeRefArrayAccess.calcCircularRefElementOffset(pIndex, mask), e);
        //seq++
        UNSAFE.putOrderedLong(sBuffer, seqOffset, pIndex + 1);
        return ;
    }

    @Override
    public E dequeue() {
        final long[] sBuffer = sequenceBuffer;
        final long mask = this.mask;

        long cIndex;
        long seq;
        long seqOffset;
        long expectedSeq;
        long pIndex = -1;

        do {
            cIndex = this.takeIndex;
            seqOffset = UnsafeLongArrayAccess.calcCircularLongElementOffset(cIndex, mask);
            seq = UNSAFE.getLongVolatile(sBuffer, seqOffset);
            expectedSeq = cIndex + 1;
            if (seq < expectedSeq) {
                //槽位没有被生产者移动
                if (cIndex >= pIndex &&
                        cIndex == (pIndex = this.takeIndex))//说明队列空了,该生产了。
                    return null;
                else
                    seq = expectedSeq + 1;
            }
        } while (seq > expectedSeq ||
                !UNSAFE.compareAndSwapLong(this, C_INDEX_OFFSET, cIndex, cIndex + 1));
        final long offset = UnsafeRefArrayAccess.calcCircularRefElementOffset(cIndex, mask);
        final E e = (E) UNSAFE.getObject(buffer, cIndex);
        UNSAFE.putObject(buffer, offset, null);
        //i.e. seq+=capacity
        UNSAFE.putOrderedLong(sBuffer, seqOffset, cIndex + mask + 1);
        return e;
    }

    @Override
    public int size() {
        long after = this.takeIndex;
        long size;
        while (true) {
            final long before = after;
            final long currentProducerIndex = this.putIndex;
            after = this.takeIndex;
            if (before == after) {
                size = currentProducerIndex - after;
                break;
            }
        }
        if (size > Integer.MAX_VALUE)
            return Integer.MAX_VALUE;
        else if (size < 0)
            return 0;
        else if (capacity() != -1 && size > capacity())//-1表示无界容量
            return capacity();
        else
            return (int) size;
    }

    /**
     * 获取容量
     * @return
     */
    public int capacity(){
        return (int) (this.mask+1);
    }

原理是利用自旋不断的去取sequence,只要sequence不等于期待的sequence就去自旋,因为不等的情况就是其它线程或put或take,等着就好了。相等自增指针,指针正确,cas。
附上所有的代码:完整代码看release包

番外

有些小问题主要注意:

  • size()拿了两次takeIndex主要是防止被其它线程给改了,只有两次一致才返回。
  • 满或空时直接返回,不然一直自旋会导致CPU使用率飙升。个人测试时一不小心把程序都搞崩了,CPU100%,(;´༎ຶД༎ຶ`) 。

总结

整个无锁队列比较难的是这个long数组,其它的都比较简单。
最后

  • 感谢JCTools全体贡献者contributors和D. Vyukov
  • 好的算法比好的工具好

如有疑问和建议,请在评论区留言。

你可能感兴趣的:(netty,学习,java,后端,算法,数据结构)