深入理解併發程式設計藝術之計算機記憶體模型

碼農談IT發表於2023-10-30

來源:碼農本農

瞭解java記憶體模型不得不先了解計算機記憶體模型,我們接下來就從計算記憶體模型說起

計算機發展

我們都知道 CPU 和 記憶體是計算機中比較核心的兩個東西,任何在計算機上執行的程式其實都是對資料的存取和處理計算,最終都會對映成cpu和記憶體之間的頻繁互動,最原始計算機就是cpu讀取記憶體進行處理,然後回寫記憶體。

CPU在摩爾定律的指導下以每18個月翻一番的速度在發展,cpu的處理速度不斷增速,其處理速度遠遠超出了記憶體的讀寫速度,導致的後果就是cpu大量的時間都花費在磁碟 I/O、網路通訊或者資料庫訪問上,cpu大部分的時間都處於空閒的等待狀態。

為了充分壓榨cpu的效能,避免cpu效能浪費,就必須使用一些手段去把處理器的運算能力“壓榨”出來,最容易想到的就是讓計算機同時處理幾項任務。為了實現這一目標,計算機系統不得不加入一層或多層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝:將運算需要使用的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。深入理解併發程式設計藝術之計算機記憶體模型

上圖為計算機多核cpu多級快取圖,即當下流行的cpu架構,計算機記憶體模型主要涉及到的元件:處理器,暫存器,快取記憶體,記憶體,快取行。

處理器:負責做邏輯運算,程式程式碼都會變成運算指令或計算公式,在處理器裡面其實就是二進位制的各種組合,處理器計算後會得到一個結果。

暫存器:離處理器最近的一塊儲存介質,可以說位於記憶體模型的頂端,它的速度非常之快,快到可以和處理器相媲美,處理器從裡面拿資料,運算完之後又把資料存回去。暫存器是處理器裡面的一部分,處理器可能有多個暫存器,比如資料計數器,指令指標暫存器等等。

快取記憶體:是一個比記憶體速度快很多接近處理器速度的儲存區域,目的是把處理器要用到的一堆資料從主記憶體中複製進來供處理器使用,處理器運算處理完了之後又把結果同步回主記憶體,這樣處理器只做自己的事,而快取記憶體就成了傳話筒。快取記憶體有分為一級快取,二級快取和三級快取,離處理器最近的是一級快取,依次往後排。
儲存器儲存空間大小:記憶體>L3>L2>L1>暫存器
儲存器速度快慢排序:暫存器>L1>L2>L3>記憶體

快取行:快取是由最小的儲存區塊-快取行(cacheline)組成,快取行大小通常為64byte。快取行是什麼意思呢?比如你的L1快取大小是512kb,而cacheline = 64byte,那麼就是L1裡有512 * 1024/64個cacheline,也是cpu中暫存器從快取中取資料的最小單位,即取數為x=0,那麼在快取中找到x=0後不是隻把x=0取走,而是把x=0所在的快取行取走。

記憶體:就是我們通常講的記憶體,比如現在的電腦動不動8G,16G啊等等,在記憶體模型中叫做主記憶體,它比磁碟的讀寫速度快很多,但是又跟快取記憶體沒法比,因此,程式啟動的時候,程式相關的資料會載入到主記憶體,然後處理器處理某塊邏輯的時候,比較佔空間的東西會丟到主記憶體,比如Java裡面的物件,就是存放在堆上面的,而Java虛擬機器裡面的堆就是放在主記憶體的。

在CPU訪問儲存裝置時會遵循一定的原理,無論是存取資料抑或存取指令,都趨於聚集在一片連續的區域中,這就是區域性性原理。這也是cpu架構提高效能的一個關鍵性因素。

時間區域性性(Temporal Locality):如果一個資訊項正在被訪問,那麼在近期它很可能還會被再次訪問。比如迴圈、遞迴、方法的反覆呼叫等。

空間區域性性(Spatial Locality):如果一個儲存器的位置被引用,那麼將來他附近的位置也會被引用。比如順序執行的程式碼、連續建立的兩個物件、陣列等。

帶有快取記憶體的CPU執行計算的流程:
1.程式以及資料被載入到主記憶體
2.指令和資料被載入到CPU的快取記憶體
3.CPU執行指令,把結果寫到快取記憶體
4.快取記憶體中的資料寫回主記憶體

講到這裡我們知道以上新型cpu架構是為充分壓榨cpu效能而來,那麼就單看以上架構,在不做任何最佳化的情況下,當多核cpu併發工作的時候必然會引入快取一致性問題。在多路處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體,當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致。如果真的發生這種情況,那同步回到主記憶體時該以誰的快取資料為準呢?例如:假設主記憶體中存在一個共享變數 x,現在有 A 和 B 兩個核心(也可以直接說分佈在兩個核上的執行緒)分別對該變數 x=1 進行操作,A/B 核各自快取記憶體中存在共享變數副本 x。假設現在 A 想要修改 x 的值為 2,而 B 卻想要讀取 x 的值,那麼 B 讀取到的值是 A 更新後的值 2,還是更新前的值 1 呢?答案是,不確定,即 B 有可能讀取到 A 更新前的值 1,也有可能讀取到 A 更新後的值 2,這是因為快取記憶體是每個核私有的資料區域,而 A 在操作變數 x 時,首先是將變數從主記憶體複製到 A 的快取記憶體中,然後對變數進行操作,操作完成後再將變數 x 寫回主記憶體,而對於 B 也是類似的,這樣就有可能造成主記憶體與工作記憶體間資料存在一致性問題,假如 A 修改完後正在將資料寫回主記憶體,而 B 此時正在讀取主記憶體,即將 x=1 複製到自己的工作快取記憶體中,這樣 B 讀取到的值就是 x=1,但如果 A 已將 x=2 寫回主記憶體後,B 才開始讀取的話,那麼此時 B 讀取到的就是 x=2,但到底是哪種情況先發生呢,在併發訪問過程中這些都是不確定的。

除了增加快取記憶體之外,為了使處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行最佳化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致,因此如果存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不能靠程式碼的先後順序來保證,顧名思義,當單執行緒執行的時候,無論怎樣亂序,最終的結果都是預期的結果,但是當多執行緒的時候呢,就不一定了,特別是存在共享變數的或者說一個執行緒依賴於另一個執行緒的計算結果的時候,就很有可能因為亂序帶來不正確的結果。

透過以上可以得知,cpu架構自身存在資料一致性的問題和亂序重排問題,其實也可以理解為java的併發訪問的原子性問題,可見性問題,有序性問題。

計算機記憶體模型

在多核cpu架構中,每個核心都有自己的L1 L2快取記憶體,同個cpu的多個核心共享L3快取,不同cpu之間共享主記憶體,為了保證共享記憶體的正確性,記憶體模型定義了共享記憶體系統中多執行緒程式讀寫操作行為的規範。透過這些規則來規範對記憶體的讀寫操作,從而保證指令執行的正確性。它與處理器有關、與快取有關、與併發有關、與編譯器也有關。他解決了 CPU 多級快取、處理器最佳化、指令重排等導致的記憶體訪問問題,保證了併發場景下的一致性、原子性和有序性。

記憶體模型解決併發問題主要採用兩種方式:
1.限制處理器最佳化
2.使用記憶體屏障

我們來看下記憶體模型的具體做法

解決快取不一致問題

解決快取不一致的方法有很多,比如:匯流排加鎖(此方法效能較低,現在已經不會再使用)MESI 協議:當一個 CPU 修改了 Cache 中的資料,會通知其他快取了這個資料的 CPU,其他 CPU 會把 Cache 中這份資料的 Cache Line 置為無效,要讀取資料的話,直接去記憶體中獲取,不會再從 Cache 中獲取了。當然還有其他的解決方案,MESI 協議是其中比較出名的。

MESI 協議中的狀態
CPU 中每個快取行使用的 4 種狀態進行標記(使用額外的兩位 bit 表示)深入理解併發程式設計藝術之計算機記憶體模型

  • M 和 E 的資料都是本 core 獨有的,不同之處是 M 狀態的資料是 dirty(和記憶體中的不一致),E 狀態的資料是 clean(和記憶體中的一致)
  • S 狀態是所有 Core 的資料都是共享的,只有 clean 的資料才能被多個 core 共享
  • I-表示這個 Cache line 無效

E 狀態
只有 Core 0 訪問變數 x,它的 Cache line 狀態為 E(Exclusive)。深入理解併發程式設計藝術之計算機記憶體模型

S 狀態
3 個 Core 都訪問變數 x,它們對應的 Cache line 為 S(Shared)狀態。深入理解併發程式設計藝術之計算機記憶體模型

M 狀態和I狀態之間的轉化
Core 0 修改了 x 的值之後,這個 Cache line 變成了 M(Modified)狀態,其他 Core 對應的 Cache line 變成了 I(Invalid)狀態 在 MESI 協議中,每個 Cache 的 Cache 控制器不僅知道自己的讀寫操作,而且也監聽(snoop)其它 Cache 的讀寫操作。每個 Cache line 所處的狀態根據本核和其它核的讀寫操作在 4 個狀態間進行遷移深入理解併發程式設計藝術之計算機記憶體模型

MESI 協議透過標識快取資料的狀態,來決定 CPU 何時把快取的資料寫入到記憶體,何時從快取讀取資料,何時從記憶體讀取資料。

MESI 協議看似解決了快取的一致性問題,但是並不那麼完美,因為當多個快取對資料進行了快取時,一個快取對資料進行修改需要同過指令的形式與其他 CPU 進行通訊,這個過程是同步的,必須其他 CPU 都把快取裡的資料都置為 Invalid 狀態成功後,我們修改資料的 CPU 才能進行下一步指令,整個過程中需要同步的和多個快取通訊,這個過程是不穩定的,容易產生問題,而且通訊的過程中 CPU 是必須處於等待的狀態,那麼也影響著 CPU 的效能。

為了避免這種 CPU 運算能力的浪費,解決 CPU 切換狀態阻塞,Store Bufferes 被引入使用。處理器把它想要寫入到主存的值寫到快取,然後繼續去處理其他事情。當所有失效確認都接收到時,資料才會最終被提交。

指令重排問題

public class config{
    // 此變數必須定義為
 1   boolean initialized = false;
 2   public Object cache(@NotNull String key) {
 3       if (!initialized) {
 4           doSomethingWithConfig();
 5       }
 6       configText = readConfigFile("pz");
 7       processConfigOptions(configText, "xx");
 8       initialized = true;
 9       if (!initialized) {
 10           doSomethingWithConfig();
        }
    }  
}

拿上面的程式碼來說明下亂序,簡單來講就是initialized = false;cpu為了高效,避免再次去快取取值,很有可能接著執行initialized = true(判斷為無依賴關係的情況下),這個時候6、7行還沒有執行,單執行緒情況下不會有問題,但是併發情況下就會有問題。下一篇我們詳細講解。

指令重排序解決方案:硬體工程師其無法預知未知的程式邏輯場景,所以一些問題還是遺留給了軟體工程師,但是他們給我們提供了一套對應場景的解決方案就是“記憶體屏障指令”,我們的軟體工程師可以同記憶體屏障來針對不同場景來選擇性的“禁用快取
記憶體屏障,又稱記憶體柵欄,是一個CPU指令,硬體分為下面幾種:

lfence(讀屏障 load Barrier):
在讀取指令前插入讀屏障,讓快取中的資料失效,重新從主記憶體載入資料,保證資料是最新的。
Sfence(寫屏障 store Barrier):
在寫入指令後插入屏障,同步把快取的資料寫回記憶體,保證其資料立即對其他快取可見。
Mfence(全能屏障):
擁有讀屏障和寫屏障的功能。
Lock 字首指令:
Lock不是一種記憶體屏障,但是它能完成類似記憶體屏障的功能。Lock會對CPU匯流排和快取記憶體加鎖,可以理解為CPU指令級的一種鎖。它後面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

注意:不同的硬體快取一致性協議和記憶體屏障可能不同

總結

隨著計算機高速發展,CPU 技術遠超過記憶體技術,所以多級快取被使用,解決了記憶體和 cpu 的讀寫速度問題,隨著多執行緒的發展,快取一致性問題油然而生,好在可以透過快取一致性協議來解決,比較出名的快取一致性協議是MESI,MESI協議的引入,微微降低了 cpu 的速度。

為了更好的壓榨 cpu 的效能,於是Store Bufferes 概念被引入,將 cpu 寫入主存從同步阻塞變為非同步,大大提高了 cpu 執行效率

指令重排序問題預期而至,這時候祭出終極武器:記憶體屏障指令,在程式碼裡面禁用快取。

至此,計算機發展中遇到的問題都一一解決,而這一系列問題解決方案,都是記憶體模型規範的。

記憶體模型就是為了解決計算機發展中遇到的快取一致性、處理器最佳化和指令重排、併發程式設計等問題的一系列規範,他定義了共享記憶體系統中多執行緒程式讀寫操作行為的規範,透過這些規則來規範對記憶體的讀寫操作,從而保證指令執行的正確性。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024924/viewspace-2991873/,如需轉載,請註明出處,否則將追究法律責任。

相關文章