系統性能百倍提升典型案例分析:高性能隊列Disruptor RingBuffer 如何提升性能 如何避免“僞共享” Disruptor 中的無鎖算法 總結

Disruptor 是一款高性能的有界內存隊列,目前應用非常廣泛,Log4j2、SpringMessaging、HBase、Storm 都用到了 Disruptor,那 Disruptor 的性能爲什麼這麼高呢?Disruptor 項目團隊曾經寫過一篇論文,詳細解釋了其原因,可以總結爲如下:

  1. 內存分配更加合理,使用 RingBuffer 數據結構,數組元素在初始化時一次性全部創建,提升緩存命中率;對象循環利用,避免頻繁 GC。
  2. 能夠避免僞共享,提升緩存利用率。3. 採用無鎖算法,避免頻繁加鎖、解鎖的性能消耗。
  3. 支持批量消費,消費者可以無鎖方式消費多個消息。

其中,前三點涉及到的知識比較多,所以今天咱們重點講解前三點,不過在詳細介紹這些知識之前,我們先來聊聊 Disruptor 如何使用,好讓你先對 Disruptor 有個感官的認識。

下面的代碼出自官方示例,我略做了一些修改,相較而言,Disruptor 的使用比 Java SDK提供 BlockingQueue 要複雜一些,但是總體思路還是一致的,其大致情況如下:

  • 在 Disruptor 中,生產者生產的對象(也就是消費者消費的對象)稱爲 Event,使用Disruptor 必須自定義 Event,例如示例代碼的自定義 Event 是 LongEvent;
  • 構建 Disruptor 對象除了要指定隊列大小外,還需要傳入一個 EventFactory,示例代碼中傳入的是LongEvent::new;
  • 消費 Disruptor 中的 Event 需要通過 handleEventsWith() 方法註冊一個事件處理器,發佈 Event 則需要通過 publishEvent() 方法。
/* 自定義 Event */
class LongEvent {
    private long value;
    public void set( long value )
    {
        this.value = value;
    }
}
/* 指定 RingBuffer 大小, 9 // 必須是 2 的 N 次方 */
int bufferSize = 1024;

/* 構建 Disruptor */
Disruptor<LongEvent> disruptor
    = new Disruptor<>(
LongEvent: : new,
    bufferSize,
    DaemonThreadFactory.INSTANCE );

/* 註冊事件處理器 */
disruptor.handleEventsWith(
    (event, sequence, endOfBatch) - >
    System.out.println( "E: " + event ) );

/* 啓動 Disruptor */
disruptor.start();

/* 獲取 RingBuffer */
RingBuffer<LongEvent> ringBuffer
    = disruptor.getRingBuffer();
/* 生產 Event */
ByteBuffer bb = ByteBuffer.allocate( 8 );
for ( long l = 0; true; l++ )
{
    bb.putLong( 0, l );
    /* 生產者生產消息 */
    ringBuffer.publishEvent(
        (event, sequence, buffer) - >
        event.set( buffer.getLong( 0 ) ), bb );
    Thread.sleep( 1000 );
}

RingBuffer 如何提升性能

Java SDK 中 ArrayBlockingQueue 使用數組作爲底層的數據存儲,而 Disruptor 是使用RingBuffer作爲數據存儲。RingBuffer 本質上也是數組,所以僅僅將數據存儲從數組換成RingBuffer 並不能提升性能,但是 Disruptor 在 RingBuffer 的基礎上還做了很多優化,其中一項優化就是和內存分配有關的。

在介紹這項優化之前,你需要先了解一下程序的局部性原理。簡單來講,程序的局部性原理指的是在一段時間內程序的執行會限定在一個局部範圍內。這裏的“局部性”可以從兩個方面來理解,一個是時間局部性,另一個是空間局部性。時間局部性指的是程序中的某條指令一旦被執行,不久之後這條指令很可能再次被執行;如果某條數據被訪問,不久之後這條數據很可能再次被訪問。而空間局部性是指某塊內存一旦被訪問,不久之後這塊內存附近的內存也很可能被訪問。

CPU 的緩存就利用了程序的局部性原理:CPU 從內存中加載數據 X 時,會將數據 X 緩存在高速緩存 Cache 中,實際上 CPU 緩存 X 的同時,還緩存了 X 周圍的數據,因爲根據程序具備局部性原理,X 周圍的數據也很有可能被訪問。從另外一個角度來看,如果程序能夠很好地體現出局部性原理,也就能更好地利用 CPU 的緩存,從而提升程序的性能。Disruptor 在設計 RingBuffer 的時候就充分考慮了這個問題,下面我們就對比着ArrayBlockingQueue 來分析一下。

首先是 ArrayBlockingQueue。生產者線程向 ArrayBlockingQueue 增加一個元素,每次增加元素 E 之前,都需要創建一個對象 E,如下圖所示,ArrayBlockingQueue 內部有 6個元素這 6 個元素都是由生產者線程創建的,由於創建這些元素的時間基本上是離散的,所以這些元素的內存地址大概率也不是連續的。

下面我們再看看 Disruptor 是如何處理的。Disruptor 內部的 RingBuffer 也是用數組實現的,但是這個數組中的所有元素在初始化時是一次性全部創建的,所以這些元素的內存地址大概率是連續的,相關的代碼如下所示。

for ( int i = 0; i < bufferSize; i++ )
{
    /*
     * entries[] 就是 RingBuffer 內部的數組
     * eventFactory 就是前面示例代碼中傳入的 LongEvent::new
     */
    entries[BUFFER_PAD + i]
        = eventFactory.newInstance();
}

Disruptor 內部 RingBuffer 的結構可以簡化成下圖,那問題來了,數組中所有元素內存地址連續能提升性能嗎?能!爲什麼呢?因爲消費者線程在消費的時候,是遵循空間局部性原理的,消費完第 1 個元素,很快就會消費第 2 個元素;當消費第 1 個元素 E1 的時候,CPU 會把內存中 E1 後面的數據也加載進 Cache,如果 E1 和 E2 在內存中的地址是連續的,那麼 E2 也就會被加載進 Cache 中,然後當消費第 2 個元素的時候,由於 E2 已經在Cache 中了,所以就不需要從內存中加載了,這樣就能大大提升性能。

除此之外,在 Disruptor 中,生產者線程通過 publishEvent() 發佈 Event 的時候,並不是創建一個新的 Event,而是通過 event.set() 方法修改 Event, 也就是說 RingBuffer 創建的 Event 是可以循環利用的,這樣還能避免頻繁創建、刪除 Event 導致的頻繁 GC 問題。

如何避免“僞共享”

高效利用 Cache,能夠大大提升性能,所以要努力構建能夠高效利用 Cache 的內存結構。而從另外一個角度看,努力避免不能高效利用 Cache 的內存結構也同樣重要。

有一種叫做“僞共享(False sharing)”的內存佈局就會使 Cache 失效,那什麼是“僞共享”呢?

僞共享和 CPU 內部的 Cache 有關,Cache 內部是按照緩存行(Cache Line)管理的,緩存行的大小通常是 64 個字節;CPU 從內存中加載數據 X,會同時加載 X 後面(64-size(X))個字節的數據。下面的示例代碼出自 Java SDK 的 ArrayBlockingQueue,其內部維護了 4 個成員變量,分別是隊列數組 items、出隊索引 takeIndex、入隊索引putIndex 以及隊列中的元素總數 count。

 /** 隊列數組 */
 final Object[] items;
 /** 出隊索引 */
 int takeIndex;
 /** 入隊索引 */
 int putIndex;
 /** 隊列中元素總數 */
 int count;

當 CPU 從內存中加載 takeIndex 的時候,會同時將 putIndex 以及 count 都加載進Cache。下圖是某個時刻 CPU 中 Cache 的狀況,爲了簡化,緩存行中我們僅列出了takeIndex 和 putIndex。

假設線程 A 運行在 CPU-1 上,執行入隊操作,入隊操作會修改 putIndex,而修改putIndex 會導致其所在的所有核上的緩存行均失效;此時假設運行在 CPU-2 上的線程執行出隊操作,出隊操作需要讀取 takeIndex,由於 takeIndex 所在的緩存行已經失效,所以 CPU-2 必須從內存中重新讀取。入隊操作本不會修改 takeIndex,但是由於 takeIndex和 putIndex 共享的是一個緩存行,就導致出隊操作不能很好地利用 Cache,這其實就是僞共享。簡單來講,僞共享指的是由於共享緩存行導致緩存無效的場景

ArrayBlockingQueue 的入隊和出隊操作是用鎖來保證互斥的,所以入隊和出隊不會同時發生。如果允許入隊和出隊同時發生,那就會導致線程 A 和線程 B 爭用同一個緩存行,這樣也會導致性能問題。所以爲了更好地利用緩存,我們必須避免僞共享,那如何避免呢?

方案很簡單,每個變量獨佔一個緩存行、不共享緩存行就可以了,具體技術是緩存行填充。比如想讓 takeIndex 獨佔一個緩存行,可以在 takeIndex 的前後各填充 56 個字節,這樣就一定能保證 takeIndex 獨佔一個緩存行。下面的示例代碼出自 Disruptor,Sequence對象中的 value 屬性就能避免僞共享,因爲這個屬性前後都填充了 56 個字節。Disruptor中很多對象,例如 RingBuffer、RingBuffer 內部的數組都用到了這種填充技術來避免僞共享。

/* 前:填充 56 字節 */
class LhsPadding {
    long p1, p2, p3, p4, p5, p6, p7;
}
class Value extends LhsPadding {
    volatile long value;
}
/* 後:填充 56 字節 */
class RhsPadding extends Value {
    long p9, p10, p11, p12, p13, p14, p15;
}
class Sequence extends RhsPadding {
    /* 省略實現 */
}

Disruptor 中的無鎖算法

ArrayBlockingQueue 是利用管程實現的,中規中矩,生產、消費操作都需要加鎖,實現起來簡單,但是性能並不十分理想。Disruptor 採用的是無鎖算法,很複雜,但是核心無非是生產和消費兩個操作。Disruptor 中最複雜的是入隊操作,所以我們重點來看看入隊操作是如何實現的。

對於入隊操作,最關鍵的要求是不能覆蓋沒有消費的元素;對於出隊操作,最關鍵的要求是不能讀取沒有寫入的元素,所以 Disruptor 中也一定會維護類似出隊索引和入隊索引這樣兩個關鍵變量。Disruptor 中的 RingBuffer 維護了入隊索引,但是並沒有維護出隊索引,這是因爲在 Disruptor 中多個消費者可以同時消費,每個消費者都會有一個出隊索引,所以 RingBuffer 的出隊索引是所有消費者裏面最小的那一個。

下面是 Disruptor 生產者入隊操作的核心代碼,看上去很複雜,其實邏輯很簡單:如果沒有足夠的空餘位置,就出讓 CPU 使用權,然後重新計算;反之則用 CAS 設置入隊索引。

/* 生產者獲取 n 個寫入位置 */
do
{
    /* cursor 類似於入隊索引,指的是上次生產到這裏 */
    current = cursor.get();
    /*
     * 目標是在生產 n 個 6 next = current + 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 );
    } else if ( cursor.compareAndSet( current, next ) )
    {
        /* 獲取寫入位置成功,跳出循環 */
        break;
    }
}
while ( true );

總結

Disruptor 在優化併發性能方面可謂是做到了極致,優化的思路大體是兩個方面,一個是利用無鎖算法避免鎖的爭用,另外一個則是將硬件(CPU)的性能發揮到極致。尤其是後者,在 Java 領域基本上屬於經典之作了。

發揮硬件的能力一般是 C 這種面向硬件的語言常乾的事兒,C 語言領域經常通過調整內存佈局優化內存佔用,而 Java 領域則用的很少,原因在於 Java 可以智能地優化內存佈局,內存佈局對 Java 程序員的透明的。這種智能的優化大部分場景是很友好的,但是如果你想通過填充方式避免僞共享就必須繞過這種優化,關於這方面 Disruptor 提供了經典的實現,你可以參考。

由於僞共享問題如此重要,所以 Java 也開始重視它了,比如 Java 8 中,提供了避免僞共享的註解:@sun.misc.Contended,通過這個註解就能輕鬆避免僞共享(需要設置 JVM參數 -XX:-RestrictContended)。不過避免僞共享是以犧牲內存爲代價的,所以具體使用的時候還是需要仔細斟酌。

如果想深入瞭解併發編程原理可閱讀:《不愧是阿里P7私傳“併發編程核心講義”,實戰案例,個個是經典

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章