優化技術專題-執行緒間的高效能訊息框架-深入淺出Disruptor的使用和原理

李浩宇Alex發表於2021-09-11

前提概要

簡單回顧 jdk 裡的佇列

阻塞佇列:

ArrayBlockingQueue主要通過:陣列(Object[])+ 計數器(count)+ ReetrantLock的Condition (notEmpty:非空、notFull:非飽和)進行阻塞。

入隊操作:
  • 操作不阻塞:
    • add:新增失敗,則會直接進行返回。
    • offer:新增失敗後(滿了)直接丟擲異常,注意:offer(E o, long timeout, TimeUnit unit):可以設定等待的時間,如果在指定的時間內,還不能往佇列中加入BlockingQueue,則返回失敗。
  • 操作阻塞:
    • put:滿了,通過Condition:notFull.await()阻塞當前資料資訊,當出隊和刪除元素時喚醒 put 操作。
出隊操作:
  • 操作不阻塞:
    • poll:當空時直接返回 null。poll(long timeout, TimeUnit unit):從BlockingQueue取出一個隊首的物件,如果在指定時間內,佇列一旦有資料可取,則立即返回佇列中的資料。否則知道時間,超時還沒有資料可取,返回失敗。
    • remove:刪除元素情況相關元素資訊控制,InterruptException異常
  • 操作阻塞:
    • take:當空時,notEmpty.await()(當有元素入隊時喚醒)。
  • drainTo():一次性從BlockingQueue獲取所有可用的資料物件(還可以指定獲取資料的個數),通過該方法,可以提升獲取資料效率;不需要多次分批加鎖或釋放鎖。

與ArrayBlockingQueue相對的是LinkedBlockingQueue:Node 實現、加鎖(讀鎖、寫鎖分離)、可選的有界佇列。需要考慮實際使用中的記憶體問題,防止溢位。

實際應用

執行緒池佇列

Excutors 預設是使用 LinkedBlockingQueue,但是在實際應用中,更應該手動建立執行緒池使用有界佇列,防止生產者生產過快,導致記憶體溢位。

延遲佇列(ScheduleService也是採用了延時佇列哦!):

DelayQueue : PriorityQueue (優先順序佇列) + Lock.condition (延遲等待) + leader (避免不必要的空等待)。

主要方法:
  • getDelay() 延遲時間。

  • compareTo() 通過該方法比較從PriorityQueue裡取值。

入隊:

與BlockingQueue很相似,add、put、offer:入隊時會將換喚醒等待中的執行緒,進行一次出隊處理。

出隊:
  • 如果佇列裡無資料,元素入隊時會被喚醒。

  • 如果佇列裡有資料,會阻塞至時間滿足。

    • take-阻塞:
    • poll-滿足佇列有資料並且 delay 時間小於0時候會取出元素,否則立即返回 null 可能會搶佔成為 leader
應用場景:
  • 延時任務:設定任務延遲多久執行;需要設定過期值的處理,例如快取過期。

  • 實現方式:每次 getDelay() 方法提供一個快取建立時間與當前時間的差值,出隊時 compareTo() 方法取差值最小的。每次入隊時都會重新取出佇列裡差值最小的值進行處理。

  • 使用佇列更多的是像生產者、消費者這種場景,這種場景大多數情況又對處理速度有著要求,所以我們會使用多執行緒技術。

  • 使用多執行緒就可能會出現併發,為了避免出錯,我們會選擇執行緒安全的佇列。

    • ArrayBlockingQueue、LinkedBlockingQueue 或者是 ConcurrentLinkedQueue。前倆者是通過加鎖取實現,後面一種是通過 cas 去實現執行緒安全。

    • 要考慮到生產者過快可能造出的記憶體溢位的問題,所以看起來 ArrayBlockingQueue 是最符合要求的。

但是因為加鎖效率又會變慢,所以就引出了:Disruptor服務框架 !


Disruptor簡介介紹

  • Disruptor的原始碼Git倉庫地址:https://github.com/LMAX-Exchange/disruptor
  • Disruptor的概念定義:非同步體系的執行緒間的高效能訊息框架
  • Disruptor的核心思想:把多執行緒併發寫的執行緒安全問題轉化為執行緒本地寫,即:不需要做同步,不許要進行加鎖操作。

Disruptor優點介紹

  • 非常輕量,但效能卻非常強悍,得益於其優秀的設計和對計算機底層原理的運用
    • 單執行緒每秒能處理超600W的資料(Disruptor能在1秒內將600W資料傳送給消費者,現在的硬體水平會遠遠在這個水平之上了!)
  • 基於事件驅動模型,不用消費者主動拉取訊息
  • 比JDK的ArrayBlockingQueue效能高一個數量級
為什麼這麼快
  • 無鎖序號柵欄
  • 快取行填充,消除偽共享
  • 記憶體預分配
  • 環形佇列RingBuffer

Disruptor核心概念

  • RingBuffer(環形佇列):基於陣列的記憶體級別快取,是建立sequencer(序號)與定義WaitStrategy(拒絕策略)的入口。

  • Disruptor(總體執行入口):對RingBuffer的封裝,持有RingBuffer、消費者執行緒池Executor、消費之集合ConsumerRepository等引用。

  • Sequence(序號分配器):對RingBuffer中的元素進行序號標記,通過順序遞增的方式來管理進行交換的資料(事件/Event),一個Sequence可以跟蹤標識某個事件的處理進度,同時還能消除偽共享。

  • Sequencer(資料傳輸器):Sequencer裡面包含了Sequence,是Disruptor的核心,Sequencer有兩個實現類:SingleProducerSequencer(單生產者實現)、MultiProducerSequencer(多生產者實現),Sequencer主要作用是實現生產者和消費者之間快速、正確傳遞資料的併發演算法

  • SequenceBarrier(消費者屏障):用於控制RingBuffer的Producer和Consumer之間的平衡關係,並且決定了Consumer是否還有可處理的事件的邏輯。

  • WaitStrategy(消費者等待策略):決定了消費者如何等待生產者將Event生產進Disruptor,WaitStrategy有多種實現策略,分別是:

    1. BlockingWaitStrategy(最穩定的策略):阻塞方式,效率較低,但對cpu消耗小,內部使用的是典型的鎖和條件變數機制(java的ReentrantLock),來處理執行緒的喚醒,這個策略是disruptor等待策略中最慢的一種,但是是最保守使用消耗cpu的一種用法,並且在不同的部署環境下最能保持效能一致。 但是,隨著我們可以根據部署的服務環境優化出額外的效能。
    2. BusySpinWaitStrategy(效能最好的策略):自旋方式,無鎖,BusySpinWaitStrategy是效能最高的等待策略,但是受部署環境的制約依賴也越強。 僅當event處理執行緒數少於物理核心數的時候才應該採用這種等待策略。 例如,超執行緒不可開啟。
    3. LiteBlockingWaitStrategy(幾乎不用,最接近原生的策略機制):BlockingWaitStrategy的變體版本,目前感覺不建議使用
    4. LiteTimeoutBlockingWaitStrategy:LiteBlockingWaitStrategy的超時版本
    5. PhasedBackoffWaitStrategy(最低CPU配置的策略):自旋 + yield + 自定義策略,當吞吐量和低延遲不如CPU資源重要,CPU資源緊缺,可以使用此策略。
    6. SleepingWaitStrategy:自旋休眠方式(無鎖),效能和BlockingWaitStrategy差不多,但是這個對生產者執行緒影響最小,它使用一個簡單的loop繁忙等待迴圈,但是在迴圈體中間它呼叫了LockSupport.parkNanos(1)
      • 一般情況在linux系統這樣會使得執行緒停頓大約60微秒。不過這樣做的好處是,生產者執行緒不需要額外的累加計數器,也不需要產生條件變數訊號量開銷。
      • 負面影響是,在生產者執行緒與消費者執行緒之間傳遞event資料的延遲變高。所以SleepingWaitStrategy適合在不需要低延遲, 但需要很低的生產者執行緒影響的情形。一個典型的案例是非同步日誌記錄功能。
    7. TimeoutBlockingWaitStrategy:BlockingWaitStrategy的超時阻塞方式
    8. YieldingWaitStrategy(充分進行實現CPU吞吐效能策略):自旋執行緒切換競爭方式(Thread.yield()),最快的方式,適用於低延時的系統,在要求極高效能且事件處理線數小於CPU邏輯核心數的場景中推薦使用此策略,它會充分使用壓榨cpu來達到降低延遲的目標。
      • 通過不斷的迴圈等待sequence去遞增到合適的值。 在迴圈體內,呼叫Thread.yield()來允許其他的排隊執行緒執行。 這是一種在需要極高效能並且event handler執行緒數少於cpu邏輯核心數的時候推薦使用的策略。
      • 這裡說一下YieldingWaitStrategy使用要小心,不是特別要求效能的情況下,要謹慎使用,否則會引起服務起cpu飆升的情況,因為他的內部實現是線上程做100次遞減然後Thread.yield(),可能會壓榨cpu效能來換取速度。

注意:超執行緒是intel研發的一種cpu技術,可以使得一個核心提供兩個邏輯執行緒,比如4核心超執行緒後有8個執行緒。


  • Event:從生產者到消費者過程中所處理的資料單元,Event由使用者自定義。
  • EventHandler:由使用者自定義實現,就是我們寫消費者邏輯的地方,代表了Disruptor中的一個消費者的介面。
  • EventProcessor:這是個事件處理器介面,實現了Runnable,處理主要事件迴圈,處理Event,擁有消費者的Sequence,這個介面有2個重要實現:
    • WorkProcessor:多執行緒處理實現,在多生產者多消費者模式下,確保每個sequence只被一個processor消費,在同一個WorkPool中,確保多個WorkProcessor不會消費同樣的sequence
    • BatchEventProcessor:單執行緒批量處理實現,包含了Event loop有效的實現,並回撥到了一個EventHandler介面的實現物件,這介面實際上是通過重寫run方法,輪詢獲取資料物件,並把資料經過等待策略交給消費者去處理。

Disruptor整體架構

接下來我們來看一下 Disruptor 是如何做到無阻塞、多生產、多消費的。

  • 構建 Disruptor 的各個引數以及 ringBuffer 的構造:
    • EventFactory:建立事件(任務)的工廠類。
    • ringBufferSize:容器的長度。
    • Executor:消費者執行緒池,執行任務的執行緒。
    • ProductType:生產者型別:單生產者、多生產者。
    • WaitStrategy:等待策略。
    • RingBuffer:存放資料的容器。
    • EventHandler:事件處理器。

Disruptor使用方式

maven依賴:
<dependency>
    <groupId>com.lmax</groupId>
    <artifactId>disruptor</artifactId>
    <version>3.4.2</version>
</dependency>
生產單消費簡單模式案例:
Event資料模型
import lombok.Data;
@Data
public class SampleEventModel {
    private String data;
}
Event事件模型Factory工廠類
import com.lmax.disruptor.EventFactory;
/**
 * 訊息物件生產工廠
 */
public class SampleEventModelFactory implements EventFactory<SampleEventModel> {
    @Override
    public SampleEventModel newInstance() {
        //返回空的訊息物件資料Event
        return new SampleEventModel();
    }
}
EventHandler處理器操作
import com.lmax.disruptor.EventHandler;
/**
 * 訊息事件處理器
 */
public class SampleEventHandler implements EventHandler<SampleEventModel> {
    /**
     * 事件驅動模式
     */
    @Override
    public void onEvent(SampleEventModel event, long sequence, boolean endOfBatch) throws Exception {
        // do ...
        System.out.println("消費者消費處理資料:" + event.getData());
    }
}
EventProducer工廠生產者服務處理器操作
import com.lmax.disruptor.RingBuffer;
/**
 * 訊息傳送
 */
public class SampleEventProducer {
    private RingBuffer<SampleEventModel> ringBuffer;
    public SampleEventProducer(RingBuffer<SampleEventModel> ringBuffer) {
        this.ringBuffer = ringBuffer;
    }
    /**
     * 釋出資料資訊
     * @param data
     */
    public void publish(String data){
        //從ringBuffer獲取可用sequence序號
        long sequence = ringBuffer.next();
        try {
            //根據sequence獲取sequence對應的Event 
			//這個Event是一個沒有賦值具體資料的物件
            TestEvent testEvent = ringBuffer.get(sequence);
            testEvent.setData(data);
        } finally {
            //提交發布
            ringBuffer.publish(sequence);
        }
    }
}
EventProducer工廠生產者服務處理器操作
public class TestMain {
    public static void main(String[] args) {
        SampleEventModelFactory eventFactory = new SampleEventModelFactory();
        int ringBufferSize = 1024 * 1024;
		//這個執行緒池最好自定義
        ExecutorService executor = Executors.newCachedThreadPool();
        //例項化disruptor
        Disruptor<SampleEventModel> disruptor = new Disruptor<SampleEventModel>(
                eventFactory,                   //訊息工廠
                ringBufferSize,                 //ringBuffer容器最大容量長度
                executor,                       //執行緒池,最好自定義一個
                ProducerType.SINGLE,            //單生產者模式
                new BlockingWaitStrategy()      //等待策略
        );
        //新增消費者監聽 把TestEventHandler繫結到disruptor
        disruptor.handleEventsWith(new SampleEventHandler());
        //啟動disruptor
        disruptor.start();
        //獲取實際儲存資料的容器RingBuffer
        RingBuffer<SampleEventModel> ringBuffer = disruptor.getRingBuffer();
        //生產傳送資料
        SampleEventProducer producer = new SampleEventProducer(ringBuffer);
        for(long i = 0; i < 100; i ++){
            producer.publish(i);
        }
        disruptor.shutdown();
        executor.shutdown();
    }
}

Disruptor的原理分析

  • 使用迴圈陣列代替佇列生產者消費者模型自然是離不開佇列的,使用預先填充資料的方式來避免 GC;
  • 使用CPU快取行填充的方式來避免極端情況下的資料爭用導致的效能下降;
  • 多執行緒程式設計中儘量避免鎖爭用的編碼技巧。

Disruptor為我們提供了一個思路和實踐,基本的迴圈陣列實現,定義一個陣列,長度為2的次方冪。

迴圈陣列

  • 設定一個數字標誌表示當前的可用的位置(可以從0開始)。

    • 頭標誌位表示下一個可以插入的位置。
      • 頭標誌位不能大於尾標誌位一個陣列長度(因為這樣就插入的位置和讀取的位置就重疊了會導致資料丟失)。
    • 尾標誌位表示下一個可以讀取的位置。
      • 尾標誌位不能等於頭標誌位(因為這樣讀取的資料實際上是上一輪的舊資料) 預先填充提高效能,我們知道在java中如果創造大量的物件使用後棄用,JVM 會在適當的時候進行GC操作。
  • 當這個數字標誌不斷增長到大於陣列長度時進行與陣列長度的並運算,得到的新數字依然在陣列的長度範圍內,就又可以插入。

  • 這樣就好像一直插入直到陣列末尾又再次從頭開始,故而稱之為迴圈陣列。 一般的迴圈陣列有頭尾兩個標誌位。這點和佇列很像。

迴圈陣列(初始化資料資訊)

在迴圈陣列中,可以事先在陣列中填充好資料。一旦有新資料的產生,要做的就是修改陣列中某一位中的一些屬性值。這樣可以避免頻繁建立資料和棄用資料導致的 GC。

這點比起佇列是要好的。 只保留一個標誌位,多執行緒在佇列也好,迴圈陣列也好,必然存在對標誌位的競爭。無論是使用鎖來避免競爭,還是使用 CAS 來進行無鎖演算法。

只要爭用的情況存在,並且執行緒較多,都會出現對資源的不斷消耗。爭用的物件越多,爭用中消耗掉的資源也就越多。

為了避免這樣的情況,減少爭用的資源就是一個手段。比如在迴圈陣列中只保留一個標誌位,也就是下一個可以寫入資料位置的標誌位。而尾部標誌位則在各個消費者執行緒中儲存(具體的程式設計手法後續細講)。

迴圈陣列在單執行緒

  • 迴圈陣列在單執行緒中的使用,如果確定只有一個生產者,也就是說只有一個寫執行緒。則在迴圈陣列中的使用會更加簡化。

  • 具體來說單執行緒更新陣列上的標誌位,那這種情況,標誌位就無需採用CAS寫的方式來確定下一個可寫入的位置,直接就是在單執行緒內進行普通的更新即可。

迴圈陣列在多執行緒

  • 迴圈陣列在多執行緒中的使用,如果存在多個生產者,則可寫入的標誌位需要用CAS 演算法來進行爭奪,避免鎖的使用。

  • 多個執行緒通過CAS得到唯一的不衝突的下一個可寫序號,由於需要獲得序號後才能進行寫入,而寫入完成才可以讓消費者執行緒進行消費。

  • 所以才獲得序號後,完成寫入前,必須有一種方式讓消費者檢測是否完成。

  • 以避免消費者拿到還未填入輸入的陣列位。 為了達到這個目標,存在簡單—效率低和複雜—效率高兩種方式。

簡單但是可能效率低的方式使用兩個標誌位。

  • prePut:表示下一個可以供生產者放入的位置;

    • 多個生產者通過 CAS 獲得 prePut 的不同的值,在獲得的序號並且完成資料寫入後,將 put 的值以 CAS 方式遞增(比如獲得的序號是7,只有 put 是6的時候才允許設定成功),稱之為釋出。
    • 這種方式存在一個缺點,如果多個執行緒併發寫入,獲取 prePut 的值不會堵塞,假設其中一個生產者在寫入資料的時候稍慢,則其他的執行緒寫入完畢也無法完成釋出。就會導致迴圈等待,浪費了 CPU 效能。
  • put:表示最後一個生產者已經放入的位置。

  • 複雜但是可能效率高的方式,在上面的方式中,主要的爭奪環節集中在多執行緒釋出中,序號大的執行緒釋出需要等到序號小的執行緒釋出完成後才能釋出。那我們的優化的點也在這個地方。

  • 這樣就可以避免釋出的爭奪。 但是又來帶來一個問題,用什麼數字來表示是否已經發布完成?如果只是0和1,那麼寫過1輪以後,標誌陣列位上就都是1了。又無法區分。

  • 所以標誌陣列上的數字應該在迴圈陣列的每一輪迴圈的值都不同。

比如一開始都是-1,第一輪中是0的表示已釋出,第二輪中是0表示沒釋出,是1的表示已釋出。

快取行填充

要了解快取行填充消除偽共享,首先要了解什麼是系統快取行:

  • CPU 為了更快的執行程式碼。於是當從記憶體中讀取資料時,並不是只讀自己想要的部分。而是讀取足夠的位元組來填入快取記憶體行。根據不同的 CPU ,快取記憶體行大小不同。如 X86 是 32BYTES ,而 ALPHA 是 64BYTES 。並且始終在第 32 個位元組或第 64 個位元組處對齊。這樣,當 CPU 訪問相鄰的資料時,就不必每次都從記憶體中讀取,提高了速度。 因為訪問記憶體要比訪問快取記憶體用的時間多得多。

  • 這個快取是CPU內部自己的快取,內部的快取單位是行,叫做快取行。在多核環境下會出現CPU之間的記憶體同步問題(比如一個核載入了一份快取,另外一個核也要用到同一份資料),如果每個核每次需要時都往記憶體中存取(一個在讀快取,一個在寫快取時,造成資料不一致),這會帶來比較大的效能損耗。

  • 資料在快取中不是以獨立的項來儲存的,如不是一個單獨的變數,也不是一個單獨的指標。快取是由快取行組成的,通常是64位元組(譯註:這篇文章發表時常用處理器的快取行是64位元組的,比較舊的處理器快取行是32位元組),並且它有效地引用主記憶體中的一塊地址。一個Java的long型別是8位元組,因此在一個快取行中可以存8個long型別的變數。

  • 當陣列中的一個值被載入到快取中,它會額外載入另外7個。因此你能非常快地遍歷這個陣列。事實上,你可以非常快速的遍歷在連續的記憶體塊中分配的任意資料結構。

  • 因此如果你資料結構中的項在記憶體中不是彼此相鄰的,你將得不到免費快取載入所帶來的優勢。並且在這些資料結構中的每一個項都可能會出現快取未命中。

  • 設想你的long型別的資料不是陣列的一部分。設想它只是一個單獨的變數。讓我們稱它為head,這麼稱呼它其實沒有什麼原因。然後再設想在你的類中有另一個變數緊挨著它。讓我們直接稱它為tail。現在,當你載入head到快取的時候,你也免費載入了tail

相關文章