高性能隊列Disruptor系列1--傳統隊列的不足


在前一篇文章Java中的阻塞隊列(BlockingQueue)中介紹了Java中的阻塞隊列。從性能上我們能得出一個結論:數組優於鏈表,CAS優於鎖。那么有沒有一種隊列,通過數組的方式實現,而且采用無鎖的結構?嗯,那就是Disruptor,而且比想象中更為強大。

1. 無處不在的鎖

Java中的阻塞隊列采用鎖來實現對臨界區資源的同步訪問,保證操作的線程安全。
在上一篇文章中我們知道ArrayBlockingQueue通過ReentrantLock以及它的兩個condition來控制並發:

final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;

lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull =  lock.newCondition();

比如在壓入元素的時候:

public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length)
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

如果數組已滿,則等待notfull,在enqueue中如果消費者去除元素,則會調用notFull.signal(),put方法將會被喚醒。
這種wait-notify模式很好的實現了阻塞隊列。
但是在性能上因為鎖的緣故,會有額外的性能消耗。

2. 無聲的偽共享

從計算機的存儲結構說起,在CPU和主存之間插入一個更小、更快的存儲設備(例如,高速緩存存儲器)已成為存儲設備的一個設計主流,在計算機系統中,存儲設備被組織成一個存儲器層次模型,如下圖(來自《深入理解計算機系統》),在此層次中,從上至下,容量越來越大,訪問速度越來越慢,但是造價也更便宜。

![存儲器層次模型(來自《深入理解計算機系統》)](http://images2015.cnblogs.com/blog/658141/201706/658141-20170602163117805-429263978.png)

下圖是簡化版的多核計算機基本結構,當CPU執行運算的時候,先去L1查找所需要的數據源,再去L2,如果這些緩存中都沒有,所需的數據就得去主存中拿。

![](http://images2015.cnblogs.com/blog/658141/201706/658141-20170609003509778-1191749694.png)

下面是Martin 和 Mike的 QCon presentation 演講中給出了一些緩存未命中的消耗數據:

今天的CPU不再是按字節訪問內存,而是以64字節(64位系統)為單位的塊(chunk)拿取,稱為一個緩存行(cache line)。當你讀一個特定的內存地址,整個緩存行將從主存換入緩存,並且訪問同一個緩存行內的其它值的開銷是很小的。

比如,Java中的long類型是8個字節,因此在一個緩沖行中可以存8個long類型的變量,也就是說如果訪問一個long類型的數組,訪問第一個元素的時候,會把另外7個也加載到緩存中,可以非常快速的遍歷數組,這也是數組比鏈表快的原因。

美團點評技術團隊寫了測試程序,利用一個long型的二維數組,測試cache line的特性的效果:

public class CacheLineEffect {

    //考慮一般緩存行大小是64字節,一個 long 類型占8字節
    static  long[][] arr;

    public static void main(String[] args) {
        arr = new long[1024 * 1024][];
        for (int i = 0; i < 1024 * 1024; i++) {
            arr[i] = new long[8];
            for (int j = 0; j < 8; j++) {
                arr[i][j] = 0L;
            }
        }
        long sum = 0L;
        long marked = System.currentTimeMillis();
        for (int i = 0; i < 1024 * 1024; i+=1) {
            for(int j =0; j< 8;j++){
                sum = arr[i][j];
            }
        }
        System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms");

        marked = System.currentTimeMillis();
        for (int i = 0; i < 8; i+=1) {
            for(int j =0; j< 1024 * 1024;j++){
                sum = arr[j][i];
            }
        }
        System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms");
    }

}

運行結果:

Loop times:24ms
Loop times:97ms

緩存行利用局部性的確能提高效率,但是有一個弊端,當我們的數據不相關,只是一個單獨的變量,這兩個數據在一個緩存行中,而且他們的訪問頻率都很高,這時候反而會影響效率。如下圖:

![](http://images2015.cnblogs.com/blog/658141/201706/658141-20170609000114090-1014829094.png)

比如我們有一個類存放了兩個變量的值data1,data2。當加載data1的時候,data2也被加載到緩存中,也就是存在於同一個緩存行。當core1改變data1的值的時候,core1緩存中的值和內存中的值都被改變了,這時候core2也會重新加載這個緩存行,因為data1變了,而core2只是想讀取自己緩存中的data2,卻任然要等從內存中重新加載這個緩存行。

這種無法充分使用緩存行特性的現象,稱為偽共享

3. 總結

無論是鎖還是偽共享,都對我們程序的性能產生了或多或少的影響,而Disruptor很好的解決了這些問題,采用了無鎖的數據結構,而且利用 cache line padding(緩存行填充)很好的解決了偽共享問題。

引用范仲淹在infoq接受采訪時的語錄:

我個人的觀點,就看你對性能的要求有多高。如果你要達到極致的性能,對延遲要求非常低,而且對高並發要求性能非常高的時候,你肯定要選擇Disruptor。但是從易用性上來講,Disruptor使用起來並沒有傳統的queue使用上更方便。你在百萬級別並發的時候,我推薦大家使用Java的ConcurrentQueue跟BlockingQueue。但是如果你需要更低的延遲的話,我推薦用Disruptor。

參考資料:

高性能隊列——Disruptor

Disruptor入門


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM