CPU三級緩存技術解析


CPU三級緩存技術解析

cpu存取數據

cpu存取數據大致可以認為是下圖的流程(此處圖比較簡單)

 

 

 cpu拿到需要的內存地址,之后這個地址會被mmu轉換成真正的物理地址,接下來會去查接下來查L1 cache,L1 cache不命中查L2 cache,L2 cache不命中查L3 cache,L3 cache不能命中查內存。

其實現在查到內存還算完,現在有了虛擬內存內存其實也是一層cache,是磁盤的cache,也就是說查內存也有可能不會命中,因為內存中的數據可能被虛擬內存系統放到磁盤中了,如果內存也不能命中就要查磁盤

為什么需要cache

程序局部性原理

如果訪問內存中的一個數據A,那么很有可能接下來再次訪問到,同時還很有可能訪問與數據A相鄰的數據B,這分別叫做時間局部性空間局部性

cpu cache 有多快

根據摩爾定律,CPU 的訪問速度每 18 個月就會翻 倍,相當於每年增⻓ 60% 左右,內存的速度當然也會不斷增⻓,但是增⻓的速度遠小於 CPU,平均每年 只增⻓ 7% 左右。於是,CPU 與內存的訪問性能的差距不斷拉大。

為了彌補 CPU 與內存兩者之間的性能差異,就在 CPU 內部引入了 CPU Cache,也稱高速緩存。

CPU Cache 通常分為大小不等的三級緩存,分別是 L1 CacheL2 CacheL3 Cache。其中L3是多個核心共享的。

程序執行時,會先將內存中的數據加載到共享的 L3 Cache 中,再加載到每個核心獨有的 L2 Cache,最后 進入到最快的 L1 Cache,之后才會被 CPU 讀取。之間的層級關系,如下圖。

 

 

 越靠近 CPU 核心的緩存其訪問速度越快

 

 

 cpu cache 讀取過程

CPU Cache 的數據是從內存中讀取過來的,以一小塊一小塊讀取數據的,而不是按照單個數組元素來

讀取數據的,在 CPU Cache 中的,這樣一小塊一小塊的數據,稱為 Cache Line(緩存塊)。

可以在linux機器下執行一下命令查詢L1cache的大小,單位是字節

#查看cache line 大小

cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

#查看各級緩存大小 inde0-3分別是 L1數據緩存 L1指令緩存 L2數據緩存 L3數據緩存

cat /sys/devices/system/cpu/cpu0/cache/index0/size

比如,有一個 int array[100] 的數組,當載入 array[0] 時,由於這個數組元素的大小在內存只占 4 字 節,不足 64 字節,CPU 就會順序加載數組元素到 array[15] ,意味着 array[0]~array[15] 數組元素都會 被緩存在 CPU Cache 中了,因此當下次訪問這些數組元素時,會直接從 CPU Cache 讀取,而不用再從內 存中讀取,大大提高了 CPU 讀取數據的性能。

如何寫出讓cpu跑的更快的代碼

其實,這個問題定義為如何提高cpu緩存利用率更好

大家可以看下如下代碼哪個執行效率更高

func main() {

 n := 100

 x := 0

 arr := createArray(n)

 //var arr [][]int

 t := time.Now().UnixNano()

 for i := 0; i < n; i++ {

  for j := 0; j < n; j++ {

   x = arr[i][j]

  }

 }

 

 t1 := time.Now().UnixNano()

 for i := 0; i < n; i++ {

  for j := 0; j < n; j++ {

   x = arr[j][i]

  }

 }

 fmt.Println(x)

 

}

 

//創建二維數組

func createArray(n int) [][]int {

 var arr [][]int

 

 for i := 0; i < n; i++ {

  var tmp []int

  for j := 0; j < n; j++ {

   tmp = append(tmp, i+j)

  }

  arr = append(arr, tmp)

 }

 

 return arr

}

 

/**

 經過測試,形式一 array[i][j] 執行時間比形式二 array[j][i] 快好幾倍。

 之所以有這么大的差距,是因為二維數組 array 所占用的內存是連續的,比如⻓度 N 的指是 2 的 話,那么內存中的數組元素的布局順序是這樣的:

 array[0][0] array[0][1] array[1][0] array[1][1]

 形式一用 array[i][j] 訪問數組元素的順序,正是和內存中數組元素存放的順序一致。當 CPU 訪問 array[0][0] 時,由於該數據不在 Cache 中,

 於是會「順序」把跟隨其后的 3 個元素從內存中加載到 CPU Cache,這樣當 CPU 訪問后面的 3 個數組元素時,就能在 CPU Cache 中成功地找到數據,

 這意味着緩存命中率很高,緩存命中的數據不需要訪問內存,這便大大提高了代碼的性能。

 而如果用形式二的 array[j][i] 來訪問,則訪問的順序就是:

 array[0][0] array[1][0] array[0][1]  array[1][1]

 可以看到,訪問的方式跳躍式的,而不是順序的,那么如果 N 的數值很大,那么操作 array[j][i] 時,是 沒辦法把 array[j+1][i] 也讀入到

 CPU Cache 中的,既然 array[j+1][i] 沒有讀取到 CPU Cache,那么就 需要從內存讀取該數據元素了。很明顯,這種不連續性、跳躍式訪問數據元素

 的方式,可能不能充分利用 到了 CPU Cache 的特性,從而代碼的性能不高。那訪問 array[0][0] 元素時,CPU 具體會一次從內存中加載多少元素到

 CPU Cache 呢?這個問題,在前 面也提到過,這跟 CPU Cache Line 有關,表示 CPU Cache 一次性能加載數據的大小,可以在 Linux 里通過

 coherency_line_size 配置查看大小,通常是 64 個字節。

  */

cpu cache的結構

CPU Cache 是由很多個 Cache Line 組成的,CPU Line 是 CPU 從內存讀取數據的基本單位,而 CPU Line 是由各種標志(Tag)+ 數據塊(Data Block)組成

 

 

 cpu cache數據的寫入

事實上,數據不止有讀取還有寫入,如果數據寫入cache之后,內存和cache的數據就不同了,需要把cache同步到內存中。

問題的關鍵就在於在什么時機去把數據寫到內存?一般來講有以下兩種策略

寫直達

保持內存與 Cache 一致性最簡單的方式是,把數據同時寫入內存和 Cache 中,這種方法稱為寫直達 (Write Through)。

 

 

 在這個方法里,寫入前會先判斷數據是否已經在 CPU Cache 里面了:

如果數據已經在 Cache 里面,先將數據更新到 Cache 里面,再寫入到內存里面; 如果數據沒有在 Cache 里面,就直接把數據更新到內存里面。

寫直達法很直觀,也很簡單,但是問題明顯,無論數據在不在 Cache 里面,每次寫操作都會寫回到內存, 這樣寫操作將會花費大量的時間,無疑性能會受到很大的影響。

寫回

由於寫直達的機制會有性能問題,所以產生了寫回(Write Back)的方法

寫回機制中,當發生寫操作時,新的數據僅僅被寫入 Cache Block 里,只有當修改過的 Cache Block 「被替換」時才需要寫到內存中,減少了數據寫回內存的頻率,這樣便可以提高系統的性能。

 

  1. 如果當發生寫操作時,數據已經在 CPU Cache 里的話,則把數據更新到 CPU Cache 里,同時標記 CPU Cache 里的這個 Cache Block 為臟(Dirty)的,這個臟的標記代表這個時候, CPU Cache 里面的這個 Cache Block 的數據和內存是不一致的,這種情況是不用把數據寫到內存里的;
  2. 如果當發生寫操作時,數據所對應的 Cache Block 里存放的是「別的內存地址的數據」的話,就要檢 查這個 Cache Block 里的數據有沒有被標記為臟的,如果是臟的話,就要把這個 Cache Block 里的數據寫回到內存,然后再把當前要寫入的數據,寫入到這個 Cache Block 里,同時標記為臟的;如果 Cache Block 里面的數據沒有被標記為臟,則就直接將數據寫入到這個 Cache Block 里,然后再把這個 Cache Block 標記為臟的就好了。

可以發現寫回這個方法,在把數據寫入到 Cache 的時候,只有在緩存不命中,同時數據對應的 Cache 中 的 Cache Block 為臟標記的情況下,才會將數據寫到內存中,而在緩存命中的情況下,則在寫入后 Cache 后,只需把該數據對應的 Cache Block 標記為臟即可,而不用寫到內存里。

這樣的好處是,如果大量的操作都能夠命中緩存,那么大部分時間里 CPU 都不需要讀寫內存,自然性 能相比寫直達會高很多。

緩存一致性問題

現在的CPU都是多核的,由於L1/L2cache是各個核心獨有的,那么會帶來多核心的緩存一致性問題,如果不能保證緩存一致性問題就會造成錯誤的結果

那緩存一致性的問題具體是怎么發生的呢?以一個含有兩個核心的 CPU 作為例子看一看。

假設 A 號核心和 B 號核心同時運行兩個線程,都操作共同的變量 i(初始值為 0 )。

 

 

 這時如果 A 號核心執行了 i++ 語句的時候,為了考慮性能,使用了前面所說的寫回策略,先把值為 1 的執行結果寫入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中對應的 Block 標記為臟的,這個時候數據其實沒有被同步到內存中的,因為寫回策略,只有在 A 號核心中的這個 Cache Block 要被替換的時候,數據才會寫入到內存里。

如果這時旁邊的 B 號核心嘗試從內存讀取 i 變量的值,則讀到的將會是錯誤的值,因為剛才 A 號核心更新 i 值還沒寫入到內存中,內存中的值還依然是 0。這個就是所謂的緩存一致性問題,A 號核心和 B 號核心的緩存,在這個時候是不一致,從而會導致執行結果的錯誤。

 

 

 那么,要解決這一問題,就需要一種機制,來同步兩個不同核心里面的緩存數據。要實現的這個機制的話,要保證做到下面這 2 點:

  • 第一點,某個 CPU 核心里的 Cache 數據更新時,必須要傳播到其他核心的 Cache,這個稱為寫傳播(*Wreite Propagation*)
  • 第二點,某個 CPU 核心里對數據的操作順序,必須在其他核心看起來順序是一樣的,這個稱為事務的串形化(*Transaction Serialization*)

第一點寫傳播很容易就理解,當某個核心在 Cache 更新了數據,就需要同步到其他核心的 Cache 里。

而對於第二點事務的串形化,舉個例子來理解。

假設有一個含有 4 個核心的 CPU,這 4 個核心都操作共同的變量 i(初始值為 0 )。A 號核心先把 i 值變為 100,而此時同一時間,B 號核心先把 i 值變為 200,這里兩個修改,都會「傳播」到 C 和 D 號核心。

 

 

 那么問題就來了,C 號核心先收到了 A 號核心更新數據的事件,再收到 B 號核心更新數據的事件,因此 C 號核心看到的變量 i 是先變成 100,后變成 200。

而如果 D 號核心收到的事件是反過來的,則 D 號核心看到的是變量 i 先變成 200,再變成 100,雖然是做到了寫傳播,但是各個 Cache 里面的數據還是不一致的。

所以,要保證 C 號核心和 D 號核心都能看到相同順序的數據變化,比如變量 i 都是先變成 100,再變成 200,這樣的過程就是事務的串形化。

要實現事務串形化,要做到 2 點:

  • CPU 核心對於 Cache 中數據的操作,需要同步給其他 CPU 核心;
  • 要引入「鎖」的概念,如果兩個 CPU 核心里有相同數據的 Cache,那么對於這個 Cache 數據的更新,只有拿到了「鎖」,才能進行對應的數據更新。

那接下來看看,寫傳播和事務串形化具體是用什么技術實現的。

總線

寫傳播的原則就是當某個 CPU 核心更新了 Cache 中的數據,要把該事件廣播通知到其他核心。最常⻅實 現的方式是總線嗅探(Bus Snooping)。

還是以前面的 i 變量例子來說明總線嗅探的工作機制,當 A 號 CPU 核心修改了 L1 Cache 中 i 變量的 值,通過總線把這個事件廣播通知給其他所有的核心,然后每個 CPU 核心都會監聽總線上的廣播事件,並 檢查是否有相同的數據在自己的 L1 Cache 里面,如果 B 號 CPU 核心的 L1 Cache 中有該數據,那么也需 要把該數據更新到自己的 L1 Cache。

可以發現,總線嗅探方法很簡單, CPU 需要每時每刻監聽總線上的一切活動,但是不管別的核心的 Cache 是否緩存相同的數據,都需要發出一個廣播事件,這無疑會加重總線的負載。

另外,總線嗅探只是保證了某個 CPU 核心的 Cache 更新數據這個事件能被其他 CPU 核心知道,但是並 不能保證事務串形化。

於是,有一個協議基於總線嗅探機制實現了事務串形化,也用狀態機機制降低了總線帶寬壓力,這個協議 就是 MESI 協議,這個協議就做到了 CPU 緩存一致性。

MESI協議

MESI 協議其實是 4 個狀態單詞的開頭字母縮寫,分別是:

  • Modified,已修改
  • Exclusive,獨占
  • Shared,共享
  • Invalidated,已失效

這四個狀態來標記 Cache Line 四個不同的狀態。

「已修改」狀態就是前面提到的臟標記,代表該 Cache Block 上的數據已經被更新過,但是還沒有寫到內存里。而「已失效」狀態,表示的是這個 Cache Block 里的數據已經失效了,不可以讀取該狀態的數據。

「獨占」和「共享」狀態都代表 Cache Block 里的數據是干凈的,也就是說,這個時候 Cache Block 里的數據和內存里面的數據是一致性的。

「獨占」和「共享」的差別在於,獨占狀態的時候,數據只存儲在一個 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 沒有該數據。這個時候,如果要向獨占的 Cache 寫數據,就可以直接自由地寫入,而不需要通知其他 CPU 核心,因為只有這有這個數據,就不存在緩存一致性的問題了,於是就可以隨便操作該數據。

另外,在「獨占」狀態下的數據,如果有其他核心從內存讀取了相同的數據到各自的 Cache ,那么這個時候,獨占狀態下的數據就會變成共享狀態。

那么,「共享」狀態代表着相同的數據在多個 CPU 核心的 Cache 里都有,所以當要更新 Cache 里面的數據的時候,不能直接修改,而是要先向所有的其他 CPU 核心廣播一個請求,要求先把其他核心的 Cache 中對應的 Cache Line 標記為「無效」狀態,然后再更新當前 Cache 里面的數據。

舉個例子

當 A 號 CPU 核心從內存讀取變量 i 的值,數據被緩存在 A 號 CPU 核心自己的 Cache 里面,此時其他 CPU 核心的 Cache 沒有緩存該數據,於是標記 Cache Line 狀態為「獨占」,此時其 Cache 中的數據與內存是一致的;

然后 B 號 CPU 核心也從內存讀取了變量 i 的值,此時會發送消息給其他 CPU 核心,由於 A 號 CPU 核心已經緩存了該數據,所以會把數據返回給 B 號 CPU 核心。在這個時候, A 和 B 核心緩存了相同的數據,Cache Line 的狀態就會變成「共享」,並且其 Cache 中的數據與內存也是一致的;

當 A 號 CPU 核心要修改 Cache 中 i 變量的值,發現數據對應的 Cache Line 的狀態是共享狀態,則要向所有的其他 CPU 核心廣播一個請求,要求先把其他核心的 Cache 中對應的 Cache Line 標記為「無效」狀態,然后 A 號 CPU 核心才更新 Cache 里面的數據,同時標記 Cache Line 為「已修改」狀態,此時 Cache 中的數據就與內存不一致了。

如果 A 號 CPU 核心「繼續」修改 Cache 中 i 變量的值,由於此時的 Cache Line 是「已修改」狀態,因此不需要給其他 CPU 核心發送消息,直接更新數據即可。

如果 A 號 CPU 核心的 Cache 里的 i 變量對應的  Cache Line 要被「替換」,發現  Cache Line 狀態是「已修改」狀態,就會在替換前先把數據同步到內存。

所以,可以發現當 Cache Line 狀態是「已修改」或者「獨占」狀態時,修改更新其數據不需要發送廣播給其他 CPU 核心,這在一定程度上減少了總線帶寬壓力。

事實上,整個 MESI 的狀態可以用一個有限狀態機來表示狀態流轉。還有一點,對於不同狀態觸發的事件操作,可能是來自本地 CPU 核心發出的廣播事件,也可以是來自其他 CPU 核心通過總線發出的廣播事件。下圖即是 MESI 協議的狀態圖:

 

 

 MESI 協議的四種狀態之間的流轉過程,匯總成了下面的表格,可以更詳細的看到每個狀態轉換的原因:

 

 

 mesi可視化

MESI 緩存一致性協議

這個 VivioJS 動畫旨在幫助了解 MESI 緩存一致性協議。

描述了一個多處理器系統,包括 3 個具有本地高速緩存和主存儲器的 CPU。為簡單起見,主存儲器包括 4 個位置 a0、a1、a2 和 a3。緩存是直接映射的並且包含兩個集合。偶數地址(a0 和 a2)映射到集合 0,而奇數地址(a1 和 a3)映射到集合 1。

注意:為了簡化這個動畫,緩存行的大小和 CPU 讀/寫操作的大小是相同的。然而,在寫未命中時,CPU 會讀取內存,即使完全覆蓋高速緩存行。這模擬了實際緩存的行為,其中緩存行的大小通常大於 CPU 讀/寫操作的大小。

 

 

 每個 CPU 都包含在指定內存位置啟動讀取或寫入事務的按鈕。“CPU 寫入”將遞增值(最初為 1)寫入“內存”。

這個想法是按下按鈕,看看是否可以跟隨發生的動作和狀態轉換。按下右上角的“無錯誤”按鈕可以將錯誤引入動畫。看看是否可以確定錯誤是什么!

地址總線和數據總線上的流量方向分別用藍色和紅色箭頭表示。事務中涉及的高速緩存行和內存位置為綠色。陳舊的內存位置是灰色的。

高速緩存行可以處於 4 種狀態之一。無效:緩存中不存在緩存行。獨占:緩存行僅存在於此緩存中,與內存中的副本相同。已修改:僅此緩存中存在緩存行且內存副本已過期(陳舊)。 SHARED:此緩存和可能的其他緩存中的緩存行,所有副本與內存副本相同。對SHARED高速緩存行的寫入是直寫,而對EXCLUSIVE高速緩存行的寫入是回寫。如果高速緩存觀察到包含的地址的總線事務,將斷言共享總線線路。MESI 是一種無效的緩存一致性協議。

這是緩存行的狀態轉換圖:

 

 

 

 

 https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESIHelp.htm

MMU

百度百科:MMU是Memory Management Unit的縮寫,中文名是內存管理單元,有時稱作分頁內存管理單元(英語:paged memory management unit,縮寫為PMMU)。一種負責處理中央處理器(CPU)的內存訪問請求的計算機硬件。

為什么需要mmu?

在單片機時代,是沒有操作系統的,每次寫完代碼都需要借助工具把程序燒進去,這樣程序才能跑起來,單片機的CPU是直接操作內存的物理地址

在這種情況下要想在內存中同時運行兩個程序是不可能的。比如第一個程序在2000這個寫入一個新的值,將會擦掉第二個程序存放在相同位置的內容。

所以操作系統引入虛擬地址,進程都有自己的,互不干涉。

操作系統會提供一種機制,將不同進程的虛擬地址和不同內存的物理地址映射起來。如果程序要訪問虛擬地址的時候,由操作系統轉換成不同的物理地址,這樣不同的進程運行的時候,寫入
的是不同的物理地址,這樣就不會沖突了。

在現在的硬件情況下,雖然內存在一步步的變大,但是對應的程序使用的內存也在一步步變大,這個時候虛擬內存就可以提供遠遠超實際內存限制的空間,使計算機能夠同時執行更多的程序。

這個edge瀏覽器占用的內存

 

 

 mmu的好處

  1. 為編程提供方便統一的內存空間抽象,在應用開發而言,好似都完全擁有各自獨立的用戶內存空間的訪問權限,這樣隱藏了底層實現細節,提供了統一可移植用戶抽象。
  2. 以最小的開銷換取性能最大化,利用MMU管理內存肯定不如直接對內存進行訪問效率高,為什么需要用這樣的機制進行內存管理,是因為並發進程每個進程都擁有完整且相互獨立的內存空間。那么實際上內存是昂貴的,即使內存成本遠比從前便宜,但是應用進程對內存的尋求仍然無法在實際硬件中,設計足夠大的內存實現直接訪問,即使能滿足,CPU利用地址總線直接尋址空間也是有限的。
  3. 其實更重要的不是擴展了內存而是給每個程序提供了一個連續的內存空間,降低了操作內存的復雜性。

實際上虛擬內存可以實現的是 將內存看作一個存儲在硬盤上的虛擬地址空間的高速緩存,並且只在內存中緩存活動區域(比如一個瀏覽器打開需要200mb內存 每個頁面需要200內存 瀏覽器即使開十幾個頁面,內存占用也只是400mb,當然這是一個簡單的例子)

CPU尋址

內存通常被組織為一個由M個連續的字節大小的單元組成的數組,每個字節都有一個唯一的物理地址(Physical Address PA),作為到數組的索引。CPU訪問內存最簡單直接的方法就是使用物理地址,這種尋址方式被稱為物理尋址。

現代處理器使用的是一種稱為虛擬尋址(Virtual Addressing)的尋址方式。使用虛擬尋址,CPU需要將虛擬地址翻譯成物理地址,這樣才能訪問到真實的物理內存。

 

 

 CPU:Central Processing Unit

MMU:Memory Management Unit

TLB:Translation Lookaside Buffer

虛擬地址

虛擬尋址需要硬件與操作系統之間互相合作。CPU中含有一個被稱為內存管理單元(Memory Management Unit, MMU)的硬件,功能是將虛擬地址轉換為物理地址。MMU需要借助存放在內存中的頁表來動態翻譯虛擬地址,該頁表由操作系統管理。

分頁

分⻚是把整個虛擬和物理內存空間切成一段段固定尺寸的大小。這樣一個連續並且尺寸固定的內存空間, 叫⻚(Page)。在 Linux 下,每一⻚的大小為 4KB 。

CPU在獲得虛擬地址之后,需要通過MMU將虛擬地址翻譯為物理地址。而在翻譯的過程中還需要借助頁表,所謂頁表就是一個存放在物理內存中的數據結構,記錄了虛擬頁與物理頁的映射關系。

頁表是一個元素為頁表條目(Page Table Entry, PTE)的集合,每個虛擬頁在頁表中一個固定偏移量的位置上都有一個PTE。下面是PTE僅含有一個有效位標記的頁表結構,該有效位代表這個虛擬頁是否被緩存在物理內存中。

 

 image-20211228160304209

虛擬頁VP 0、VP 4、VP 6、VP 7被緩存在物理內存中,虛擬頁VP 2和VP 5被分配在頁表中,但並沒有緩存在物理內存,虛擬頁VP 1和VP 3還沒有被分配。

在進行動態內存分配時,例如malloc()函數或者其他高級語言中的new關鍵字,操作系統會在硬盤中創建或申請一段虛擬內存空間,並更新到頁表(分配一個PTE,使該PTE指向硬盤上這個新創建的虛擬頁)。

由於CPU每次進行地址翻譯的時候都需要經過PTE,所以如果想控制內存系統的訪問,可以在PTE上添加一些額外的許可位(例如讀寫權限、內核權限等),這樣只要有指令違反了這些許可條件,CPU就會觸發一個一般保護故障,將控制權傳遞給內核中的異常處理程序。一般這種異常被稱為“段錯誤(Segmentation Fault)”。

頁命中

 

 image-20211228160408348

如上圖所示,MMU根據虛擬地址在頁表中尋址到了PTE 4,該PTE的有效位為1,代表該虛擬頁已經被緩存在物理內存中了,最終MMU得到了PTE中的物理內存地址(指向PP 1)。

缺頁

 

 image-20211228160442595

如上圖所示,MMU根據虛擬地址在頁表中尋址到了PTE 2,該PTE的有效位為0,代表該虛擬頁並沒有被緩存在物理內存中。虛擬頁沒有被緩存在物理內存中(緩存未命中)被稱為缺頁。

當CPU遇見缺頁時會觸發一個缺頁異常,缺頁異常將控制權轉向操作系統內核,然后調用內核中的缺頁異常處理程序,該程序會選擇一個犧牲頁,如果犧牲頁已被修改過,內核會先將復制回硬盤(采用寫回機制而不是直寫也是為了盡量減少對硬盤的訪問次數),然后再把該虛擬頁覆蓋到犧牲頁的位置,並且更新PTE。

當缺頁異常處理程序返回時,會重新啟動導致缺頁的指令,該指令會把導致缺頁的虛擬地址重新發送給MMU。由於現在已經成功處理了缺頁異常,所以最終結果是頁命中,並得到物理地址。

這種在硬盤和內存之間傳送頁的行為稱為頁面調度(paging):頁從硬盤換入內存和從內存換出到硬盤。當缺頁異常發生時,才將頁面換入到內存的策略稱為按需頁面調度(demand paging),所有現代操作系統基本都使用的是按需頁面調度的策略。

虛擬內存跟CPU高速緩存(或其他使用緩存的技術)一樣依賴於局部性原則。雖然處理缺頁消耗的性能很多(畢竟還是要從硬盤中讀取),而且程序在運行過程中引用的不同虛擬頁的總數可能會超出物理內存的大小,但是局部性原則保證了在任意時刻,程序將趨向於在一個較小的活動頁面(active page)集合上工作,這個集合被稱為工作集(working set)。根據空間局部性原則(一個被訪問過的內存地址以及其周邊的內存地址都會有很大幾率被再次訪問)與時間局部性原則(一個被訪問過的內存地址在之后會有很大幾率被再次訪問),只要將工作集緩存在物理內存中,接下來的地址翻譯請求很大幾率都在其中,從而減少了額外的硬盤流量。

如果一個程序沒有良好的局部性,將會使工作集的大小不斷膨脹,直至超過物理內存的大小,這時程序會產生一種叫做抖動(thrashing)的狀態,頁面會不斷地換入換出,如此多次的讀寫硬盤開銷,性能自然會十分“恐怖”。所以,想要編寫出性能高效的程序,首先要保證程序的時間局部性與空間局部性。

多級頁表

在 32 位的環境下,虛擬地址空間共有 4GB,假設一個⻚的大小是 4KB(2^12),那么就需要大約 100 萬 (2^20) 個⻚,每個「⻚表項」需要 4 個字節大小來存儲,那么整個 4GB 空間的映射就需要有 4MB 的內存來存儲⻚表。

這 4MB 大小的⻚表,看起來也不是很大。但是要知道每個進程都是有自己的虛擬地址空間的,也就說都有 自己的⻚表。

那么, 100 個進程的話,就需要 400MB 的內存來存儲⻚表,這是非常大的內存了,更別說 64 位的環 境了。

要解決上面的問題,就需要采用一種叫作多級⻚表(Multi-Level Page Table)的解決方案。

把這個 100 多萬個「⻚表項」的單級⻚表再分⻚,將⻚表(一級⻚表)分為 1024 個⻚表(二級⻚ 表),每個表(二級⻚表)中包含 1024 個「⻚表項」,形成二級分⻚。

雖然分了二級表,映射 4GB 地址空間就需要 4KB(一級⻚表)+ 4MB(二級⻚表)的內存,這樣 占用空間不是更大了嗎?

當然如果 4GB 的虛擬地址全部都映射到了物理內存上的話,二級分⻚占用空間確實是更大了,但是, 往往不會為一個進程分配那么多內存。

如果使用了二級分⻚,一級⻚表就可以覆蓋整個 4GB 虛擬地址空間,但如果某個一級⻚表的⻚表項沒有被 用到,也就不需要創建這個⻚表項對應的二級⻚表了,即可以在需要時才創建二級⻚表。做個簡單的計 算,假設只有 20% 的一級⻚表項被用到了,那么⻚表占用的內存空間就只有 4KB(一級⻚表) + 20% * 4MB(二級⻚表)= 0.804MB ,這對比單級⻚表的 4MB 是巨大的節約。

 

 

 這個結構看起來很像是一個B-Tree,這種層次結構有效的減緩了內存要求:

  • 如果一個一級頁表的一個PTE是空的,那么相應的二級頁表也不會存在。這代表一種巨大的潛在節約(對於一個普通的程序來說,虛擬地址空間的大部分都會是未分配的)。
  • 只有一級頁表才總是需要緩存在內存中的,這樣虛擬內存系統就可以在需要時創建、頁面調入或調出二級頁表(只有經常使用的二級頁表才會被緩存在內存中),這就減少了內存的壓力。

對於 64 位的系統,兩級分⻚肯定不夠了,就變成了四級目錄,分別是:
全局⻚目錄項 PGD(Page Global Directory); 上層⻚目錄項 PUD(Page Upper Directory); 中間⻚目錄項 PMD(Page Middle Directory); ⻚表項 PTE(Page Table Entry);

地址翻譯的過程

從形式上來說,地址翻譯是一個N元素的虛擬地址空間中的元素和一個M元素的物理地址空間中元素之間的映射。

下圖為MMU利用頁表進行尋址的過程:

 

 

 頁表基址寄存器(PTBR)指向當前頁表。一個n位的虛擬地址包含兩個部分,一個p位的虛擬頁面偏移量(Virtual Page Offset, VPO)和一個(n - p)位的虛擬頁號(Virtual Page Number, VPN)。

MMU根據VPN來選擇對應的PTE,例如VPN 0代表PTE 0、VPN 1代表PTE 1....因為物理頁與虛擬頁的大小是一致的,所以物理頁面偏移量(Physical Page Offset, PPO)與VPO是相同的。那么之后只要將PTE中的物理頁號(Physical Page Number, PPN)與虛擬地址中的VPO串聯起來,就能得到相應的物理地址

多級頁表的地址翻譯也是如此,只不過因為有多個層次,所以VPN需要分成多段。假設有一個k級頁表,虛擬地址會被分割成k個VPN和1個VPO,每個VPN i都是一個到第i級頁表的索引。為了構造物理地址,MMU需要訪問k個PTE才能拿到對應的PPN。

 

 

 TLB

多級⻚表雖然解決了空間上的問題,但是虛擬地址到物理地址的轉換就多了幾道轉換的工序,這顯然就降
低了這倆地址轉換的速度,也就是帶來了時間上的開銷。

又是無處不在的局部性原理

就可以利用這一原理,把最常訪問的幾個⻚表項存儲到訪問速度更快的硬件,於是計算機科學家們, 就在 CPU 芯片中,加入了一個專⻔存放程序最常訪問的⻚表項的 Cache,這個 Cache 就是 TLB (Translation Lookaside Buffer) ,通常稱為⻚表緩存、轉址旁路緩存、快表等。

 

 

 image-20211228161346517

在 CPU 芯片里面,封裝了內存管理單元(Memory Management Unit)芯片,用來完成地址轉換和 TLB 的訪問與交互。

有了 TLB 后,那么 CPU 在尋址時,會先查 TLB,如果沒找到,才會繼續查常規的⻚表。TLB 的命中率其實是很高的,因為程序最常訪問的⻚就那么幾個。

頁表是被緩存在內存中的,盡管內存的速度相對於硬盤來說已經非常快了,但與CPU還是有所差距。為了防止每次地址翻譯操作都需要去訪問內存,CPU使用了高速緩存與TLB來緩存PTE。

在最糟糕的情況下(不包括缺頁),MMU需要訪問內存取得相應的PTE,這個代價大約為幾十到幾百個周期,如果PTE湊巧緩存在L1高速緩存中(如果L1沒有還會從L2中查找,不過忽略多級緩沖區的細節),那么性能開銷就會下降到1個或2個周期。然而,許多系統甚至需要消除即使這樣微小的開銷,TLB由此而生。

 

 

 TLB(Translation Lookaside Buffer, TLB)被稱為翻譯后備緩沖器或翻譯旁路緩沖器,MMU中的一個緩沖區,其中每一行都保存着一個由單個PTE組成的塊。用於組選擇和行匹配的索引與標記字段是從VPN中提取出來的,如果TLB中有T = 2^t個組,那么TLB索引(TLBI)是由VPN的t個最低位組成的,而TLB標記(TLBT)是由VPN中剩余的位組成的。

下圖為地址翻譯的流程(TLB命中的情況下):

 

  • 第一步,CPU將一個虛擬地址交給MMU進行地址翻譯。
  • 第二步和第三步,MMU通過TLB取得相應的PTE。
  • 第四步,MMU通過PTE翻譯出物理地址並發送給高速緩存/內存。
  • 第五步,高速緩存返回數據到CPU(如果緩存命中的話,否則還需要訪問內存)。

當TLB未命中時,MMU必須從高速緩存/內存中取出相應的PTE,並將新取得的PTE存放到TLB(如果TLB已滿會覆蓋一個已經存在的PTE)。

 

 

 Linux中的虛擬內存系統

Linux為每個進程維護了一個單獨的虛擬地址空間。虛擬地址空間分為內核空間與用戶空間,用戶空間包括代碼、數據、堆、共享庫以及棧,內核空間包括內核中的代碼和數據結構,內核空間的某些區域被映射到所有進程共享的物理頁面。Linux也將一組連續的虛擬頁面(大小等於內存總量)映射到相應的一組連續的物理頁面,這種做法為內核提供了一種便利的方法來訪問物理內存中任何特定的位置。

 

 

 Linux將虛擬內存組織成一些區域(也稱為段)的集合,區域的概念允許虛擬地址空間有間隙。一個區域就是已經存在着的已分配的虛擬內存的連續片(chunk)。例如,代碼段、數據段、堆、共享庫段,以及用戶棧都屬於不同的區域,每個存在的虛擬頁都保存在某個區域中,而不屬於任何區域的虛擬頁是不存在的,也不能被進程所引用。

內核為系統中的每個進程維護一個單獨的任務結構(task_struct)。任務結構中的元素包含或者指向內核運行該進程所需的所有信息(PID、指向用戶棧的指針、可執行目標文件的名字、程序計數器等)。

 

  • mm_struct:描述了虛擬內存的當前狀態。pgd指向一級頁表的基址(當內核運行這個進程時,pgd會被存放在CR3控制寄存器,也就是頁表基址寄存器中),mmap指向一個vm_area_structs的鏈表,其中每個vm_area_structs都描述了當前虛擬地址空間的一個區域。
  • vm_starts:指向這個區域的起始處。
  • vm_end:指向這個區域的結束處。
  • vm_prot:描述這個區域內包含的所有頁的讀寫許可權限。
  • vm_flags:描述這個區域內的頁面是與其他進程共享的,還是這個進程私有的以及一些其他信息。
  • vm_next:指向鏈表的下一個區域結構。

內存映射

Linux通過將一個虛擬內存區域與一個硬盤上的文件關聯起來,以初始化這個虛擬內存區域的內容,這個過程稱為內存映射(memory mapping)。這種將虛擬內存系統集成到文件系統的方法可以簡單而高效地把程序和數據加載到內存中。

一個區域可以映射到一個普通硬盤文件的連續部分,例如一個可執行目標文件。文件區(section)被分成頁大小的片,每一片包含一個虛擬頁的初始內容。由於按需頁面調度的策略,這些虛擬頁面沒有實際交換進入物理內存,直到CPU引用的虛擬地址在該區域的范圍內。如果區域比文件區要大,那么就用零來填充這個區域的余下部分。

一個區域也可以映射到一個匿名文件,匿名文件是由內核創建的,包含的全是二進制零。當CPU第一次引用這樣一個區域內的虛擬頁面時,內核就在物理內存中找到一個合適的犧牲頁面,如果該頁面被修改過,就先寫回到硬盤,之后用二進制零覆蓋犧牲頁並更新頁表,將這個頁面標記為已緩存在內存中的。

簡單的來說:普通文件映射就是將一個文件與一塊內存建立起映射關系,對該文件進行IO操作可以繞過內核直接在用戶態完成(用戶態在該虛擬地址區域讀寫就相當於讀寫這個文件)。匿名文件映射一般在用戶空間需要分配一段內存來存放數據時,由內核創建匿名文件並與內存進行映射,之后用戶態就可以通過操作這段虛擬地址來操作內存了。匿名文件映射最熟悉的應用場景就是動態內存分配(malloc()函數)。

Linux很多地方都采用了“懶加載”機制,自然也包括內存映射。不管是普通文件映射還是匿名映射,Linux只會先划分虛擬內存地址。只有當CPU第一次訪問該區域內的虛擬地址時,才會真正的與物理內存建立映射關系。

只要虛擬頁被初始化了,就在一個由內核維護的交換文件(swap file)之間換來換去。交換文件又稱為交換空間(swap space)或交換區域(swap area)。swap區域不止用於頁交換,在物理內存不夠的情況下,還會將部分內存數據交換到swap區域(使用硬盤來擴展內存)。

共享對象

虛擬內存系統為每個進程提供了私有的虛擬地址空間,這樣可以保證進程之間不會發生錯誤的讀寫。但多個進程之間也含有相同的部分,例如每個C程序都使用到了C標准庫,如果每個進程都在物理內存中保持這些代碼的副本,那會造成很大的內存資源浪費。

內存映射提供了共享對象的機制,來避免內存資源的浪費。一個對象被映射到虛擬內存的一個區域,要么是作為共享對象,要么是作為私有對象的。

如果一個進程將一個共享對象映射到虛擬地址空間的一個區域內,那么這個進程對這個區域的任何寫操作,對於那些也把這個共享對象映射到虛擬內存的其他進程而言,也是可見的。相對的,對一個映射到私有對象的區域的任何寫操作,對於其他進程來說是不可見的。一個映射到共享對象的虛擬內存區域叫做共享區域,類似地,也有私有區域。

為了節約內存,私有對象開始的生命周期與共享對象基本上是一致的(在物理內存中只保存私有對象的一份副本),並使用寫時復制的技術來應對多個進程的寫沖突。

 

 

 只要沒有進程試圖寫私有區域,那么多個進程就可以繼續共享物理內存中私有對象的一個單獨副本。然而,只要有一個進程試圖對私有區域的某一頁面進行寫操作,就會觸發一個保護異常。在上圖中,進程B試圖對私有區域的一個頁面進行寫操作,該操作觸發了保護異常。異常處理程序會在物理內存中創建這個頁面的一個新副本,並更新PTE指向這個新的副本,然后恢復這個頁的可寫權限。

還有一個典型的例子就是fork()函數,該函數用於創建子進程。當fork()函數被當前進程調用時,內核會為新進程創建各種必要的數據結構,並分配一個唯一的PID。為了給新進程創建虛擬內存,復制了當前進程的mm_structvm_area_struct和頁表的原樣副本。並將兩個進程的每個頁面都標為只讀,兩個進程中的每個區域都標記為私有區域(寫時復制)。

這樣,父進程和子進程的虛擬內存空間完全一致,只有當這兩個進程中的任一個進行寫操作時,再使用寫時復制來保證每個進程的虛擬地址空間私有的抽象概念。

動態內存分配

雖然可以使用內存映射(mmap()函數)來創建和刪除虛擬內存區域來滿足運行時動態內存分配的問題。然而,為了更好的移植性與便利性,還需要一個更高層面的抽象,也就是動態內存分配器(dynamic memory allocator)。

動態內存分配器維護着一個進程的虛擬內存區域,也就是所熟悉的“堆(heap)”,內核中還維護着一個指向堆頂的指針brk(break)。動態內存分配器將堆視為一個連續的虛擬內存塊(chunk)的集合,每個塊有兩種狀態,已分配和空閑。已分配的塊顯式地保留為供應用程序使用,空閑塊則可以用來進行分配,空閑狀態直到顯式地被應用程序分配為止。已分配的塊要么被應用程序顯式釋放,要么被垃圾回收器所釋放。

 

 

 本文只講解動態內存分配的一些概念,關於動態內存分配器的實現已經超出了本文的討論范圍。如果有感興趣的同學,可以去參考dlmalloc的源碼,由Doug Lea(就是寫Java並發包的那位)實現的一個設計巧妙的內存分配器,而且源碼中的注釋十分多。

內存碎片

造成堆的空間利用率很低的主要原因是一種被稱為碎片(fragmentation)的現象,當雖然有未使用的內存但這塊內存並不能滿足分配請求時,就會產生碎片。有以下兩種形式的碎片:

  • 內部碎片:在一個已分配塊比有效載荷大時發生。例如,程序請求一個5字(這里不糾結字的大小,假設一個字為4字節,堆的大小為16字並且要保證邊界雙字對齊)的塊,內存分配器為了保證空閑塊是雙字邊界對齊的(具體實現中對齊的規定可能略有不同,但對齊是肯定會有的),只好分配一個6字的塊。在本例中,已分配塊為6字,有效載荷為5字,內部碎片為已分配塊減去有效載荷,為1字。
  • 外部碎片:當空閑內存合計起來足夠滿足一個分配請求,但是沒有一個單獨的空閑塊足夠大到可以來處理這個請求時發生。外部碎片難以量化且不可預測,所以分配器通常采用啟發式策略來試圖維持少量的大空閑塊,而不是維持大量的小空閑塊。分配器也會根據策略與分配請求的匹配來分割空閑塊與合並空閑塊(必須相鄰)。

空閑鏈表

分配器將堆組織為一個連續的已分配塊和空閑塊的序列,該序列被稱為空閑鏈表。空閑鏈表分為隱式空閑鏈表與顯式空閑鏈表。

  • 隱式空閑鏈表,是一個單向鏈表,並且每個空閑塊僅僅是通過頭部中的大小字段隱含地連接着的。
  • 顯式空閑鏈表,即是將空閑塊組織為某種形式的顯式數據結構(為了更加高效地合並與分割空閑塊)。例如,將堆組織為一個雙向空閑鏈表,在每個空閑塊中,都包含一個前驅節點的指針與后繼節點的指針。

查找一個空閑塊一般有如下幾種策略:

  • 首次適配:從頭開始搜索空閑鏈表,選擇第一個遇見的合適的空閑塊。優點在於趨向於將大的空閑塊保留在鏈表的后面,缺點是趨向於在靠近鏈表前部處留下碎片。
  • 下一次適配:每次從上一次查詢結束的地方開始進行搜索,直到遇見合適的空閑塊。這種策略通常比首次適配效率高,但是內存利用率則要低得多了。
  • 最佳適配:檢查每個空閑塊,選擇適合所需請求大小的最小空閑塊。最佳適配的內存利用率是三種策略中最高的,但需要對堆進行徹底的搜索。

對一個鏈表進行查找操作的效率是線性的,為了減少分配請求對空閑塊匹配的時間,分配器通常采用分離存儲(segregated storage)的策略,即是維護多個空閑鏈表,其中每個鏈表的塊有大致相等的大小。

一種簡單的分離存儲策略:分配器維護一個空閑鏈表數組,然后將所有可能的塊分成一些等價類(也叫做大小類(size class)),每個大小類代表一個空閑鏈表,並且每個大小類的空閑鏈表包含大小相等的塊,每個塊的大小就是這個大小類中最大元素的大小(例如,某個大小類的范圍定義為(17~32),那么這個空閑鏈表全由大小為32的塊組成)。

當有一個分配請求時,檢查相應的空閑鏈表。如果鏈表非空,那么就分配其中第一塊的全部。如果鏈表為空,分配器就向操作系統請求一個固定大小的額外內存片,將這個片分成大小相等的塊,然后將這些塊鏈接起來形成新的空閑鏈表。

要釋放一個塊,分配器只需要簡單地將這個塊插入到相應的空閑鏈表的頭部。

垃圾回收

在編寫C程序時,一般只能顯式地分配與釋放堆中的內存(malloc()free()),程序員不僅需要分配內存,還需要負責內存的釋放。

許多現代編程語言都內置了自動內存管理機制(通過引入自動內存管理庫也可以讓C/C++實現自動內存管理),所謂自動內存管理,就是自動判斷不再需要的堆內存(被稱為垃圾內存),然后自動釋放這些垃圾內存。

自動內存管理的實現是垃圾收集器(garbage collector),一種動態內存分配器,會自動釋放應用程序不再需要的已分配塊。

垃圾收集器一般采用以下兩種(之一)的策略來判斷一塊堆內存是否為垃圾內存:

  • 引用計數器:在數據的物理空間中添加一個計數器,當有其他數據與其相關時(引用),該計數器加一,反之則減一。通過定期檢查計數器的值,只要為0則認為是垃圾內存,可以釋放所占用的已分配塊。使用引用計數器,實現簡單直接,但缺點也很明顯,無法回收循環引用的兩個對象(假設有對象A與對象B,2個互相引用,但實際上對象A與對象B都已經是沒用的對象了)。
  • 可達性分析:垃圾收集器將堆內存視為一張有向圖,然后選出一組根節點(例如,在Java中一般為類加載器、全局變量、運行時常量池中的引用類型變量等),根節點必須是足夠“活躍“的對象。然后計算從根節點集合出發的可達路徑,只要從根節點出發不可達的節點,都視為垃圾內存。

垃圾收集器進行回收的算法有如下幾種:

  • 標記-清除:該算法分為標記(mark)和清除(sweep)兩個階段。首先標記出所有需要回收的對象,然后在標記完成后統一回收所有被標記的對象。標記-清除算法實現簡單,但效率不高,而且會產生許多內存碎片。
  • 標記-整理:標記-整理與標記-清除算法基本一致,只不過后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉邊界以外的內存。
  • 復制:將程序所擁有的內存空間划分為大小相等的兩塊,每次都只使用其中的一塊。當這一塊的內存用完了,就把還存活着的對象復制到另一塊內存上,然后將已使用過的內存空間進行清理。這種方法不必考慮內存碎片問題,但內存利用率很低。這個比例不是絕對的,像HotSpot虛擬機為了避免浪費,將內存划分為Eden空間與兩個Survivor空間,每次都只使用Eden和其中一個Survivor。當回收時,將Eden和Survivor中還存活着的對象一次性地復制到另外一個Survivor空間上,然后清理掉Eden和剛才使用過的Survivor空間。HotSpot虛擬機默認的Eden和Survivor的大小比例為8:1,只有10%的內存空間會被閑置浪費。
  • 分代:分代算法根據對象的存活周期的不同將內存划分為多塊,這樣就可以對不同的年代采用不同的回收算法。一般分為新生代與老年代,新生代存放的是存活率較低的對象,可以采用復制算法;老年代存放的是存活率較高的對象,如果使用復制算法,那么內存空間會不夠用,所以必須使用標記-清除或標記-整理算法。

總結

虛擬內存是對內存的一個抽象。支持虛擬內存的CPU需要通過虛擬尋址的方式來引用內存中的數據。CPU加載一個虛擬地址,然后發送給MMU進行地址翻譯。地址翻譯需要硬件與操作系統之間緊密合作,MMU借助頁表來獲得物理地址。

  • 首先,MMU先將虛擬地址發送給TLB以獲得PTE(根據VPN尋址)。
  • 如果恰好TLB中緩存了該PTE,那么就返回給MMU,否則MMU需要從高速緩存/內存中獲得PTE,然后更新緩存到TLB。
  • MMU獲得了PTE,就可以從PTE中獲得對應的PPN,然后結合VPO構造出物理地址。
  • 如果在PTE中發現該虛擬頁沒有緩存在內存,那么會觸發一個缺頁異常。缺頁異常處理程序會把虛擬頁緩存進物理內存,並更新PTE。異常處理程序返回后,CPU會重新加載這個虛擬地址,並進行翻譯。

虛擬內存系統簡化了內存管理、鏈接、加載、代碼和數據的共享以及訪問權限的保護:

  • 簡化鏈接,獨立的地址空間允許每個進程的內存映像使用相同的基本格式,而不管代碼和數據實際存放在物理內存的何處。
  • 簡化加載,虛擬內存使向內存中加載可執行文件和共享對象文件變得更加容易。
  • 簡化共享,獨立的地址空間為操作系統提供了一個管理用戶進程和內核之間共享的一致機制。
  • 訪問權限保護,每個虛擬地址都要經過查詢PTE的過程,在PTE中設定訪問權限的標記位從而簡化內存的權限保護。

操作系統通過將虛擬內存與文件系統結合的方式,來初始化虛擬內存區域,這個過程稱為內存映射。應用程序顯式分配內存的區域叫做堆,通過動態內存分配器來直接操作堆內存。

參考文獻


參考

  1. https://juejin.cn/post/6844903507594575886
  2. https://cloud.tencent.com/developer/article/1921341#:~:text=MMU
  3. https://gitlib.com/page/pc-mmu.html

參考鏈接:

https://juejin.cn/post/6844903507594575886

https://www.zhihu.com/question/55854401/answer/2292909472


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM