高效能無鎖佇列 Disruptor 核心原理分析及其在i主題業務中的應用

vivo互联网技术發表於2024-08-15

作者:來自 vivo 網際網路伺服器團隊- Li Wanghong

本文首先介紹了 Disruptor 高效能記憶體佇列的基本概念、使用 Demo、高效能原理及原始碼分析,最後透過兩個例子介紹了 Disruptor 在i主題業務中的應用。

一、i主題及 Disruptor 簡介

i主題是 vivo 旗下的一款主題商店 app,使用者可以透過下載主題、桌布、字型等,實現對手機介面風格的一鍵更換和自定義。

Disruptor 是英國外匯交易公司 LMAX 開發的一個高效能的記憶體佇列(用於系統內部執行緒間傳遞訊息,不同於 RocketMQ、Kafka 這種分散式訊息佇列),基於 Disruptor 開發的系統單執行緒能支撐每秒600萬訂單。目前,包括 Apache Storm、Camel、Log4j 2在內的很多知名專案都應用了 Disruptor 以獲取高效能。在 vivo 內部它也有不少應用,比如自定義監控中使用 Disruptor 佇列來暫存透過監控 SDK 上報的監控資料,i主題中也使用它來統計本地記憶體指標資料。

接下來從 Disruptor 和 JDK 內建佇列的對比、Disruptor 核心概念、Disruptor 使用Demo、Disruptor 核心原始碼、Disruptor 高效能原理、Disruptor 在 i主題業務中的應用幾個角度來介紹 Disruptor。

二、和 JDK 中內建的佇列對比

下面來看下 JDK 中內建的佇列和 Disruptor 的對比。佇列的底層實現一般分為三種:陣列、連結串列和堆,其中堆一般是為了實現帶有優先順序特性的佇列,暫不考慮。另外,像 ConcurrentLinkedQueue 、LinkedTransferQueue 屬於無界佇列,在穩定性要求特別高的系統中,為了防止生產者速度過快,導致記憶體溢位,只能選擇有界佇列。這樣 JDK 中剩下可選的執行緒安全的佇列還有ArrayBlockingQueue 和 LinkedBlockingQueue。

由於 LinkedBlockingQueue 是基於連結串列實現的,由於連結串列儲存的資料在記憶體裡不連續,對於快取記憶體並不友好,而且 LinkedBlockingQueue 是加鎖的,效能較差。ArrayBlockingQueue 有同樣的問題,它也需要加鎖,另外,ArrayBlockingQueue 存在偽共享問題,也會導致效能變差。而今天要介紹的 Disruptor 是基於陣列的有界無鎖佇列,符合空間區域性性原理,可以很好的利用 CPU 的快取記憶體,同時它避免了偽共享,大大提升了效能。

圖片

三、Disruptor 核心概念

如下圖,從資料流轉的角度先對 Disruptor 有一個直觀的概念。Disruptor 支援單(多)生產者、單(多)消費者模式。消費時支援廣播消費(HandlerA 會消費處理所有訊息,HandlerB 也會消費處理所有訊息)、叢集消費(HandlerA 和 HandlerB 各消費部分訊息),HandlerA 和HandlerB 消費完成後會把訊息交給 HandlerC 繼續處理。

圖片

下面結合 Disruptor 官方的架構圖介紹下 Disruptor 的核心概念:

  • RingBuffer:前文說 Disruptor 是一個高效能記憶體記憶體佇列,而 RingBuffer 就是該記憶體佇列的資料結構,它是一個環形陣列,是承載資料的載體。

  • Producer:Disruptor 是典型的生產者消費者模型。因此生產者是 Disruptor 程式設計模型中的核心組成,可以是單生產者,也可以多生產者。

  • Event:具體的資料實體,生產者生產 Event ,存入 RingBuffer,消費者從 RingBuffer 中消費它進行邏輯處理。

  • Event Handler:開發者需要實現 EventHandler 介面定義消費者處理邏輯。

  • Wait Strategy:等待策略,定義了當消費者無法從 RingBuffer 獲取資料時,如何等待。

  • Event Processor:事件迴圈處理器,EventProcessor 繼承了 Runnable 介面,它的子類實現了 run 方法,內部有一個 while 迴圈,不斷嘗試從 RingBuffer 中獲取資料,交給 EventHandler 去處理。

  • Sequence:RingBuffer 是一個陣列,Sequence (序號)就是用來標記生產者資料生產到哪了,消費者資料消費到哪了。

  • Sequencer:分為單生產者和多生產者兩種實現,生產者釋出資料時需要先申請下可用序號,Sequencer 就是用來協調申請序號的。

  • Sequence Barrier:見下文分析。

圖片

四、Disruptor 使用 Demo

4.1 定義 Event

Event 是具體的資料實體,生產者生產 Event ,存入 RingBuffer,消費者從 RingBuffer 中消費它進行邏輯處理。Event 就是一個普通的 Java 物件,無需實現 Disruptor 內定義的介面。

public class OrderEvent {
    private long value;
 
    public long getValue() {
        return value;
    }
 
    public void setValue(long value) {
        this.value = value;
    }
}

4.2 定義 EventFactory

用於建立 Event 物件。

public class OrderEventFactory implements EventFactory<OrderEvent> {
    public OrderEvent newInstance() {
        return new OrderEvent();
    }
}

4.3 定義生產者

可以看到,生成者主要是持有 RingBuffer 物件進行資料的釋出。這裡有幾個點需要注意:

  • RingBuffer 內部維護了一個 Object 陣列(也就是真正儲存資料的容器),在 RingBuffer 初始化時該 Object 陣列就已經使用 EventFactory 初始化了一些空 Event,後續就不需要在執行時來建立了,提高效能。因此這裡透過 RingBuffer 獲取指定序號得到的是一個空物件,需要對它進行賦值後,才能進行釋出。

  • 這裡透過 RingBuffer 的 next 方法獲取可用序號,如果 RingBuffer 空間不足會阻塞。

  • 透過 next 方法獲取序號後,需要確保接下來使用 publish 方法釋出資料。

public class OrderEventProducer {
 
    private RingBuffer<OrderEvent> ringBuffer;
     
    public OrderEventProducer(RingBuffer<OrderEvent> ringBuffer) {
        this.ringBuffer = ringBuffer;
    }
     
    public void sendData(ByteBuffer data) {
        // 1、在生產者傳送訊息的時候, 首先需要從我們的ringBuffer裡面獲取一個可用的序號
        long sequence = ringBuffer.next();
        try {
            //2、注意此時獲取的OrderEvent物件是一個沒有被賦值的空物件
            OrderEvent event = ringBuffer.get(sequence);
            //3、進行實際的賦值處理
            event.setValue(data.getLong(0));           
        } finally {
            //4、 提交發布操作
            ringBuffer.publish(sequence);          
        }
    }
}

4.4 定義消費者

消費者可以實現 EventHandler 介面,定義自己的處理邏輯。

public class OrderEventHandler implements EventHandler<OrderEvent> {
 
    public void onEvent(OrderEvent event,
                        long sequence,
                        boolean endOfBatch) throws Exception {
        System.out.println("消費者: " + event.getValue());
    }
}

4.5 主流程

  • 首先初始化一個 Disruptor 物件,Disruptor 有多個過載的建構函式。支援傳入 EventFactory 、ringBufferSize (需要是2的冪次方)、executor(用於執行EventHandler 的事件處理邏輯,一個 EventHandler 對應一個執行緒,一個執行緒只服務於一個 EventHandler )、生產者模式(支援單生產者、多生產者)、阻塞等待策略。在建立 Disruptor 物件時,內部會建立好指定 size 的 RingBuffer 物件。

  • 定義 Disruptor 物件之後,可以透過該物件新增消費者 EventHandler。

  • 啟動 Disruptor,會將第2步新增的 EventHandler 消費者封裝成 EventProcessor(實現了 Runnable 介面),提交到構建 Disruptor 時指定的 executor 物件中。由於 EventProcessor 的 run 方法是一個 while 迴圈,不斷嘗試從RingBuffer 中獲取資料。因此可以說一個 EventHandler 對應一個執行緒,一個執行緒只服務於一個EventHandler。

  • 拿到 Disruptor 持有的 RingBuffer,然後就可以建立生產者,透過該RingBuffer就可以釋出生產資料了,然後 EventProcessor 中啟動的任務就可以消費到資料,交給 EventHandler 去處理了。

public static void main(String[] args) {
    OrderEventFactory orderEventFactory = new OrderEventFactory();
    int ringBufferSize = 4;
    ExecutorService executor = Executors.newFixedThreadPool(1);
 
    /**
     * 1. 例項化disruptor物件
       1) eventFactory: 訊息(event)工廠物件
       2) ringBufferSize: 容器的長度
       3) executor:
       4) ProducerType: 單生產者還是多生產者
       5) waitStrategy: 等待策略
     */
    Disruptor<OrderEvent> disruptor = new Disruptor<OrderEvent>(orderEventFactory,
                                                        ringBufferSize,
                                                        executor,
                                                        ProducerType.SINGLE,
                                                        new BlockingWaitStrategy());
 
    // 2. 新增消費者的監聽
    disruptor.handleEventsWith(new OrderEventHandler());
 
    // 3. 啟動disruptor
    disruptor.start();
 
    // 4. 獲取實際儲存資料的容器: RingBuffer
    RingBuffer<OrderEvent> ringBuffer = disruptor.getRingBuffer();
 
    OrderEventProducer producer = new OrderEventProducer(ringBuffer);
 
    ByteBuffer bb = ByteBuffer.allocate(8);
 
    for (long i = 0; i < 5; i++) {
        bb.putLong(0, i);
        producer.sendData(bb);
    }
 
    disruptor.shutdown();
    executor.shutdown();
}

五、Disruptor 原始碼分析

本文分析時以單(多)生產者、單消費者為例進行分析。

5.1 建立 Disruptor

首先是透過傳入的引數建立 RingBuffer,將建立好的 RingBuffer 與傳入的 executor 交給 Disruptor 物件持有。

public Disruptor(
    final EventFactory<T> eventFactory,
    final int ringBufferSize,
    final Executor executor,
    final ProducerType producerType,
    final WaitStrategy waitStrategy){
    this(RingBuffer.create(producerType, eventFactory, ringBufferSize, waitStrategy),
         executor);
}

接下來分析 RingBuffer 的建立過程,分為單生產者與多生產者。

public static <E> RingBuffer<E> create(
        ProducerType producerType,
        EventFactory<E> factory,
        int bufferSize,
        WaitStrategy waitStrategy){
        switch (producerType){
            case SINGLE:
                // 單生產者
                return createSingleProducer(factory, bufferSize, waitStrategy);
            case MULTI:
                // 多生產者
                return createMultiProducer(factory, bufferSize, waitStrategy);
            default:
                throw new IllegalStateException(producerType.toString());
        }
}

不論是單生產者還是多生產者,最終都會建立一個 RingBuffer 物件,只是傳給 RingBuffer 的 Sequencer 物件不同。可以看到,RingBuffer 內部最終建立了一個Object 陣列來儲存 Event 資料。這裡有幾點需要注意:

  • RingBuffer 是用陣列實現的,在建立該陣列後緊接著呼叫 fill 方法呼叫 EventFactory 工廠方法為陣列中的元素進行初始化,後續在使用這些元素時,直接透過下標獲取並給對應的屬性賦值,這樣就避免了 Event 物件的反覆建立,避免頻繁 GC。

  • RingBuffe 的陣列中的元素是在初始化時一次性全部建立的,所以這些元素的記憶體地址大機率是連續的。消費者在消費時,是遵循空間區域性性原理的。消費完第一個Event 時,很快就會消費第二個 Event,而在消費第一個 Event 時,CPU 會把記憶體中的第一個 Event 的後面的 Event 也載入進 Cache 中,這樣當消費第二個 Event時,它已經在 CPU Cache 中了,所以就不需要從記憶體中載入了,這樣可以大大提升效能。

public static <E> RingBuffer<E> createSingleProducer(
    EventFactory<E> factory, int bufferSize, WaitStrategy waitStrategy){
     
    SingleProducerSequencer sequencer = new SingleProducerSequencer(bufferSize,
                                                                    waitStrategy);
    return new RingBuffer<E>(factory, sequencer);
}

RingBufferFields(
        EventFactory<E> eventFactory,
        Sequencer sequencer){
        // 省略部分程式碼...
         
        // 額外建立2個填充空間的大小, 首尾填充, 避免陣列的有效載荷和其它成員載入到同一快取行
        this.entries = new Object[sequencer.getBufferSize() + 2 * BUFFER_PAD];
        fill(eventFactory);
}
 
private void fill(EventFactory<E> eventFactory){
    for (int i = 0; i < bufferSize; i++){
        // BUFFER_PAD + i為真正的陣列索引
        entries[BUFFER_PAD + i] = eventFactory.newInstance();
    }
}

5.2 新增消費者

新增消費者的核心程式碼如下所示,核心就是為將一個 EventHandler 封裝成 BatchEventProcessor,

然後新增到 consumerRepository 中,後續啟動 Disruptor 時,會遍歷 consumerRepository 中的所有 BatchEventProcessor(實現了 Runnable 介面),將 BatchEventProcessor 任務提交到執行緒池中。

public final EventHandlerGroup<T> handleEventsWith(
                                    final EventHandler<? super T>... handlers){
    // 透過disruptor物件直接呼叫handleEventsWith方法時傳的是空的Sequence陣列
    return createEventProcessors(new Sequence[0], handlers);
}

EventHandlerGroup<T> createEventProcessors(
    final Sequence[] barrierSequences,
    final EventHandler<? super T>[] eventHandlers) {
 
    // 收集新增的消費者的序號
    final Sequence[] processorSequences = new Sequence[eventHandlers.length];
    // 本批次消費由於新增在同一個節點之後, 因此共享該屏障
    final SequenceBarrier barrier = ringBuffer.newBarrier(barrierSequences);
 
    // 為每個EventHandler建立一個BatchEventProcessor
    for (int i = 0, eventHandlersLength = eventHandlers.length;
                    i < eventHandlersLength; i++) {
        final EventHandler<? super T> eventHandler = eventHandlers[i];
 
        final BatchEventProcessor<T> batchEventProcessor =
            new BatchEventProcessor<>(ringBuffer, barrier, eventHandler);
 
        if (exceptionHandler != null){
            batchEventProcessor.setExceptionHandler(exceptionHandler);
        }
 
        // 新增到消費者資訊倉庫中
        consumerRepository.add(batchEventProcessor, eventHandler, barrier);
        processorSequences[i] = batchEventProcessor.getSequence();
    }
 
    // 更新閘道器序列(生產者只需要關注所有的末端消費者節點的序列)
    updateGatingSequencesForNextInChain(barrierSequences, processorSequences);
 
    return new EventHandlerGroup<>(this, consumerRepository, processorSequences);
}

建立完 Disruptor 物件之後,可以透過 Disruptor 物件新增 EventHandler,這裡有一需要注意:透過 Disruptor 物件直接呼叫 handleEventsWith 方法時傳的是空的 Sequence 陣列,這是什麼意思?可以看到 createEventProcessors 方法接收該空 Sequence 陣列的欄位名是 barrierSequences,翻譯成中文就是柵欄序號。怎麼理解這個欄位?

比如透過如下程式碼給 Disruptor 新增了兩個handler,記為 handlerA 和 handlerB,這種是序列消費,對於一個 Event,handlerA 消費完後才能輪到 handlerB 去消費。對於 handlerA 來說,它沒有前置消費者(生成者生產到哪裡,消費者就可以消費到哪裡),因此它的 barrierSequences 是一個空陣列。而對於 handlerB 來說,它的前置消費者是 handlerA,因此它的 barrierSequences 就是A的消費進度,也就是說 handlerB 的消費進度是要小於 handlerA 的消費進度的。

圖片

disruptor.handleEventsWith(handlerA).handleEventsWith(handlerB);

圖片

5.3 啟動 Disruptor

Disruptor的啟動邏輯比較簡潔,就是遍歷consumerRepository 中收集的 EventProcessor(實現了Runnable介面),將它提交到建立 Disruptor 時指定的executor 中,EventProcessor 的 run 方法會啟動一個while 迴圈,不斷嘗試從 RingBuffer 中獲取資料進行消費。

disruptor.start();

public RingBuffer<T> start() {
    checkOnlyStartedOnce();
    for (final ConsumerInfo consumerInfo : consumerRepository) {
        consumerInfo.start(executor);
    }
 
    return ringBuffer;
}
 
public void start(final Executor executor) {
    executor.execute(eventprocessor);
}

5.4 釋出資料

在分析 Disruptor 的釋出資料的原始碼前,先來回顧下發布資料的整體流程。

  • 呼叫 next 方法獲取可用序號,該方法可能會阻塞。

  • 透過上一步獲得的序號從 RingBuffer 中獲取對應的 Event,因為 RingBuffer 中所有的 Event 在初始化時已經建立好了,這裡獲取的只是空物件。

  • 因此接下來需要對該空物件進行業務賦值。

  • 呼叫 next 方法需要在 finally 方法中進行最終的釋出,標記該序號資料已實際生產完成。

public void sendData(ByteBuffer data) {
    long sequence = ringBuffer.next();
    try {
        OrderEvent event = ringBuffer.get(sequence);
        event.setValue(data.getLong(0));           
    } finally {
        ringBuffer.publish(sequence);          
    }
}

5.4.1 獲取序號

next 方法預設申請一個序號。nextValue 表示已分配的序號,nextSequence 表示在此基礎上再申請n個序號(此處n為1),cachedValue 表示快取的消費者的最小消費進度。

假設有一個 size 為8的 RingBuffer,當前下標為6的資料已經發布好(nextValue為6),消費者一直未開啟消費(cachedValue 和 cachedGatingSequence 為-1),此時生產者想繼續釋出資料,呼叫 next() 方法申請獲取序號為7的位置(nextSequence為7),計算得到的 wrapPoint 為7-8=-1,此時 wrapPoint 等於 cachedGatingSequence,可以繼續釋出資料,如左圖。最後將 nextValue 賦值為7,表示序號7的位置已經被生產者佔用了。

接著生產者繼續呼叫 next() 方法申請序號為0的資料,此時 nextValue為7,nextSequence 為8,wrapPoint 等於0,由於消費者遲遲未消費(cachedGatingSequence為-1),此時 wrapPoint 大於了 cachedGatingSequence,因此 next 方法的if判斷成立,會呼叫 LockSupport.parkNanos 阻塞等待消費者進行消費。其中 getMinimumSequence 方法是獲取多個消費者的最小消費進度。

圖片

public long next() {
    return next(1);
}

public long next(int n) {
 
    /**
     * 已分配的序號的快取(已分配到這裡), 初始-1. 可以看該方法的返回值nextSequence,
     * 接下來生產者就會該往該位置寫資料, 它賦值給了nextValue, 所以下一次呼叫next方
     * 法時, nextValue位置就是表示已經生產好了資料, 接下來要申請nextSequece的資料
     */
    long nextValue = this.nextValue;
 
    // 本次申請分配的序號
    long nextSequence = nextValue + n;
 
    // 構成環路的點:環形緩衝區可能追尾的點 = 等於本次申請的序號-環形緩衝區大小
    // 如果該序號大於最慢消費者的進度, 那麼表示追尾了, 需要等待
    long wrapPoint = nextSequence - bufferSize;
 
    // 上次快取的最小閘道器序號(消費最慢的消費者的進度)
    long cachedGatingSequence = this.cachedValue;
 
    // wrapPoint > cachedGatingSequence 表示生產者追上消費者產生環路(追尾), 即緩衝區已滿,
    // 此時需要獲取消費者們最新的進度, 以確定是否佇列滿
    if (wrapPoint > cachedGatingSequence || cachedGatingSequence > nextValue) {
        // 插入StoreLoad記憶體屏障/柵欄, 保證可見性。
        // 因為publish使用的是set()/putOrderedLong, 並不保證其他消費者能及時看見釋出的資料
        // 當我再次申請更多的空間時, 必須保證消費者能消費釋出的資料
        cursor.setVolatile(nextValue);
 
        long minSequence;
        // minSequence是多個消費者的最小序號, 要等所有消費者消費完了才能繼續生產
        while (wrapPoint > (minSequence = Util.getMinimumSequence(gatingSequences,
                                                                  nextValue))) {
            LockSupport.parkNanos(1L);
        }
 
        // 快取生產者們最新的消費進度
        this.cachedValue = minSequence;
    }
 
    // 這裡只寫了快取, 並未寫volatile變數, 因為只是預分配了空間但是並未被髮布資料,
    // 不需要讓其他消費者感知到。消費者只會感知到真正被髮布的序號
    this.nextValue = nextSequence;
 
    return nextSequence;
}

5.4.2 根據序號獲取 Event

直接透過 Unsafe 工具類獲取指定序號的 Event 物件,此時獲取的是空物件,因此接下來需要對該 Event 物件進行業務賦值,賦值完成後呼叫 publish 方法進行最終的資料釋出。

OrderEvent event = ringBuffer.get(sequence);

public E get(long sequence) {
    return elementAt(sequence);
}

protected final E elementAt(long sequence) {
    return (E) UNSAFE.getObject(entries,
                                REF_ARRAY_BASE +
                                ((sequence & indexMask) << REF_ELEMENT_SHIFT));
}

5.4.3 釋出資料

生產者獲取到可用序號後,首先對該序號處的空 Event 物件進行業務賦值,接著呼叫 RingBuffer 的 publish 方法釋出資料,RingBuffer 會委託給其持有的 sequencer(單生產者和多生產者對應不同的 sequencer)物件進行真正釋出。單生產者的釋出邏輯比較簡單,更新下 cursor 進度(cursor 表示生產者的生產進度,該位置已實際釋出資料,而 next 方法中的 nextSequence 表示生產者申請的最大序號,可能還未實際釋出資料),接著喚醒等待的消費者。

waitStrategy 有不同的實現,因此喚醒邏輯也不盡相同,如採用 BusySpinWaitStrategy 策略時,消費者獲取不到資料時自旋等待,然後繼續判斷是否有新資料可以消費了,因此 BusySpinWaitStrategy 策略的 signalAllWhenBlocking 就是一個空實現,啥也不做。

ringBuffer.publish(sequence);

public void publish(long sequence) {
    sequencer.publish(sequence);
}

public void publish(long sequence) {
    // 更新生產者進度
    cursor.set(sequence);
    // 喚醒等待的消費者
    waitStrategy.signalAllWhenBlocking();
}

5.4.4 消費資料

前面提到,Disruptor 啟動時,會將封裝 EventHandler 的EventProcessor(此處以 BatchEventProcessor 為例)提交到執行緒池中執行,BatchEventProcessor 的 run 方法會呼叫 processEvents 方法不斷嘗試從 RingBuffer 中獲取資料進行消費,下面分析下 processEvents 的邏輯(程式碼做了精簡)。它會開啟一個 while 迴圈,呼叫 sequenceBarrier.waitFor 方法獲取最大可用的序號,比如獲取序號一節所提的,生產者持續生產,消費者一直未消費,此時生產者已經將整個 RingBuffer 資料都生產滿了,生產者無法再繼續生產,生產者此時會阻塞。假設這時候消費者開始消費,因此 nextSequence 為0,而 availableSequence 為7,此時消費者可以批次消費,將這8條已生產者的資料全部消費完,消費完成後更新下消費進度。更新消費進度後,生產者透過 Util.getMinimumSequence 方法就可以感知到最新的消費進度,從而不再阻塞,繼續釋出資料了。

private void processEvents() {
    T event = null;
 
    // sequence記錄消費者的消費進度, 初始為-1
    long nextSequence = sequence.get() + 1L;
 
    // 死迴圈,因此不會讓出執行緒,需要獨立的執行緒(每一個EventProcessor都需要獨立的執行緒)
    while (true) {
        // 透過屏障獲取到的最大可用序號
        final long availableSequence = sequenceBarrier.waitFor(nextSequence);
 
        // 批次消費
        while (nextSequence <= availableSequence) {
            event = dataProvider.get(nextSequence);
            eventHandler.onEvent(event, nextSequence, nextSequence == availableSequence);
            nextSequence++;
        }
 
        // 更新消費進度(批次消費, 每次消費只更新一次Sequence, 減少效能消耗)
        sequence.set(availableSequence);
    }
}

下面分析下 SequenceBarrier 的 waitFor 方法。首先它會呼叫 waitStrategy 的 waitFor 方法獲取最大可用序號,以 BusySpinWaitStrategy 策略為例,它的 waitFor 方法的三個引數的含義分別是:

  • sequence:消費者期望獲得的序號,也就是當前消費者已消費的進度+1

  • cursor:當前生產者的生成進度

  • dependentSequence:消費者依賴的前置消費者的消費進度。該欄位是在新增 EventHandler,建立BatchEventProcessor 時建立的。如果當前消費者沒有前置依賴的消費者,那麼它只需要關心生產者的進度,生產者生產到哪裡,它就可以消費到哪裡,因此 dependentSequence 就是 cursor。而如果當前消費者有前置依賴的消費者,那麼dependentSequence就是FixedSequenceGroup(dependentSequences)。

因為 dependentSequence 分為兩種情況,所以 waitFor 的邏輯也可以分為兩種情況討論:

  • 當前消費者無前置消費者:假設 cursor 為6,也就是序號為6的資料已經發布了資料,此時傳入的sequence為6,則waitFor方法可以直接返回availableSequence(6),可以正常消費。序號為6的資料消費完成後,消費者繼續呼叫 waitFor 獲取資料,傳入的 sequence為7,而此時 availableSequence 還是未6,因此消費者需要自旋等待。當生產者繼續釋出資料後,因為 dependentSequence 持有的就是生產者的生成進度,因此消費者可以感知到,繼續消費。

  • 當前消費者有前置消費者:假設 cursor 為6,當前消費者C有兩個前置依賴的消費者A(消費進度為5)、B(消費進度為4),那麼此時 availableSequence(FixedSequenceGroup例項,它的 get 方法是獲取A、B的最小值,也就是4)為4。如果當前消費者C期望消費下標為4的資料,則可以正常消費,但是消費下標為5的資料就不行了,它需要等待它的前置消費者B消費完進度為5的資料後才能繼續消費。

在 waitStrategy 的 waitFor 方法返回,得到最大可用的序號 availableSequence 後,最後需要再呼叫下 sequencer 的 getHighestPublishedSequence 獲取真正可用的最大序號,這和生產者模型有關係,如果是單生產者,因為資料是連續釋出的,直接返回傳入的 availableSequence。而如果是多生產者,因為多生產者是有多個執行緒在生產資料,釋出的資料是不連續的,因此需要透過 過getHighestPublishedSequence 方法獲取已釋出的且連續的最大序號,因為獲取序號進行消費時需要是順序的,不能跳躍。

public long waitFor(final long sequence)
        throws AlertException, InterruptedException, TimeoutException {
    /**
     * sequence: 消費者期望獲取的序號
     * cursorSequence: 生產者的序號
     * dependentSequence: 消費者需要依賴的序號
     */
    long availableSequence = waitStrategy.waitFor(sequence,
                                                  cursorSequence,
                                                  dependentSequence, this);
 
    if (availableSequence < sequence) {
        return availableSequence;
    }
 
    // 目標sequence已經發布了, 這裡獲取真正的最大序號(和生產者模型有關)
    return sequencer.getHighestPublishedSequence(sequence, availableSequence);
}

public long waitFor(
    final long sequence, Sequence cursor, final Sequence dependentSequence,
    final SequenceBarrier barrier) throws AlertException, InterruptedException {
    long availableSequence;
 
    // 確保該序號已經被我前面的消費者消費(協調與其他消費者的關係)
    while ((availableSequence = dependentSequence.get()) < sequence) {
        barrier.checkAlert();
        // 自旋等待
        ThreadHints.onSpinWait();
    }
 
    return availableSequence;
}

六、Disruptor 高效能原理分析

6.1 空間預分配

前文分析原始碼時介紹到,RingBuffer 內部維護了一個 Object 陣列(也就是真正儲存資料的容器),在 RingBuffer 初始化時該 Object 陣列就已經使用EventFactory 初始化了一些空 Event,後續就不需要在執行時來建立了,避免頻繁GC。

另外,RingBuffe 的陣列中的元素是在初始化時一次性全部建立的,所以這些元素的記憶體地址大機率是連續的。消費者在消費時,是遵循空間區域性性原理的。消費完第一個Event 時,很快就會消費第二個 Event,而在消費第一個 Event 時,CPU 會把記憶體中的第一個 Event 的後面的 Event 也載入進 Cache 中,這樣當消費第二個 Event 時,它已經在 CPU Cache 中了,所以就不需要從記憶體中載入了,這樣也可以大大提升效能。

6.2 避免偽共享

6.2.1 一個偽共享的例子

如下程式碼所示,定義了一個 Pointer 類,它有2個 long 型別的成員變數x、y,然後在 main 方法中其中2個執行緒分別對同一個 Pointer 物件的x和y自增 100000000 次,最後統計下方法耗時,在我本機電腦上測試多次,平均約為3600ms。

public class Pointer {
 
    volatile long x;
 
    volatile long y;
 
    @Override
    public String toString() {
        return new StringJoiner(", ", Pointer.class.getSimpleName() + "[", "]")
                .add("x=" + x)
                .add("y=" + y)
                .toString();
    }
}

public static void main(String[] args) throws InterruptedException {
    Pointer pointer = new Pointer();
 
    int num = 100000000;
    long start = System.currentTimeMillis();
 
    Thread t1 = new Thread(() -> {
        for(int i = 0; i < num; i++){
            pointer.x++;
        }
    });
 
    Thread t2 = new Thread(() -> {
        for(int i = 0; i < num; i++){
            pointer.y++;
        }
    });
 
    t1.start();
    t2.start();
    t1.join();
    t2.join();
 
    System.out.println(System.currentTimeMillis() - start);
    System.out.println(pointer);
}

接著將 Pointer 類修改如下:在變數x和y之間插入7個 long 型別的變數,僅此而已,接著繼續透過上述的 main 方法統計耗時,平均約為500ms。可以看到,修改前的耗時是修改後(避免了偽共享)的7倍多。那麼什麼是偽共享,為什麼避免了偽共享能有這麼大的效能提升呢?

public class Pointer {
 
    volatile long x;
 
    long p1, p2, p3, p4, p5, p6, p7;
 
    volatile long y;
 
    @Override
    public String toString() {
        return new StringJoiner(", ", Pointer.class.getSimpleName() + "[", "]")
                .add("x=" + x)
                .add("y=" + y)
                .toString();
    }
}

6.2.2 避免偽共享為什麼可以提升效能

記憶體的訪問速度是遠遠慢於 CPU 的,為了高效利用 CPU,在 CPU 和記憶體之間加了快取,稱為 CPU Cache。為了提高效能,需要更多地從 CPU Cache 裡獲取資料,而不是從記憶體中獲取資料。CPU Cache 載入記憶體裡的資料,是以快取行(通常為64位元組)為單位載入的。Java 的 long 型別是8位元組,因此一個快取行可以存放8個 long 型別的變數。

但是,這種載入帶來了一個壞處,如上述例子所示,假設有一個 long 型別的變數x,另外還有一個 long 型別的變數y緊挨著它,那麼當載入x時也會載入y。如果此時 CPU Core1 的執行緒在對x進行修改,另一個 CPU Core2 的執行緒卻在對y進行讀取。當前者修改x時,會把x和y同時載入到 CPU Core1 對應的 CPU Cache 中,更新完後x和其它所有包含x的快取行都將失效。而當 CPU Core2 的執行緒讀取y時,發現這個快取行已經失效了,需要從主記憶體中重新載入。

這就是偽共享,x和y不相干,但是卻因為x的更新導致需要重新從主記憶體讀取,拖慢了程式效能。解決辦法之一就是如上述示例中所做,在x和y之間填充7個 long 型別的變數,保證x和y不會被載入到同一個快取行中去。Java8 中也增加了新的註解@Contended(JVM加上啟動引數-XX:-RestrictContended 才會生效),也可以避免偽共享。

圖片

6.2.3 Disruptor 中使用偽共享的場景

Disruptor 中使用 Sequence 類的 value 欄位來表示生產/消費進度,可以看到在該欄位前後各填充了7個 long 型別的變數,來避免偽共享。另外,向 RingBuffer 內部的陣列、

SingleProducerSequencer 等也使用了該技術。

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;
}

6.3 無鎖

生產者生產資料時,需要入隊。消費者消費資料時,需要出隊。入隊時,不能覆蓋沒有消費的元素。出隊時,不能讀取沒有寫入的元素。因此,Disruptor 中需要維護一個入隊索引(生產者資料生產到哪裡,對應 AbstractSequencer 中的 cursor )和一個出隊索引(所有消費者中消費進度最小的序號)。

Disruptor 中最複雜的是入隊操作,下面以多生產者(MultiProducerSequencer)的 next(n) 方法(申請n個序號)為例分析下 Disruptor 是如何實現無鎖操作的。程式碼如下所示,判斷下是否有足夠的序號(空餘位置),如果沒有,就讓出 CPU 使用權,然後重新判斷。如果有,則使用 CAS 設定 cursor(入隊索引)。

public long next(int n) {
    do {
        // cursor類似於入隊索引, 指的是上次生產到這裡
        current = cursor.get();
        // 目標是再生產n個
        next = current + n;
 
        // 前文分析過, 用於判斷消費者是否已經追上生產進度, 生產者能否申請到n個序號
        long wrapPoint = next - bufferSize;
        // 獲取快取的上一次的消費進度
        long cachedGatingSequence = gatingSequenceCache.get();
 
        // 第一步:空間不足就繼續等待
        if (wrapPoint > cachedGatingSequence || cachedGatingSequence > current) {
            // 重新計算下所有消費者裡的最小消費進度
            long gatingSequence = Util.getMinimumSequence(gatingSequences, current);
 
            // 依然沒有足夠的空間, 讓出CPU使用權
            if (wrapPoint > gatingSequence) {
                LockSupport.parkNanos(1);
                continue;
            }
 
            // 更新下最新的最小的消費進度
            gatingSequenceCache.set(gatingSequence);
        }
        // 第二步:看見空間足夠時嘗試CAS競爭空間
        else if (cursor.compareAndSet(current, next)) {
            break;
        }
    } while (true);
 
    return next;
}

6.4 支援批次消費定義 Event

這個比較好理解,在前文分析消費資料的邏輯時介紹了,消費者會獲取下最大可用的序號,然後批次消費這些訊息。

七、Disruptor 在i主題業務中的使用

很多開源專案都使用了 Disruptor,比如日誌框架 Log4j2 使用它來實現非同步日誌。HBase、Storm 等專案中也使用了到了 Disruptor。vivo 的 i主題業務也使用了 Disruptor,下面簡單介紹下它的2個使用場景。

7.1 監控資料上報

業務監控系統對於企業來說非常重要,可以幫助企業及時發現和解決問題,可以方便的檢測業務指標資料,改進業務決策,從而保證業務的可持續發展。i主題使用 Disruptor(多生產者單消費者)來暫存待上報的業務指標資料,然後有定時任務不斷提取資料上報到監控平臺,如下圖所示。

圖片

7.2 本地快取 key 統計分析

i主題業務中大量使用了本地快取,為了統計本地快取中key 的個數(去重)以及每種快取模式 key 的數量,考慮使用 Disruptor 來暫存並消費處理資料。因為業務程式碼裡很多地方涉及到本地快取的訪問,也就是說,生產者是多執行緒的。考慮到消費處理比較簡單,而如果使用多執行緒消費的話又涉及到加鎖同步,因此消費者採用單執行緒模式。

整體流程如下圖所示,首先在快取訪問工具類中增加快取訪問統計上報的呼叫,快取訪問資料進入到 RingBuffer 後,單執行緒消費者使用 HyperLogLog 來去重統計不同 key的個數,使用正則匹配來統計每種模式key的數量。然後有非同步任務定時獲取統計結果,進行展示。

需要注意的是,因為 RingBuffer 佇列大小是固定的,如果生產者生產過快而消費者消費不過來,如果使用 next 方法申請序號,如果剩餘空間不夠會導致生產者阻塞,因此建議使用 tryPublishEvent 方法去釋出資料,它內部是使用 tryNext 方法申請序號,該方法如果申請不到可用序號會丟擲異常,這樣生產者感知到了就可以做相容處理,而不是阻塞等待。

圖片

八、使用建議

  • Disruptor 是基於生產者消費者模式,如果生產快消費慢,就會導致生產者無法寫入資料。因此,不建議在 Disruptor 消費執行緒中處理耗時較長的業務。

  • 一個 EventHandler 對應一個執行緒,一個執行緒只服務於一個 EventHandler。Disruptor 需要為每一個EventHandler(EventProcessor) 建立一個執行緒。因此在建立 Disruptor 時不推薦傳入指定的執行緒池,而是由 Disruptor 自身根據 EventHandler 數量去建立對應的執行緒。

  • 生產者呼叫 next 方法申請序號時,如果獲取不到可用序號會阻塞,這一點需要注意。推薦使用 tryPublishEvent 方法,生產者在申請不到可用序號時會立即返回,不會阻塞業務執行緒。

  • 如果使用 next 方法申請可用序號,需要確保在 finally 方法中呼叫 publish 真正釋出資料。

  • 合理設定等待策略。消費者在獲取不到資料時會根據設定的等待策略進行等待,BlockingWaitStrategry 是最低效的策略,但其對 CPU消耗最小。YieldingWaitStrategy 有著較低的延遲、較高的吞吐量,以及較高 CPU 佔用率。當 CPU 數量足夠時,可以使用該策略。

九、總結

本文首先透過對比 JDK 中內建的執行緒安全的佇列和Disruptor 的特點,引入了高效能無鎖記憶體佇列 Disruptor。接著介紹了 Disruptor 的核心概念和基本使用,使讀者對 Disruptor 建立起初步的認識。接著從原始碼和原理角度介紹了 Disruptor 的核心實現以及高效能原理(空間預分配、避免偽共享、無鎖、支援批次消費)。其次,結合i主題業務介紹了 Disruptor 在實踐中的應用。最後,基於上述原理分析及應用實戰,總結了一些 Disruptor 最佳實踐策略。

參考文章:

https://time.geekbang.org/column/article/132477

https://lmax-exchange.github.io/disruptor/

相關文章