Disruptor在雲音樂特徵服務中的應用

雲音樂技術團隊發表於2022-05-12
作者:章北海

我們的線上特徵資料服務DataService,為了解決使用執行緒池模型導致機器cpu利用率不高,長尾請求延遲不線性(p99、p999出現J型曲線)的問題。在利用Disruptor替換執行緒池之後取得不錯的效能結果。本文主要是簡單的介紹一下對Disruptor的個人理解以及落地的結果。

背景

Disruptor是一個高效能的處理併發問題的框架,由LMAX(一個英國做金融交易的公司)設計開發用於自己金融交易系統建設。之後開源被很多知名的開源庫使用,例如前段時間爆發漏洞的Log4j。

其中Log4j2使用Disruptor來優化多執行緒模式下的日誌落盤效能,Log4j2做了一個測試使用:同步模式(Sync)、Async(ArrayBlockingQueue)、ALL_ASYNC(Disruptor)分別進行壓測,得到如下測試結論:https://logging.apache.org/lo...

Disruptor模式的吞吐能力是JDK ArrayBlockQueue的12倍,是同步模式的68倍。

響應時間P99指標Disruptor模式比BlockQueue也更加優秀,尤其是開啟了Garbage-free等優化引數之後。

通過log4j的例子看來,disruptor可以讓你的系統在達到更高吞吐的同時帶來更加穩定且低的響應時間。

那麼為什麼disruptor可以帶來這些收益而jdk的執行緒池模式又有什麼問題呢?

Disruptor介紹

LMAX是一個金融交易公司,他們的交易中有大量的生產者消費者模型業務邏輯,很自然他們將生產者產出的資料放到佇列中(eg. ArrayBlockingQueue)然後開啟多個消費者執行緒進行併發消費。

然後他們測試了一把資料在佇列中傳遞的效能跟訪問磁碟(RAID、SSD)差不多,當業務邏輯需資料要多個佇列在不同的業務Stage之間傳遞資料時,多個序列的佇列開銷是不可忍受的,然後他們開始分析為什麼JDK的佇列會有這麼嚴重的效能問題。

BolckQueue的問題

為什麼使用BlockQueue會有這麼劇烈的差別,以Java的ArrayBlockingqueue為例。底層實現其實是一個陣列,在入隊、出隊的時候通過重入鎖來保證併發情況下的佇列資料的執行緒安全。

/**
 * ArrayBlockQueue的入隊實現
 */
public boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException {
        checkNotNull(e);
        long nanos = unit.toNanos(timeout);
        final ReentrantLock lock = this.lock;
            // 全域性鎖
        lock.lockInterruptibly();
        try {
            while (count == items.length) {
                if (nanos <= 0)
                    return false;
                nanos = notFull.awaitNanos(nanos);
            }
            enqueue(e);
            return true;
        } finally {
            lock.unlock();
        }
}

/**
 * ArrayBlockQueue的出隊實現
 */
public E poll() {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            return (count == 0) ? null : dequeue();
        } finally {
            lock.unlock();
        }
}

/**
 * Inserts element at current put position, advances, and signals.
 * Call only when holding lock.
 */
private void enqueue(E x) {
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    items[putIndex] = x;
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
    notEmpty.signal();
}

/**
 * Extracts element at current take position, advances, and signals.
 * Call only when holding lock.
 */
private E dequeue() {
    // assert lock.getHoldCount() == 1;
    // assert items[takeIndex] != null;
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    E x = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length)
        takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    notFull.signal();
    return x;
}

可以看到ArrayBlockQueue是由一個ReentrantLock在讀寫時進行互斥保護,這樣做會導致兩個問題:

  1. 資料的出隊、入隊會互斥,不管是什麼特點的應用都會頻繁的引起鎖碰撞。
  2. ReentrantLock本身每次加鎖可能會引起多個cas操作,而每個Cas鎖操作的代價沒有想象中的那麼小。

    1. 鎖狀態變更觸發cas操作。
    2. 鎖競爭失敗之後進入競爭佇列會觸發cas。
    3. 當持有鎖執行緒釋放之後通過Condition同步,喚醒競爭執行緒之後,喚醒執行緒出隊還會導致Cas操作。

為了驗證這個猜想LMAX又跑了一個測試,驗證各種Lock的開銷到底有多大。

他們的測試Case是將一個int64一直累加一億次,區別只是使用單個執行緒、單個執行緒加鎖(synchronize、cas)、還是多個執行緒加鎖(synchronize、cas)。

得到的測試結果如下:https://lmax-exchange.github....,The%20Cost%20of%20Locks,-Locks%20provide%20mutual

  1. 當單個執行緒無鎖執行時只需要300ms就可以完成。
  2. 當單個執行緒加鎖(實際沒有競爭)執行時,就需要10s。
  3. 單個執行緒使用CAS執行時比互斥鎖表現好一點。
  4. 當執行緒越多,不管是互斥鎖還是CAS測試Case執行的耗時越來越大。
  5. volatile修飾符跟CAS表現在數量級上差不多。
MethodTime (ms)
Single thread300
Single thread with lock10,000
Two threads with lock224,000
Single thread with CAS5,700
Two threads with CAS30,000
Single thread with volatile write4,700

這樣看起來鎖還有CAS操作的開銷比想象中的高很多,那麼具體為什麼會有這麼大的效能開銷。

在併發環境中(在Java生態)鎖的實現有兩種:synchronize、cas,下面分別分析兩種鎖的開銷。

Synchronize開銷

jdk關於synchronize的介紹:https://wiki.openjdk.java.net...

在java中互斥鎖就體現在synchronize關鍵字修飾的程式碼塊中(synchronize在鎖升級中會使用Mutex實現的,對於Linux就是pthread_mutex_t)。

  1. 核心仲裁

    當synchronize關鍵字修飾的程式碼塊被多個執行緒競爭時就需要進行使用者態、核心態切換,需要系統核心仲裁競爭資源的歸屬,這種切換的代價是非常昂貴的(儲存和恢復一些暫存器、記憶體資料等程式上下文資料)。

  2. 快取汙染

    現在CPU都有多個核心,由於核心的計算能力遠遠高於記憶體的IO能力。為了調和處理核心跟記憶體的速度差異,引入了cpu快取。當核心執行運算時如果需要記憶體資料先從L1快取中獲取、如果沒命中就從L2快取獲取如果一直沒命中就從主從中load。

    當發生執行緒上下文切換,切換走的執行緒就會讓出CPU讓另外的執行緒去執行他的邏輯,而他剛剛從主存中load進來的資料就會被新的執行緒汙染。下次他競爭成功,還是需要再次從主從中load資料,競爭會加劇快取汙染進一步影響系統效能。

    從CPU到大約需要的CPU週期大約需要的時間
    主存-約60-80ns
    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-
  3. 偽共享

    互斥鎖還會引發本身沒有加鎖的變數被迫互斥的問題。

    CPU快取管理的基本但是是快取行,當cpu需要從主存中load資料時會按照快取行的大小將對應位置的記憶體塊一起load進去。當cpu修改記憶體中的資料時,也是直接修改快取中的資料,有快取一致性協議保證將快取中的變動刷到記憶體中。

    看下面這個例子:

    class Eg {
      private int a;
      private int b;
      
      public void synchronize incr_a(){
        a++;
      }
      
      public void incr_b(){
        b++;
      }
    }

    這個物件中a、b兩個欄位很大概率被分配到相鄰的記憶體中,當cpu觸發快取load時這塊記憶體很可能會被一起載入到同一個快取行。

    當一個執行緒呼叫incr_a的同時另外一個執行緒呼叫incr_b方法時,由於incr_a被互斥鎖保護導致持有a、b兩個變數的快取行也被互斥鎖保護起來,這樣雖然incr_b沒有顯示的互斥鎖但實際上也被鎖住了,這個現象被成為偽共享。

  4. 額外的CAS開銷

    在Synchronize在內部維護了count計數、物件頭中有持有執行緒的id等變數,當執行緒多次進入競爭塊時需要通過CAS操作去更改count計數、物件頭中的執行緒id,所以synchronize本身還會有cas的開銷。

CAS的開銷

CAS是現代處理器支援的一個原子指令(例如: lock cmpxchg x86),具體的含義是當變更的變數原始值符合期望就直接更新,不符合期望就失敗。

在Java中各種AutoXX類就是對CAS指令的封裝,其中java的重入鎖(ReentrantLock)的實現原理就是一個CAS操作。

對應Cas本身的開銷問題這裡可以考慮這樣的一個例子:

假設位於兩個核心的兩個執行緒同時CAS一個變數a,當執行緒1CAS成功時將資料變更寫入到快取A中。那麼這個時候執行緒2怎麼能夠感知到變數a現在的值已經發生了變更,本次CAS操作需要失敗呢。

這裡就需要快取一致性協議來進行保障,需要在CAS變數的變更前後插入記憶體屏障來保障變數在多個核心中的可見性,這也是java volatile關鍵字的真實含義。

所以在最開始的那個例子中,cas操作跟volatile變數的效能表現差不多的原因,就是兩者都需要進行快取同步。

這裡我們需要認識到,cas操作雖然比互斥鎖效能更好但是也不是完全沒有開銷的。當大量的cas操作失敗重試導致大量的快取失效有時候會引發更為嚴重的問題。

具體的快取一致性以及記憶體屏障的細節可以參考這個文章:http://www.rdrop.com/users/pa...

Disruptor的優化

LMAX在大量的測試跟深入分析之後,正視鎖的開銷,按照他們的業務抽象出了一套通用的可以做到無鎖的併發處理框架。

元件說明

在詳細介紹Disruptor之前先簡單的對Disruptor的核心抽象進行說明。

抽象元件說明
Ring Buffer環形佇列,用於存放生產者、消費者之間流轉的事件資料
Sequence一個自增序列,用於生產者、消費者之間存放可以被(釋出、消費)的佇列遊標,可以簡單認為是一個AutomicLong的自定義實現。
Sequencer持有生產者、消費者的Sequence,是用於協調兩邊的併發問題,是Disruptor的核心元件。
Sequence Barrier由Sequencer建立用於消費者可以跟蹤到上游生產者的情況,獲取可消費的事件。
Wait Strategy用於消費者等待可消費事件時的策略,有很多實現策略。
Event業務事件
Event Processor業務事件消費程式,可以認為是物理執行緒的抽象
Event Handler真實的業務處理邏輯,每個Processor持有一個
Producer生產者

資料生產

資料的生產非常簡單,有兩種情況:

  1. 單生產者

    單生產者的情況下,向RingBuffer中生產資料是沒有任何競爭的,唯一需要注意的點是需要關注消費者的消費能力,不要覆蓋了最慢的消費者未消費的資料。

    為了達到這個目的,需要通過Sequencer來觀察最慢的消費者的消費進度,程式碼如下,可以看到只有一次volatile操作全程不會有任何鎖:

    // 申請n個可用於釋出的slot
    public long next(int n)
        {
            if (n < 1)
            {
                throw new IllegalArgumentException("n must be > 0");
            }
    
            long nextValue = this.nextValue;
    
            long nextSequence = nextValue + n;
            long wrapPoint = nextSequence - bufferSize;
            long cachedGatingSequence = this.cachedValue;
    
            if (wrapPoint > cachedGatingSequence || cachedGatingSequence > nextValue)
            {        
                // 一次 volatile 操作
                cursor.setVolatile(nextValue);  // StoreLoad fence
    
                long minSequence;
                while (wrapPoint > (minSequence = Util.getMinimumSequence(gatingSequences, nextValue))){
                    // 當最慢的消費者進度低於當前需要申請的slot時,嘗試喚醒消費者(喚醒策略不同表現不同,很多策略根本不會阻塞會一直spin) 
                    waitStrategy.signalAllWhenBlocking();
                     // park 1 納秒繼續嘗試
                    LockSupport.parkNanos(1L); // TODO: Use waitStrategy to spin?
                }
                     // 申請成功
                this.cachedValue = minSequence;
            }
    
            this.nextValue = nextSequence;
    
            return nextSequence;
     }
  1. 多生產者

    多生產者比較複雜的點是生產者執行緒之前有寫競爭,需要CAS來進行協調。也就是生產者的Seq需要額外進行一個CAS操作、全程無鎖,申請程式碼如下:

    // 申請n個可釋出的slot
    public long next(int n)
        {
            if (n < 1)
            {
                throw new IllegalArgumentException("n must be > 0");
            }
    
            long current;
            long next;
    
            do
            {
                current = cursor.get();
                next = current + n;
    
                long wrapPoint = next - bufferSize;
                long cachedGatingSequence = gatingSequenceCache.get();
    
                if (wrapPoint > cachedGatingSequence || cachedGatingSequence > current)
                {
                    long gatingSequence = Util.getMinimumSequence(gatingSequences, current);
    
                    if (wrapPoint > gatingSequence){
                      // 當最慢的消費者進度低於當前需要申請的slot時,嘗試喚醒消費者(喚醒策略不同表現不同,很多策略根本不會阻塞會一直spin) 
                        waitStrategy.signalAllWhenBlocking();
                        LockSupport.parkNanos(1); // TODO, should we spin based on the wait strategy?
                        continue;
                    }
    
                    gatingSequenceCache.set(gatingSequence);
                }
                else if (cursor.compareAndSet(current, next)){
                     // 通過自旋 + cas去協調多個生產者的
                    break;
                }
            }
            while (true);
    
            return next;
        }

    雖然相比單生產者僅僅多了一個CAS操作,但是Disruptr的核心作者一直強調為了更高的吞吐以及跟穩定的延遲,單生產者的設計原則是非常有必要的,否則隨著吞吐的升高長尾的請求會出現不線性的延遲增長。

    具體作者的文章見:https://mechanical-sympathy.b...

資料消費

不管是單生產者、還是多生產者資料的消費都是不受影響的。Disruptor支援開啟多個Processor(也就是執行緒),每個Processor使用類似while true的模式拉取可消費的事件進行處理。

這樣的跟執行緒池模式的好處是避免執行緒建立、銷燬、上下文切換代理的效能損失(快取汙染……)。

對於多個消費者之間的競爭關係通過Sequence Barrier這個抽象元件進行協調,程式碼見下,可以看到除等待策略可能有策略是鎖實現、其他步驟全程無鎖。

while (true){
            try
            {
                // if previous sequence was processed - fetch the next sequence and set
                // that we have successfully processed the previous sequence
                // typically, this will be true
                // this prevents the sequence getting too far forward if an exception
                // is thrown from the WorkHandler
                if (processedSequence)
                {
                    processedSequence = false;
                    do
                    {
                        nextSequence = workSequence.get() + 1L;
                        // 一次 Store/Store barrier  
                        sequence.set(nextSequence - 1L);
                    }
                    while (!workSequence.compareAndSet(nextSequence - 1L, nextSequence));
                    // 通過自旋 + cas協調消費者進度
                }

                if (cachedAvailableSequence >= nextSequence){
                    // 批量申請slot進度高於當前進度,直接消費
                    event = ringBuffer.get(nextSequence);
                    workHandler.onEvent(event);
                    processedSequence = true;
                }
                else{
                    // 無訊息可消費是更具不同的策略進行等待(可以阻塞、可以自旋、可以阻塞+超時……)
                    cachedAvailableSequence = sequenceBarrier.waitFor(nextSequence);
                }
            }
      catch (final TimeoutException e){
                notifyTimeout(sequence.get());
            }
     catch (final AlertException ex){
                if (!running.get())
                {
                    break;
                }
            }
     catch (final Throwable ex){
                // handle, mark as processed, unless the exception handler threw an exception
                exceptionHandler.handleEventException(ex, nextSequence, event);
                processedSequence = true;
     }
}

可以看到最核心的生產者、消費者併發協調實現是waitStrategy,框架本身支援多種waitStrategy。

名稱措施適用場景
BlockingWaitStrategysynchronizedCPU資源緊缺,吞吐量和延遲並不重要的場景
BusySpinWaitStrategy自旋(while true)通過不斷重試,減少切換執行緒導致的系統呼叫,而降低延遲。推薦線上程繫結到固定的CPU的場景下使用
PhasedBackoffWaitStrategy自旋 + yield + 自定義策略CPU資源緊缺,吞吐量和延遲並不重要的場景
SleepingWaitStrategy自旋 + parkNanos效能和CPU資源之間有很好的折中。延遲不均勻
TimeoutBlockingWaitStrategysynchronized + 有超時限制CPU資源緊缺,吞吐量和延遲並不重要的場景
YieldingWaitStrategy自旋 + yield效能和CPU資源之間有很好的折中。延遲比較均勻

對於以上的多種策略其實可以分為兩類:

  1. 可以燃燒CPU效能,以極限高吞吐、低延遲為目標的

    1. YieldingWaitStrategy,不斷的自旋Yield
    2. BusySpinWaitStrategy,不斷的while true
    3. PhasedBackoffWaitStrategy,可以支援自定義的策略
  2. 對極限效能要求不高

    1. SleepingWaitStrategy,對主執行緒影響很小例如Log4j實現
    2. BlockingWaitStrategy
    3. TimeoutBlockingWaitStrategy

其他優化

  1. 偽共享處理

    前面提到的偽共享導致的誤鎖以及被誤殺的cpu快取問題,也有簡單的解決辦法。

    一般的Cache Line大小在64位元組左右,然後Disruptor在非常重要的欄位前後加了很多額外的無用欄位。可以讓這一個欄位佔滿一整個快取行,這樣就可以避免未共享導致的誤殺。

  1. 記憶體預分配

    在Disruptor中事件物件在ringBuffer中支援預分配,在新事件到來的時候可以將關鍵的資訊複製到預分配的結構上。避免大量事件物件代來的GC問題。

  1. 批量申請Slot

    多生產、多消費者存在競爭的時候可以批量的申請多個可消費、可釋出的slot,進一步減少競爭帶來的CAS開銷。

實際應用

在我們的特徵服務系統中使用Disruptor代替原先jdk的執行緒池,取得了非常不錯的效能結果。

測試說明

  1. 壓測機器配置

    配置項配置值
    機器物理機
    系統CentOS Linux release 7.3.1611 (Core)
    記憶體256G記憶體
    cpu40核
  2. 測試Case

    1. 通過非同步客戶端訪問特徵服務隨機查詢若干特徵,特徵儲存在(Redis、Tair、Hbase)三種外部儲存中。
    2. 特徵服務在物理機上部署單個節點。
    3. 測試執行緒池、Disruptor兩個處理佇列的吞吐能力、響應延遲分佈。

測試結果

壓測流量還是從5w/s開始逐步提高壓力直到10w/s

  1. 響應時間

    在同樣吞吐的情況下,disruptor比執行緒池模式更加問題,長尾響應更少。

  2. 超時率:

    超時率也是開啟Disruptor之後更加穩定

參考資料

  1. Disruptor使用者手冊:https://lmax-exchange.github....
  2. DIsruptor technical paper :https://lmax-exchange.github....
  3. 但生產者模式論述:https://mechanical-sympathy.b...
  4. Why Memory Bairriers:http://www.rdrop.com/users/pa...
  5. Log4j2 Asynchronous Loggers for Low-Latency Logging: https://logging.apache.org/lo...
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章