http://mechanitis.blogspot.com/search/label/disruptor
http://ifeve.com/disruptor/, 並發框架Disruptor譯文
http://blog.sina.com.cn/s/blog_68ffc7a4010150yl.html, 論文譯文
LMAX需要搭建high performance的交易平台, 所以需要基於並發編程模型 (並發編程模型和訪問控制)
當然他們也關注類似Actor或SEDA模型, 並進行了測試, 從而發現了性能瓶頸-- 對於隊列的管理
如圖這樣比較簡單的處理流程, 就需要4個queue和大量的message發送, disruptor設計了一種高效的替代方案
解決如下問題,
1, 用何種數據機構來實現Queue
如何使用Disruptor(一)Ringbuffer的特別之處
實現queue首先想到鏈表, 但使用鏈表有下列問題,
- 節點分散, 不利於cache預讀
- 節點每次需要分配和釋放, 需要大量的垃圾回收, 低效
- 不利於批量讀取
- 競爭點較多, head指針, tail指針, size
由於producer和consumer很難同步, 所以大部分queue都是滿或空狀態, 這樣會導致大量的競爭, 比較低效
- 而且習慣的編程方式導致head指針, tail指針, size常常在一個cacheline中, 造成偽共享問題
那么用數組實現, 可以部分解決前3點問題, 但仍然無法解決競爭點問題, 以及由於數組的fix size, 帶來擴展性問題
Disruptor采用特殊的ring buffer來作為queue實現的數據結構, 解決了上述的問題
並且這種ring buffer只用了一個標志指針, 即標志下一個寫入位置
求余操作本身也是一種高耗費的操作, 所以ringbuffer的size設成2的n次方, 可以利用位操作來高效實現求余
2, 減少競爭點, 分離關注
對於傳統的3個競爭點, Disruptor成功的通過ring buffer將其降低到1個, 提高了效率
只有producer需要關注這個寫入標志位, 如果只有一個producer的話, 那么完全就不需要lock, 當然如果有多個producer的時候, 就需要通過ProducerBarrier在寫入標志位上做互斥
對於consumer, 每個consumer各自記錄讀入標志位, 並且通過ConsumerBarrier不停的偵聽當前最大可讀標志位, 即寫入標志位
這樣的設計成功的將關注點分離
3, Lock-free
前面說了disruptor減少競爭點, 但是不可能完全消除競爭, 對於寫入標志位, 當多個producer的時候仍然存在競爭, 競爭就需要加鎖.
鎖是很低效的, 論文中的3.1講的比較清晰, 並通過實驗數據證明了這點, 使用鎖會慢1000倍
- 系統態的鎖會導致線程cache丟失. 鎖競爭的時候需要進行仲裁. 這個仲裁會涉及到操作系統的內核切換, 並且在此過程中操作系統需要做一系列操作, 導致原有線程的指令緩存和數據緩很可能被丟掉
- 用戶態的鎖往往是通過自旋鎖來實現(自旋即忙等), 而自旋在競爭激烈的時候開銷是很大的(一直在消耗CPU資源)
那么disruptor的怎么做? lock-free, 不使用鎖, 使用CAS(Compare And Swap/Set)
嚴格意義上說仍然是使用鎖, 因為CAS本質上也是一種樂觀鎖, 只不過是CPU級別指令, 不涉及到操作系統, 所以效率很高
Java提供CAS操作的支持, AtomicLong
CAS依賴於處理器的支持, 當然大部分現代處理器都支持.
CAS相對於鎖是非常高效的, 因為它不需要涉及內核上下文切換進行仲裁.
但CAS並不是免費的, 它會涉及到對指令pipeline加鎖, 並且會用到內存barrier(用來刷新內存狀態,簡單理解就是把緩存中,寄存器中的數據同步到內存中去)
CAS的問題就是更為復雜, 比使用lock更難於理解, 並且雖然相對於lock已經很高效, 但是由於上面提到的耗費, 仍然比不使用任何鎖機制要慢的多
所以對於disruptor, 如果能保證只有一個producer就可以完全不使用lock, 甚至CAS, 是很高效的方案
當然在不得不使用多個producer的情況下, 只能使用CAS
4, 解決偽共享(False Sharing)
剖析Disruptor:為什么會這么快?(二)神奇的緩存行填充
前面提到, CPU cache的預讀會大大提高執行效率, 這也是為什么選擇數組來替代鏈表的很重要的原因, 因為數組集中存儲可以通過預讀大大提高效率
上面談到lock的耗費, 主要也是由於內核的切換導致cache的丟失
所以cache是優化的關鍵, cache越接近core就越快,也越小
可以看出對於L1, L2級別的cache是每個core都獨立的
cache-line(緩存行)
緩存是由緩存行組成的, 通常是64字節, 一個Java的long類型是8字節,因此在一個緩存行中可以存8個long類型的變量.
緩存行是緩存更新的基本單位, 就算你只讀一個變量, 系統也會預讀其余7個, 並cache這一行, 並且這行中的任一變量發生改變, 都需要重新加載整行, 而非僅僅重新加載一個變量.
這里談的偽共享問題, 也是一種主要的cache丟失的case, 需要通過緩存行填充來解決
上面的提到的cache-line, 對於象數組這樣連續存儲的數據結構非常高效, 但是不能保證所有結構都是連續存儲的, 比如對於鏈表, 就很容易出現偽共享問題, 即這種預讀反而使效率降低.
底下是典型偽共享的例子, 在鏈表中往往會連續定義head和tail指針, 所以對於cache-line的預讀, 很有可能會導致head和tail在同一cache-line
在實際使用中, 往往producer線程會持續更改tail指針, 而consumer線程會持續更改head指針
當producer線程和consumer線程分別被分配到core2和core1, 就會出現以下狀況,
由於core1不斷改變h, 導致該cache-line過期, 對於core2, 雖然他不需要讀h, 或者t也沒有改變, 但是由於cache-line的整行更新, 所以core2仍然需要不停的更新它的cache
core2的緩存未命中被一個和它本身完全不相干的值h, 而被大大提高, 導致cache效率底下
而實際情況下, core1會不斷更新h, 而core2會不斷更新t, 導致core1和core2都需要頻繁的重新load cache, 這就是偽共享問題
那么如何解決這個問題?
既然預讀反而降低效率, 解決辦法就是消除系統預讀的影響
簡單的辦法就是緩存行填充, 來保證這個cache-line只存儲這一個數據, 從而避免其他數據的更改對該cache-line的影響
在 Disruptor里我們對 RingBuffer的cursor和 BatchEventProcessor的序列進行了緩存行填充
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
所以, disruptor在cursor前后都pading了7個long, 從而避免cursor和任意其他的變量在同一個cache-line
使用緩存行填充的准則,
獨立變量, 變量被大量線程touch, 會被頻繁使用和讀取
5, 使用內存屏障
http://ifeve.com/linux-memory-barriers/, 非常詳細的介紹了內存屏障的原理
首先, 內存屏障本身不是一種優化方式, 而是你使用lock-free(CAS)的時候, 必須要配合使用內存屏障
因為CPU和memory之間有多級cache, CPU core只會更新cache-line, 而cache-line什么時候flush到memory, 這個是有一定延時的
在這個延時當中, 其他CPU core是無法得知你的更新的, 因為只有把cache-line flush到memory后, 其他core中的相應的cache-line才會被置為過期數據
所以如果要保證使用CAS能保證線程間互斥, 即樂觀鎖, 必須當一個core發生更新后, 其他所有core立刻知道並把相應的cache-line設為過期, 否則在這些core上執行CAS讀到的都是過期數據
系統提供內存屏障就是做這個事的, 當設置內存屏障, 會立刻將cache-line flush到memory, 而沒有延時
Java中用volatile來實現內存屏障
Java語言規范第三版中對volatile的定義如下: java編程語言允許線程訪問共享變量,為了確保共享變量能被准確和一致的更新,線程應該確保通過排他鎖單獨獲得這個變量。Java語言提供了 volatile,在某些情況下比鎖更加方便。如果一個字段被聲明成volatile,java線程內存模型確保所有線程看到這個變量的值是一致的
volatile保證了線程間的可見性, 但是同樣如果要實現互斥, 必須借助CAS, 以避免讀取到更新之間的數據變更
volatile的實現實質,
- 將當前處理器緩存行的數據會寫回到系統內存
- 這個寫回內存的操作會引起在其他CPU里緩存了該內存地址的數據無效
內存屏障另一種用途, CPU出於對執行指令和數據加載的優化會調整執行順序, 所以在代碼里面先寫的指令不一定會被先執行, 當然是在保證邏輯一致性的前提下.
但內存屏障, 可以限制這種調整, 屏障之前的命令必須先於屏障執行, 而屏障之后的必須后於屏障執行, 很形象.
所以可以看到內存屏障, 雖然和lock比是高效的, 但畢竟限制了CPU的優化並會強制flush cache-line, 所以仍然是比較昂貴的操作.
6, 如何使用Disruptor替代Queue
我本來以為是用一個ringbuffer替代一個queue, 原來是用一個ringbuffer替代所有的queue, 怎么實現的?
如圖, 所有consumer都是從RingBuffer里面讀數據
而C3, 依賴於C1和C2的執行結果, 那么通過設置ConsumerBarrier2來監控C1和C2的執行序號
那么有個問題是C3, 如何獲得C1和C2的執行結果?
答案是, C1和C2執行完后, 會把結果寫回Ringbuffer中原來的entry中
如圖, 當C3拿到Entry時, 里面有3個值, 本來的value, C1處理的結果, C2處理的結果, 並且不同的consumer寫的字段不一樣來避免沖突
而Producer在監控consumer消費序號時, 只需要監控最后一層的, 即C3的, 因為只有C3處理完, 這個entry才能被覆蓋.
看起來非常的復雜, 但是在使用時, 對用戶很多機制其實是透明的, 比如上面的workflow的代碼如下
ConsumerBarrier consumerBarrier1 = ringBuffer.createConsumerBarrier(); BatchConsumer consumer1 = new BatchConsumer(consumerBarrier1, handler1); BatchConsumer consumer2 = new BatchConsumer(consumerBarrier1, handler2); ConsumerBarrier consumerBarrier2 = ringBuffer.createConsumerBarrier(consumer1, consumer2); BatchConsumer consumer3 = new BatchConsumer(consumerBarrier2, handler3); ProducerBarrier producerBarrier = ringBuffer.createProducerBarrier(consumer3);
總結
總體來說, disprutor從兩個方面來對Actor模式的queue做了優化
最重要的是, Mechanical Sympathy(機械的共鳴), 了解硬件的工作方式來編寫和硬件完美結合的軟件, 很高的境界
通過利用CAS+內存屏障實現lock-free, 並使用緩存行填充來解決偽共享, 可見雖然編程語言已經發展到很高級的地步, 但是如果要追求效率的機制, 必須要具有Mechanical Sympathy, 人劍合一
其次, 是通過ringbuffer來實現queue來替代鏈表的實現, 尤其當場景比較復雜需要很多queue的時候, 效率應該會得到很大的提高
其實, disruptor並沒有實現queue的互斥consumer, 每個consumer都是自己保持序號, 各讀各得, 但是對於普通queue, 被一個線程pop掉的數據, 其他線程是無法讀到的