天天看點

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

作者:馬士兵老師

先提出三個問題:

  • 為什麼會有 CPU 高速緩存?
  • 為什麼 CPU 設計了三級緩存?
  • 為什麼 CPU 的一級緩存容量比較小?

為什麼會有 CPU 高速緩存?

我們常常把 CPU 比喻成計算機的“大腦”。我們思考的東西,就好比 CPU 中的寄存器(Register)。寄存器與其說是存儲器,其實它更像是 CPU 本身的一部分,隻能存放極其有限的資訊,但是速度非常快,和 CPU 同步。而我們大腦中的記憶,就好比CPU Cache(CPU 高速緩存,我們常常簡稱為“緩存”)。CPU Cache 解決的是記憶體通路速度和 CPU 的速度差距太大的問題。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

CPU Cache 用的是一種叫作SRAM(Static Random-Access Memory,靜态随機存取存儲器)的晶片。

SRAM

SRAM 之是以被稱為“靜态”存儲器,是因為隻要處在通電狀态,裡面的資料就可以保持存在。而一旦斷電,裡面的資料就會丢失了。在 SRAM 裡面,一個比特的資料,需要 6~8 個半導體。是以 SRAM 的存儲密度不高。同樣的實體空間下,能夠存儲的資料有限。不過,因為 SRAM 的電路簡單,是以通路速度非常快。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

6 個(場效應)半導體組成 SRAM 的一個比特

DRAM

另外一種叫作DRAM(Dynamic Random Access Memory,動态随機存取存儲器)的晶片,比起 SRAM 來說,它的密度更高,有更大的容量,而且它也比 SRAM 晶片便宜不少。

DRAM 被稱為“動态”存儲器,是因為 DRAM 需要靠不斷地“重新整理”,才能保持資料被存儲起來。DRAM 的一個比特,隻需要一個半導體和一個電容就能存儲。是以,DRAM 在同樣的實體空間下,能夠存儲的資料也就更多,也就是存儲的“密度”更大。但是,因為資料是存儲在電容裡的,電容會不斷漏電,是以需要定時重新整理充電,才能保持資料不丢失。DRAM 的資料通路電路和重新整理電路都比 SRAM 更複雜,是以通路延時也就更長。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

1 個半導體和一個電容組成 DRAM 的一個比特

兩種存儲器對比因子:

  • 讀寫時延高低
  • 存儲密度(機關體積的容量大小)

CPU 緩存與記憶體

  • CPU Cache 用的是SRAM(Static Random-Access Memory,靜态随機存取存儲器)的晶片。在 CPU 裡,通常會有 L1、L2、L3 這樣三層高速緩存。 每個 CPU 核心都有一塊屬于自己的 L1 高速緩存,通常分成指令緩存和資料緩存,分開存放 CPU 使用的指令和資料。L1 的 Cache 往往就嵌在 CPU 核心的内部。 L2 的 Cache 同樣是每個 CPU 核心都有的,不過它往往不在 CPU 核心的内部。是以,L2 Cache 的通路速度會比 L1 稍微慢一些。 而 L3 Cache,則通常是多個 CPU 核心共用的,尺寸會更大一些,通路速度自然也就更慢一些。
  • 記憶體用的晶片和 Cache 有所不同,它用的是 DRAM(Dynamic Random Access Memory,動态随機存取存儲器)的晶片,比起 SRAM 來說,它的密度更高,有更大的容量,而且它也比 SRAM 晶片便宜不少。
探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

你可以把 CPU 中的 L1 Cache 了解為我們的短期記憶,把 L2/L3 Cache 了解成長期記憶,把記憶體當成我們擁有的書架或者書桌。 當我們自己記憶中沒有資料的時候,可以從書桌或者書架上拿書來翻閱。這個過程中就相當于,資料從記憶體中加載到 CPU 的寄存器和 Cache 中,然後通過“大腦”,也就是 CPU,進行處理和運算。

多級 CPU 緩存

在高速緩存出現後不久,系統變得更加複雜。高速緩存與主存之間的速度差異進一步拉 大,直到加入了另一級緩存。新加入的這一級緩存比第一級緩存更大,但是更 慢。由于加大 一級緩存的做法從經濟上考慮是行不通的,是以有了二級緩存,甚至現在的有些系統擁有三 級緩存。随着單個 CPU 中核數的增加,未來甚至可能會出現更多層級的緩存。

存儲器的層次

資料是海量的,那麼我看是不是可以建構一個大容量的虛拟存儲器,它能像小容量存儲器一樣被快速通路?答案是可以的,海量資料就像一個圖書館中的書,你不可能同時檢視所有的書,而程式通路也是一樣,不可能同時通路所有資料,并且要求快速查找。前輩們已經探索出了答案,那就是,存儲器中資料的局部性原理(Principle of Locality)。我們可以利用這個局部性原理,來制定管理和通路資料的政策。

  • 時間局部性(temporal locality):某個資料項在被通路之後可能很快被再次通路的特性。
  • 空間局部性(spatial locality):某個資料項在被通路之後,與其位址相近的資料項可能很快被通路的特性。

我們可以利用局部性原理将計算機存儲器組織成為存儲器層次結構 (memory hierarchy)。存儲器層次結構由不同速度和容量的多級存儲器構成。快速存儲器每比特的成本要比慢速存儲器高很多,因而通常它們的容量也比較小。

存儲器層次結構:一種由多存儲器層次組成的結構,存儲器的容量和通路時問随着離處理器距離的增加而增加。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

通路方式

從 Cache、記憶體,到 SSD 和 HDD 硬碟,一台現代計算機中,就用上了所有這些存儲器裝置。其中,容量越小的裝置速度越快,而且,CPU 并不是直接和每一種存儲器裝置打交道,而是每一種存儲器裝置,隻和它相鄰的儲存設備打交道。比如,CPU Cache 是從記憶體裡加載而來的,或者需要寫回記憶體,并不會直接寫回資料到硬碟,也不會直接從硬碟加載資料到 CPU Cache 中,而是先加載到記憶體,再從記憶體加載到 Cache 中。

性能與價格

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

具體可參考:

SSD:www.cnblogs.com/whl320124/a…

FLASH:ica123.com/archives/15…

閃存:zh.wikipedia.org/zh-my/%E9%9…

高速緩存在計算機體系中的位置

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道
探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道
探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

相關術語:L1d 是一級資料緩存,L1i 是 一級指令緩存。

Cache 的資料結構和讀取過程是什麼樣的

CPU 高速緩存的讀取

現代 CPU 進行資料讀取的時候,無論資料是否已經存儲在 Cache 中,CPU 始終會首先通路 Cache。隻有當 CPU 在 Cache 中找不到資料的時候,才會去通路記憶體,并将讀取到的資料寫入 Cache 之中。當時間局部性原理起作用後,這個最近剛剛被通路的資料,會很快再次被通路。而 Cache 的通路速度遠遠快于記憶體,這樣,CPU 花在等待記憶體通路上的時間就大大變短了。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

Cache的資料放置的政策決定了記憶體中的資料塊會拷貝到CPU Cache中的哪個位置上,因為Cache的大小遠遠小于記憶體,是以,需要有一種位址關聯的算法,能夠讓記憶體中的資料可以被映射到Cache中來。

Cache Line:緩存基本上來說就是把後面的資料加載到離自己近的地方,對于CPU來說,它是不會一個位元組一個位元組的加載的,因為這非常沒有效率,一般來說都是要一塊一塊的加載的,對于這樣的一塊一塊的資料機關,術語叫“Cache Line”,一般來說,一個主流的CPU的Cache Line 是 64 Bytes(也有的CPU用32Bytes和128Bytes),64Bytes也就是16個32位的整型,這就是CPU從記憶體中撈資料上來的最小資料機關。

比如:Cache Line是最小機關(64Bytes),是以先把Cache分布多個Cache Line,比如:L1有32KB,那麼,32KB/64B = 512 個 Cache Line。

基本上來說,我們會有如下三種方法。

  • 全相聯映射:任何一個記憶體位址的資料可以被緩存在任何一個Cache Line裡,這種方法是最靈活的,但是,如果我們要知道一個記憶體是否存在于Cache中,我們就需要進行O(n)複雜度的Cache周遊,這是很沒有效率的。
  • 直相聯映射:為了降低緩存搜尋算法,我們需要使用像Hash Table這樣的資料結構,最簡單的hash table就是做“求模運算”,比如:我們的L1 Cache有512個Cache Line,那麼,公式:(記憶體位址 mod 512)* 64 就可以直接找到所在的Cache位址的偏移了。
  • 組相聯映射:為了避免上述的兩種方案的問題,于是就要容忍一定的hash沖突,也就出現了 N-Way 關聯。也就是把連續的N個Cache Line綁成一組,然後,先把找到相關的組,然後再在這個組内找到相關的Cache Line。
探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

CPU 如何知道要通路的記憶體資料,存儲在 Cache 的哪個位置呢?接下來,從最基本的直接映射 Cache(Direct Mapped Cache)說起,來看整個 Cache 的資料結構和通路邏輯。

CPU 通路記憶體資料,是一小塊一小塊資料來讀取的。對于讀取記憶體中的資料,我們首先拿到的是資料所在的記憶體塊(Block)的位址。

為了友善索引記憶體位址,把記憶體位址分為了三段概念表達:

  • **Tag:**記憶體塊的高位資訊為 Cache Line 的組标記,這個組标記會記錄,目前緩存塊記憶體儲的資料對應的記憶體塊,而緩存塊本身的位址表示通路位址的低 N 位。
  • Index:直接映射 Cache 采用的政策,就是確定任何一個記憶體塊的位址,始終映射到一個固定的 CPU Cache 位址(Cache Line)。而這個映射關系,通常用 mod 運算(求餘運算)來實作。求餘的結果為 Cache Line 的索引位址。
  • Offset:CPU 在讀取資料的時候,并不是要讀取一整個 Block,而是讀取一個他需要的整數。這樣的資料,我們叫作 CPU 裡的一個字(Word)。具體是哪個字,就用這個字在整個 Block 裡面的位置來決定。這個位置,我們叫作偏移量(Offset)。

一個記憶體的通路位址,最終包括高位代表的組标記、低位代表的索引,以及在對應的 Data Block 中定位對應字的位置偏移量。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

而記憶體位址對應到 Cache 裡的資料結構,則多了一個有效位和對應的資料,由“索引 + 有效位 + 組标記 + 資料”組成。如果記憶體中的資料已經在 CPU Cache 裡了,那一個記憶體位址的通路,就會經曆這樣 4 個步驟:

  1. 根據記憶體位址的低位,計算在 Cache 中的索引;
  2. 判斷有效位,确認 Cache 中的資料是有效的;
  3. 對比記憶體通路位址的高位,和 Cache 中的組标記,确認 Cache 中的資料就是我們要通路的記憶體資料,從 Cache Line 中讀取到對應的資料塊(Data Block);
  4. 根據記憶體位址的 Offset 位,從 Data Block 中,讀取希望讀取到的字。

如果在 2、3 這兩個步驟中,CPU 發現,Cache 中的資料并不是要通路的記憶體位址的資料,那 CPU 就會通路記憶體,并把對應的 Block Data 更新到 Cache Line 中,同時更新對應的有效位群組标記的資料。

除了組标記資訊之外,緩存塊中還有兩個資料。一個自然是從主記憶體中加載來的實際存放的資料,另一個是有效位(valid bit)。啥是有效位呢?它其實就是用來标記,對應的緩存塊中的資料是否是有效的,確定不是機器剛剛啟動時候的空資料。如果有效位是 0,無論其中的組标記和 Cache Line 裡的資料内容是什麼,CPU 都不會管這些資料,而要直接通路記憶體,重新加載資料。

CPU 高速緩存的寫入

下面這段代碼的輸出結果是什麼樣的?

java複制代碼public class App {
    private static volatile int COUNTER = 0;
 
    public static void main(String[] args) {
        new ChangeListener().start();
        new ChangeMaker().start();
    }
 
    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                }
            }
        }
    }
 
    static class ChangeMaker extends Thread{
        @Override
        public void run() {
            int threadValue = COUNTER;
            while (COUNTER <5){
                System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                COUNTER = ++threadValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
}
           

結果:

java複制代碼Incrementing COUNTER to : 1
Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Got Change for COUNTER : 5
           

如歸把 volatile 删掉又是什麼輸出?

java複制代碼Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5
           

下面這段代碼輸出結果是什麼樣的?

java複制代碼public class App {
    private static int COUNTER = 0;
 
    public static void main(String[] args) {
        new ChangeListener().start();
        new ChangeMaker().start();
    }
 
    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                } else {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
 
    static class ChangeMaker extends Thread{
        @Override
        public void run() {
            int threadValue = COUNTER;
            while (COUNTER <5){
                System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                COUNTER = ++threadValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
}
           

volatile 關鍵字究竟代表什麼含義呢?

它會確定我們對于這個變量的讀取和寫入,都一定會同步到主記憶體裡,而不是從 Cache 裡面讀取。

現代通常都是多核的的。每一個 CPU 核裡面,都有獨立屬于自己的 L1、L2 的 Cache,然後再有多個 CPU 核共用的 L3 的 Cache、主記憶體。

因為 CPU Cache 的通路速度要比主記憶體快很多,而在 CPU Cache 裡面,L1/L2 的 Cache 也要比 L3 的 Cache 快。是以,上一講我們可以看到,CPU 始終都是盡可能地從 CPU Cache 中去擷取資料,而不是每一次都要從主記憶體裡面去讀取資料。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

寫入 Cache 的性能也比寫入主記憶體要快,那我們寫入的資料,到底應該寫到 Cache 裡還是主記憶體呢?如果我們直接寫入到主記憶體裡,Cache 裡的資料是否會失效呢?

兩種寫入政策:

第一:寫操作隻在cache上,然後再flush到記憶體上。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

第二:每一次資料都要寫入到主記憶體裡面。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

多個 CPU 核的緩存一緻性如何解決?

要解決這個問題,我們需要引入一個新的方法,叫作 MESI 協定。這是一個維護緩存一緻性協定。這個協定不僅可以用在 CPU Cache 之間,也可以廣泛用于各種需要使用緩存,同時緩存之間需要同步的場景下。

什麼是緩存一緻性

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

如果我們要更新 iPhone 的價格,操作修改了核心 1 的緩存,這樣會産生核心 2 緩存資料的不一緻問題。為了解決這個問題,需要做到以下兩點:

  • 第一點叫寫傳播(Write Propagation)。寫傳播是說,在一個 CPU 核心裡,我們的 Cache 資料更新,必須能夠傳播到其他的對應節點的 Cache Line 裡。
  • 第二點叫事務的串行化(Transaction Serialization),事務串行化是說,我們在一個 CPU 核心裡面的讀取和寫入,在其他的節點看起來,順序是一樣的。

事務串行化

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

而在 CPU Cache 裡做到事務串行化,需要做到兩點:

  • 第一點是一個 CPU 核心對于資料的操作,需要同步通信給到其他 CPU 核心。
  • 第二點是,如果兩個 CPU 核心裡有同一個資料的 Cache,那麼對于這個 Cache 資料的更新,需要有一個“鎖”的概念。隻有拿到了對應 Cache Block 的“鎖”之後,才能進行對應的資料更新。

實作了這兩個機制有一個叫做 MESI 的協定。Modified(已修改), Exclusive(獨占的),Shared(共享的),Invalid(無效的)。

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

狀态流轉機制還是比較複雜的,下面通過一個表格來模拟寫回算法的緩存狀态的變化:

探秘CPU:為何存在層次結構與讀寫過程,解決多核緩存一緻性之道

MESI 這種協定在資料更新後,會标記其它共享的CPU緩存的資料拷貝為Invalid狀态,然後當其它CPU再次read的時候,就會出現 cache miss 的問題,此時再從記憶體中更新資料。從記憶體中更新資料意味着20倍速度的降低。我們能不能直接從我隔壁的CPU緩存中更新?是的,這就可以增加很多速度了,但是狀态控制也就變麻煩了。還需要多來一個狀态:Owner(宿主),用于标記,我是更新資料的源。于是,出現了 MOESI 協定。MOESI協定允許 CPU Cache 間同步資料,于是也降低了對記憶體的操作,性能是非常大的提升,但是控制邏輯也非常複雜。

作者:我是大明哥

連結:https://juejin.cn/post/7249201537913241659

繼續閱讀