上一講,咱們學習了一個精妙的想法,Disruptor經過緩存行填充,來利用好CPU的高速緩存。不知道你作完課後思考題以後,有沒有體會到高速緩存在實踐中帶來的速度提高呢?java
不過,利用CPU高速緩存,只是Disruptor「快」的一個因素,那今天咱們就來看一看Disruptor快的另外一個因素,也就是「無鎖」,而儘量發揮CPU自己的高速處理性能。數組
Disruptor做爲一個高性能的生產者-消費者隊列系統,一個核心的設計就是經過RingBuffer實現一個無鎖隊列。緩存
上一講裏咱們講過,Java裏面的基礎庫裏,就有像LinkedBlockingQueue這樣的隊列庫。可是,這個隊列庫比起Disruptor利用的RingBuffer要慢上不少。慢的第益個緣由咱們說過,
由於鏈表的數據在內存裏面的佈局對於高速緩存並不友好,而RingBuffer所使用的數組則否則。服務器
LinkedBlockingQueue慢,有另一個重要的因素,那就是它對於鎖的依賴。在生產者-消費者模式裏,咱們可能有多個消費者,一樣也可能有多個生產者。多個生產者都要往隊列的尾指針裏面添加新的任務,
就會產生多個線程的競爭。因而,在作這個事情的時候,生產者就須要拿到對於隊列尾部的鎖。一樣地,在多個消費者去消費隊列頭的時候,也就產生競爭。一樣消費者也要拿到鎖。函數
那只有意個生產者,或者一個消費者,咱們是否是就沒有這個鎖競爭的問題了呢?很遺憾,答案仍是否認的。通常來講,在生產者-消費者模式下,消費者要比生產者快。否則的話,
隊列會產生積壓,隊列裏面的任務會越堆越多。佈局
一方面,你會發現愈來愈多的任務沒有可以及時完成;另外一方面,咱們的內存也會放不下。雖然生產者-消費者模型下,咱們都有一個隊列來做爲緩衝區,可是一部分狀況下,這個緩衝區裏面是空的。
也就是說,即便只有一個生產者和一個消費者者,這個生產者指向的隊列尾和消費者指向的隊列頭是贊成個節點。因而,這兩個生產者和消費者之間意樣會產生鎖競爭。性能
在LinkedBlockingQueue上,這個鎖機制是經過synchronized這個Java關鍵字來實現的。⼀般狀況下,這個鎖最終會對應到操做系統層面的加鎖機制(OS-based Lock),這個鎖機制須要由操做系統的內核來進行
裁決。這個裁決,也須要經過一次上下文切換(Context Switch),把沒有拿到鎖的線程掛起等待。學習
不知道你還記不記得,咱們在第28講講過的異常和中斷,這裏的上下文切換要作的和異常和中斷裏的是同樣的。上下文切換的過程,須要把當前執行線程的寄存器等等的信息,保存到線程棧裏面。
而這個過程也必然意味着,已經加載到高速緩存裏面的指令或者數據,又回到了主內存裏面,會進一步拖慢咱們的性能。this
咱們能夠按照Disruptor介紹資料裏提到的Benchmark,寫一段代碼來看看,是否是真是這樣的。這裏我放了一段Java代碼,代碼的邏輯很簡單,就是把一個long類型的counter,從0自增到5億。
一種方式是沒有任何鎖,另一個方式是每次自增的時候都要去取一個鎖。atom
你能夠在本身的電腦上試試跑一下這個程序。在我這裏,兩個方式執行所須要的時間分別是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
加鎖很慢,因此Disruptor的解決⽅案就是「⽆鎖」。這個「⽆鎖」指的是沒有操做系統層面的鎖。實際上,Disruptor仍是利用了一個CPU硬件支持的指令,稱之爲CAS(Compare 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;
這個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硬件,搞清楚從高級語言到系統調用,乃至最後的彙編指令,這整個過程是怎麼執行代碼的。而這個,也是學習組成原理這門專欄的意義所在。