背景
最近学习 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 的继承关系如下: 可以看到,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;
}
}
每一步骤的作用已经通过注释给出,下面只对关键步骤进行说明。
- 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 保证多线程写入的顺序即可。
- 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 操作。
每一步骤的作用已经通过注释给出,下面只对关键步骤进行说明。
- 为什么需要使用 do while 的方式不断循环获取数组里的元素?
上面分析 offer 的时候,提到使用 putOrderedObject 的方式来更新数据,会有纳秒级的延迟,所以这里使用 do while 的方式不断循环,直到看到生产者填充的元素。
- 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() 方法可以保证每次读取数据都可以从内存中拿到最新值。
- 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() 不会使用任何内存屏障,它会直接更新对象对应偏移量的值。
- 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…