前言
現代計算機通常由CPU
,以及主機板、記憶體、硬碟等主要硬體結構組成,而決定計算機效能的最核心部件是CPU
+記憶體,CPU
負責處理程式指令,記憶體負責儲存指令執行結果。在這個工作機制當中CPU
的讀寫效率其實是遠遠高於記憶體的,為提升執行效率減少CPU
與記憶體的互動,一般在CPU
上設計了快取結構,常見的為三級快取結構:
-
L1 Cache,分為資料快取和指令快取,邏輯核獨佔
-
L2 Cache,物理核獨佔,邏輯核共享
-
L3 Cache,所有物理核共享
下圖為CPU-Core(TM)I7-10510U
型號快取結構
儲存器儲存空間大小:記憶體>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;
}
分析:
- 變數p1~p7本身沒有實際意義,只能用於快取行填充,為了儘可能地用上CPU Cache!
- 訪問CPU裡的L1 Cache或者L2 Cache、L3 Cache,訪問延時是記憶體的1/15乃至1/100(記憶體的訪問速度,是遠遠慢於CPU Cache的)
- 因此,為了追求極限效能,需要儘可能地從CPU Cache裡面讀取資料
- CPU Cache裝載記憶體裡面的資料,不是一個個欄位載入的,而是載入一整個快取行
- 64位的Intel CPU,快取行通常是64 Bytes,一個long型別的資料需要8 Bytes,因此會一下子載入8個long型別的資料
-
- 遍歷陣列元素速度很快,後面連續7次的資料訪問都會命中CPU Cache,不需要重新從記憶體裡面去讀取資料
快取行失效
p1-p7僅用來填充快取行,我們跟本用不到它,但是我們為什麼要填充滿一個快取行呢?
-
CPU在載入資料的時候,會把這個資料從記憶體載入到CPU Cache裡面
-
此時,CPU Cache裡面除了這個資料,還會載入這個資料前後定義的其他變數
- 釋義:在高併發場景下,假定併發訪問變數p0,在p0後定義的其它變數也一併會被快取load
-
Disruptor是一個多執行緒的伺服器框架,在這個資料前後定義的其他變數,可能會被多個不同的執行緒去更新資料,讀取資料
- 這些寫入和讀取請求,可能會來自於不同的CPU Core
- 為了保證資料的同步更新,不得不把CPU Cache裡面的資料,重新寫回到記憶體裡面或者重新從記憶體裡面載入
- CPU Cache的寫回和載入,都是以整個Cache Line作為單位的
-
如果常量的快取失效,當再次讀取這個值的時候,需要重新從記憶體讀取,讀取速度會大大變慢
快取行填充
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;
...
}
- Disruptor在
RingBufferFields
裡面定義的變數前後分別定義了7個long型別的變數- 前面7個繼承自
RingBufferPad
,後面7個直接定義在RingBuffer
類中 - 這14個變數沒有任何實際用途,既不會去讀,也不會去寫
- 前面7個繼承自
RingBufferFields
裡面定義的變數都是final
的,第一次寫入之後就不會再進行修改- 一旦被載入到CPU Cache之後,只要被頻繁地讀取訪問,就不會被換出CPU Cache
- 無論在記憶體的什麼位置,這些變數所在的Cache Line都不會有任何寫更新的請求
空間區域性性+分支預測
- Disruptor整個框架是一個高速的生產者-消費者模型下的佇列
- 生產者不停地往佇列裡面生產新的需要處理的任務
- 消費者不停地從佇列裡面處理掉這些任務
- 要實現一個佇列,最合適的資料結構應該是連結串列,如Java中的LinkedBlockingQueue
- Disruptor並沒有使用LinkedBlockingQueue,而是使用了RingBuffer的資料結構
- RingBuffer的底層實現是一個固定長度的陣列
- 比起連結串列形式的實現,陣列的資料在記憶體裡面會存在空間區域性性
- 陣列的連續多個元素會一併載入到CPU Cache裡面,所以訪問遍歷的速度會更快
- 連結串列裡面的各個節點的資料,多半不會出現在相鄰的記憶體空間
- 資料的遍歷訪問還有一個很大的優勢,就是CPU層面的分支預測會很準確
- 可以更有效地利用CPU裡面的多級流水線
CAS無鎖
鎖對效能的影響
- Disruptor作為一個高效能的生產者-消費者佇列系統,一個核心的設計:通過RingBuffer實現一個無鎖佇列
- Java裡面的
LinkedBlockingQueue
,比起Disruptor的RingBuffer要慢很多,主要原因- 連結串列的資料在記憶體裡面的佈局對於快取記憶體並不友好
LinkedBlockingQueue
對於鎖的依賴- 一般來說消費者比生產者快(不然佇列會堆積),因為大部分時候,佇列是空的,生產者和消費者一樣會產生競爭
LinkedBlockingQueue
的鎖機制是通過ReentrantLock
,需要JVM進行裁決- 鎖的爭奪,會把沒有拿到鎖的執行緒掛起等待,也需要進行一次上下文切換
- 上下文切換的過程,需要把當前執行執行緒的暫存器等資訊,儲存到記憶體中的執行緒棧裡面
- 意味:已經載入到快取記憶體裡面的指令或者資料,又回到主記憶體裡面,進一步拖慢效能
RingBuffer 無鎖方案
- 加鎖很慢,所以Disruptor的解決方案是無鎖(沒有作業系統層面的鎖)
- Disruptor利用了一個CPU硬體支援的指令,稱之為CAS(Compare And Swap)
- Disruptor的RingBuffer建立一個
Sequence
物件,用來指向當前的RingBuffer的頭和尾- 頭和尾的標識,不是通過一個指標來實現的,而是通過一個序號
- RingBuffer在進行生產者和消費者之間的資源協調,採用的是對比序號的方式
- 當生產者想要往佇列裡面加入新資料的時候,會把當前生產者的Sequence的序號,加上需要加入的新資料的數量
- 然後和實際的消費者所在的位置進行對比,看下佇列裡是不是有足夠的空間加入這些資料
- 而不是直接覆蓋掉消費者還沒處理完的資料
- 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操作的時候,不需要單獨進行加鎖,直接呼叫即可
- 在Intel CPU上,為
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架構技術棧社群