高效能服務端系列–處理器篇

周波發表於2016-03-14

從JMM說起,作為一名JAVA開發,特別在多執行緒程式設計實踐中,瞭解和熟悉JAVA記憶體模型是很有必要的。剛開始接觸記憶體模型的時候,有很多概念非常陌生,比如happens-before,可見性,順序性等等。要理解這些關鍵詞,需要先對編譯器、處理器的知識有一些瞭解。
還有一些框架例如disruptor,在設計的時候就考慮了CPU的特點,充分發揮CPU的效能。要理解這類框架,也需要對處理器有一定了解。

introduction

先看下面這個表格,一些場景下的延時,比如CPU執行一條指令大約是1納秒,從L1 cache獲取資料需要0.5奈米,從主存中取資料需要100納秒等等。

操作 延時
execute typical instruction 1/1,000,000,000 sec = 1 nanosec
fetch from L1 cache memory 0.5 nanosec
branch misprediction 5 nanosec
fetch from L2 cache memory 7 nanosec
Mutex lock/unlock 25 nanosec
fetch from main memory 100 nanosec
send 2K bytes over 1Gbps network 20,000 nanosec
read 1MB sequentially from memory 250,000 nanosec
fetch from new disk location (seek) 8,000,000 nanosec
read 1MB sequentially from disk 20,000,000 nanosec
send packet US to Europe and back 150 milliseconds = 150,000,000 nanosec

經典的RISC pipeline由以下幾步組成:

1. 取指令
2. 譯指令
3. 執行
4. 記憶體訪問
5. 暫存器回寫

由於訪問主存的延時和指令執行的延時不在一個數量級,所以CPU一般會使用訪問速度更快的快取,現代處理器的快取一般是分為三級,下圖是一般CPU的快取結構。每一個CPU核共享L1、L2 Cache,所有的CPU核共享L3 Cache。

cpu

cache line

在JVM中,我們都知道物件都是存在於記憶體堆中的,也就是主存中,而對於CPU來說,它並不關心程式中操作的物件,它只關心對某個記憶體塊的讀和寫,為了讓讀寫速度更快,CPU會首先把資料從主存中的資料以cache line的粒度讀到CPU cache中,一個cache line一般是64 bytes。假設程式中讀取某一個int變數,CPU並不是只從主存中讀取4個位元組,而是會一次性讀取64個位元組,然後放到cpu cache中。因為往往緊挨著的資料,更有可能在接下來會被使用到。比如遍歷一個陣列,因為陣列空間是連續的,所以並不是每次取陣列中的元素都要從主存中去拿,第一次從主存把資料放到cache line中,後續訪問的資料很有可能已經在cache中了,

cache hit

CPU獲取的記憶體地址在cache中存在,叫做cache hit。

cache miss

如果CPU的訪問的記憶體地址不在L1 cache中,就叫做L1 cache miss,由於訪問主存的速度遠遠慢於指令的執行速度,一旦發生cache miss,CPU就會在上一級cache中獲取,最差的情況需要從主存中獲取。一旦要從主存中獲取資料,當前指令的執行相對來說就會顯得非常慢。

cache associativity

根據記憶體和cache的對映關係不同,有三種對映方式。

  1. direct mapped
  2. mapped方式查詢最快,因為只有一個坑,只需要比較一次。但是容易發生衝突。
  3. n-way set associative
    n-way associative是一種折中的方式,可以有較高的快取命中率,又不至於每次查詢比較慢。
  4. full associative
    只要cache沒有滿還能把主存中的資料放到cache中,但是查詢的時候需要全掃描,效率低。

其實direct mapped和full associative是n-way associative的特殊形式。

下面這張是我看到的最容易理解的資料。
cpu_associative

cache coherency

現在的CPU一般都有多個核,我們知道當某個核讀取某個記憶體地址時,會把這個記憶體地址附近的64個位元組放到當前核的cache line中,假設此時另外一個CPU核同時把這部分資料放到了對應的cache line中,這時候這64位元組的資料實際上有三份,兩份在CPU cache中,一份在主存中。自然而然就要考慮到資料一致性的問題,如何保證在某一個核中的資料做了改動時,其它的資料副本也能感知到變化呢?是由快取一致性協議來保證的。快取一致性協議也叫作MESI協議。簡單的來說,就是CPU的cache line被標記為以下四種狀態之一。
Modified
當前cache line中的資料被CPU修改過,並且只在當前核對應的cache中,資料還沒有被回寫到主存中,那麼當前cache line就處於Modified狀態。如果這個時候其它的核需要讀取該cache line中的,需要把當前cache line中的資料回寫到主存中去。一旦回寫到主存中去後,當前cache line的狀態變為Shared
Exclusive
當前cache line只在一個核對應的cache中,資料和主存中的資料一致。如果有另外一個核讀取當前cache line,則狀態變為Shared,如果當前核修改了其中的資料,則變成Modified狀態。
Shared
如果cache line處於Shared狀態,則表示該cache line在其它核對應的cache中也有副本,而且這兩個副本和主存中的資料一致。
Invalid
如果cache line處於Invalid狀態,則表示這塊cache line中的資料已經無效了,如果要讀取其中的資料的話,需要重新從主存中獲取。

  • 只有cache line處於Exclusive或者Modified狀態時才能進行寫操作。如果處於Shared狀態,那麼要先廣播一個訊息(Request For Ownership),invalidate其它核對應的cache line。
  • 如果cache line處於Modified狀態,那麼需要能探測到其它試圖讀取該cache line的操作。
  • 如果cache line處於Shared狀態,它必須監聽其它cache的invalidate資訊,一旦其它核修改了對應的cache line,其它cache 中對應的cache line需要變為invalid狀態。

MESI協議中有兩個行為效率會比較低,

  1. 當cache line狀態為Invalid時,需要寫入資料。
  2. 把cache line的狀態變為invalid

CPU通過store buffer和invalid queue來降低延時。
當在invalid狀態進行寫入時,首先會給其它CPU核傳送invalid訊息,然後把當前寫入的資料寫入到store buffer中。然後在某個時刻在真正的寫入到cache line中。由於不是馬上寫入到cache line中,所以當前核如果要讀cache line中的資料,需要先掃描store buffer,同時其它CPU核是看不到當前核store buffer中的資料的。除非store buffer中的資料被刷到cache中。
對於invalid queue,當收到invalid訊息時,cache line不會馬上變成invalid狀態,而是把訊息寫入invalid queue中。和store buffer不同的是當前核是無法掃描invalid queue的。
為了保證資料的一致性,這就需要memory barrier了。store barrier會把store buffer中的資料刷到cache中,read barrier會執行invalid queue中的訊息。

注意
要保證資料的一致性,僅僅有MESI協議還不夠,通常還需要memory barrier的配合。

memory barrier

memory barrier的作用有兩個

  • 保證資料的可見性
    我們知道,記憶體中的資料除了在記憶體中的副本,還有可能在各個核的CPU中,當某個核修改了對應cache中的資料後,這時其它核中對應記憶體地址的資料還有主存中的資料就不是最新的了,其它核為了能夠讀取到最新的資料,需要執行memory barrier指令,把store buffer中的修改寫到主存中。
  • 防止指令之間的重排序
    前面講到一條指令的執行會分為幾個步驟,也就是pipeline,為了得到更高的效能,編譯器或者處理器有可能會改變指令的執行順序,以此來提高指令執行的並行度。不管是編譯器還是處理器的重排序,都要遵守as-if-serial語義。as-if-serial說的是,不管怎麼重排序,在單執行緒中執行這些指令,其結果應該是一樣的。在多執行緒的情況下,需要memory barrier來保證整體的順序,否則會出現意想不到的結果。

不同的處理器架構的memory barrier也不太一樣,以Intel x86為例,有三種memory barrier

store barrier

對應sfence指令

  1. 保證了sfence前後store指令的順序,防止重排序。
  2. 通過重新整理store buffer保證了sfence之後的store指令全域性可見之前,sfence之前的store要指令要先全域性可見。

load barrier

對應lfence指令,

  1. 保證了lfence前後的load指令的順序,防止重排序。
  2. 重新整理load buffer。

full barrier

對應mfence指令

  1. 保證了mfence前後的store和load指令的順序,防止重排序。
  2. 保證了mfence之後的store指令全域性可見之前,mfence之前的store指令要先全域性可見。

以java中的volatile為例,volatile的語義有幾點:

  1. volatile的操作是原子的
  2. volatile的操作是全域性可見的
  3. 在一定程度上防止重排序

一般是通過插入記憶體屏障或者具有屏障功能的其它指令(如lock指令)來保證上面的第二和第三點。

總結
記憶體屏障本身非常複雜,不同的處理器的實現也很不一樣,編譯期間和執行期間都有記憶體屏障,上面是以X86為例,做了簡單的介紹。但是不管是哪個平臺,他們都是解決兩個問題,一個是指令的排序,另一個是全域性可見性。

false sharing

前面我講到,記憶體中的資料是以cache line為單位從記憶體中讀到CPU cache中的,比如有兩個變數X,Y,在記憶體中他們倆非常近,那麼很有可能在讀X的時候,Y也被放到了相同cache line中。假設Thread1需要不停的寫X,比如在一個迴圈中,而Thread2需要不停的寫Y,那麼在Thread1寫X的時候,需要Invalid其他cache中對應的cache line,Thread2寫Y的時候也要做同樣的事情,這樣就會不停的碰到上面說過的MESI協議的兩個比較耗時的操作:

  1. 當cache line狀態為Invalid時,需要寫入資料。
  2. 把cache line的狀態變為invalid

會嚴重影響效能。
false_sharing
解決false sharing也比較簡單,做padding就可以了。下面這段程式碼是disruptor中Sequence的一段程式碼,一般cache line是64 byte,long型別的value加上7個long做padding,正好是64byte,這樣當以Sequence[]的方式使用時,不同下標的Sequence物件就會落在不同的cache line中。

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

class Value extends LhsPadding
{
    protected volatile long value;
}

class RhsPadding extends Value
{
    protected long p9, p10, p11, p12, p13, p14, p15;
}

context switch

我們知道CPU的核數量是有限的,一般是1-32核不等,而現代作業系統是多工作業系統,同一時刻在執行的程式數量一般都會遠遠超過CPU的核數量。簡單的說就是並行執行的任務數量最多就是CPU的核數,但是併發執行的任務數量可以有很多。打個比方,對於單核的CPU,如果不能併發執行多個任務的話,那麼所有任務都會是序列的,假設某個任務進行一次遠端呼叫,而遠端呼叫的時間比較長,那麼這樣的系統效率將會非常低,如果能併發執行的話,在一個任務等待的時候,作業系統可以把CPU時間片分給其它其它任務執行,而前一個任務等待完畢後,作業系統再次排程CPU,重新讓它繼續執行,這樣對於使用者來說,感覺就像是同時在執行多個任務。

context switch的開銷

儲存和恢復context

那是不是併發執行的任務越多越好呢?答案當然是否定的,併發執行任務帶來的最大的缺點就是上下文切換(context switch)帶來的開銷。上下文(context)指的是當前任務執行時,在CPU暫存器,程式計數器中儲存的狀態。每一次進行執行緒切換時,需要儲存當前所有暫存器、程式計數器、棧指標等,等執行緒切換回來的時候又要對這些內容進行恢復。

汙染CPU快取

當頻繁的進行執行緒切換的時候,因為執行的任務不一樣了,對應的CPU cache中的資料也不一樣,當被阻塞的執行緒重新執行的時候,CPU cache中的內容很可能已經發生了變化,以前在快取中的資料可能要重新從主存中載入。

因此,在系統設計的時候,應該儘量避免不必要的上下文切換。比如nodejs、golang、actor model、netty等等這些併發模型,都減少了不必要的上下文切換。

引用

JSR 133 (Java Memory Model) FAQ
Latency Numbers Every Programmer Should Know
Memory Barriers/Fences
Memory Barriers and JVM Concurrency
How L1 and L2 CPU caches work, and why they’re an essential part of modern chips
False Sharing
How long does it take to make a context switch?
Context Switch Definition


相關文章