你應該知道的高效能無鎖佇列Disruptor

咖啡拿鐵發表於2019-03-04

1.何為佇列

聽到佇列相信大家對其並不陌生,在我們現實生活中佇列隨處可見,去超市結賬,你會看見大家都會一排排的站得好好的,等待結賬,為什麼要站得一排排的,你想象一下大家都沒有素質,一窩蜂的上去結賬,不僅讓這個超市崩潰,還會容易造成各種踩踏事件,當然這些事其實在我們現實中也是會經常發生。

當然在計算機世界中,佇列是屬於一種資料結構,佇列採用的FIFO(first in firstout),新元素(等待進入佇列的元素)總是被插入到尾部,而讀取的時候總是從頭部開始讀取。在計算中佇列一般用來做排隊(如執行緒池的等待排隊,鎖的等待排隊),用來做解耦(生產者消費者模式),非同步等等。

2.jdk中的佇列

在jdk中的佇列都實現了java.util.Queue介面,在佇列中又分為兩類,一類是執行緒不安全的,ArrayDeque,LinkedList等等,還有一類都在java.util.concurrent包下屬於執行緒安全,而在我們真實的環境中,我們的機器都是屬於多執行緒,當多執行緒對同一個佇列進行排隊操作的時候,如果使用執行緒不安全會出現,覆蓋資料,資料丟失等無法預測的事情,所以我們這個時候只能選擇執行緒安全的佇列。在jdk中提供的執行緒安全的佇列下面簡單列舉部分佇列:

佇列名字 是否加鎖 資料結構 關鍵技術點 是否有鎖 是否有界
ArrayBlockingQueue 陣列array ReentrantLock 有鎖 有界
LinkedBlockingQueue 連結串列 ReentrantLock 有鎖 有界
LinkedTransferQueue 連結串列 CAS 無鎖 無界
ConcurrentLinkedQueue 連結串列 CAS 無鎖 無界

我們可以看見,我們無鎖的佇列是無界的,有鎖的佇列是有界的,這裡就會涉及到一個問題,我們在真正的線上環境中,無界的佇列,對我們系統的影響比較大,有可能會導致我們記憶體直接溢位,所以我們首先得排除無界佇列,當然並不是無界佇列就沒用了,只是在某些場景下得排除。其次還剩下ArrayBlockingQueue,LinkedBlockingQueue兩個佇列,他們兩個都是用ReentrantLock控制的執行緒安全,他們兩個的區別一個是陣列,一個是連結串列,在佇列中,一般獲取這個佇列元素之後緊接著會獲取下一個元素,或者一次獲取多個佇列元素都有可能,而陣列在記憶體中地址是連續的,在作業系統中會有快取的優化(下面也會介紹快取行),所以訪問的速度會略勝一籌,我們也會盡量去選擇ArrayBlockingQueue。而事實證明在很多第三方的框架中,比如早期的log4j非同步,都是選擇的ArrayBlockingQueue。

當然ArrayBlockingQueue,也有自己的弊端,就是效能比較低,為什麼jdk會增加一些無鎖的佇列,其實就是為了增加效能,很苦惱,又需要無鎖,又需要有界,這個時候恐怕會忍不住說一句你咋不上天呢?但是還真有人上天了。

3.Disruptor

Disruptor就是上面說的那個天,Disruptor是英國外匯交易公司LMAX開發的一個高效能佇列,並且是一個開源的併發框架,並獲得2011Duke’s程式框架創新獎。能夠在無鎖的情況下實現網路的Queue併發操作,基於Disruptor開發的系統單執行緒能支撐每秒600萬訂單。目前,包括Apache Storm、Camel、Log4j2等等知名的框架都在內部整合了Disruptor用來替代jdk的佇列,以此來獲得高效能。

3.1為什麼這麼牛逼?

上面已經把Disruptor吹出了花了,你肯定會產生疑問,他真的能有這麼牛逼嗎,我的回答是當然的,在Disruptor中有三大殺器:

  • CAS
  • 消除偽共享
  • RingBuffer
    有了這三大殺器,Disruptor才變得如此牛逼。

3.1.1鎖和CAS

我們ArrayBlockingQueue為什麼會被拋棄的一點,就是因為用了重量級lock鎖,在我們加鎖過程中我們會把鎖掛起,解鎖後,又會把執行緒恢復,這一過程會有一定的開銷,並且我們一旦沒有獲取鎖,這個執行緒就只能一直等待,這個執行緒什麼事也不能做。

CAS(compare and swap),顧名思義先比較在交換,一般是比較是否是老的值,如果是的進行交換設定,大家熟悉樂觀鎖的人都知道CAS可以用來實現樂觀鎖,CAS中沒有執行緒的上下文切換,減少了不必要的開銷。
這裡使用JMH,用兩個執行緒,每次1一次呼叫,在我本機上進行測試,程式碼如下:

@BenchmarkMode({Mode.SampleTime})
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations=3, time = 5, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations=1,batchSize = 100000000)
@Threads(2)
@Fork(1)
@State(Scope.Benchmark)
public class Myclass {
    Lock lock = new ReentrantLock();
    long i = 0;
    AtomicLong atomicLong = new AtomicLong(0);
    @Benchmark
    public void measureLock() {
        lock.lock();
        i++;
        lock.unlock();
    }
    @Benchmark
    public void measureCAS() {
        atomicLong.incrementAndGet();
    }
    @Benchmark
    public void measureNoLock() {
        i++;
    }
}
複製程式碼

測試出來結果如下:

測試專案 測試結果
Lock 26000ms
CAS 4840ms
無鎖 197ms

可以看見Lock是五位數,CAS是四位數,無鎖更小是三位數。
由此我們可以知道Lock>CAS>無鎖。

而我們的Disruptor中使用的就是CAS,他利用CAS進行佇列中的一些下標設定,減少了鎖的衝突,提高了效能。

另外對於jdk中其他的無鎖佇列也是使用CAS,原子類也是使用CAS。

3.1.2偽共享

談到了偽共享就不得不說計算機CPU快取,快取大小是CPU的重要指標之一,而且快取的結構和大小對CPU速度的影響非常大,CPU內快取的執行頻率極高,一般是和處理器同頻運作,工作效率遠遠大於系統記憶體和硬碟。實際工作時,CPU往往需要重複讀取同樣的資料塊,而快取容量的增大,可以大幅度提升CPU內部讀取資料的命中率,而不用再到記憶體或者硬碟上尋找,以此提高系統效能。但是從CPU晶片面積和成本的因素來考慮,快取都很小。

你應該知道的高效能無鎖佇列Disruptor

CPU快取可以分為一級快取,二級快取,如今主流CPU還有三級快取,甚至有些CPU還有四級快取。每一級快取中所儲存的全部資料都是下一級快取的一部分,這三種快取的技術難度和製造成本是相對遞減的,所以其容量也是相對遞增的。

為什麼CPU會有L1、L2、L3這樣的快取設計?主要是因為現在的處理器太快了,而從記憶體中讀取資料實在太慢(一個是因為記憶體本身速度不夠,另一個是因為它離CPU太遠了,總的來說需要讓CPU等待幾十甚至幾百個時鐘週期),這個時候為了保證CPU的速度,就需要延遲更小速度更快的記憶體提供幫助,而這就是快取。對這個感興趣可以把電腦CPU拆下來,自己把玩一下。

每一次你聽見intel釋出新的cpu什麼,比如i7-7700k,8700k,都會對cpu快取大小進行優化,感興趣可以自行下來搜尋,這些的釋出會或者釋出文章。

Martin和Mike的 QConpresentation演講中給出了一些每個快取時間:

從CPU到 大約需要的CPU週期 大約需要的時間
主存 約60-80納秒
QPI 匯流排傳輸(between sockets, not drawn) 約20ns
L3 cache 約40-45 cycles 約15ns
L2 cache 約10 cycles 約3ns
L1 cache 約3-4 cycles 約1ns
暫存器 1 cycle

快取行

在cpu的多級快取中,並不是以獨立的項來儲存的,而是類似一種pageCahe的一種策略,以快取行來儲存,而快取行的大小通常是64位元組,在Java中Long是8個位元組,所以可以儲存8個Long,舉個例子,你訪問一個long的變數的時候,他會把幫助再載入7個,我們上面說為什麼選擇陣列不選擇連結串列,也就是這個原因,在陣列中可以依靠緩衝行得到很快的訪問。

你應該知道的高效能無鎖佇列Disruptor

快取行是萬能的嗎?NO,因為他依然帶來了一個缺點,我在這裡舉個例子說明這個缺點,可以想象有個陣列佇列,ArrayQueue,他的資料結構如下:

class ArrayQueue{
    long maxSize;
    long currentIndex;
}
複製程式碼

對於maxSize是我們一開始就定義好的,陣列的大小,對於currentIndex,是標誌我們當前佇列的位置,這個變化比較快,可以想象你訪問maxSize的時候,是不是把currentIndex也載入進來了,這個時候,其他執行緒更新currentIndex,就會把cpu中的快取行置位無效,請注意這是CPU規定的,他並不是只吧currentIndex置位無效,如果此時又繼續訪問maxSize他依然得繼續從記憶體中讀取,但是MaxSize卻是我們一開始定義好的,我們應該訪問快取即可,但是卻被我們經常改變的currentIndex所影響。

你應該知道的高效能無鎖佇列Disruptor

Padding的魔法

為了解決上面快取行出現的問題,在Disruptor中採用了Padding的方式,

class LhsPadding
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

class Value extends LhsPadding
{
    protected volatile long value;
}

class RhsPadding extends Value
{
    protected long p9, p10, p11, p12, p13, p14, p15;
}
複製程式碼

其中的Value就被其他一些無用的long變數給填充了。這樣你修改Value的時候,就不會影響到其他變數的快取行。

最後順便一提,在jdk8中提供了@Contended的註解,當然一般來說只允許Jdk中內部,如果你自己使用那就得配置Jvm引數 -RestricContentended = fase,將限制這個註解置位取消。很多文章分析了ConcurrentHashMap,但是都把這個註解給忽略掉了,在ConcurrentHashMap中就使用了這個註解,在ConcurrentHashMap每個桶都是單獨的用計數器去做計算,而這個計數器由於時刻都在變化,所以被用這個註解進行填充快取行優化,以此來增加效能。

你應該知道的高效能無鎖佇列Disruptor

3.1.3RingBuffer

在Disruptor中採用了陣列的方式儲存了我們的資料,上面我們也介紹了採用陣列儲存我們訪問時很好的利用快取,但是在Disruptor中進一步選擇採用了環形陣列進行儲存資料,也就是RingBuffer。在這裡先說明一下環形陣列並不是真正的環形陣列,在RingBuffer中是採用取餘的方式進行訪問的,比如陣列大小為 10,0訪問的是陣列下標為0這個位置,其實10,20等訪問的也是陣列的下標為0的這個位置。

實際上,在這些框架中取餘並不是使用%運算,都是使用的&與運算,這就要求你設定的大小一般是2的N次方也就是,10,100,1000等等,這樣減去1的話就是,1,11,111,就能很好的使用index & (size -1),這樣利用位運算就增加了訪問速度。
如果在Disruptor中你不用2的N次方進行大小設定,他會丟擲buffersize必須為2的N次方異常。

當然其不僅解決了陣列快速訪問的問題,也解決了不需要再次分配記憶體的問題,減少了垃圾回收,因為我們0,10,20等都是執行的同一片記憶體區域,這樣就不需要再次分配記憶體,頻繁的被JVM垃圾回收器回收。

你應該知道的高效能無鎖佇列Disruptor

自此三大殺器已經說完了,有了這三大殺器為Disruptor如此高效能墊定了基礎。接下來還會在講解如何使用Disruptor和Disruptor的具體的工作原理。

3.2Disruptor怎麼使用

下面舉了一個簡單的例子:

ublic static void main(String[] args) throws Exception {
        // 佇列中的元素
        class Element {
            @Contended
            private String value;


            public String getValue() {
                return value;
            }

            public void setValue(String value) {
                this.value = value;
            }
        }

        // 生產者的執行緒工廠
        ThreadFactory threadFactory = new ThreadFactory() {
            int i = 0;
            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r, "simpleThread" + String.valueOf(i++));
            }
        };

        // 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) throws InterruptedException {
                System.out.println("Element: " + Thread.currentThread().getName() + ": " + element.getValue() + ": " + sequence);
//                Thread.sleep(10000000);
            }
        };


        // 阻塞策略
        BlockingWaitStrategy strategy = new BlockingWaitStrategy();

        // 指定RingBuffer的大小
        int bufferSize = 8;

        // 建立disruptor,採用單生產者模式
        Disruptor<Element> disruptor = new Disruptor(factory, bufferSize, threadFactory, ProducerType.SINGLE, strategy);

        // 設定EventHandler
        disruptor.handleEventsWith(handler);

        // 啟動disruptor的執行緒
        disruptor.start();
        for (int i = 0; i < 10; i++) {
            disruptor.publishEvent((element, sequence) -> {
                System.out.println("之前的資料" + element.getValue() + "當前的sequence" + sequence);
                element.setValue("我是第" + sequence + "個");
            });

        }
    }
複製程式碼

在Disruptor中有幾個比較關鍵的:
ThreadFactory:這是一個執行緒工廠,用於我們Disruptor中生產者消費的時候需要的執行緒。
EventFactory:事件工廠,用於產生我們佇列元素的工廠,在Disruptor中,他會在初始化的時候直接填充滿RingBuffer,一次到位。
EventHandler:用於處理Event的handler,這裡一個EventHandler可以看做是一個消費者,但是多個EventHandler他們都是獨立消費的佇列。
WorkHandler:也是用於處理Event的handler,和上面區別在於,多個消費者都是共享同一個佇列。
WaitStrategy:等待策略,在Disruptor中有多種策略,來決定消費者獲消費時,如果沒有資料採取的策略是什麼?下面簡單列舉一下Disruptor中的部分策略

  • BlockingWaitStrategy:通過執行緒阻塞的方式,等待生產者喚醒,被喚醒後,再迴圈檢查依賴的sequence是否已經消費。

  • BusySpinWaitStrategy:執行緒一直自旋等待,可能比較耗cpu

  • LiteBlockingWaitStrategy:執行緒阻塞等待生產者喚醒,與BlockingWaitStrategy相比,區別在signalNeeded.getAndSet,如果兩個執行緒同時訪問一個訪問waitfor,一個訪問signalAll時,可以減少lock加鎖次數.

  • LiteTimeoutBlockingWaitStrategy:與LiteBlockingWaitStrategy相比,設定了阻塞時間,超過時間後拋異常。

  • YieldingWaitStrategy:嘗試100次,然後Thread.yield()讓出cpu

EventTranslator:實現這個介面可以將我們的其他資料結構轉換為在Disruptor中流通的Event。

3.3工作原理

上面已經介紹了CAS,減少偽共享,RingBuffer三大殺器,介紹下來說一下Disruptor中生產者和消費者的整個流程。

3.3.1生產者

對於生產者來說,可以分為多生產者和單生產者,用ProducerType.Single,和ProducerType.MULTI區分,多生產者和單生產者來說多了CAS,因為單生產者由於是單執行緒,所以不需要保證執行緒安全。

在disruptor中通常用disruptor.publishEvent和disruptor.publishEvents()進行單發和群發。

在disruptor釋出一個事件進入佇列需要下面幾個步驟:

  1. 首先獲取RingBuffer中下一個在RingBuffer上可以釋出的位置,這個可以分為兩類:
  • 從來沒有寫過的位置
  • 已經被所有消費者讀過,可以在寫的位置。
    如果沒有讀取到會一直嘗試去讀,disruptor做的很巧妙,並沒有一直佔據CPU,而是通過LockSuport.park(),進行了一下將執行緒阻塞掛起操作,為的是不讓CPU一直進行這種空迴圈,不然其他執行緒都搶不到CPU時間片。
    你應該知道的高效能無鎖佇列Disruptor

    獲取位置之後會進行cas進行搶佔,如果是單執行緒就不需要。

  1. 接下來呼叫我們上面所介紹的EventTranslator將第一步中RingBuffer中那個位置的event交給EventTranslator進行重寫。
  2. 進行釋出,在disruptor還有一個額外的陣列用來記錄當前ringBuffer所在位置目前最新的序列號是多少,拿上面那個0,10,20舉例,寫到10的時候這個avliableBuffer就在對應的位置上記錄目前這個是屬於10,有什麼用呢後面會介紹。進行釋出的時候需要更新這個avliableBuffer,然後進行喚醒所有阻塞的生產者。

下面簡單畫一下流程,上面我們拿10舉例是不對的,因為bufferSize必須要2的N次方,所以我們這裡拿Buffersize=8來舉例:下面介紹了當我們已經push了8個event也就是一圈的時候,接下來再push 3條訊息的一些過程:
1.首先呼叫next(3),我們當前在7這個位置上所以接下來三條是8,9,10,取餘也就是0,1,2。
2.重寫0,1,2這三個記憶體區域的資料。
3.寫avaliableBuffer。

你應該知道的高效能無鎖佇列Disruptor

對了不知道大家對上述流程是不是很熟悉呢,對的那就是類似我們的2PC,兩階段提交,先進行RingBuffer的位置鎖定,然後在進行提交和通知消費者。具體2PC的介紹可以參照我的另外一篇文章再有人問你分散式事務,給他看這篇文章

3.3.1消費者

對於消費者來說,上面介紹了分為兩種,一種是多個消費者獨立消費,一種是多個消費者消費同一個佇列,這裡介紹一下較為複雜的多個消費者消費同一個佇列,能理解這個也就能理解獨立消費。
在我們的disruptor.strat()方法中會啟動我們的消費者執行緒以此來進行後臺消費。在消費者中有兩個佇列需要我們關注,一個是所有消費者共享的進度佇列,還有個是每個消費者獨立消費進度佇列。
1.對消費者共享佇列進行下一個Next的CAS搶佔,以及對自己消費進度的佇列標記當前進度。
2.為自己申請可讀的RingBuffer的Next位置,這裡的申請不僅僅是申請到next,有可能會申請到比Next大的一個範圍,阻塞策略的申請的過程如下:

  • 獲取生產者對RingBuffer最新寫的位置
  • 判斷其是否小於我要申請讀的位置
  • 如果大於則證明這個位置已經寫了,返回給生產者。
  • 如果小於證明還沒有寫到這個位置,在阻塞策略中會進行阻塞,其會在生產者提交階段進行喚醒。
    3.對這個位置進行可讀校驗,因為你申請的位置可能是連續的,比如生產者目前在7,接下來申請讀,如果消費者已經把8和10這個序列號的位置寫進去了,但是9這個位置還沒來得及寫入,由於第一步會返回10,但是9其實是不能讀的,所以得把位置向下收縮到8。
    你應該知道的高效能無鎖佇列Disruptor

    4.如果收縮完了之後比當前next要小,則繼續迴圈申請。
    5.交給handler.onEvent()處理

一樣的我們舉個例子,我們要申請next=8這個位置。
1.首先在共享佇列搶佔進度8,在獨立佇列寫入進度7
2.獲取8的可讀的最大位置,這裡根據不同的策略進行,我們選擇阻塞,由於生產者生產了8,9,10,所以返回的是10,這樣和後續就不需要再次和avaliableBuffer進行對比了。
3.最後交給handler進行處理。

你應該知道的高效能無鎖佇列Disruptor

4.Log4j中的Disruptor

下面的圖展現了Log4j使用Disruptor,ArrayBlockingQueue以及同步的Log4j吞吐量的對比,可以看見使用了Disruptor完爆了其他的,當然還有更多的框架使用了Disruptor,這裡就不做介紹了。

你應該知道的高效能無鎖佇列Disruptor

最後

本文介紹了傳統的阻塞佇列的缺點,後文重點吹逼了下Disruptor,以及他這麼牛逼的原因,以及具體的工作流程。

如果以後有人問你叫你設計一個高效無鎖佇列,需要怎麼設計?相信你能從文章中總結出答案,如果對其有疑問或者想和我交流思路,可以關注我的公眾號,加我好友和我一起討論。

最後這篇文章被我收錄於JGrowing,一個全面,優秀,由社群一起共建的Java學習路線,如果您想參與開源專案的維護,可以一起共建,github地址為:github.com/javagrowing…
麻煩給個小星星喲。

如果大家覺得這篇文章對你有幫助,或者你有什麼疑問想提供1v1免費vip服務,都可以關注我的公眾號,你的關注和轉發是對我最大的支援,O(∩_∩)O:

你應該知道的高效能無鎖佇列Disruptor

相關文章