learn-tech/专栏/深入浅出计算机组成原理/55理解Disruptor(下):不需要换挡和踩刹车的CPU,有多快?.md
2024-10-16 09:22:22 +08:00

198 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
55 理解Disruptor不需要换挡和踩刹车的CPU有多快
上一讲我们学习了一个精妙的想法Disruptor 通过缓存行填充,来利用好 CPU 的高速缓存。不知道你做完课后思考题之后,有没有体会到高速缓存在实践中带来的速度提升呢?
不过,利用 CPU 高速缓存,只是 Disruptor“快”的一个因素那今天我们就来看一看 Disruptor 快的另一个因素,也就是“无锁”,而尽可能发挥 CPU 本身的高速处理性能。
缓慢的锁
Disruptor 作为一个高性能的生产者 - 消费者队列系统,一个核心的设计就是通过 RingBuffer 实现一个无锁队列。
上一讲里我们讲过Java 里面的基础库里,就有像 LinkedBlockingQueue 这样的队列库。但是,这个队列库比起 Disruptor 里用的 RingBuffer 要慢上很多。慢的第一个原因我们说过,因为链表的数据在内存里面的布局对于高速缓存并不友好,而 RingBuffer 所使用的数组则不然。
LinkedBlockingQueue 慢,有另外一个重要的因素,那就是它对于锁的依赖。在生产者 - 消费者模式里,我们可能有多个消费者,同样也可能有多个生产者。多个生产者都要往队列的尾指针里面添加新的任务,就会产生多个线程的竞争。于是,在做这个事情的时候,生产者就需要拿到对于队列尾部的锁。同样地,在多个消费者去消费队列头的时候,也就产生竞争。同样消费者也要拿到锁。
那只有一个生产者,或者一个消费者,我们是不是就没有这个锁竞争的问题了呢?很遗憾,答案还是否定的。一般来说,在生产者 - 消费者模式下,消费者要比生产者快。不然的话,队列会产生积压,队列里面的任务会越堆越多。
一方面,你会发现越来越多的任务没有能够及时完成;另一方面,我们的内存也会放不下。虽然生产者 - 消费者模型下,我们都有一个队列来作为缓冲区,但是大部分情况下,这个缓冲区里面是空的。也就是说,即使只有一个生产者和一个消费者者,这个生产者指向的队列尾和消费者指向的队列头是同一个节点。于是,这两个生产者和消费者之间一样会产生锁竞争。
在 LinkedBlockingQueue 上,这个锁机制是通过 synchronized 这个 Java 关键字来实现的。一般情况下这个锁最终会对应到操作系统层面的加锁机制OS-based Lock这个锁机制需要由操作系统的内核来进行裁决。这个裁决也需要通过一次上下文切换Context Switch把没有拿到锁的线程挂起等待。
不知道你还记不记得,我们在第 28 讲讲过的异常和中断,这里的上下文切换要做的和异常和中断里的是一样的。上下文切换的过程,需要把当前执行线程的寄存器等等的信息,保存到线程栈里面。而这个过程也必然意味着,已经加载到高速缓存里面的指令或者数据,又回到了主内存里面,会进一步拖慢我们的性能。
我们可以按照 Disruptor 介绍资料里提到的 Benchmark写一段代码来看看是不是真是这样的。这里我放了一段 Java 代码,代码的逻辑很简单,就是把一个 long 类型的 counter从 0 自增到 5 亿。一种方式是没有任何锁,另外一个方式是每次自增的时候都要去取一个锁。
你可以在自己的电脑上试试跑一下这个程序。在我这里,两个方式执行所需要的时间分别是 207 毫秒和 9603 毫秒,性能差出了将近 50 倍。
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();
加锁和不加锁自增 counter
Time spent is 207ms without lock
Time spent is 9603ms with lock
性能差出将近 10
无锁的 RingBuffer
加锁很慢所以 Disruptor 的解决方案就是无锁这个无锁指的是没有操作系统层面的锁实际上Disruptor 还是利用了一个 CPU 硬件支持的指令称之为 CASCompare And Swap比较和交换 Intel CPU 里面这个对应的指令就是 cmpxchg那么下面我们就一起从 Disruptor 的源码到具体的硬件指令来看看这是怎么一回事儿
Disruptor RingBuffer 是这么设计的它和直接在链表的头和尾加锁不同Disruptor RingBuffer 创建了一个 Sequence 对象用来指向当前的 RingBuffer 的头和尾这个头和尾的标识呢不是通过一个指针来实现的而是通过一个序号这也是为什么对应源码里面的类名叫 Sequence
在这个 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;
Sequence 源码中的 addAndGet如果 CAS 的操作没有成功它会不断忙等待地重试
这个 CAS 指令也就是比较和交换的操作并不是基础库里的一个函数它也不是操作系统里面实现的一个系统调用而是一个 CPU 硬件支持的机器指令在我们服务器所使用的 Intel CPU 就是 cmpxchg 这个指令
compxchg [ax] (隐式参数EAX 累加器), [bx] (源操作数地址), [cx] (目标操作数地址)
复制代码
cmpxchg 指令一共有三个操作数第一个操作数不在指令里面出现是一个隐式的操作数也就是 EAX 累加寄存器里面的值第二个操作数就是源操作数并且指令会对比这个操作数和上面的累加寄存器里面的值
如果值是相同的那一方面CPU 会把 ZF也就是条件码寄存器里面零标志位的值设置为 1然后再把第三个操作数也就是目标操作数设置到源操作数的地址上如果不相等的话就会把源操作数里面的值设置到累加器寄存器里面
我在这里放了这个逻辑对应的伪代码你可以看一下如果你对汇编指令条件码寄存器这些知识点有点儿模糊了可以回头去看看第 5讲 6 讲关于汇编指令的部分
IF [ax]< == [bx] THEN [ZF] = 1, [bx] = [cx]
ELSE [ZF] = 0, [ax] = [bx]
单个指令是原子的这也就意味着在使用 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();
}
Time spent is 3867ms with cas
复制代码
和上面的 counter 自增一样只不过这一次自增我们采用了 AtomicLong 这个 Java 里面的 incrementAndGet 最终到了 CPU 指令层面在实现的时候用的就是 CAS 操作可以看到它所花费的时间虽然要比没有任何锁的操作慢上一个数量级但是比起使用 ReentrantLock 这样的操作系统锁的机制还是减少了一半以上的时间
总结延伸
好了咱们专栏的正文内容到今天就要结束了今天最后一讲我带着你一起看了 Disruptor 代码的一个核心设计也就是它的 RingBuffer 是怎么做到无锁的
Java 基础库里面的 BlockingQueue都需要通过显示地加锁来保障生产者之间消费者之间乃至生产者和消费者之间不会发生锁冲突的问题
但是加锁会大大拖慢我们的性能在获取锁过程中CPU 没有去执行计算的相关指令而要等待操作系统进行锁竞争的裁决而那些没有拿到锁而被挂起等待的线程则需要进行上下文切换这个上下文切换会把挂起线程的寄存器里的数据放到线程的程序栈里面去这也意味着加载到高速缓存里面的数据也失效了程序就变得更慢了
Disruptor 里的 RingBuffer 采用了一个无锁的解决方案通过 CAS 这样的操作去进行序号的自增和对比使得 CPU 不需要获取操作系统的锁而是能够继续顺序地执行 CPU 指令没有上下文切换没有操作系统锁自然程序就跑得快了不过因为采用了 CAS 这样的忙等待Busy-Wait的方式会使得我们的 CPU 始终满负荷运转消耗更多的电算是一个小小的缺点
程序里面的 CAS 调用映射到我们的 CPU 硬件层面就是一个机器指令这个指令就是 cmpxchg可以看到当想要追求最极致的性能的时候我们会从应用层贯穿到操作系统乃至最后的 CPU 硬件搞清楚从高级语言到系统调用乃至最后的汇编指令这整个过程是怎么执行代码的而这个也是学习组成原理这门专栏的意义所在
推荐阅读
不知道上一讲说的 Disruptor 相关材料你有没有读完呢如果没有读完的话我建议你还是先去研读一下
如果你已经读完了这里再给你推荐一些额外的阅读材料那就是著名的Implement Lock-Free Queues这篇论文你可以更深入地学习一下怎么实现一个无锁队列