原文地址:
http://mechanitis.blogspot.com/2011/08/dissecting-disruptor-why-its-so-fast.html, 作者是 Trisha Gee, LMAX 公司的一位女工程师。
我最近写文章的速度变慢了,是因为我一直在尝试写一篇博客解释内存屏障(Memory Barrier)以及它在 Disruptor 的应用。问题是,无论我阅读了多少次,无论我向永远耐心的 Martin 和 Mike 提出多少个问题试图弄明白一些重点,我还是没办法直观的把握主题。我猜我没有完全理解它所需要的深厚背景知识。
因此,与其让一个像我这样的傻瓜去解释一些连自己都没有真正了解的东西,我准备尝试和覆盖的,是在一个抽象和大量简化的层面、我确实已经在这个领域内弄懂的东西。Martin 写过一篇详细的 走进内存屏障(Memory Barrier),这太有助于我回避和忽略本文的主题了。
免责申明:在这篇解答里的任何错误全都是我自己的事,并不反映 Disruptor 的实现或者真正理解这个东西的 LMAX 同事水平。
重点是?
在这一系列的博客中,我的主要目标是解释 Disruptor 是如何工作的——并且,稍微小小的扩展到,它为什么这样工作。理论上,从想要使用它的开发者视角描述 Disruptor,我可以在代码与 技术文献 间建立一座桥梁。
本文提到 Memory Barrier(内存屏障),我打算理解它是什么,并且如何使用。
什么是 Memory Barrier(内存屏障)?
这是一条 CPU 指令。是的,再一次,我们去思考 CPU 层面的特性以获得我们需要的性能(请参考 Martin 著名的 Mechanical Sympathy 理论)。基本上它是一条这样的指令:a) 保证特定操作的执行顺序,以及 b) 影响某些数据(也许是某些指令的执行结果)的可见性。
编译器和 CPU 能够重排序指令,保证最终相同的结果,并尝试优化性能。插入一条 Memory Barrier 会告诉 CPU 和编译器:在这条命令之前发生的必须待在这条命令之面,在这条命令之后的必须待在命令之后。所有这一切都像一趟拉斯维加斯之旅完全占据了你的脑子一样。
Memory Barrier(内存屏障)所做的另一件事是强制刷出各种 CPU cache(高速缓存)——比如,一个 Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此,任何尝试去读这些数据的线程将会读到它们的最新版本,而不管线程在哪个 CPU 核心或者哪个 CPU 插槽(Socket)上执行。
这与 JAVA 有什么关系?
我知道你在想什么——这不是汇编,是 Java。
这里的魔法咒语是关键字 "volatile"(我觉得这是 Java 认证里从来没有解释清楚过的东西)。如果你的字段是 volatile 的,Java 内存模型(Java Memory Model)会在你写入字段之后插进一个 Write-Barrier 指令,并且在你读这个字段之前插入一个 Read-Barrier 指令。
这意味着,如果写入一个 volatile 字段,你知道这些事会发生:
1. 从写入字段这个时间点开始,任何线程访问该字段都会拿到更新后的数据。
2. 在写入字段之前的操作都确实执行过了,并且它们更新的数据也是可见的,
因为 Memory Barrier 会刷出 cache 中所有先前的写入。
请举例!
很高兴你提出这个要求。这是我又要开始画“甜甜圈”的时间了。
RingBuffer 游标字段(cursor)是这些神奇的“volatile ”变量之一,它也是我们可以不用锁而实现 Disruptor 的一个原因。
生产者(Producer)先拿到下一个(或者下一批)Entry 对象,然后随意访问这些对象,用各种想写入的值更新它们。与 你知道的 一样,在全部更新结束后,生产者会调用 RingBuffer 的 commit 方法,由它去更新序号。这个对“volatile”字段(cursor)的写入产生了一个 Memory Barrier,最终它会刷出所有的 cache(或者至少让它们失效)。
在这个时间点之后,消费者会拿到最新的序号(8),因为 Memory Barrier 也同样保证先前发生的 CPU 指令顺序,因此消费者可以确信生产者对序号 7 之上的 Entry 对象所做的全部修改也都生效了。
... 在消费者那边呢?
消费者上的序号也是“volatile”变量,它被一系列外部对象读取——其他的 下游 消费者可能在追踪这个消费者,而且 ProducerBarrier/RingBuffer(取决于你看的是老代码还是新代码)也会追踪它以保证环不会重叠(wrap)。
因此,如果下游消费者(C2)看到前面的消费者(C1)读到了序号 12,它接着从 RingBuffer 读序号 12 之前的节点时,就可以读到消费者(C1)更新序号前对节点做的全部更改。
基本上,消费者(C2)拿到更新序号后做的一切操作(在上图用蓝色表示)都必须发生在消费者(C1)在更新序号前对 RingBuffer 所做的一切操作(用黑色表示)之后。
对性能的影响
内存屏障(Memory Barrier)作为另一种 CPU 级别的指令,不像 加锁的开销 那么大 —— 操作系统内核并不需要在多个线程间调度和协调。但是任何东西都不是免费的。内存屏障的确有一些开销 —— 编译器/CPU 不能重排序指令,会潜在的导致代码没有尽可能高效的利用 CPU,而且刷新 CPU cache 会对性能有明显的影响。因此,不要以为用“volatile”字段代替锁就能让你永远逍遥法外。
你会注意到 Disruptor 在实现里尝试尽可能少的读写序号。每次读写“volatile”字段都是一次相对开销较大的操作。认识到这一点可以很好的进行批处理 —— 如果你知道不应该太频繁的读写序号,那么先抓取一批节点进行处理,然后再更新序号 —— 这样无论对于生产端和消费端都是有意义的。下面是一个来自 BatchConsumer 的例子:
long nextSequence = sequence + 1; while (running) { try { final long availableSequence = consumerBarrier.waitFor(nextSequence); while (nextSequence <= availableSequence) { entry = consumerBarrier.getEntry(nextSequence); handler.onAvailable(entry); nextSequence++; } handler.onEndOfBatch(); sequence = entry.getSequence(); } ... catch (final Exception ex) { exceptionHandler.handle(ex, entry); sequence = entry.getSequence(); nextSequence = entry.getSequence() + 1; } }
(你会注意到这儿还是“老”代码和命名规则,因为这篇文章紧接着我的上一篇博客,我想这样比直接切换到新命名规则会稍微减少一些混乱)
在上面的代码中,我们在消费者处理节点的循环中递增的是一个局部变量。这意味着我们读写 sequence 这个 volatile 字段(用粗体表示)的次数尽可能的降到了最低。
总结
内存屏障(Memory Barrier)是一个 CPU 指令,它允许你对数据在什么时候被其他进程可见作出确定的假设。在 Java 中,你可以用 volatile 关键字来实现它们。使用 volatile 关键字意味着你不需要被迫的、别无选择的加锁,而且使用还会让你获得性能提升。但是,这需要更加小心的思考你的设计,特别是你对 volatile 字段的使用有多频繁,以及读写它们有多频繁。
PS: 鉴于 Disruptor 当前的“世界新秩序”与我至今为止博客里提到的一切相比,用的是完全不同的命名规则,我想下一篇文章是该把“旧世界”映射到“新世界”了。
译注
这是 Trisha Gee 博客里有关 Disruptor 原理介绍的最后一篇,其他的几篇介绍大家可以阅读我博客中的译文: