天天看點

10、CPU 如何讀寫資料的?

先來認識 CPU 的架構,隻有了解了 CPU 的 架構,才能更好地了解 CPU 是如何讀寫資料的,對于現代 CPU 的架構圖如下:

10、CPU 如何讀寫資料的?

可以看到,一個 CPU 裡通常會有多個 CPU 核心,比如上圖中的 1 号和 2 号 CPU 核心,并且每個 CPU 核心都有自己的 L1 Cache 和 L2 Cache,而 L1 Cache 通常分為 dCache(資料緩存) 和 iCache(指令緩存),L3 Cache 則是多個核心共享的,這就是 CPU 典型的緩存層次。

上面提到的都是 CPU 内部的 Cache,放眼外部的話,還會有記憶體和硬碟,這些儲存設備共同構成了金字塔存儲層次。如下圖所示:

10、CPU 如何讀寫資料的?

從上圖也可以看到,從上往下,儲存設備的容量會越大,而通路速度會越慢。至于每個儲存設備的通路延時,你可以看下圖的表格:

10、CPU 如何讀寫資料的?

你可以看到, CPU 通路 L1 Cache 速度比通路記憶體快 100 倍,這就是為什麼 CPU 裡會有 L1~L3 Cache 的原因,目的就是把 Cache 作為 CPU 與記憶體之間的緩存層,以減少對記憶體的通路頻率。

CPU 從記憶體中讀取資料到 Cache 的時候,并不是一個位元組一個位元組讀取,而是一塊一塊的方式來讀取資料的,這一塊一塊的資料被稱為 CPU Cache Line(緩存塊),是以 CPU Cache Line 是 CPU 從記憶體讀取資料到 Cache 的機關。

至于 CPU Cache Line 大小,在 Linux 系統可以用下面的方式檢視到,你可以看我伺服器的 L1 Cache Line 大小是 64 位元組,也就意味着 L1 Cache 一次載入資料的大小是 64 位元組。

10、CPU 如何讀寫資料的?

那麼對數組的加載, CPU 就會加載數組裡面連續的多個資料到 Cache 裡,是以我們應該按照實體記憶體位址分布的順序去通路元素,這樣通路數組元素的時候,Cache 命中率就會很高,于是就能減少從記憶體讀取資料的頻率, 進而可提高程式的性能。

但是,在我們不使用數組,而是使用單獨的變量的時候,則會有 Cache 僞共享的問題,Cache 僞共享問題上是一個性能殺手,我們應該要規避它。

1、僞共享

先來看看 Cache 僞共享是什麼?又如何避免這個問題?

現在假設有一個雙核心的 CPU,這兩個 CPU 核心并行運作着兩個不同的線程,它們同時從記憶體中讀取兩個不同的資料,分别是類型為 ​

​long​

​ 的變量 A 和 B,這個兩個資料的位址在實體記憶體上是連續的,如果 Cahce Line 的大小是 64 位元組,并且變量 A 在 Cahce Line 的開頭位置,那麼這兩個資料是位于同一個 Cache Line 中,又因為 CPU Cache Line 是 CPU 從記憶體讀取資料到 Cache 的機關,是以這兩個資料會被同時讀入到了兩個 CPU 核心中各自 Cache 中。

10、CPU 如何讀寫資料的?

我們來思考一個問題,如果這兩個不同核心的線程分别修改不同的資料,比如 1 号 CPU 核心的線程隻修改了 變量 A,或 2 号 CPU 核心的線程的線程隻修改了變量 B,會發生什麼呢?

①. 最開始變量 A 和 B 都還不在 Cache 裡面,假設 1 号核心綁定了線程 A,2 号核心綁定了線程 B,線程 A 隻會讀寫變量 A,線程 B 隻會讀寫變量 B。

10、CPU 如何讀寫資料的?

②. 1 号核心讀取變量 A,由于 CPU 從記憶體讀取資料到 Cache 的機關是 Cache Line,也正好變量 A 和 變量 B 的資料歸屬于同一個 Cache Line,是以 A 和 B 的資料都會被加載到 Cache,并将此 Cache Line 标記為「獨占」狀态。

10、CPU 如何讀寫資料的?

③. 接着,2 号核心開始從記憶體裡讀取變量 B,同樣的也是讀取 Cache Line 大小的資料到 Cache 中,此 Cache Line 中的資料也包含了變量 A 和 變量 B,此時 1 号和 2 号核心的 Cache Line 狀态變為「共享」狀态。

10、CPU 如何讀寫資料的?

④. 1 号核心需要修改變量 A,發現此 Cache Line 的狀态是「共享」狀态,是以先需要通過總線發送消息給 2 号核心,通知 2 号核心把 Cache 中對應的 Cache Line 标記為「已失效」狀态,然後 1 号核心對應的 Cache Line 狀态變成「已修改」狀态,并且修改變量 A。

10、CPU 如何讀寫資料的?

⑤. 之後,2 号核心需要修改變量 B,此時 2 号核心的 Cache 中對應的 Cache Line 是已失效狀态,另外由于 1 号核心的 Cache 也有此相同的資料,且狀态為「已修改」狀态,是以要先把 1 号核心的 Cache 對應的 Cache Line 寫回到記憶體,然後 2 号核心再從記憶體讀取 Cache Line 大小的資料到 Cache 中,最後把變量 B 修改到 2 号核心的 Cache 中,并将狀态标記為「已修改」狀态。

10、CPU 如何讀寫資料的?

是以,可以發現如果 1 号和 2 号 CPU 核心這樣持續交替的分别修改變量 A 和 B,就會重複 ④ 和 ⑤ 這兩個步驟,Cache 并沒有起到緩存的效果,雖然變量 A 和 B 之間其實并沒有任何的關系,但是因為同時歸屬于一個 Cache Line ,這個 Cache Line 中的任意資料被修改後,都會互相影響,進而出現 ④ 和 ⑤ 這兩個步驟。

是以,這種因為多個線程同時讀寫同一個 Cache Line 的不同變量時,而導緻 CPU Cache 失效的現象稱為僞共享(False Sharing)。

2、避免僞共享的方法

是以,對于多個線程共享的熱點資料,即經常會修改的資料,應該避免這些資料剛好在同一個 Cache Line 中,否則就會出現為僞共享的問題。

接下來,看看在實際項目中是用什麼方式來避免僞共享的問題的。

在 Linux 核心中存在 ​

​__cacheline_aligned_in_smp​

​ 宏定義,是用于解決僞共享的問題。

10、CPU 如何讀寫資料的?

從上面的宏定義,我們可以看到:

  • 如果在多核(MP)系統裡,該宏定義是​

    ​__cacheline_aligned​

    ​,也就是 Cache Line 的大小;
  • 而如果在單核系統裡,該宏定義是空的;

是以,針對在同一個 Cache Line 中的共享的資料,如果在多核之間競争比較嚴重,為了防止僞共享現象的發生,可以采用上面的宏定義使得變量在 Cache Line 裡是對齊的。

舉個例子,有下面這個結構體:

10、CPU 如何讀寫資料的?

結構體裡的兩個成員變量 a 和 b 在實體記憶體位址上是連續的,于是它們可能會位于同一個 Cache Line 中,如下圖:

10、CPU 如何讀寫資料的?

是以,為了防止前面提到的 Cache 僞共享問題,我們可以使用上面介紹的宏定義,将 b 的位址設定為 Cache Line 對齊位址,如下:

10、CPU 如何讀寫資料的?

這樣 a 和 b 變量就不會在同一個 Cache Line 中了,如下圖:

10、CPU 如何讀寫資料的?

是以,避免 Cache 僞共享實際上是用空間換時間的思想,浪費一部分 Cache 空間,進而換來性能的提升。

我們再來看一個應用層面的規避方案,有一個 Java 并發架構 Disruptor 使用「位元組填充 + 繼承」的方式,來避免僞共享的問題。

Disruptor 中有一個 RingBuffer 類會經常被多個線程使用,代碼如下:

10、CPU 如何讀寫資料的?

你可能會覺得 RingBufferPad 類裡 7 個 long 類型的名字很奇怪,但事實上,它們雖然看起來毫無作用,但卻對性能的提升起到了至關重要的作用。

我們都知道,CPU Cache 從記憶體讀取資料的機關是 CPU Cache Line,一般 64 位 CPU 的 CPU Cache Line 的大小是 64 個位元組,一個 long 類型的資料是 8 個位元組,是以 CPU 一下會加載 8 個 long 類型的資料。