剖析Disruptor:为什么会这么快?(二)神奇的缓存行填充
原文链接:http://mechanitis.blogspot.com/2011/07/dissecting-disruptor-why-its-so-fast_22.html 需FQ
计算机入门
我喜欢在LMAX工作的原因之一是,在这里工作让我明白从大学和A Level Computing所学的东西实际上还是有意义的。做为一个开发者你可以逃避不去了解CPU,数据结构或者大O符号 —— 而我用了10年的职业生涯来忘记这些东西。但是现在看来,如果你知道这些知识并应用它,你能写出一些非常巧妙和非常快速的代码。
因此,对在学校学过的人是种复习,对未学过的人是个简单介绍。但是请注意,这篇文章包含了大量的过度简化。
CPU是你机器的心脏,最终由它来执行所有运算和程序。主内存(RAM)是你的数据(包括代码行)存放的地方。本文将忽略硬件驱动和网络之类的东西,因为Disruptor的目标是尽可能多的在内存中运行。
CPU和主内存之间有好几层缓存,因为即使直接访问主内存也是非常慢的。如果你正在多次对一块数据做相同的运算,那么在执行运算的时候把它加载到离CPU很近的地方就有意义了(比如一个循环计数-你不想每次循环都跑到主内存去取这个数据来增长它吧)。
越靠近CPU的缓存越快也越小。所以L1缓存很小但很快,并且紧靠着在使用它的CPU内核。L2大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一块主存,由全部插槽上的所有 CPU 核共享。
当CPU执行运算的时候,它先去L1查找所需的数据,再去L2,然后是L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要确保数据在L1缓存中。
Martin 和 Mike 的 QCon presentation 演讲中给出了一些缓存未命中的消耗数据:
从CPU到 | 大约需要的 CPU 周期 | 大约需要的时间 |
主存 | 约60-80纳秒 | |
QPI 总线传输 (between sockets, not drawn) |
约20ns | |
L3 cache | 约40-45 cycles, | 约15ns |
L2 cache | 约10 cycles, | 约3ns |
L1 cache | 约3-4 cycles, | 约1ns |
寄存器 | 1 cycle |
如果你的目标是让端到端的延迟只有 10毫秒,而其中花80纳秒去主存拿一些未命中数据的过程将占很重的一块。
缓存行
现在需要注意一件有趣的事情:数据在缓存中不是以独立的项来存储的。如不是一个单独的变量,也不是一个单独的指针。缓存是由缓存行组成的,通常是64字节(译注:64位处理器),并且它有效地引用主内存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。(此处忽略了多级缓存)
奇妙的是:如果你访问一个long数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个,因此你能非常快地遍历这个数组。事实上,遍历在内存中连续分配的任意数据结构都是非常快的,因为存在这样的机制。
因此如果你数据结构中的项在内存中不是彼此相邻的(比如说:链表),你将得不到免费缓存加载所带来的优势。并且在这些数据结构中的每一个项都可能会出现缓存未命中。
不过,这种免费加载存在弊端。设想:你的long类型的数据不是数组的一部分,它只是一个单独的变量,暂时称它为
head;同时
你的类中有另一个变量紧挨着它,暂时称它为tail
。现在,当你加载head
到缓存的时候,你也免费加载了tail
。
现在看起来还不错。直到生产者要将生产的内容放到tail,此时
head中保存的内容也
正被消费者(译注:和生产者不在同一个内核中)消费。这两个变量并无直接联系,但需要被这两个线程使用,且这两个线程运行在不同的内核(译注:这里是指物理上的内核,即多核CPU)中。 设想某一时刻消费者更新了
head
的值。缓存中的值和内存中的值都被更新了,而其他所有存储head
的缓存行都会都会失效,因为其它缓存中head
不是最新值了。而我们必须以整个缓存行作为单位来处理,不能只把head
标记为无效。
假如此时生产者进程要访问tail中的内容,就导致整个缓存行需要从主内存重新读取,因为缓存未命中。一个和消费者无关的进程(生产者),想要访问一个和head无关的数据(tail),却被意外拖慢了速度。
如果两个独立的线程同时写这两个值会更糟。因为每次线程对缓存行进行写操作时,每个内核都要把另一个内核上的缓存块无效掉并重新读取里面的数据(译注:这里假设每个线程独占一个内核)。尽管它们写入的是不同的变量,但几乎等于两个线程之间的写冲突。
这叫作“伪共享”(False sharing),因为每次你访问
head
你也会得到tail
,而且每次你访问tail
,你也会得到head
。这一切都在后台发生,并且没有任何编译警告会告诉你,你正在写一个并发访问效率很低的代码。解决方案-神奇的缓存行填充
Disruptor采用了缓存行填充的方法,来消除这个问题。这种做法适用于64字节(或更小)的处理器架构,通过增加补全来确保ring buffer的序列号不会和其他数据同时存在于一个缓存行中。
public long p1, p2, p3, p4, p5, p6, p7; // cache line padding private volatile long cursor = INITIAL_CURSOR_VALUE; public long p8, p9, p10, p11, p12, p13, p14; // cache line padding
(译注:前后各七位填充字段,保证cursor[1]在缓冲行中任意位置,其周围都有足够的填充字段)
因此没有伪共享,就没有和其它任何变量的意外冲突,没有不必要的缓存未命中。 在你的
Entry
类中也值得这样做,如果你有不同的消费者往不同的字段写入,你需要确保各个字段间不会出现伪共享。译者注:
[1]不同于传统队列的head、tail、size variables定义的队列,Disruptor对外只有一个变量,那就是队尾元素的下标,Disruptor称其为cursor。