標籤:

高性能隊列——Disruptor

背景

Disruptor是英國外匯交易公司LMAX開發的一個高性能隊列,研發的初衷是解決內存隊列的延遲問題(在性能測試中發現竟然與I/O操作處於同樣的數量級)。基於Disruptor開發的系統單線程能支撐每秒600萬訂單,2010年在QCon演講後,獲得了業界關注。2011年,企業應用軟體專家Martin Fowler專門撰寫長文介紹。同年它還獲得了Oracle官方的Duke大獎。

目前,包括Apache Storm、Camel、Log4j 2在內的很多知名項目都應用了Disruptor以獲取高性能。在美團點評技術團隊它也有不少應用,有的項目架構借鑒了它的設計機制。本文從實戰角度剖析了Disruptor的實現原理。

需要特別指出的是,這裡所說的隊列是系統內部的內存隊列,而不是Kafka這樣的分散式隊列。另外,本文所描述的Disruptor特性限於3.3.4。

Java內置隊列

介紹Disruptor之前,我們先來看一看常用的線程安全的內置隊列有什麼問題。Java的內置隊列如下表所示。

隊列有界性鎖數據結構ArrayBlockingQueuebounded加鎖linkedlistLinkedBlockingQueueoptionally-bounded加鎖linkedlistConcurrentLinkedQueueunbounded無鎖linkedlistLinkedTransferQueueunbounded無鎖linkedlistPriorityBlockingQueueunbounded加鎖heapDelayQueueunbounded加鎖heap

隊列的底層一般分成三種:數組、鏈表和堆。其中,堆一般情況下是為了實現帶有優先順序特性的隊列,暫且不考慮。

我們就從數組和鏈表兩種數據結構來看,基於數組線程安全的隊列,比較典型的是ArrayBlockingQueue,它主要通過加鎖的方式來保證線程安全;基於鏈表的線程安全隊列分成LinkedBlockingQueue和ConcurrentLinkedQueue兩大類,前者也通過鎖的方式來實現線程安全,而後者以及上面表格中的LinkedTransferQueue都是通過原子變數compare and swap(以下簡稱「CAS」)這種不加鎖的方式來實現的。

通過不加鎖的方式實現的隊列都是無界的(無法保證隊列的長度在確定的範圍內);而加鎖的方式,可以實現有界隊列。在穩定性要求特別高的系統中,為了防止生產者速度過快,導致內存溢出,只能選擇有界隊列;同時,為了減少Java的垃圾回收對系統性能的影響,會盡量選擇array/heap格式的數據結構。這樣篩選下來,符合條件的隊列就只有ArrayBlockingQueue。

ArrayBlockingQueue的問題

ArrayBlockingQueue在實際使用過程中,會因為加鎖和偽共享等出現嚴重的性能問題,我們下面來分析一下。

加鎖

現實編程過程中,加鎖通常會嚴重地影響性能。線程會因為競爭不到鎖而被掛起,等鎖被釋放的時候,線程又會被恢復,這個過程中存在著很大的開銷,並且通常會有較長時間的中斷,因為當一個線程正在等待鎖時,它不能做任何其他事情。如果一個線程在持有鎖的情況下被延遲執行,例如發生了缺頁錯誤、調度延遲或者其它類似情況,那麼所有需要這個鎖的線程都無法執行下去。如果被阻塞線程的優先順序較高,而持有鎖的線程優先順序較低,就會發生優先順序反轉。

Disruptor論文中講述了一個實驗:

  • 這個測試程序調用了一個函數,該函數會對一個64位的計數器循環自增5億次。
  • 機器環境:2.4G 6核
  • 運算: 64位的計數器累加5億次

MethodTime (ms)Single thread300Single thread with CAS5,700Single thread with lock10,000Single thread with volatile write4,700Two threads with CAS30,000Two threads with lock224,000

CAS操作比單線程無鎖慢了1個數量級;有鎖且多線程並發的情況下,速度比單線程無鎖慢3個數量級。可見無鎖速度最快。

單線程情況下,不加鎖的性能 > CAS操作的性能 > 加鎖的性能。

在多線程情況下,為了保證線程安全,必須使用CAS或鎖,這種情況下,CAS的性能超過鎖的性能,前者大約是後者的8倍。

綜上可知,加鎖的性能是最差的。

關於鎖和CAS

保證線程安全一般分成兩種方式:鎖和原子變數。

圖1 通過加鎖的方式實現線程安全

採取加鎖的方式,默認線程會衝突,訪問數據時,先加上鎖再訪問,訪問之後再解鎖。通過鎖界定一個臨界區,同時只有一個線程進入。如上圖所示,Thread2訪問Entry的時候,加了鎖,Thread1就不能再執行訪問Entry的代碼,從而保證線程安全。

下面是ArrayBlockingQueue通過加鎖的方式實現的offer方法,保證線程安全。

public boolean offer(E e) { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lock(); try { if (count == items.length) return false; else { insert(e); return true; } } finally { lock.unlock(); }}

原子變數

原子變數能夠保證原子性的操作,意思是某個任務在執行過程中,要麼全部成功,要麼全部失敗回滾,恢復到執行之前的初態,不存在初態和成功之間的中間狀態。例如CAS操作,要麼比較並交換成功,要麼比較並交換失敗。由CPU保證原子性。

通過原子變數可以實現線程安全。執行某個任務的時候,先假定不會有衝突,若不發生衝突,則直接執行成功;當發生衝突的時候,則執行失敗,回滾再重新操作,直到不發生衝突。

圖2 通過原子變數CAS實現線程安全

如圖所示,Thread1和Thread2都要把Entry加1。若不加鎖,也不使用CAS,有可能Thread1取到了myValue=1,Thread2也取到了myValue=1,然後相加,Entry中的value值為2。這與預期不相符,我們預期的是Entry的值經過兩次相加後等於3。

CAS會先把Entry現在的value跟線程當初讀出的值相比較,若相同,則賦值;若不相同,則賦值執行失敗。一般會通過while/for循環來重新執行,直到賦值成功。

代碼示例是AtomicInteger的getAndAdd方法。CAS是CPU的一個指令,由CPU保證原子性。

/** * Atomically adds the given value to the current value. * * @param delta the value to add * @return the previous value */public final int getAndAdd(int delta) { for (;;) { int current = get(); int next = current + delta; if (compareAndSet(current, next)) return current; }}/** * Atomically sets the value to the given updated value * if the current value {@code ==} the expected value. * * @param expect the expected value * @param update the new value * @return true if successful. False return indicates that * the actual value was not equal to the expected value. */public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update);}

在高度競爭的情況下,鎖的性能將超過原子變數的性能,但是更真實的競爭情況下,原子變數的性能將超過鎖的性能。同時原子變數不會有死鎖等活躍性問題。

偽共享

什麼是共享

下圖是計算的基本結構。L1、L2、L3分別表示一級緩存、二級緩存、三級緩存,越靠近CPU的緩存,速度越快,容量也越小。所以L1緩存很小但很快,並且緊靠著在使用它的CPU內核;L2大一些,也慢一些,並且仍然只能被一個單獨的CPU核使用;L3更大、更慢,並且被單個插槽上的所有CPU核共享;最後是主存,由全部插槽上的所有CPU核共享。

圖3 計算機CPU與緩存示意圖

當CPU執行運算的時候,它先去L1查找所需的數據、再去L2、然後是L3,如果最後這些緩存中都沒有,所需的數據就要去主內存拿。走得越遠,運算耗費的時間就越長。所以如果你在做一些很頻繁的事,你要盡量確保數據在L1緩存中。

另外,線程之間共享一份數據的時候,需要一個線程把數據寫回主存,而另一個線程訪問主存中相應的數據。

下面是從CPU訪問不同層級數據的時間概念:

從CPU到大約需要的CPU周期大約需要的時間主存約60-80nsQPI 匯流排傳輸(between sockets, not drawn)約20nsL3 cache約40-45 cycles約15nsL2 cache約10 cycles約3nsL1 cache約3-4 cycles約1ns寄存器1 cycle

可見CPU讀取主存中的數據會比從L1中讀取慢了近2個數量級。

緩存行

Cache是由很多個cache line組成的。每個cache line通常是64位元組,並且它有效地引用主內存中的一塊兒地址。一個Java的long類型變數是8位元組,因此在一個緩存行中可以存8個long類型的變數。

CPU每次從主存中拉取數據時,會把相鄰的數據也存入同一個cache line。

在訪問一個long數組的時候,如果數組中的一個值被載入到緩存中,它會自動載入另外7個。因此你能非常快的遍歷這個數組。事實上,你可以非常快速的遍歷在連續內存塊中分配的任意數據結構。

下面的例子是測試利用cache line的特性和不利用cache line的特性的效果對比。

package com.meituan.FalseSharing;/** * @author gongming * @description * @date 16/6/4 */public class CacheLineEffect { //考慮一般緩存行大小是64位元組,一個 long 類型佔8位元組 static long[][] arr; public static void main(String[] args) { arr = new long[1024 * 1024][]; for (int i = 0; i < 1024 * 1024; i++) { arr[i] = new long[8]; for (int j = 0; j < 8; j++) { arr[i][j] = 0L; } } long sum = 0L; long marked = System.currentTimeMillis(); for (int i = 0; i < 1024 * 1024; i+=1) { for(int j =0; j< 8;j++){ sum = arr[i][j]; } } System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms"); marked = System.currentTimeMillis(); for (int i = 0; i < 8; i+=1) { for(int j =0; j< 1024 * 1024;j++){ sum = arr[j][i]; } } System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms"); }}

在2G Hz、2核、8G內存的運行環境中測試,速度差一倍。

結果:

Loop times:30ms

Loop times:65ms

什麼是偽共享

ArrayBlockingQueue有三個成員變數:

  • takeIndex:需要被取走的元素下標
  • putIndex:可被元素插入的位置的下標
  • count:隊列中元素的數量

這三個變數很容易放到一個緩存行中,但是之間修改沒有太多的關聯。所以每次修改,都會使之前緩存的數據失效,從而不能完全達到共享的效果。

圖4 ArrayBlockingQueue偽共享示意圖

如上圖所示,當生產者線程put一個元素到ArrayBlockingQueue時,putIndex會修改,從而導致消費者線程的緩存中的緩存行無效,需要從主存中重新讀取。

這種無法充分使用緩存行特性的現象,稱為偽共享。

對於偽共享,一般的解決方案是,增大數組元素的間隔使得由不同線程存取的元素位於不同的緩存行上,以空間換時間。

package com.meituan.FalseSharing;public class FalseSharing implements Runnable{ public final static long ITERATIONS = 500L * 1000L * 100L; private int arrayIndex = 0; private static ValuePadding[] longs; public FalseSharing(final int arrayIndex) { this.arrayIndex = arrayIndex; } public static void main(final String[] args) throws Exception { for(int i=1;i<10;i++){ System.gc(); final long start = System.currentTimeMillis(); runTest(i); System.out.println("Thread num "+i+" duration = " + (System.currentTimeMillis() - start)); } } private static void runTest(int NUM_THREADS) throws InterruptedException { Thread[] threads = new Thread[NUM_THREADS]; longs = new ValuePadding[NUM_THREADS]; for (int i = 0; i < longs.length; i++) { longs[i] = new ValuePadding(); } for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new FalseSharing(i)); } for (Thread t : threads) { t.start(); } for (Thread t : threads) { t.join(); } } public void run() { long i = ITERATIONS + 1; while (0 != --i) { longs[arrayIndex].value = 0L; } } public final static class ValuePadding { protected long p1, p2, p3, p4, p5, p6, p7; protected volatile long value = 0L; protected long p9, p10, p11, p12, p13, p14; protected long p15; } public final static class ValueNoPadding { // protected long p1, p2, p3, p4, p5, p6, p7; protected volatile long value = 0L; // protected long p9, p10, p11, p12, p13, p14, p15; }}

在2G Hz,2核,8G內存, jdk 1.7.0_45 的運行環境下,使用了共享機制比沒有使用共享機制,速度快了4倍左右。

結果:

Thread num 1 duration = 447

Thread num 2 duration = 463

Thread num 3 duration = 454

Thread num 4 duration = 464

Thread num 5 duration = 561

Thread num 6 duration = 606

Thread num 7 duration = 684

Thread num 8 duration = 870

Thread num 9 duration = 823

把代碼中ValuePadding都替換為ValueNoPadding後的結果:

Thread num 1 duration = 446

Thread num 2 duration = 2549

Thread num 3 duration = 2898

Thread num 4 duration = 3931

Thread num 5 duration = 4716

Thread num 6 duration = 5424

Thread num 7 duration = 4868

Thread num 8 duration = 4595

Thread num 9 duration = 4540

備註:在jdk1.8中,有專門的註解@Contended來避免偽共享,更優雅地解決問題。

Disruptor的設計方案

Disruptor通過以下設計來解決隊列速度慢的問題:

  • 環形數組結構

為了避免垃圾回收,採用數組而非鏈表。同時,數組對處理器的緩存機制更加友好。

  • 元素位置定位

數組長度2^n,通過位運算,加快定位的速度。下標採取遞增的形式。不用擔心index溢出的問題。index是long類型,即使100萬QPS的處理速度,也需要30萬年才能用完。

  • 無鎖設計

每個生產者或者消費者線程,會先申請可以操作的元素在數組中的位置,申請到之後,直接在該位置寫入或者讀取數據。

下面忽略數組的環形結構,介紹一下如何實現無鎖設計。整個過程通過原子變數CAS,保證操作的線程安全。

一個生產者

寫數據

生產者單線程寫數據的流程比較簡單:

  1. 申請寫入m個元素;
  2. 若是有m個元素可以寫入,則返回最大的序列號。這兒主要判斷是否會覆蓋未讀的元素;
  3. 若是返回的正確,則生產者開始寫入元素。

圖5 單個生產者生產過程示意圖

多個生產者

多個生產者的情況下,會遇到「如何防止多個線程重複寫同一個元素」的問題。Disruptor的解決方法是,每個線程獲取不同的一段數組空間進行操作。這個通過CAS很容易達到。只需要在分配元素的時候,通過CAS判斷一下這段空間是否已經分配出去即可。

但是會遇到一個新問題:如何防止讀取的時候,讀到還未寫的元素。Disruptor在多個生產者的情況下,引入了一個與Ring Buffer大小相同的buffer:available Buffer。當某個位置寫入成功的時候,便把availble Buffer相應的位置置位,標記為寫入成功。讀取的時候,會遍歷available Buffer,來判斷元素是否已經就緒。

下面分讀數據和寫數據兩種情況介紹。

讀數據

生產者多線程寫入的情況會複雜很多:

  1. 申請讀取到序號n;
  2. 若writer cursor >= n,這時仍然無法確定連續可讀的最大下標。從reader cursor開始讀取available Buffer,一直查到第一個不可用的元素,然後返回最大連續可讀元素的位置;
  3. 消費者讀取元素。

如下圖所示,讀線程讀到下標為2的元素,三個線程Writer1/Writer2/Writer3正在向RingBuffer相應位置寫數據,寫線程被分配到的最大元素下標是11。

讀線程申請讀取到下標從3到11的元素,判斷writer cursor>=11。然後開始讀取availableBuffer,從3開始,往後讀取,發現下標為7的元素沒有生產成功,於是WaitFor(11)返回6。

然後,消費者讀取下標從3到6共計4個元素。

圖6 多個生產者情況下,消費者消費過程示意圖

寫數據

多個生產者寫入的時候:

  1. 申請寫入m個元素;
  2. 若是有m個元素可以寫入,則返回最大的序列號。每個生產者會被分配一段獨享的空間;
  3. 生產者寫入元素,寫入元素的同時設置available Buffer裡面相應的位置,以標記自己哪些位置是已經寫入成功的。

如下圖所示,Writer1和Writer2兩個線程寫入數組,都申請可寫的數組空間。Writer1被分配了下標3到下表5的空間,Writer2被分配了下標6到下標9的空間。

Writer1寫入下標3位置的元素,同時把available Buffer相應位置置位,標記已經寫入成功,往後移一位,開始寫下標4位置的元素。Writer2同樣的方式。最終都寫入完成。

圖7 多個生產者情況下,生產者生產過程示意圖

防止不同生產者對同一段空間寫入的代碼,如下所示:

public long tryNext(int n) throws InsufficientCapacityException{ if (n < 1) { throw new IllegalArgumentException("n must be > 0"); } long current; long next; do { current = cursor.get(); next = current + n; if (!hasAvailableCapacity(gatingSequences, n, current)) { throw InsufficientCapacityException.INSTANCE; } } while (!cursor.compareAndSet(current, next)); return next;}

通過do/while循環的條件cursor.compareAndSet(current, next),來判斷每次申請的空間是否已經被其他生產者佔據。假如已經被佔據,該函數會返回失敗,While循環重新執行,申請寫入空間。

消費者的流程與生產者非常類似,這兒就不多描述了。

總結

Disruptor通過精巧的無鎖設計實現了在高並發情形下的高性能。

在美團點評內部,很多高並發場景借鑒了Disruptor的設計,減少競爭的強度。其設計思想可以擴展到分散式場景,通過無鎖設計,來提升服務性能。

代碼樣例

使用Disruptor比使用ArrayBlockingQueue略微複雜,為方便讀者上手,增加代碼樣例。

代碼實現的功能:每10ms向disruptor中插入一個元素,消費者讀取數據,並列印到終端。詳細邏輯請細讀代碼。

以下代碼基於3.3.4版本的Disruptor包。

package com.meituan.Disruptor;/** * @description disruptor代碼樣例。每10ms向disruptor中插入一個元素,消費者讀取數據,並列印到終端 */import com.lmax.disruptor.*;import com.lmax.disruptor.dsl.Disruptor;import com.lmax.disruptor.dsl.ProducerType;import java.util.concurrent.ThreadFactory;public class DisruptorMain{ public static void main(String[] args) throws Exception { // 隊列中的元素 class Element { private int value; public int get(){ return value; } public void set(int value){ this.value= value; } } // 生產者的線程工廠 ThreadFactory threadFactory = new ThreadFactory(){ @Override public Thread newThread(Runnable r) { return new Thread(r, "simpleThread"); } }; // RingBuffer生產工廠,初始化RingBuffer的時候使用 EventFactory<Element> factory = new EventFactory<Element>() { @Override public Element newInstance() { return new Element(); } }; // 處理Event的handler EventHandler<Element> handler = new EventHandler<Element>(){ @Override public void onEvent(Element element, long sequence, boolean endOfBatch) { System.out.println("Element: " + element.get()); } }; // 阻塞策略 BlockingWaitStrategy strategy = new BlockingWaitStrategy(); // 指定RingBuffer的大小 int bufferSize = 16; // 創建disruptor,採用單生產者模式 Disruptor<Element> disruptor = new Disruptor(factory, bufferSize, threadFactory, ProducerType.SINGLE, strategy); // 設置EventHandler disruptor.handleEventsWith(handler); // 啟動disruptor的線程 disruptor.start(); RingBuffer<Element> ringBuffer = disruptor.getRingBuffer(); for (int l = 0; true; l++) { // 獲取下一個可用位置的下標 long sequence = ringBuffer.next(); try { // 返回可用位置的元素 Element event = ringBuffer.get(sequence); // 設置該位置元素的值 event.set(l); } finally { ringBuffer.publish(sequence); } Thread.sleep(10); } }}

性能

以下面這些模式測試性能:

吞吐量測試數據(每秒的數量)如下。

環境:

  • CPU:Intel Core i7 860 @ 2.8 GHz without HT
  • JVM:Java 1.6.0_25 64-bit
  • OS:Windows 7

ABQDisruptorUnicast: 1P – 1C5,339,25625,998,336Pipeline: 1P – 3C2,128,91816,806,157Sequencer: 3P – 1C5,539,53113,403,268Multicast: 1P – 3C1,077,3849,377,871Diamond: 1P – 3C2,113,94116,143,613

環境:

  • CPU:Intel Core i7-2720QM
  • JVM:Java 1.6.0_25 64-bit
  • OS:Ubuntu 11.04

ABQDisruptorUnicast: 1P – 1C4,057,45322,381,378Pipeline: 1P – 3C2,006,90315,857,913Sequencer: 3P – 1C2,056,11814,540,519Multicast: 1P – 3C260,73310,860,121Diamond: 1P – 3C2,082,72515,295,197

依據並發競爭的激烈程度的不同,Disruptor比ArrayBlockingQueue吞吐量快4~7倍。

按照Pipeline: 1P – 3C的連接模式測試延遲,生產者兩次寫入之間的延遲為1ms。

運行環境:

  • CPU:2.2GHz Core i7-2720QM
  • Java: 1.6.0_25 64-bit
  • OS:Ubuntu 11.04.

Array Blocking Queue (ns)Disruptor (ns)99% observations less than2,097,15212899.99% observations less than4,194,3048,192Max Latency5,069,086175,567Mean Latency32,75752Min Latency14529

可見,平均延遲差了3個數量級。

等待策略

生產者的等待策略

暫時只有休眠1ns。

LockSupport.parkNanos(1);

消費者的等待策略

名稱措施適用場景BlockingWaitStrategy加鎖CPU資源緊缺,吞吐量和延遲並不重要的場景BusySpinWaitStrategy自旋通過不斷重試,減少切換線程導致的系統調用,而降低延遲。推薦在線程綁定到固定的CPU的場景下使用PhasedBackoffWaitStrategy自旋 + yield + 自定義策略CPU資源緊缺,吞吐量和延遲並不重要的場景SleepingWaitStrategy自旋 + yield + sleep性能和CPU資源之間有很好的折中。延遲不均勻TimeoutBlockingWaitStrategy加鎖,有超時限制CPU資源緊缺,吞吐量和延遲並不重要的場景YieldingWaitStrategy自旋 + yield + 自旋性能和CPU資源之間有很好的折中。延遲比較均勻

Log4j 2應用場景

Log4j 2相對於Log4j 1最大的優勢在於多線程並發場景下性能更優。該特性源自於Log4j 2的非同步模式採用了Disruptor來處理。

在Log4j 2的配置文件中可以配置WaitStrategy,默認是Timeout策略。下面是Log4j 2中對WaitStrategy的配置官方文檔:

System PropertyDefault ValueDescriptionAsyncLogger.WaitStrategyTimeoutValid values: Block, Timeout, Sleep, Yield. Block is a strategy that uses a lock and condition variable for the I/O thread waiting for log events. Block can be used when throughput and low-latency are not as important as CPU resource. Recommended for resource constrained/virtualised environments. Timeout is a variation of the Block strategy that will periodically wake up from the lock condition await() call. This ensures that if a notification is missed somehow the consumer thread is not stuck but will recover with a small latency delay (default 10ms). Sleep is a strategy that initially spins, then uses a Thread.yield(), and eventually parks for the minimum number of nanos the OS and JVM will allow while the I/O thread is waiting for log events. Sleep is a good compromise between performance and CPU resource. This strategy has very low impact on the application thread, in exchange for some additional latency for actually getting the message logged. Yield is a strategy that uses a Thread.yield() for waiting for log events after an initially spinning. Yield is a good compromise between performance and CPU resource, but may use more CPU than Sleep in order to get the message logged to disk sooner.

性能差異

loggers all async採用的是Disruptor,而Async Appender採用的是ArrayBlockingQueue隊列。

由圖可見,單線程情況下,loggers all async與Async Appender吞吐量相差不大,但是在64個線程的時候,loggers all async的吞吐量比Async Appender增加了12倍,是Sync模式的68倍。

圖8 Log4j 2各個模式性能比較

美團點評在公司內部統一推行日誌接入規範,要求必須使用Log4j 2,使普通單機QPS的上限不再只停留在幾千,極高地提升了服務性能。

參考文檔

  1. brokendreams.iteye.com/
  2. ifeve.com/dissecting-di
  3. github.com/LMAX-Exchang
  4. lmax-exchange.github.io
  5. logging.apache.org/log4

不想錯過技術博客更新?想給文章評論、和作者互動?第一時間獲取技術沙龍信息?

請關注我們的官方微信公眾號「美團點評技術團隊」。


推薦閱讀:

RAID 6 應用於消息隊列
LocalMQ:從零構建類 RocketMQ 高性能消息隊列
Kafka入門簡介
目前linux進程間通信的常用方法是什麼(pipe?信號量?消息隊列?)?

TAG:消息队列 |