計算機組成-無鎖編程追求極致性能

前言

​ 現代計算機一般由CPU,以及主板、內存、硬盤等主要硬件結構組成,而決定計算機性能的最核心部件是CPU+內存,CPU負責處理程序指令,內存負責存儲指令執行結果。在這個工做機制當中CPU的讀寫效率實際上是遠遠高於內存的,爲提高執行效率減小CPU與內存的交互,通常在CPU上設計了緩存結構,常見的爲三級緩存結構:java

  • L1 Cache,分爲數據緩存和指令緩存,邏輯核獨佔shell

  • L2 Cache,物理核獨佔,邏輯核共享數組

  • L3 Cache,全部物理核共享緩存

下圖爲CPU-Core(TM)I7-10510U型號緩存結構服務器

1604646633836

存儲器存儲空間大小:內存>L3>L2>L1>寄存器。數據結構

存儲器速度快慢排序:寄存器>L1>L2>L3>內存。多線程

緩存行大小


[root@192 ~]# getconf -a|grep CACHE
LEVEL1_ICACHE_SIZE                 32768 #L1緩存大小
LEVEL1_ICACHE_ASSOC                8 #L1緩存行大小
LEVEL1_ICACHE_LINESIZE             64
LEVEL1_DCACHE_SIZE                 32768
LEVEL1_DCACHE_ASSOC                8
LEVEL1_DCACHE_LINESIZE             64
LEVEL2_CACHE_SIZE                  262144 #L2緩存大小
LEVEL2_CACHE_ASSOC                 4
LEVEL2_CACHE_LINESIZE              64 #L2緩存行大小
LEVEL3_CACHE_SIZE                  8388608 #L3緩存大小
LEVEL3_CACHE_ASSOC                 16
LEVEL3_CACHE_LINESIZE              64 #L3緩存行大小
LEVEL4_CACHE_SIZE                  0
LEVEL4_CACHE_ASSOC                 0
LEVEL4_CACHE_LINESIZE              0
[root@192 ~]# cat /proc/cpuinfo |grep -i cache
cache size	: 8192 KB
cache_alignment	: 64
cache size	: 8192 KB
cache_alignment	: 64

JAVA程序毫無疑問也必須是運行在硬件機器之上,如何利用底層硬件工做原理,提高性能也必然是咱們須要考慮的,筆者今天以無鎖併發高性能框架Disruptor爲例分析如何高效的利用CPU緩存。架構

Who is Disruptor?

​ Disruptor是一個開源框架,研發的初衷是爲了解決高併發下隊列鎖的問題,最先由LMAX(一種新型零售金融交易平臺)提出並使用,可以在無鎖的狀況下實現隊列的併發操做,並號稱可以在一個線程裏每秒處理6百萬筆訂單。併發

緩存行填充

下方示例爲Disruptor框架的內部代碼:框架

abstract class RingBufferPad
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

分析:

  1. 變量p1~p7自己沒有實際意義,只能用於緩存行填充,爲了儘量地用上CPU Cache
  2. 訪問CPU裏的L1 Cache或者L2 Cache、L3 Cache,訪問延時是內存的1/15乃至1/100(內存的訪問速度,是遠遠慢於CPU Cache的)
    • 所以,爲了追求極限性能,須要儘量地從CPU Cache裏面讀取數據
  3. CPU Cache裝載內存裏面的數據,不是一個個字段加載的,而是加載一整個緩存行
    • 64位的Intel CPU,緩存行一般是64 Bytes,一個long類型的數據須要8 Bytes,所以會一會兒加載8個long類型的數據
  • 1604710347879
    • 遍歷數組元素速度很快,後面連續7次的數據訪問都會命中CPU Cache,不須要從新從內存裏面去讀取數據

緩存行失效

p1-p7僅用來填充緩存行,咱們跟本用不到它,可是咱們爲何要填充滿一個緩存行呢?

  1. CPU在加載數據的時候,會把這個數據從內存加載到CPU Cache裏面

  2. 此時,CPU Cache裏面除了這個數據,還會加載這個數據先後定義的其餘變量

    • 釋義:在高併發場景下,假定併發訪問變量p0,在p0後定義的其它變量也一併會被緩存load
  3. Disruptor是一個多線程的服務器框架,在這個數據先後定義的其餘變量,可能會被多個不一樣的線程去更新數據,讀取數據

    • 這些寫入和讀取請求,可能會來自於不一樣的CPU Core
    • 爲了保證數據的同步更新,不得不把CPU Cache裏面的數據,從新寫回到內存裏面或者從新從內存裏面加載
    • CPU Cache的寫回加載,都是以整個Cache Line做爲單位的
  4. 若是常量的緩存失效,當再次讀取這個值的時候,須要從新從內存讀取,讀取速度會大大變慢

緩存行填充

abstract class RingBufferPad
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

abstract class RingBufferFields<E> extends RingBufferPad
{
    ...
    private final long indexMask;
    private final Object[] entries;
    protected final int bufferSize;
    protected final Sequencer sequencer;
    ...
}

public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventSequencer<E>, EventSink<E>
{
    ...
    protected long p1, p2, p3, p4, p5, p6, p7;
    ...
}
  1. Disruptor在RingBufferFields裏面定義的變量先後分別定義了7個long類型的變量
    • 前面7個繼承RingBufferPad,後面7個直接定義RingBuffer類中
    • 這14個變量沒有任何實際用途,既不會去,也不會去
  2. RingBufferFields裏面定義的變量都是final的,第一次寫入以後就不會再進行修改
    • 一旦被加載到CPU Cache以後,只要被頻繁地讀取訪問,就不會被換出CPU Cache
    • 不管在內存的什麼位置,這些變量所在的Cache Line都不會有任何寫更新的請求

空間局部性+分支預測

  1. Disruptor整個框架是一個高速的生產者-消費者模型下的隊列
    • 生產者不停地往隊列裏面生產新的須要處理的任務
    • 消費者不停地從隊列裏面處理掉這些任務
  2. 要實現一個隊列,最合適的數據結構應該是鏈表,如Java中的LinkedBlockingQueue
  3. Disruptor並無使用LinkedBlockingQueue,而是使用了RingBuffer的數據結構
    • RingBuffer的底層實現是一個固定長度的數組
    • 比起鏈表形式的實現,數組的數據在內存裏面會存在空間局部性
      • 數組的連續多個元素會一併加載到CPU Cache裏面,因此訪問遍歷的速度會更快
      • 鏈表裏面的各個節點的數據,多半不會出如今相鄰的內存空間
    • 數據的遍歷訪問還有一個很大的優點,就是CPU層面的分支預測會很準確
      • 能夠更有效地利用CPU裏面的多級流水線

CAS無鎖

鎖對性能的影響

  1. Disruptor做爲一個高性能的生產者-消費者隊列系統,一個核心的設計:經過RingBuffer實現一個無鎖隊列
  2. Java裏面的LinkedBlockingQueue,比起Disruptor的RingBuffer要慢不少,主要緣由
    • 鏈表的數據在內存裏面的佈局對於高速緩存不友好
    • LinkedBlockingQueue對於鎖的依賴
      • 通常來講消費者比生產者快(否則隊列會堆積),由於大部分時候,隊列是的,生產者和消費者同樣會產生競爭
  3. LinkedBlockingQueue的鎖機制是經過ReentrantLock,須要JVM進行裁決
    • 鎖的爭奪,會把沒有拿到鎖的線程掛起等待,也須要進行一次上下文切換
    • 上下文切換的過程,須要把當前執行線程的寄存器等信息,保存到內存中的線程棧裏面
      • 意味:已經加載到高速緩存裏面的指令或者數據,又回到主內存裏面,進一步拖慢性能

RingBuffer 無鎖方案

  1. 加鎖很慢,因此Disruptor的解決方案是無鎖(沒有操做系統層面的鎖)
  2. Disruptor利用了一個CPU硬件支持的指令,稱之爲CAS(Compare And Swap)
  3. Disruptor的RingBuffer建立一個Sequence對象,用來指向當前的RingBuffer的頭和尾
    • 頭和尾的標識,不是經過一個指針來實現的,而是經過一個序號
  4. RingBuffer在進行生產者和消費者之間的資源協調,採用的是對比序號的方式
    • 當生產者想要往隊列裏面加入新數據的時候,會把當前生產者的Sequence的序號,加上須要加入的新數據的數量
    • 而後和實際的消費者所在的位置進行對比,看下隊列裏是否是有足夠的空間加入這些數據
      • 而不是直接覆蓋掉消費者還沒處理完的數據
  5. CAS指令,既不是基礎庫裏的一個函數,也不是操做系統裏面實現的一個系統調用,而是一個CPU硬件支持的機器指令
    • 在Intel CPU上,爲cmpxchg指令:compxchg [ax] (隱式參數,EAX累加器), [bx] (源操做數地址), [cx] (目標操做數地址)
    • 第一個操做數不在指令裏面出現,是一個隱式的操做數,即EAX累加寄存器裏面的值
    • 第二個操做數就是源操做數,指令會對比這個操做數和上面EAX累加寄存器裏面的值
    • 僞代碼:IF [ax]== [bx] THEN [ZF] = 1, [bx] = [cx] ELSE [ZF] = 0, [ax] = [bx]
    • 單個指令是原子的,意味着使用CAS操做的時候,不須要單獨進行加鎖,直接調用便可

Sequence關鍵代碼以下:

public long addAndGet(final long increment)
{
    long currentValue;
    long newValue;

    // 若是CAS操做沒有成功,會不斷等待重試
    do
    {
        currentValue = get();
        newValue = currentValue + increment;
    }
    while (!compareAndSet(currentValue, newValue));

    return newValue;
}

public boolean compareAndSet(final long expectedValue, final long newValue)
{
    // 調用CAS指令
    return UNSAFE.compareAndSwapLong(this, VALUE_OFFSET, expectedValue, newValue);
}

Benchmark

互斥鎖競爭、CAS樂觀鎖與無鎖測試:

public class LockBenchmark {

    private static final long MAX = 500_000_000L;

    private static void runIncrement() {
        long counter = 0;
        long start = System.currentTimeMillis();
        while (counter < MAX) {
            counter++;
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start) + "ms without lock");
    }

    private static void runIncrementWithLock() {
        Lock lock = new ReentrantLock();
        long counter = 0;
        long start = System.currentTimeMillis();
        while (counter < MAX) {
            if (lock.tryLock()) {
                counter++;
                lock.unlock();
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start) + "ms with lock");
    }

    private static void runIncrementAtomic() {
        AtomicLong counter = new AtomicLong(0);
        long start = System.currentTimeMillis();
        while (counter.incrementAndGet() < MAX) {
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start) + "ms with cas");
    }

    public static void main(String[] args) {
        runIncrement();
        runIncrementWithLock();
        runIncrementAtomic();

        // Time spent is 153ms without lock
        // Time spent is 7801ms with lock
        // Time spent is 3164ms with cas
        // 7801 / 153 ≈ 51
        // 3164 / 153 ≈ 21   
    }
}得出

** 結論:無鎖性能要遠高於cas與lock,cas要大於lock**

更多好文章,請關注公衆號:奇客時間,原創JAVA架構技術棧社區

相關文章
相關標籤/搜索