1. 程式人生 > >Disruptor:一種高效能的、在併發執行緒間資料交換領域用於替換有界限佇列的方案

Disruptor:一種高效能的、在併發執行緒間資料交換領域用於替換有界限佇列的方案

Disruptor一種高效能的、在併發執行緒間資料交換領域用於替換有界限佇列的方案

Martin Thompson

Dave Farley

Micheal Barker

Patricia Gee

Andrew Stewart

摘要

LMAX公司被建立去構建一種高效能的金融交易平臺。作為我們為達到這樣的目標所做的工作的一部分,我們論證了一些設計這個系統的方案。但是隨著我們的測試,我們發現傳統方案的一些根本的侷限性。

許多應用使用佇列來實現在其執行緒間的資料互動。通過測試我們發現,非常戲劇性的——使用佇列造成的延遲與磁碟IO操作(RAIDSSD磁碟)造成的延遲同樣的多!如果在一個端對端操作中使用多個佇列,這將會增加數百毫秒的總延遲。顯然,這是一個需要優化的領域。

進一步的研究和專注於電腦科學的學習使我們認識到,傳統方案固有的合併特點導致了在多執行緒實現中出現了爭用現象,這意味著或許應該有更好的解決辦法。

考慮下現代CPU的工作原理,有一種我們稱之為硬體機制共鳴的程式設計優化方法,使用優化設計方案、專注於分離問題我們最後建立了一套資料結構和對應的設計模式,我們稱之為Disruptor

測試表明,使用Disruptor的三個執行緒管道的平均延遲要比相當的使用佇列的方案低得多,而且在同樣的配置下,Disruptor的吞吐量大約為佇列的8倍。

這些效能上的提高,使我們開始重新思考併發程式設計的方式。Disruptor,這個全新的模式對於任何需要高吞吐低延遲特性的非同步事件驅動架構來說都是一個理想的借鑑基礎。

LMAX公司,我們在Disruptor模式基礎上構建了次序匹配引擎、實時風險管理系統、高可用性記憶體事務處理系統,這些專案都獲得了巨大的成功。這些系統的效能都為業界設定了新的標杆——在我們看來、在目前一段可以預期的時間內不會被超越!

Disruptor並不是只能應用於金融業的解決方案,它是一種通用的機制,以簡單的實現來最大程度的提高效能,用來解決併發程式設計中的複雜問題。儘管Disruptor中的一些概念似乎不大主流,但是以我們的成功經驗證明,使用這種模式構建系統要比使用同類機制實現起來簡單得多。

Disruptor框架顯著的減少了寫的爭用、具有更低的系統開銷和比之同類其他機制更好的快取(譯者注:這裡指

CPU的快取)友好特性,所有這些優點使Disruptor具有更加強大的吞吐效能和平穩的低延遲處理能力。使用中等的時鐘頻率的處理器,我們測試得到了每秒2500萬訊息傳送,上述延遲低於50納秒(譯者注:1納秒=一秒的10億分之一,神乎其技啊~~!)。這樣的效能相對於我們見過的任何其他的實現方案來說都是顯著的提高。這已經非常接近現代CPU處理器在核心之間交換資料的理論極限了。(譯者注:再次驚歎,神乎其技啊!)

概述

Disruptor是我們在LMAX公司構建世界上最快的高效能金融交易平臺過程中的研究成果。早期,我們的設計是基於SEDA派生和Actors模式的,使用管道來提供吞吐量。在評測了各種不同的實現之後,事實證明在各個管道的事件排隊是主要的系統消耗來源。我們也發現佇列產生相當的延遲和較高的時間偏差。我們花費了大量的精力去實現更高效能的佇列,但是,事實證明佇列作為一種基礎的資料結構帶有它的侷限性——在生產者、消費者、以及它們的資料儲存之間的合併設計問題。Disruptor就是我們在構建這樣一種能夠清晰地分割這些關注問題的資料結構過程中所誕生的成果。

併發的複雜性

在我們這一段裡我們來討論併發。在電腦科學中,併發的意思是兩個或兩個以上的任務同時並行的執行,但是也要通過爭搶來接入資源。爭搶的資源可能是資料庫、檔案系統、套接字、甚至或者說記憶體中的一塊區域。

併發的執行程式碼包括兩個方面:互斥性和改變的可見性。互斥性是指執行緒對資源進行爭用狀態的改變的管理(譯者注:從後面看出,這裡的爭用狀態主要是指寫的操作要保持互斥性),而改變可見性是指控制何時這種改變對其他執行緒可見。很明顯,如果能消除爭用就能夠避免互斥性管理——如果有某種演算法,能夠確保任何給定的資源同一時刻只被一個執行緒修改,那麼互斥性就不是必要的了。讀和寫操作需要所有改變對其他執行緒都是可見的,但是隻有爭用寫操作需要保持互斥性。

在任何併發的環境中,爭用寫操作是花費最大代價的。為了支援多個併發的執行緒對同一塊資源進行寫操作是需要花費複雜而昂貴的代價來進行協調的。最典型的解決這種協調的方法是引入某種鎖的策略。

3.1 鎖的代價

鎖提供了互斥性並且確保改變對其他執行緒的可見性以一種命令式的方式發生。鎖的代價消耗是難以置信的大——因為它們當遇到爭用時需要進行仲裁。這種仲裁是通過一種上下文切換到作業系統的核心,掛起執行緒等待鎖的釋放。在這樣的上下文切換過程中,也會交還控制權給作業系統——作業系統此時可能同時決定去做其他一些請理性的工作,這樣正在執行中的上下文物件將會丟失之前預讀的快取中的資料和指令(譯者注:這裡的快取指的是CPU快取)。對現代CPU而言,這將會造成一系列的效能上的壞的影響。可以使用快速的使用者模式的鎖,但是這也僅當沒有爭用的時候能夠帶來真正的益處。

我們將會舉一個簡單的演示例子說明鎖的代價。這個實驗的主要內容是呼叫一個函式,執行一個迴圈5億次的增量為64bit的計數迴圈。我們用Java編寫這個程式後在2,4GhzIntelWestmere EP處理器(譯者注:一款6核企業級應用CPU)上單執行緒執行,僅僅花費大約300毫秒的時間。其實用什麼語言對這個實驗來說並不重要,使用相同基本底層原語的語言編寫的程式執行時間都差不多。

但是,一旦使用了鎖來提供互斥性,那麼即使當鎖還未被爭搶的時候,資源的消耗(譯者注:這裡主要指時間上的損耗)仍會顯著的提高。而當多個執行緒開始發生爭用現象時,花費在巨大的排隊操作上的工作將會使程式對資源的消耗繼續增加。這個小實驗的結果如下面的表格所示:


3.2 CAS

在需要修改的記憶體資料僅為一個字長時,有些更有效的方案可以用來替代鎖來進行記憶體修改。這些替代方案是基於原子性、互鎖性的現代CPU實現的指令的。這些指令通常被稱為“CAS”(CompareAnd Swap)操作,例如x86處理器上的"lockcmpxchg"CAS操作是一種特殊的機器碼指令,它在一定條件下可以允許對記憶體中一個字長的操作成為原子操作。在上一小節的計數器小實驗中,每個執行緒輪流在一個迴圈中讀取計算器並嘗試在同一個原子指令中將計算器累加為新的值。累加後的新值和累加前的舊值一起作為該指令的引數,如果指令執行後計數器的值與指令的新值引數相同,則指令執行成功,將計數器的值置為新值;否則,如果計數器此時與指令的新值引數不同,則該CAS操作失敗,此時回到執行緒重新讀取計數器增量加到原來的新值引數上作為新的新值引數(譯者注:即設指令為f,新值引數為B,舊值引數為A,計數增量為k:由f(A,B)改為f(B,B+k)重新執行指令),重複上述過程,直到指令執行成功。CAS方法比鎖要有效得多,因為它不需要切換上下文到作業系統核心去仲裁。但是,CAS操作也並不是沒有資源消耗的,處理器必須鎖定它的指令通道去確保原子性,並且要建立一個記憶體柵欄(memorybarrier)來使得狀態的變化對其他執行緒可見。具體到實際的程式設計實現上,CAS操作在Java開發中,可以使用java.util.concurrent.Atomic.*包中的類來實現。

如果程式的關鍵部分要比我們上面舉的計數器例子複雜,這就需要更加複雜的使用多個CAS操作的機器指令來協調執行緒間的爭用。用鎖來編寫併發程式很難;用CAS操作和記憶體柵欄開發無鎖演算法來編寫併發程式有時候更難!而且這樣的程式更加難以測試,難以確保其正確性!

理想的演算法應該是僅僅使用一個單執行緒來處理所有的對一個資源的寫操作,而有多個執行緒執行讀取處理結果的操作。在一個多處理器或多核處理器環境下處理對資源的讀操作需要記憶體柵欄來確保一個執行緒狀態改變對其他處理器上執行的執行緒可見。

3.3 記憶體柵欄

現代的處理器為了提高效率,採用無序的方式執行其指令、在記憶體和對應的執行單元間載入和儲存資料。處理器僅僅需要確保程式邏輯執行出正確的結果而不去關心其執行順序。這不是單執行緒程式的特性。但是當執行緒間彼此共享狀態時為了確保資料交換的成功處理,在需要的時點,記憶體的改變能夠按次序發生就是很重要的了。記憶體柵欄是處理器用來指出程式碼塊在哪裡修改記憶體是需要有序的進行的。它們是硬體排序和執行緒間保持彼此改變可見性的重要手段。編譯器會在適當的位置設定合適的軟體柵欄來確保程式碼按照正確的順序編譯,處理器本身也會使用這樣的軟體柵欄作為硬體柵欄的一種補充。

現代的處理器要比同代的記憶體快得多的多。為了填補這樣一個速度差距的鴻溝,CPU使用了複雜的快取系統——非常快的通過硬體實現的無鏈雜湊表。這些快取系統通過訊息傳遞協議與其他處理器CPU的快取系統保持協調一致。另外作為補充,處理器的儲存緩衝可以將寫操作從上述緩衝上解除安裝下來,在一個寫操作將要發生的時候,快取協調協議通過這樣的一個失效佇列快速的通知失效訊息。

這些對於資料來說意味著,當某個資料值的最後一個版本剛剛被寫操作執行之後,將會被儲存登記給一個儲存緩衝——可能是CPU的某一層快取、或者是一塊記憶體區域。如果執行緒想要共享這一資料,那麼它需要以一種有序的方式輪流對其他執行緒可見,這是通過處理器協調訊息的協調來實現的。這些及時的協調訊息的生成,是又記憶體柵欄來控制的。

讀操作記憶體柵欄對CPU的載入指令進行排序,通過失效佇列來得知當前快取的改變。這使得讀操作記憶體柵欄對其之前的已排序的寫操作有了一個持久化的視界。

寫操作記憶體柵欄對CPU的儲存指令進行排序,通過儲存緩衝執行,因此,通過對應的CPU快取來重新整理寫輸出。寫操作記憶體柵欄提供了一個在其之前的儲存操作如何發生的、有序的視界。

一個完整的記憶體柵欄即對載入排序也對儲存排序,但這隻針對執行該柵欄的CPU

一些CPU還有上述三種元件的變體,但是介紹這三種元件已經足夠來理解相關的複雜聯絡了。在Java的記憶體模型中,對一個volatile型別成員變數的域的讀和寫,分別實現了讀記憶體柵欄和寫記憶體柵欄。(譯者注:對volatile型別的成員變數虛擬機器不採用優化策略,即不在每個執行緒中儲存其副本,每次讀取和修改都將到共享的記憶體域中進行)這在關於Java記憶體模型的一篇文章中已經有很詳細的描述(http://www.ibm.com/developerworks/library/j-jtp02244/index.html),上述這種特性已經隨著Java 5一起釋出。

3.4 快取行

(譯者注:這裡快取行實際上是單詞cacheline的拙劣翻譯-_-!意思是CPU快取與實體記憶體間互動時所一次發生的資料,一般為64個位元組)

現代處理器使用快取的方式對成功的高效能操作而言具有重要的意義。這種處理器架構在資料攪動和指令儲存方面有極大作用,反之,如果快取出現丟失的話將對系能造成極大的影響。

我們的硬體在移動記憶體資料的時候不是以位元組和字長為單位的,為了更加有效的工作,快取被組織成快取行的形式,每個快取行為32-256個位元組大小,一般是64位元組。這是快取協調協議操作的粒度層級。這意味著如果兩個變數在同一個快取行內,並且它們是被兩個不同的執行緒寫入的話,它們將會呈現出相同的寫爭用問題,就好像它們是一個變數一樣!這就是偽共享概念。所以為了提高效能,應該確保獨立的且併發的寫操作、並且寫操作的變數不在同一個快取行內,可以使爭用最小化。

當訪問記憶體時,一個具有預讀功能的CPU會通過預讀的方式來減少訪問時花費的延遲——CPU會預讀有可能下次需要訪問的資料、並在後臺將其提取到快取中。這種機制僅僅在處理器偵測到某種訪問上的模式是時候啟動,就好像是本來一步一步走路的記憶體訪問突然來了個跳躍一樣。比如,在對一個數組中的內容進行遍歷的時候,上述的預讀跳躍是會啟動的,所以相應的記憶體資料會被提前提取到CPU快取中,最大可能的提高了訪問效率。上述跳躍一般來講是不大於2048個位元組的,或者說CPU能夠預測到的位元組也不大於這個數字。但是,像連結串列或樹集這種由分散在記憶體空間不同位置的節點組成的資料結構,是沒有辦法啟動預讀跳躍的。這種在記憶體中沒有規律的儲存限制了系統預讀記憶體行,會導致記憶體的訪問效率下降兩個數量級。

3.5 佇列所帶來的問題

一般來說佇列是使用連結串列或者陣列來作為其中元素的基本儲存的。如果一個記憶體中的佇列沒有被限制大小成為無界佇列時,在很多種類的問題當中它可能會沒有被校驗的增長——直到災難性的出現記憶體耗盡為止,這種情況發生在生產者的比消費者快的時候。無界的佇列在生產者確保不會跑的比消費者快的並且記憶體資源比較稀缺的系統中比較有用,但是如果這種假設不成立佇列變得無限制的增長的話總會有一定的風險的。為了避免這種災難發生,佇列一般會被限制大小成為有界佇列,方法是要麼使用陣列來實現佇列、要麼實時的去跟蹤佇列的大小。

佇列的實現可能會在隊首、隊尾和記錄佇列大小的變數上發生寫操作爭用。在使用過程中,由於生產者和消費中跑的快慢不同,佇列總是處於將滿或者將空狀態,而很少處於一種生產者快慢相當的平衡的中間狀態。這種大部分時間處於將滿或將空狀態的傾向導致了大量的爭用和昂貴的快取協調代價。而且問題還在於即使用不同的併發物件(比如鎖或CAS變數)來分別實現頭尾機制,它們通常會佔用同一塊快取行。(譯者注:發生偽共享,導致並行失敗)

使用單個大粒度的帶鎖佇列,生產者宣告隊首、消費者宣告隊尾、中間的儲存節點用來設計併發,這樣的實現管理起來非常複雜。佇列上為了puttake操作的大粒度的鎖實現起來很簡單,但是會導致吞吐量上很大的瓶頸問題。如果只使用佇列自有的語義模型來消除併發矛盾的話,那麼除了單生產者單消費者這種情況之外,其他情況實現起來相當複雜。

Java中使用佇列還有另外一個問題,佇列是很容易產生垃圾的結構。首先物件會被分配並置放在佇列中,其次如果是使用連結串列實現的佇列,那麼物件需要被分配去實現連結串列中的節點。當不再被引用的時候,所有這些為支援佇列實現所分配的物件需要被重新宣告。(譯者注:實際上是說在Java中,佇列的垃圾回收代價很大,特別是對連結串列式佇列而言)

3.6 管線和圖

在許多種類的問題中把幾個階段處理捆綁為一個管線是個好辦法,這些管線一般具有並行的路徑,組成一種圖狀的拓撲結構。每個階段之間的連結一般使用佇列來實現,每個階段具有其自己的執行緒。

這種方案的代價可不便宜——在每個階段我們不得不花費工作單元進隊和出隊的開銷。當有多個目標其路徑分叉時會加倍這種開銷,並且當上述分叉路徑必須合併時也會遭受無法避免的爭用的代價。

如果處理圖狀依賴拓撲結構時,能夠避免在各階段之間使用佇列的開銷的話,那將是非常理想的。

4  LMAX Disruptor的獨特設計

在試著定位上面幾段中描述的那些個問題的時候,一個通過嚴格剔除像佇列導致的一系列問題為目標的設計浮現出來了。這個方案關注確保任何一塊資料同一時刻只被一個執行緒執行寫操作,因此便消除了寫爭用,這便是“Disruptor”框架。之所以叫這個名字,是因為它在處理依賴性拓撲結構的時候與Java 7中支援分叉合併的"Phasers"(譯者注:Java7中引入的一種新的併發特性,屬於一種新型併發barrier)有著相似的地方。

LMAX公司的Disruptor框架的設計被定位於上述問題,通過嘗試最大化記憶體分配的效率、以快取友好的工作方式優化在現代硬體上的效能。

處於Disruptor機制中心臟地位的是一個預先分配的有界資料結構形式——環狀緩衝。資料通過一個或多個生產者新增到環狀緩衝中,並通過一個或多個消費者從其中取出處理。

4.1 記憶體分配

環狀緩衝(ringbuffer)的所有記憶體空間是在啟動的時候預先分配好的。環狀緩衝既可以儲存一整個陣列的指向實體的指標、或者是代表實體本身的資料結構。由於Java語言本身的限制意味著實體是以物件的引用的形式存放在環狀緩衝中的。每一個實體一般並不是直接存放的,而是放在一個容器裡,而把容器放在緩衝中。這種實體存放空間的預分配的形式終結了支援垃圾回收機制的語言所帶來的問題,因為實體會被重複使用並在Disruptor例項的生命週期中一致存在。這些實體的記憶體空間是在同一時刻分配好的,並且一般來說是在記憶體中連續的一塊地址,因此支援快取跳躍(譯者注:即前文中提到的預讀跳躍)。John Rose有一篇關於Java語言的建議,介紹什麼樣的值型別允許Java的陣列能夠像其他語言、例如C語言中那樣確保分配給其的記憶體是連續的,從而避免使用指標定址。

在一個像Java這樣被管理的執行時環境中開發低延遲系統時,垃圾回收可能會是個問題。分配的記憶體越多,垃圾收集器的負擔就越大。當物件的生命週期都極短或者物件永不銷燬的情況下,垃圾收集器可以達到最佳效能(譯者注:實際上是最小的負擔)。環狀緩衝中的實體記憶體是預先分配好的,意味著它在垃圾回收器工作的時候是永不銷燬的,所以只帶來很少的負擔。

由於基於佇列的系統在高負載時會導致執行率降低、並且導致分配的物件釋放其所在空間上的延遲,所以一代又一代的垃圾收集器都在這一點上進行不斷優化。這有兩層意思:第一,物件不得不在每一代之間進行復制,這導致了不定的延遲。第二,這些物件可能會從舊代中收集,這可能會是更加消耗性的操作,可能增加世界停止一般的暫停,這發生在零碎的記憶體空間被重新壓實的時候。在大記憶體堆中這會導致每GB數秒的暫停。

4.2 梳理影響因素

4.3  使用序列號

4.4  批量效應

當消費者等待環狀緩衝中最新可用的遊標序列號時會有一定機率發生一個在佇列中不會發生的有趣的現象:如果消費者發現與它上次檢查的時候相比,環狀緩衝的遊標已經向前走了許多步的話,它可以直接處理到那個最新的序列號而不必糾纏於併發機制。這樣的結果是本來落後的生產者會重新贏得與之前突然爆發的生產者的賽跑比賽,重新平衡了系統。這種批量效應增加了處理吞吐量並減少和平穩了延遲。根據我們的觀察,在記憶體子系統飽和之前,不管負載多大,這種效應的延遲始終接近一個時間常量,對應的變化曲線是線性的並遵循利特爾法則,這與我們使用佇列時在負載不斷增加時延遲呈指數級增長得到的J形曲線是截然不同的。

4.5  依賴關係

佇列代表著一個生產者與消費者之間的簡單單步管線依賴。如果消費者之間形成了某種鏈狀或圖狀依賴關係的話,在圖狀依賴的每個階段就都需要一個佇列。在圖的各個依賴階段之間導致大量的佇列固定時間消耗。在我們設計LMAX公司的金融交易平臺的時候,我們的研究表明,基於佇列的方案在事務處理中的執行延遲大量是花費在排隊上了(譯者注:大量花費在排隊上而不是事務本身的處理邏輯)。

因為使用Disruptor模式分離了生產者與消費者矛盾,使得我們可以僅僅使用核心的環狀緩衝來表示複雜的多個消費者之間的依賴關係。這減少了大量的執行上的固定消耗,並增加了吞吐處理能力、減少了延遲。

一個環狀緩衝可以用來儲存表示一整個工作流的複雜資料結構的實體。在設計這樣的資料結構的時候必須注意,在被獨立的不同消費者寫入的時候要避免導致快取行的偽共享。

4.6  Disruptor的類結構圖

下面的類圖描述了Disruptor框架的核心關係。如圖中所示,易於使用的類可以簡化程式設計模型。在建立了依賴關係之後,程式設計模式變得很簡單。生產者通過ProducerBarrier使用序列號宣告實體,在宣告好的實體中寫入改變,然後通過ProducerBarrier將實體提交回來並使其可以被消費者使用。而消費者僅僅需要提供一個BatchHandler的實現即可,該實現負責接收當一個新的實體可用時的回撥請求。這種結果驅動程式設計模型是基於事件的,與Actor模型有很多相似的地方。

在分離了使用佇列實現所帶來的問題之後,便可以實現更靈活的設計。Disruptor模式的核心——RingBuffer,可以提供儲存使得資料的交換在不發生爭用的情況下進行。經由生產者、消費者與RingBuffer的互動中分離出了傳統併發所帶來的問題。ProducerBarrier負責管理所有在環狀緩衝中宣告序列位置的併發問題,並跟蹤各個消費者以確保這個環不會纏繞。(譯者注:在RingBuffer這個環狀的跑道上,最快的生產者超過了最慢的消費者,即為環的纏繞。)ConsumerBarrier用來提醒消費者是否有新的實體可用,這樣消費者便可以構造圖狀的依賴關係用來表示一個處理關係中多個階段。


4.7  程式碼示例

下面的程式碼是一個單生產者單消費者的例子,使用了方便的BatchHandler介面來實現消費者。消費者使用單獨的執行緒來接收那些可用的實體。

//Callbackhandler which can be implemented by consumers

finalBatchHandler<ValueEntry>batchHandler = newBatchHandler<ValueEntry>()

{

    publicvoid onAvailable(final ValueEntryentry) throws Exception

    {

        //process a new entry as it becomesavailable.

    }

    publicvoid onEndOfBatch() throws Exception

    {

        //useful for flushing results to an IOdevice if necessary.

    }

    publicvoid onCompletion()

    {

        //do any necessary clean up beforeshutdown

    }

};

RingBuffer<ValueEntry>ringBuffer=

    newRingBuffer<ValueEntry>(ValueEntry.ENTRY_FACTORY,SIZE,

                              ClaimStrategy.Option.SINGLE_THREADED,

                               WaitStrategy.Option.YIELDING);

ConsumerBarrier<ValueEntry>consumerBarrier= ringBuffer.createConsumerBarrier();       

BatchConsumer<ValueEntry>batchConsumer= 

    newBatchConsumer<ValueEntry>(consumerBarrier,batchHandler);

ProducerBarrier<ValueEntry>producerBarrier= ringBuffer.createProducerBarrier(batchConsumer);   

//Eachconsumer can run on a separate thread

EXECUTOR.submit(batchConsumer);

//Producersclaim entries in sequence

ValueEntryentry= producerBarrier.nextEntry();

//copydata into the entry container

//makethe entry available to consumers

producerBarrier.commit(entry);

5 吞吐量效能測試

作為對比,我們選取了DougLea的優秀資料結構java.util.concurrent.ArrayBlockingQueue來作為參照,這種佇列在我們測試中在所有的有界佇列中是效能最好的。這些測試設定成一個阻塞的程式設計用來匹配Disruptor。這些個測試的詳細程式碼已經包含在Disruptor的開源專案裡了。注意:想執行這些測試的話需要能夠並行執行四個執行緒能力的硬體環境。(譯者注:恩,沒錯,這個Disruptor是個白富美框架,想護到她的,實力低於i5雙核4執行緒的就趕緊去升級CPU吧。。。)



在上面的配置中,每條資料流的弧是用ArrayBlockingQueue來實現的,與之對比Disruptor使用的是一種記憶體柵欄。下面的表格列出了每秒運算元的效能測試的結果,使用Java 1.6.0_25 64-bit Sun JVMWindows 7Intel Core i7 860 @2.8GHz不帶超執行緒,另一組使用IntelCore i7-2720QMUbuntu11.04,取3組處理5億次訊息測試中最好的一組作為測試成績。測試在不同的JVM上執行都得到類似的結果,並且如下資料上的差距並不是我們測試觀察中最大的。

 

6 延遲效能測試

為了測量延遲,我們準備了一個三個階段的管線,並生成少於飽和的事件量。這通過在注入一個事件後等待1毫秒然後在注入另一個事件來實現,上述動作執行5千萬次。要做到這如此精密的情況下計時,有必要使用CPU的時間戳計數器了。我們選擇帶有不變TSC值的CPU,因為舊型號的CPU為了節省電源休眠狀態通常切換比較頻繁,IntelNehalem和其之後的處理器使用了不變的TSC,可以在Ubuntu11.04上用最新的OracleJVM訪問。這個測試沒有使用CPU繫結。

為了對比,我們再一次使用了ArrayBlockingQueue,我們本可以用ConcurrentLinkedQueue的,這樣可能會得到更好的測試成績,但是想使用一種有界佇列的實現來避免產生更多後臺壓力使生產者比消費者跑得快。下面的結果是基於2.2GHzCore i7-2720QMJava 1.6.0_25 64-bitUbuntu 11.04

每次迴圈的平均延遲,Disruptor52納秒,ArrayBlockingQueue32757納秒。研究表明,鎖和通過條件變數傳遞訊號是ArrayBlockingQueue主要的延遲來源。

 


7 結論

Disruptor在一個許多應用都要考慮的重要方面,邁出了重要一步,——即如何在一個可預估的延遲範圍內,提高併發執行的上下文吞吐量和減少延遲。我們的測試表明它是具有出色效能的程序間資料互動方案。我們相信對這種資料互動來說,Disruptor是最高效能的機制。通過專注於準確的分離執行緒間資料互動帶來的問題、通過避免寫爭用和最少化的讀爭用、通過保證程式程式碼能夠適應現代處理器的快取模式,我們創造了一種高效的在應用的執行緒間交換資料的機制。

    Disruptor特有的批量效應允許多個消費者在沒有任何爭用搶佔的情況下、在一個確定的起始點下處理輸入,這為高效能系統引入了一個新的特性。對於大多數的系統來說,隨著負載的不斷加大、爭用搶佔的情況不斷增多,系統的延遲會呈指數級增加——典型的“J”形曲線(譯者注:以負載為x軸,延遲為y軸,由壓測資料製作的曲線)。而對於Disruptor,隨著負載的增加,延遲在系統的記憶體飽和之前幾乎是保持平穩的,一條几乎水平的曲線。

我們相信,Disruptor為高效能運算建立了一個新的基準,也(譯者注:在Java應用程式碼層面)為更有效的利用現代處理器和計算機結構提供了新的參考意義。