无锁的缓存框架: Disruptor
Disruptor框架是由LMAX公司开发的一款高效的无锁内存队列。它使用无锁的方式实现了一个环形队列,非常适合于实现生产者和消费者模式,比如事件和消息的发布。在Disruptor中,别出心裁地使用了环形队列(RingBuffer)来代替普通线性队列,这个环形队列内部实现为一个普通的数组。对于一般的队列,势必要提供队列同步head和尾部tail两个指针,用于出队和入队,这样无疑就增加了线程协作的复杂度。但如果队列是环形的,则只需要对外提供一个当前位置cursor, 利用这个指针既可以进入入队也可以进行出队操作。由于环形队列的缘故,队列的总大小必须事先指定,不能动态扩展。为了能够快速从一-个序列(sequence) 对应到数组的实际位置(每次有元素入队,序列就加1),Disruptor要求我们必须将数组的大小设置为2的整数次方。这样通过sequence &(queueSize- 1)就能立即定位到实际的元素位置index。这个要比取余(%)操作快得多。
如果大家不理解上面的sequence &(queueSize-1),我在这里再简单说下。如果queueSize是2的整数次幕,则这个数字的二:进制表示必然是10、100、1000、10000等形式。因此,queueSize-1的二:进制则是一个全1的数字。因此它可以将sequence限定在queueSize-1范围内,并且不会有任何一位是浪费的。
如图5.3所示,显示了RingBuffer的结构。生产者向缓冲区中写入数据,而消费者从中读取数据。生产者写入数据时,使用CAS操作,消费者读取数据时,为了防止多个消费者处理同一个数据,也使用CAS操作进行数据保护。这种固定大小的环形队列的另外一个好处就是可以做到完全的内存复用。在系统的运行过程中,不会有新的空间需要分配或者老的空间需要回收。因此,可以大大减少系统分配空间以及回收空间的额外开销。
用Disruptor实现生产者-消费者案例:
现在我们已经基本了解了Disruptor 的基本实现。下面我们将展示一下Disruptor的基本使用和API,这里,我们使用的版本是disruptor-3.3.2,不同版本的disruptor 可能会差别,也请大家留意。这里,我们的生产者不断产生整数,消费者读取生产者的数据,并计算其平方。
首先,我们还是需要一个代表数据的PCData:
pom.xml:
<dependency>
<groupId>com.lmax</groupId>
<artifactId>disruptor</artifactId>
<version>3.2.0</version>
</dependency>
public class PCData {
private long value;
public void set(long value) {
this.value = value;
}
public long get() {
return value;
}
}
//消费者实现为WorkHandler接口,它来自Disruptor框架;
public class Consumer implements WorkHandler<PCData> {
@Override
public void onEvent(PCData event) throws Exception {
System.out.println(Thread.currentThread().getId() + ":Event: --");
+event.get()*event.get() + "--");
}
}
消费者的作用是读取数据进行处理。这里,数据的读取已经由Disruptor进行封装,onEvent()方法为框架的回调方法。因此,这里只需要简单地进行数据处理即可。还需要一个产生PCData的工厂类。它会在Disruptor系统初始化时,构造所有的缓冲区中的对象实例(之前说过Disruptor会预先分配空间) :
public class PCDataFactory implements EventFactory<PCData>{
@Override
public PCData newInstance() {
return new PCData();
}
}
接着,让我们来看一下生产者,它比前面几个类稍微复杂一点:
public class Producer {
private final RingBuffer<PCData> ringBuffer;
public Producer(RingBuffer<PCData> ringBuffer) {
this.ringBuffer = ringBuffer;
}
public void pushData(ByteBuffer bb) {
// Grab the next sequence
long sequence = ringBuffer.next();
try {
//Get the entry in the Disruptor for the sequence
PCData event = ringBuffer.get(sequence);
//Fill with data
event.set(bb.getLong(0));
} finally {
ringBuffer.publish(sequence);
}
}
}
生产者需要一个RingBuffer 的引用,也就是环形缓冲区。它有一个重要的方法pushData()将产生的数据推入缓冲区。方法pushData()接收一个ByteBuffer对象。在ByteBuffer中可以用来包装任何数据类型。这里用来存储long整数,pushData()的功能就是将传入的ByteBuffer 中的数据提取出来,并装载到环形缓冲区中。上述第12行代码,通过next()方法得到一个可用的序列号。通过序列号,取得下一个空闲可用的PCData,并且将PCData的数据设为期望值,这个值最终会传递给消费者。最后,在
第21行,进行数据发布。只有发布后的数据才会真正被消费者看见。
至此,我们的生产者、消费者和数据都已经准备就绪。只差一个统筹规划的主函数将所有
的内容整合起来:
public class Main {
public static void main(String[] args) throws Exception {
Executor executor = Executors.newCachedThreadPool();
PCDataFactory factory = new PCDataFactory();
// Specify the size of the ring buffer, must be power of 2.
int bufferSize = 1024;
Disruptor<PCData> disruptor = new Disruptor<>(factory,
bufferSize,
executor,
ProducerType.MULTI,
new BlockingWaitStrategy());
disruptor.handleEventsWithWorkerPool(
new Consumer(),
new Consumer(),
new Consumer(),
new Consumer()
);
disruptor.start();
RingBuffer<PCData> ringBuffer = disruptor.getRingBuffer();
Producer producer = new Producer(ringBuffer);
ByteBuffer bb = ByteBuffer.allocate(8);
for (long l = 0; true; l++) {
bb.putLong(0, l);
producer.pushData(bb);
Thread.sleep(100);
System.out.println("add data " + l);
}
}
}
上述代码第6行,设置缓冲区大小为1024。显然是2的整数次幂一一一个合理的大小。第7~12创建了disruptor 对象。它封装了整个disruptor 库的使用,提供了一些便捷的API。第13~17行,设置了用于处理数据的消费者。这里设置了4个消费者实例,系统会为将每一个消费者实例映射到一个线程中,也就是这里提供了4个消费者线程。第18行,启动并初始化disruptor系统。在第23~29行中,由一个生产者不断地向缓冲区中存入数据。
系统执行后,你就可以得到类似以下的输出:
10:Event: --0--
add data 0
11:Event: --1--
add data 1
12:Event: --4--
add data 2
13:Event: --9--
add data 3
10:Event: --16--
add data 4
11:Event: --25--
add data 5
12:Event: --36--
生产者和消费者正常工作。根据Disruptor的官方报告,Disruptor的性能要比BlockingQueue至少高一个数量级以上。如此诱人的性能,当然值得我们去尝试!
提高消费者 的响应时间:选择合适的策略:
当有新数据在Disruptor的环形缓冲区中产生时,消费者如何知道这些新产生的数据呢?或者说,消费者如何监控缓冲区中的信息呢?为此Disruptor 提供了几种策略,这些策略由WaitStrategy接口进行封装,主要有以下几种实现。
BlockingWaitStrategy:
这是默认的策略。使BlockingWaitStrategy和使用BlockingQueue是非常类似的,它们都使用锁和条件(Condition) 进行数据的监控和线程的唤醒。因为涉及到线程的切换BlockingWaitStrategy 策略是最节省CPU,但是在高并发下性能表现最糟糕的一-种等待策略。
SleepingWaitStrategy:
这个策略也是对CPU使用率非常保守的。它会在循环中不断等待数据。它会先进行自旋等待,如果不成功,则使用Thread.yield(让出 CPU,并最终使用LockSupport,parkNanos(1)进行线程休眠,以确保不占用太多的CPU数据。因此,这个策略对于数据处理可能产生比较高的平均延时。它比较适合于对延时要求不是特别高的场合,好处是它对生产者线程的影响最小。典型的应用场景是异步日志。
YieldingWaitStrategy:
这个策略用于低延时的场合。消费者线程会不断循环监控缓冲区变化,在循环内部,它会使用Thread.yield()让出CPU给别的线程执行时间。如果你需要一个高性能的系统,并且对延时有较为严格的要求,则可以考虑这种策略。使用这种策略时,相当于你的消费者线程变身成为了一个内部执行了Thread.yield()的死循环。因此,你最好有多于消费者线程数量的逻辑CPU数量(这里的逻辑CPU,我指的是“双核四z程”中的那个四线程,否则,整个应用程序恐怕都会受到影响。
BusySpinWaitStrategy:
这个是最疯狂的等待策略了。它就是一个死循环!消费者线程会尽最大努力疯狂监控缓冲区的变化。因此,它会吃掉所有的CPU资源。你只有在对延迟非常苛刻的场合可以考虑使用它(或者说,你的系统真的非常繁忙)。因为在这里你等同开启了一个死循环监控,所以,你的物理CPU数量必须要大于消费者线程数。注意,我这里说的是物理CPU,如果你在一个物理核上使用超线程技术模拟两个逻辑核,另外一个逻辑核显然会受到这种超密集计算的影响而不能正常工作。在上面的例子中,使用的是BlockingWaitStrategy (第11行)。读者可以替换这个实现,
体验一下不同等待策略的效果。
除了使用CAS和提供了各种不同的等待策略来提高系统的吞吐量外。Disruptor大有将优化进行到底的气势,它甚至尝试解决CPU缓存的伪共享问题。
CPU Cache的优化:解决伪共享问题