Netty 笔记 - 高性能无锁队列 Mpsc Queue 分析

929 阅读8分钟

背景

最近学习 Netty 的时候,看到 NioEventLoop 里的变量 taskQueue 使用到了 Mpsc Queue,深入学习以后发现里面运用到了很多底层调优的方法,遂记录下这个学习过程。

概述

Mpsc 的全称是 Multi Producer Single Consumer,多生产者单消费者。Mpsc Queue 可以保证多个生产者同时访问队列是线程安全的,而且同一时刻只允许一个消费者从队列中读取数据。

Netty 里的 Mpsc Queue 来源于 JCTools 开源项目,Github 地址为 github.com/JCTools/JCT…。JCTools 是适用于 JVM 并发开发的工具,主要提供了一些 JDK 缺失的并发数据结构,例如非阻塞 Map、非阻塞 Queue 等。

源码分析

基础知识

Mpsc Queue 有多种的实现类,这里以 MpscArrayQueue 来分析,这是最基础的实现类,其他实现类特供了一些特性功能。 MpscArrayQueue 的继承关系如下: image.png 可以看到,MpscArrayQueue 继承了一些类似 MpscXXXPad 和 MpscXXXField 的类,实际上这就是解决伪共享的秘诀所在。 看下这些 MpscXXXPad 和 MpscXXXField 类的内容。

// ConcurrentCircularArrayQueueL0Pad
long p01, p02, p03, p04, p05, p06, p07;
long p10, p11, p12, p13, p14, p15, p16, p17;

// ConcurrentCircularArrayQueue
protected final long mask;
protected final E[] buffer;

// MpmcArrayQueueL1Pad
long p00, p01, p02, p03, p04, p05, p06, p07;
long p10, p11, p12, p13, p14, p15, p16;

// MpmcArrayQueueProducerIndexField
private volatile long producerIndex;

// MpscArrayQueueMidPad
long p01, p02, p03, p04, p05, p06, p07;
long p10, p11, p12, p13, p14, p15, p16, p17;

// MpscArrayQueueProducerLimitField
private volatile long producerLimit;

// MpscArrayQueueL2Pad
long p00, p01, p02, p03, p04, p05, p06, p07;
long p10, p11, p12, p13, p14, p15, p16;

// MpscArrayQueueConsumerIndexField
protected long consumerIndex;

// MpscArrayQueueL3Pad
long p01, p02, p03, p04, p05, p06, p07;
long p10, p11, p12, p13, p14, p15, p16, p17;

其中,XXXField 包含了三个特别重要的 long 类型变量,productIndex,producerLimit,consumerIndex,XXXPad 则包含了大量的 long 类型的变量,实际上这些变量没什么意义,只是起到填充缓存行的作用,从而解决伪共享的问题,这是一种用空间换时间的策略。

通过上面的处理,可以使变量 productIndex,producerLimit,consumerIndex 分布到不同的缓存行上,这样修改任一变量,并不会使其他变量所在的缓存行失效,在多线程环境下可以极大的提高性能。

因为缓存行失效后,需要从内存重新获取最新值,这个过程相对来说很昂贵。

如果没有解决伪共享问题,producerIndex 和 consumerIndex 变量在同一缓存行上,producerIndex 变量只会被生产者修改,consumerIndex 只会被消费者修改,对任一变量的变更都会使其所在缓存行失效,但是由于这些变量都在同一缓存行上,所以生产和消费过程中会互相影响。在生产和消费频率都很高的情况下,会极大的影响性能。

仔细观察,可以看到只有 productIndex 和 producerLimit 变量使用了 volatile 修饰,而 consumerIndex 变量并没有,这也是跟这个队列多生产者单消费者的特性有关。

关于伪共享的问题这里不会过多描述,网上资料很多。

Mpsc Queue 的使用方式跟普通 Queue 没什么区别,其中最关键的就是入队和出队方式。

入队

回顾关键变量:

// ConcurrentCircularArrayQueue
protected final long mask; // 计算数组下标的掩码
protected final E[] buffer; // 存放队列数据的数组
// MpmcArrayQueueProducerIndexField
private volatile long producerIndex; // 生产者的索引
// MpscArrayQueueProducerLimitField
private volatile long producerLimit; // 生产者索引的最大值
// MpscArrayQueueConsumerIndexField
protected long consumerIndex; // 消费者索引

队列内部使用环形数组来维护数据,数组长度为2的次幂,mask = 数组长度 - 1,这样可以通过位运算快速得到数组下标,有点类似 HashMap

public boolean offer(E e) {
    if (null == e) {
        throw new NullPointerException();
    } else {
        long mask = this.mask;
        long producerLimit = this.lvProducerLimit(); // 获取生产者索引最大限制
        long pIndex;
        long offset;
        do {
            pIndex = this.lvProducerIndex(); // 获取生产者索引
            if (pIndex >= producerLimit) {
                offset = this.lvConsumerIndex(); // 获取消费者索引
                producerLimit = offset + mask + 1L; // 重新计算 producerLimit,已消费位置的可以重新利用
                if (pIndex >= producerLimit) {
                    return false; // 队列已满
                }
                this.soProducerLimit(producerLimit); // 更新 producerLimit
            }
        } while(!this.casProducerIndex(pIndex, pIndex + 1L)); // CAS 更新生产者索引,更新成功则退出,说明当前生产者已经占领索引值
        offset = calcElementOffset(pIndex, mask); // 计算生产者索引在数组中下标
        UnsafeRefArrayAccess.soElement(this.buffer, offset, e); // 向数组中放入数据
        return true;
    }
}

每一步骤的作用已经通过注释给出,下面只对关键步骤进行说明。

  1. UnsafeRefArrayAccess.soElement(this.buffer, offset, e):更新数据
// UnsafeRefArrayAccess#soElement
public static <E> void soElement(E[] buffer, long offset, E e) {
    UnsafeAccess.UNSAFE.putOrderedObject(buffer, offset, e);
}

这里会调用 UNSAFE 类的 putOrderedObject 方法更新数据,而 UNSAFE 类还有另一个方法 putObject 作用也是更新数据,这两个方法的区别是 putOrderedObject 不会立即把数据更新到内存中,并把缓存行置为失效。putOrderedObject 方法采用的是 LazySet 延迟更新机制,所以性能会比 putObject 高。

Java 中有四种类型的内存屏障,分别为 LoadLoad、StoreStore、LoadStore 和 StoreLoad。putOrderedObject() 会前置一个 StoreStore Barrier,对于 Store1,StoreStore,Store2 这样的操作序列,在 Store2 进行写入之前,会保证 Store1 的写操作对其他处理器可见。

这个 LazySet机制会使写操作会有纳秒级的延迟,所以更新不会立即对其他线程可见。而在 Mpsc Queue 的使用场景中,多个生产者只负责写入数据,并没有写入之后立刻读取的需求,所以使用 LazySet 机制是没有问题的,只要 StoreStore Barrier 保证多线程写入的顺序即可。

  1. lvConsumerIndex():获取消费者索引
// MpscArrayQueueConsumerIndexField#lvConsumerIndex
public final long lvConsumerIndex() {
    return UnsafeAccess.UNSAFE.getLongVolatile(this, C_INDEX_OFFSET);
}

其中调用了 UNSAFE 的 getLongVolatile() 方法,getLongVolatile() 使用volatile的加载语义并使用了 StoreLoad Barrier,对于 Store1,StoreLoad,Load2 的操作序列,在 Load2 以及后续的读取操作之前,都会保证 Store1 的写入操作对其他处理器可见。StoreLoad 是四种内存屏障开销最大的,现在你是不是可以体会到引入 producerLimit 的好处了呢?假设我们的消费速度和生产速度比较均衡的情况下,差不多走完一圈数组才需要获取一次消费者索引 consumerIndex,从而大幅度减少了 getLongVolatile() 操作的执行次数,性能提升是显著的。

出队

poll() 方法的作用是移除队列的首个元素并返回,如果队列为空则返回 NULL。

public E poll() {
    long cIndex = this.lpConsumerIndex(); // 直接返回消费者索引 consumerIndex
    long offset = this.calcElementOffset(cIndex); // 计算数组对应的偏移量
    E[] buffer = this.buffer;
    E e = UnsafeRefArrayAccess.lvElement(buffer, offset); // 取出数组中 offset 对应的元素
    if (null == e) {
        if (cIndex == this.lvProducerIndex()) { // 队列为空
            return null;
        }
        do {
            e = UnsafeRefArrayAccess.lvElement(buffer, offset); 
        } while(e == null); // 等待生产者填充元素
    }
    UnsafeRefArrayAccess.spElement(buffer, offset, (Object)null); // 消费成功后将当前位置置为 NULL
    this.soConsumerIndex(cIndex + 1L); // 更新 consumerIndex 到下一个位置
    return e;
}

因为只有一个消费者线程,所以整个 poll() 的过程没有 CAS 操作。

每一步骤的作用已经通过注释给出,下面只对关键步骤进行说明。

  1. 为什么需要使用 do while 的方式不断循环获取数组里的元素?

上面分析 offer 的时候,提到使用 putOrderedObject 的方式来更新数据,会有纳秒级的延迟,所以这里使用 do while 的方式不断循环,直到看到生产者填充的元素。

  1. UnsafeRefArrayAccess.lvElement(buffer, offset):获取元素
// UnsafeRefArrayAccess#lvElement
public static <E> E lvElement(E[] buffer, long offset) {
    return UnsafeAccess.UNSAFE.getObjectVolatile(buffer, offset);
}

获取数组元素的时候同样使用了 UNSAFE 系列方法,getObjectVolatile() 方法则使用的是 LoadLoad Barrier,对于 Load1,LoadLoad,Load2 操作序列,在 Load2 以及后续读取操作之前,会保证 Load1 的读取操作执行完毕,所以 getObjectVolatile() 方法可以保证每次读取数据都可以从内存中拿到最新值。

  1. UnsafeRefArrayAccess.spElement(buffer, offset, (Object)null):消费成功后将当前位置置为 NULL
// UnsafeRefArrayAccess#spElement
public static <E> void spElement(E[] buffer, long offset, E e) {
    UnsafeAccess.UNSAFE.putObject(buffer, offset, e);
}

又看到了 UNSAFE put 系列方法的运用,其中 putObject() 不会使用任何内存屏障,它会直接更新对象对应偏移量的值。

  1. soConsumerIndex(cIndex + 1L):更新 consumerIndex 到下一个位置
// MpscArrayQueueConsumerIndexField#soConsumerIndex
protected void soConsumerIndex(long newValue) {
    UnsafeAccess.UNSAFE.putOrderedLong(this, C_INDEX_OFFSET, newValue);
}

putOrderedLong 与 putOrderedObject() 是一样的,会前置一个 StoreStore Barrier,使 UnsafeRefArrayAccess.spElement(buffer, offset, (Object)null)soConsumerIndex(cIndex + 1L) 不会发生写与写的重排序。而且也是延迟更新 LazySet 机制。

总结

MpscArrayQueue 还只是 Jctools 中的冰山一角,其中蕴藏着丰富的技术细节,我们对 MpscArrayQueue 的知识点做一个简单的总结。

  • 通过大量填充 long 变量解决伪共享问题
  • 环形数组的容量设置为 2 的次幂,可以通过位运算快速定位到数组对应下标。
  • 入队和出队操作中都大量使用了 UNSAFE 系列方法,针对生产者和消费者的场景不同,使用的 UNSAFE 方法也是不一样的,实际上是对内存屏障的灵活使用。

参考

另外分享一篇最近读到的关于解决缓存行伪共享的文章 mp.weixin.qq.com/s/vkCskOVSp…