重慶網(wǎng)站建設(shè)排名武漢seo首頁
Disruptor 的開發(fā)語言,并不是很多人心目中最容易做到性能極限的 C/C++,而是性能受限于 JVM 的 Java。其實(shí)只要通曉硬件層面的原理,即使是像 Java 這樣的高級語言,也能夠把 CPU 的性能發(fā)揮到極限。
一、Padding Cache Line,體驗(yàn)高速緩存的威力
Disruptor 里面一段神奇的代碼。這段代碼里,Disruptor 在 RingBufferPad 這個類里面定義了 p1,p2 一直到 p7 這樣 7 個 long 類型的變量。這些變量沒有實(shí)際意義,只是幫助我們進(jìn)行緩存行填充(Padding Cache Line),使得我們能夠盡可能地用上 CPU 高速緩存(CPU Cache)。
abstract class RingBufferPad
{protected long p1, p2, p3, p4, p5, p6, p7;
}
CPU Cache 裝載內(nèi)存里面的數(shù)據(jù),不是一個一個字段加載的,而是加載一整個緩存行。舉個例子,如果我們定義了一個長度為 64 的 long 類型的數(shù)組。那么數(shù)據(jù)從內(nèi)存加載到 CPU Cache 里面的時候,會一次性加載固定長度的一個緩存行。
我們現(xiàn)在的 64 位 Intel CPU 的計算機(jī),緩存行通常是 64 個字節(jié)(Bytes)。一個 long 類型的數(shù)據(jù)需要 8 個字節(jié),所以一下子會加載 8 個 long 類型的數(shù)據(jù)。也就是說,一次加載數(shù)組里面連續(xù)的 8 個數(shù)值。這樣的加載方式可以加快遍歷數(shù)組元素時的速度。因?yàn)楹竺孢B續(xù) 7 次的數(shù)據(jù)訪問都會命中緩存,不需要重新從內(nèi)存里面去讀取數(shù)據(jù)。
但是,不使用數(shù)組,而是使用單獨(dú)的變量的時候,這里就會出現(xiàn)問題了。在 Disruptor 的 RingBuffer(環(huán)形緩沖區(qū))的代碼里面,定義了一個 RingBufferFields 類,里面有 indexMask 和其他幾個變量,用來存放 RingBuffer 的內(nèi)部狀態(tài)信息。
CPU 在加載數(shù)據(jù)的時候,自然也會把這個數(shù)據(jù)從內(nèi)存加載到高速緩存里面來。不過,這個時候,高速緩存里面除了這個數(shù)據(jù),還會加載這個數(shù)據(jù)前后定義的其他變量。問題就來了,Disruptor 是一個多線程的服務(wù)器框架,在這個數(shù)據(jù)前后定義的其他變量,可能會被多個不同的線程去更新數(shù)據(jù)、讀取數(shù)據(jù)。這些寫入以及讀取的請求,會來自于不同的 CPU Core。于是,為了保證數(shù)據(jù)的同步更新,我們不得不把 CPU Cache 里面的數(shù)據(jù),重新寫回到內(nèi)存里面去或者重新從內(nèi)存里面加載數(shù)據(jù)。
CPU Cache 的寫回和加載,都不是以一個變量作為單位的。這些動作都是以整個 Cache Line 作為單位的。所以,當(dāng) INITIAL_CURSOR_VALUE 前后的那些變量被寫回到內(nèi)存的時候,這個字段自己也寫回到了內(nèi)存,這個常量的緩存也就失效了。要再次讀取這個值的時候,還需重新從內(nèi)存讀取。這也就意味著,讀取速度大大變慢了。
面臨這樣一個情況,Disruptor 里有一種神奇的代碼技巧,就是緩存行填充。Disruptor 在 RingBufferFields 里面定義的變量的前后,分別定義了 7 個 long 類型的變量。前面的 7 個來自繼承的 RingBufferPad 類,后面的 7 個則是直接定義在 RingBuffer 類里面。這 14 個變量沒有任何實(shí)際的用途。我們既不會去讀他們,也不會去寫他們。
......abstract class RingBufferPad
{protected long p1, p2, p3, p4, p5, p6, p7;
}abstract class RingBufferFields<E> extends RingBufferPad
{...... private final long indexMask;private final Object[] entries;protected final int bufferSize;protected final Sequencer sequencer;......
}public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventSequencer<E>, EventSink<E>
{...... protected long p1, p2, p3, p4, p5, p6, p7;......
}
?RingBufferFields 里面定義的這些變量都是 final 的,第一次寫入之后不會再進(jìn)行修改。所以,一旦它被加載到 CPU Cache 之后,只要被頻繁地讀取訪問,就不會再被換出 Cache 了。這也就意味著,對于這個值的讀取速度,會是一直是 CPU Cache 的訪問速度,而不是內(nèi)存的訪問速度。
二、使用 RingBuffer,利用緩存和分支預(yù)測
利用 CPU Cache 性能的思路,貫穿了整個 Disruptor。Disruptor 整個框架,其實(shí)就是一個高速的生產(chǎn)者 - 消費(fèi)者模型(Producer-Consumer)下的隊(duì)列。生產(chǎn)者不停地往隊(duì)列里面生產(chǎn)新的需要處理的任務(wù),而消費(fèi)者不停地從隊(duì)列里面處理掉這些任務(wù)。
實(shí)現(xiàn)一個隊(duì)列,最合適的數(shù)據(jù)結(jié)構(gòu)應(yīng)該是鏈表。只要維護(hù)好鏈表的頭和尾,就能很容易實(shí)現(xiàn)一個隊(duì)列。生產(chǎn)者只要不斷地往鏈表的尾部不斷插入新的節(jié)點(diǎn),而消費(fèi)者只需要不斷從頭部取出最老的節(jié)點(diǎn)進(jìn)行處理就好了。實(shí)際上,Java 自己的基礎(chǔ)庫里面就有 LinkedBlockingQueue 這樣的隊(duì)列庫,可以直接用在生產(chǎn)者 - 消費(fèi)者模式上。
不過,Disruptor 里面并沒有用 LinkedBlockingQueue,而是使用了一個 RingBuffer 這樣的數(shù)據(jù)結(jié)構(gòu),這個 RingBuffer 的底層實(shí)現(xiàn)是一個固定長度的數(shù)組。比起鏈表形式的實(shí)現(xiàn),數(shù)組的數(shù)據(jù)在內(nèi)存里面會存在空間局部性。
數(shù)組的連續(xù)多個元素會一并加載到 CPU Cache 里面來,所以訪問遍歷的速度會更快。而鏈表里面各個節(jié)點(diǎn)的數(shù)據(jù),多半不會出現(xiàn)在相鄰的內(nèi)存空間,自然也就享受不到整個 Cache Line 加載后數(shù)據(jù)連續(xù)從高速緩存里面被訪問到的優(yōu)勢。
除此之外,數(shù)據(jù)的遍歷訪問還有一個很大的優(yōu)勢,就是 CPU 層面的分支預(yù)測會很準(zhǔn)確??梢愿行У乩?CPU 里面的多級流水線,使程序就會跑得更快。
三、無鎖的 RingBuffer
(一)緩慢的鎖
Disruptor 作為一個高性能的生產(chǎn)者 - 消費(fèi)者隊(duì)列系統(tǒng),一個核心的設(shè)計就是通過 RingBuffer 實(shí)現(xiàn)一個無鎖隊(duì)列。
Java 里面的基礎(chǔ)庫里像?LinkedBlockingQueue 這樣的隊(duì)列庫比起 Disruptor 里用的 RingBuffer 要慢上很多。慢的第一個原因我們說過,因?yàn)?strong>鏈表的數(shù)據(jù)在內(nèi)存里面的布局對于高速緩存并不友好,而 RingBuffer 所使用的數(shù)組則不然。
另外一個重要的因素是?LinkedBlockingQueue 對鎖的依賴較強(qiáng)。在生產(chǎn)者 - 消費(fèi)者模式里,我們可能有多個消費(fèi)者,同樣也可能有多個生產(chǎn)者。多個生產(chǎn)者都要往隊(duì)列的尾指針里面添加新的任務(wù),就會產(chǎn)生多個線程的競爭。于是,在做這個事情的時候,生產(chǎn)者就需要拿到對于隊(duì)列尾部的鎖。同樣地,在多個消費(fèi)者去消費(fèi)隊(duì)列頭的時候,也就產(chǎn)生競爭。同樣消費(fèi)者也要拿到鎖。
就算只有一個生產(chǎn)者,一個消費(fèi)者,也是需要鎖的。一般來說,在生產(chǎn)者 - 消費(fèi)者模式下,消費(fèi)者要比生產(chǎn)者快。不然的話,隊(duì)列會產(chǎn)生積壓,隊(duì)列里面的任務(wù)會越堆越多。任務(wù)不能及時完成,內(nèi)存也會放不下。雖然生產(chǎn)者 - 消費(fèi)者模型下,有一個隊(duì)列來作為緩沖區(qū),但是大部分情況下,這個緩沖區(qū)里面是空的。也就是說,即使只有一個生產(chǎn)者和一個消費(fèi)者者,這個生產(chǎn)者指向的隊(duì)列尾和消費(fèi)者指向的隊(duì)列頭是同一個節(jié)點(diǎn)。因此,生產(chǎn)者和消費(fèi)者之間一樣會產(chǎn)生鎖競爭。
在 LinkedBlockingQueue 上,這個鎖機(jī)制是通過 Java 基礎(chǔ)庫 ReentrantLock?來實(shí)現(xiàn)的。這個鎖是一個用 Java 在 JVM 上直接實(shí)現(xiàn)的加鎖機(jī)制,鎖機(jī)制需要由 JVM 來進(jìn)行裁決。鎖的爭奪,會把沒有拿到鎖的線程掛起等待,也就需要經(jīng)過一次上下文切換(Context Switch)。
上下文切換的過程,需要把當(dāng)前執(zhí)行線程的寄存器等的信息,保存到線程棧里面。而這個過程也必然意味著,已經(jīng)加載到高速緩存里面的指令或者數(shù)據(jù),又回到了主內(nèi)存里面,會進(jìn)一步拖慢性能。
加鎖和不加鎖自增到5億性能對比:
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();
Time spent is 207ms without lock
Time spent is 9603ms with lock
(二)無鎖的 RingBuffer
加鎖很慢,所以 Disruptor 的解決方案就是“無鎖”。這個“無鎖”指的是沒有操作系統(tǒng)層面的鎖。實(shí)際上,Disruptor 還是利用了一個 CPU 硬件支持的指令,稱之為 CAS(Compare And Swap,比較和交換)。在 Intel CPU 里面,這個對應(yīng)的指令就是 cmpxchg。
Disruptor 的 RingBuffer 是這么設(shè)計的:和直接在鏈表的頭和尾加鎖不同,Disruptor 的 RingBuffer 創(chuàng)建了一個 Sequence 對象,用來指向當(dāng)前的 RingBuffer 的頭和尾。這個頭和尾的標(biāo)識不是通過指針來實(shí)現(xiàn)的,而是通過一個序號。
在這個 RingBuffer 當(dāng)中,進(jìn)行生產(chǎn)者和消費(fèi)者之間的資源協(xié)調(diào),采用的是對比序號的方式。當(dāng)生產(chǎn)者想要往隊(duì)列里加入新數(shù)據(jù)的時候,它會把當(dāng)前的生產(chǎn)者的 Sequence 的序號,加上需要加入的新數(shù)據(jù)的數(shù)量,然后和實(shí)際的消費(fèi)者所在的位置進(jìn)行對比,看看隊(duì)列里是不是有足夠的空間加入這些數(shù)據(jù),而不會覆蓋掉消費(fèi)者還沒有處理完的數(shù)據(jù)。
在 Sequence 的代碼里面,就是通過 compareAndSet 這個方法,并且最終調(diào)用到了 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;
這個 CAS 指令,也就是比較和交換的操作,并不是基礎(chǔ)庫里的一個函數(shù)。它也不是操作系統(tǒng)里面實(shí)現(xiàn)的一個系統(tǒng)調(diào)用,而是一個 CPU 硬件支持的機(jī)器指令。在我們服務(wù)器所使用的 Intel CPU 上,就是 cmpxchg 這個指令。
compxchg [ax] (隱式參數(shù),EAX累加器), [bx] (源操作數(shù)地址), [cx] (目標(biāo)操作數(shù)地址)
cmpxchg 指令,一共有三個操作數(shù),第一個操作數(shù)不在指令里面出現(xiàn),是一個隱式的操作數(shù),也就是 EAX 累加寄存器里面的值。第二個操作數(shù)就是源操作數(shù),并且指令會對比這個操作數(shù)和上面的累加寄存器里面的值。如果值是相同的,那一方面,CPU 會把 ZF(也就是條件碼寄存器里面零標(biāo)志位的值)設(shè)置為 1,然后再把第三個操作數(shù)(也就是目標(biāo)操作數(shù)),設(shè)置到源操作數(shù)的地址上。如果不相等的話,就會把源操作數(shù)里面的值,設(shè)置到累加器寄存器里面。
單個指令是原子的,這也就意味著在使用 CAS 操作的時候,不再需要單獨(dú)進(jìn)行加鎖,直接調(diào)用就可以了。沒有了鎖,CPU 這部高速跑車就像在賽道上行駛,不會遇到需要上下文切換這樣的紅燈而停下來。雖然會遇到像 CAS 這樣復(fù)雜的機(jī)器指令,就好像賽道上會有 U 型彎一樣,不過不用完全停下來等待,?CPU 運(yùn)行起來仍然會快很多。
那么,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();}
?和上面的 counter 自增一樣,只不過這一次,自增采用了 AtomicLong 這個 Java 類。里面的 incrementAndGet 最終到了 CPU 指令層面,在實(shí)現(xiàn)的時候用的就是 CAS 操作。可以看到,它所花費(fèi)的時間,雖然要比沒有任何鎖的操作慢上一個數(shù)量級,但是比起使用 ReentrantLock 這樣的操作系統(tǒng)鎖的機(jī)制,還是減少了一半以上的時間。
當(dāng)想要追求最極致的性能的時候,我們會從應(yīng)用層、貫穿到操作系統(tǒng),乃至最后的 CPU 硬件,搞清楚從高級語言到系統(tǒng)調(diào)用,乃至最后的匯編指令,這整個過程是怎么執(zhí)行代碼的。而這個,也是學(xué)習(xí)組成原理的意義所在。
【推薦閱讀】Disruptor 官方文檔,里面不僅包含了怎么用好 Disruptor,也包含了整個 Disruptor 框架的設(shè)計思路,是一份很好的閱讀學(xué)習(xí)材料。另外,Disruptor 的官方文檔里,還有很多文章、演講,詳細(xì)介紹了這個框架,很值得深入去看一看。Disruptor 的源代碼其實(shí)并不復(fù)雜,很適合用來學(xué)習(xí)怎么閱讀開源框架代碼。
課程鏈接:深入淺出計算機(jī)組成原理_組成原理_計算機(jī)基礎(chǔ)-極客時間?