[Golang三關-典藏版]一站式Golang記憶體洗髓經

劉丹冰Aceld發表於2022-05-23

本篇文章已收錄於《Golang修養之路》www.yuque.com/aceld/golang/ithv8f 第一章第9篇

Golang的記憶體管理及設計也是開發者需要了解的領域之一,要理解 Go 語言的記憶體管理,就必須先理解作業系統以及機器硬體是如何管理記憶體的。因為 Go 語言的內部機制是建立在這個基礎之上的,它的設計,本質上就是儘可能的會發揮作業系統層面的優勢,而避開導致低效情況。

本章節會圍繞以下六個話題逐步展開。

(1)何為記憶體。

(2)記憶體為什麼需要管理。

(3)作業系統是如何管理記憶體的。

(4)如何用Golang自己實現一個記憶體管理模型。

(5)Golang記憶體管理之魂:TCMalloc。

(6)Golang中是如何管理記憶體的。

1 何為記憶體

說到記憶體,及時沒有任何的軟體基礎知識,那麼第一印象應該想到的是如下實物,如圖1所示。

圖1 實體記憶體條

圖1中常被稱之為記憶體條,是計算機硬體組成的一個部分,也是真正給軟體提供記憶體的物理空間。如果計算機沒有記憶體條,那麼根本談不上有記憶體之說。

那麼記憶體的作用在於什麼呢?如果將計算機的儲存媒介中的處理效能與容量做一個對比,會出現如下的金字塔模型,如圖2所示。

圖2 計算機儲存媒介金字塔模型

從圖中可以得出處理速度與儲存容量是成反比的。也就是說,效能越大的計算機硬體資源,越是稀缺,所以合理的利用和分配就越重要。

比如記憶體與硬碟的對比,因為硬碟的容量是非常廉價的,雖然記憶體目前也可以用到10G級別的使用,但是從處理速度來看的話,兩者的差距還是相差甚大的,具體如表1所示。

表1 硬碟與記憶體對比表
DDR3**記憶體讀寫速度大概10G/s10000M)** DDR4**記憶體讀寫速度大概50G/s50000M)**
固態硬碟速度是300M/s,是記憶體的三十分之一 固態硬碟速度是300M/s,是記憶體的二百分之一
機械硬碟的速度是100M/s,是記憶體的百分之一 機械硬碟的速度是100M/s,是記憶體的五百分之一

所以將大部分程式邏輯臨時用的資料,全部都存在記憶體之中,比如,變數、全域性變數、函式跳轉地址、靜態庫、執行程式碼、臨時開闢的記憶體結構體(物件)等。

2 記憶體為什麼需要管理

當儲存的東西越來越多,也就發現實體記憶體的容量依然是不夠用,那麼對實體記憶體的利用率和合理的分配,管理就變得非常的重要。

(1)作業系統就會對記憶體進行非常詳細的管理。

(2)基於作業系統的基礎上,不同語言的記憶體管理機制也應允而生,有的一些語言並沒有提供自動的記憶體管理模式,有的語言就已經提供了自身程式的記憶體管理模式,如表2所示。

表2 自動與非自動記憶體管理的語言
記憶體自動管理的語言(部分) 記憶體非自動管理的語言(部分)
Golang C
Java C++
Python Rust

所以為了降低記憶體管理的難度,像C、C++這樣的程式語言會完全將分配和回收記憶體的許可權交給開發者,而Rust則是通過生命週期限定開發者對非法許可權記憶體的訪問來自動回收,因而並沒有提供自動管理的一套機制。但是像Golang、Java、Python這類為了完全讓開發則關注程式碼邏輯本身,語言層提供了一套管理模式。因為Golang程式語言給開發者提供了一套記憶體管理模式,所以開發者有必要了解一下Golang做了哪些助力的功能。

在理解Golang語言層記憶體管理之前,應先了解作業系統針對實體記憶體做了哪些管理的方式。當插上記憶體條之後,通過作業系統是如何將軟體存放在這個綠色的實體記憶體條中去的。

3 作業系統是如何管理記憶體的

計算機對於記憶體真正的載體是實體記憶體條,這個是實打實的物理硬體容量,所以在作業系統中定義這部門的容量叫實體記憶體。

實則實體記憶體的佈局實際上就是一個記憶體大陣列,如圖3所示。

圖3 實體記憶體佈局

每一個元素都會對應一個地址,稱之為實體記憶體地址。那麼CPU在運算的過程中,如果需要從記憶體中取1個位元組的資料,就需要基於這個資料的實體記憶體地址去運算即可,而且實體記憶體地址是連續的,可以根據一個基準地址進行偏移來取得相應的一塊連續記憶體資料。

一個作業系統是不可能只執行一個程式的,那麼這個大陣列實體記憶體勢必要被N個程式分成N分,供每個程式使用。但是程式是活的,一個程式可能一會需要1MB的記憶體,一會又需要1GB的記憶體。作業系統只能取這個程式允許的最大記憶體極限來分配記憶體給這個程式,但這樣會導致每個程式都會多要去一大部分記憶體,而這些多要的記憶體卻大概率不會被使用,如圖4所示。

圖4 實體記憶體分配的困局

當N個程式同時使用同一塊記憶體時,那麼產生讀寫的衝突也在所難免。這樣就會導致這些昂貴的實體記憶體條,幾乎跑不了幾個程式,記憶體的利用率也就提高不上來。

所以就引出了作業系統的記憶體管理方式,作業系統提供了虛擬記憶體來解決這件事。

3.1 虛擬記憶體

所謂虛擬,類似是假、憑空而造的大致意思。對比上圖3.3所示的實體記憶體佈局,虛擬記憶體的大致表現方式如圖5所示。

圖5 虛擬記憶體佈局

虛擬記憶體地址是基於實體記憶體地址之上憑空而造的一個新的邏輯地址,而作業系統暴露給使用者程式的只是虛擬記憶體地址,作業系統內部會對虛擬記憶體地址和真實的實體記憶體地址做對映關係,來管理地址的分配,從而使實體記憶體的利用率提高。

這樣使用者程式(程式)只能使用虛擬的記憶體地址來獲取資料,系統會將這個虛擬地址翻譯成實際的實體地址。這裡面每一個程式統一使用一套連續虛擬地址,比如 0x 0000 0000 ~ 0x ffff ffff。從程式的角度來看,它覺得自己獨享了一整塊記憶體,且不用考慮訪問衝突的問題。系統會將虛擬地址翻譯成實體地址,從記憶體上載入資料。

但如果僅僅把虛擬記憶體直接理解為地址的對映關係,那就是過於低估虛擬記憶體的作用了。

虛擬記憶體的目的是為了解決以下幾件事:

(1)實體記憶體無法被最大化利用。

(2)程式邏輯記憶體空間使用獨立。

(3)記憶體不夠,繼續虛擬磁碟空間。

對於(1),(2)兩點,上述應該已經有一定的描述了,其中針對(1)的最大化,虛擬記憶體還實現了“讀時共享,寫時複製”的機制,可以在物理層同一個位元組的記憶體地址被多個虛擬記憶體空間對映,表現方式如圖6所示。

圖6 讀時共享,寫時複製

上圖所示如果一個程式需要進行寫操作,則這個記憶體將會被複制一份,成為當前程式的獨享記憶體。如果是讀操作,可能會多個程式訪問的物理空間是相同的空間。

如果一個記憶體幾乎大量都是被讀取的,則可能會多個程式共享同一塊實體記憶體,但是他們的各自虛擬記憶體是不同的。當然這個共享並不是永久的,當其中有一個程式對這個記憶體發生寫,就會複製一份,執行寫操作的程式就會將虛擬記憶體地址對映到新的實體記憶體地址上。

對於第(3)點,是虛擬記憶體為了最大化利用實體記憶體,如果程式使用的記憶體足夠大,則導致實體記憶體短暫的供不應求,那麼虛擬記憶體也會“開疆拓土”從磁碟(硬碟)上虛擬出一定量的空間,掛在虛擬地址上,而且這個動作程式本身是不知道的,因為程式只能夠看見自己的虛擬記憶體空間,如圖7所示。

圖7 虛擬記憶體從磁碟對映空間

綜上可見虛擬記憶體的重要性,不僅提高了利用率而且整條記憶體排程的鏈路完全是對使用者態實體記憶體透明,使用者可以安心的使用自身程式獨立的虛擬記憶體空間進行開發。

3.2 MMU記憶體管理單元

那麼對於虛擬記憶體地址是如何對映到實體記憶體地址上的呢?會不會是一個固定匹配地址邏輯處理的?假設使用固定匹配地址邏輯做對映,可能會出現很多虛擬記憶體打到同一個實體記憶體上,如果發現被佔用,則會再重新打。這樣對對映地址定址的代價極大,所以作業系統又加了一層專門用來管理虛擬記憶體和實體記憶體對映關係的東西,就是MMU(Memory Management Unit),如圖8所示。

圖 8 MMU記憶體管理單元

MMU是在CPU裡的,或者說是CPU具有一個記憶體管理單元MMU,下面來介紹一下MMU具體的管理邏輯。

3.3虛擬記憶體本身怎麼存放

虛擬記憶體本身是通過一個叫頁表(Page Table)的東西來實現的,接下來介紹頁和頁表這兩個概念。

1.頁

頁是作業系統中用來描述記憶體大小的一個單位名稱。一個頁的含義是大小為4K(1024*4=4096位元組)的記憶體空間。作業系統對虛擬記憶體空間是按照這個單位來管理的。

2.頁表

頁表實際上就是頁的集合,就是基於頁的一個陣列。頁只是表示記憶體的大小,而頁表條目(PTE[1]), 才是頁表陣列中的一個元素。

為了方便讀者理解,下面用一個抽象的圖來表示頁、頁表、和頁表元素PTE的概念和關係,如圖9所示。

圖 9 頁、頁表、PTE之間的關係

虛擬記憶體的實現方式,大多數都是通過頁表來實現的。作業系統虛擬記憶體空間分成一頁一頁的來管理,每頁的大小為 4K(當然這是可以配置的,不同作業系統不一樣)。磁碟和主記憶體之間的置換也是以為單位來操作的。4K 算是通過實踐折中出來的通用值,太小了會出現頻繁的置換,太大了又浪費記憶體。

虛擬記憶體到實體記憶體的對映關係的儲存結構就是由類似上述圖3.9中的頁表記錄,實則是一個陣列。這裡要注意的是,頁是一次讀取的記憶體單元,但是真正起到虛擬記憶體定址的是PTE,也就是頁表中的一個元素。PTE的大致內部結構如圖10所示。

圖 10 PTE內部構造

可以看出每個PTE是由一個有效位和一個包含物理頁號或者磁碟地址組成,有效位表示當前虛擬頁是否已經被快取在主記憶體中(或者CPU的快取記憶體Cache中)。

虛擬頁為何有會是否已經被快取在主記憶體中一說?虛擬頁表(簡稱頁表)雖然作為虛擬記憶體與實體記憶體的對映關係,但是本身也是需要存放在某個位置上,所以自身本身也是佔用一定記憶體的。所以頁表本身也是被作業系統放在實體記憶體的指定位置。CPU 把虛擬地址給MMU,MMU去實體記憶體中查詢頁表,得到實際的實體地址。當然 MMU 不會每次都去查的,它自己也有一份快取叫Translation Lookaside Buffer (TLB)[2],是為了加速地址翻譯。CPU、MMU與TLB的相互關係如圖11所示。

圖 11 CPU、MMU與TLB的互動關係

從上圖可以看出,TLB是虛擬記憶體頁,即虛擬地址和實體地址對映關係的快取層。MMU當收到地址查詢指令,第一時間是請求TLB的,如果沒有才會進行從記憶體中的虛擬頁進行查詢,這樣可能會觸發多次記憶體讀取,而讀取TLB則不需要記憶體讀取,所程式讀取的步驟順序為:

(1)CPU進行虛擬地址請求MMU。

(2)MMU優先從TLB中得到虛擬頁。

(3)如果得到則返回給上層。

(4)如果沒有則從主存的虛擬頁表中查詢關係。

下面繼續分析PTE的內部構造,根據有效位的特徵可以得到不同的含義如下:

(1)有效位為1,表示虛擬頁已經被快取在記憶體(或者CPU快取記憶體TLB-Cache)中。

(2)有效位為0,表示虛擬頁未被建立且沒有佔用記憶體(或者CPU快取記憶體TLB-Cache),或者表示已經建立虛擬頁但是並沒有儲存到記憶體(或者CPU快取記憶體TLB-Cache)中。

通過上述的標識位,可以將虛擬頁集合分成三個子集,如表3所示。

表3 虛擬頁被分成的三種子集
有效位 集合特徵
1 虛擬記憶體已建立和分配頁,已快取在實體記憶體(或TLB-Cache)中。
0 虛擬記憶體還未分配或建立。
0 虛擬記憶體已建立和分配頁,但未快取在實體記憶體(或TLB-Cache)中。

對於Golang開發者,對虛擬記憶體的儲存結構瞭解到此步即可,如果想更深入的瞭解MMU儲存結果可以翻閱其他作業系統或硬體相關書籍或資料。下面來分析一下在訪問一次記憶體的整體流程。

3.4 CPU記憶體訪問過程

一次CPU記憶體訪問的流程如圖12所示。

圖 12 CPU記憶體訪問的詳細流程

當某個程式進行一次記憶體訪問指令請求,將觸發如圖3.12的記憶體訪問具體的訪問流程如下:

(1)程式將記憶體相關的暫存器指令請求運算髮送給CPU,CPU得到具體的指令請求。

(2)計算指令被CPU載入到暫存器當中,準備執行相關指令邏輯。

(3)CPU對相關可能請求的記憶體生成虛擬記憶體地址。一個虛擬記憶體地址包括虛擬頁號VPN(Virtual Page Number)和虛擬頁偏移量VPO(Virtual Page Offset)[3]

(4)從虛擬地址中得到虛擬頁號VPN。

(5)通過虛擬頁號VPN請求MMU記憶體管理單元。

(6)MMU通過虛擬頁號查詢對應的PTE條目(優先層TLB快取查詢)。

(7)通過得到對應的PTE上的有效位來判斷當前虛擬頁是否在主存中。

(8)如果索引到的PTE條目的有效位為1,則表示命中,將對應PTE上的物理頁號PPN(Physical Page Number)和虛擬地址中的虛擬頁偏移量VPO進行串聯從而構造出主存中的實體地址PA(Physical Address)[4],進入步驟(9)。

(9)通過實體記憶體地址訪問實體記憶體,當前的定址流程結束。

(10)如果有效位為0,則表示未命中,一般稱這種情況為缺頁。此時MMU將產生一個缺頁異常,拋給作業系統。

(11)作業系統捕獲到缺頁異常,開始執行異常處理程式。

(12)此時將選擇一個犧牲頁並將對應的所缺虛擬頁調入並更正新頁表上的PTE,如果當前犧牲頁有資料,則寫入磁碟,得到實體記憶體頁號PPN(Physical Page Number)。

(13)缺頁處理程式更新之前索引到的PTE,並且寫入實體記憶體怒頁號PPN,有效位設定為1。

(14)缺頁處理程式再次返回到原來的程式,且再次執行缺頁指令,CPU重新將虛擬地址發給MMU,此時虛擬頁已經存在實體記憶體中,本次一定會命中,通過(1)~(9)流程,最終將請求的實體記憶體返回給處理器。

以上就是一次CPU訪問記憶體的詳細流程。可以看出來上述流程中,從第(10)步之後的流程就稍微有一些繁瑣。類似產生異常訊號、捕獲異常,再處理缺頁流程,如選擇犧牲頁,還要將犧牲頁的資料儲存到磁碟上等等。所以如果頻繁的執行(10)~(14)步驟會對效能影響很大。因為犧牲頁有可能會涉及到磁碟的訪問,而磁碟的訪問速度非常的慢,這樣就會引發程式效能的急劇下降。

一般從(1)~(9)步流程結束則表示頁命中,反之為未命中,所以就會出現一個新的效能級指標,即命中率。命中率是訪問次數與頁命中次數之比。一般命中率低說明實體記憶體不足,資料在記憶體和磁碟之間交換頻繁,但如果實體記憶體充分,則不會出現頻繁的記憶體顛簸現象。

3.4 記憶體的區域性性

上述瞭解到記憶體的命中率實際上是一衡量每次記憶體訪問均能被頁直接定址到而不是產生缺頁的指標。所以如果經常在一定範圍內的記憶體則出現缺頁的情況就會降低。這就是程式的一種區域性性特性的體現。

區域性性就是在多次記憶體引用的時候,會出現有的記憶體被經常引用多次,而且在該位置附近的其他位置,也有可能接下來被引用到。一般大多數程式都會具備區域性性的特點。

實際上作業系統在設計過程中經常會用到快取來提升效能,或者在設計解決方案等架構的時候也會考慮到快取或者緩衝層的概念,實則就是利用程式或業務天然的區域性性特徵。因為如果沒有區域性性的特性,則快取級別將起不到太大的作用,所以在設計程式或者業務的時候應該多考慮增強程式區域性性的特徵,這樣的程式會更快。

下面是一個非常典型的案例來驗證程式區域性性的程式示例,具體程式碼如下:

package MyGolang

func Loop(nums []int, step int) {
   l := len(nums)
   for i := 0; i < step; i++ {
      for j := i; j < l; j += step {
         nums[j] = 4 //訪問記憶體,並寫入值
      }
   }
}

Loop()函式的功能是遍歷陣列nums,並且將nums中的每個元素均設定為4。但是這裡用了一個step來規定每次遍歷的跨度。可以跟讀上述程式碼,如果step等於1,則外層for迴圈只會執行1次。內層for迴圈則正常遍歷nums。實則相當於程式碼如下:

func Loop(nums []int, step int) {
   l := len(nums)
   for j := 0; j < l; j += 1 {
       nums[j] = 4 //訪問記憶體,並寫入值
   }
}

如果Step等於3,則表示外層for迴圈要一共完成3次,內層for迴圈每次遍歷的陣列下標值都相差3。第一次遍歷會被遍歷的nums下標為0、3、6、9、12……,第二次遍歷會遍歷的nums下標為1、4、7、10、13……,第三次遍歷會遍歷的nums下標為2、5、8、11、14……。那麼三次外迴圈就會將全部遍歷完整個nums陣列。

上述的程式表示了訪問陣列的區域性性,step跨度越小,則表示訪問nums相鄰記憶體的區域性性約好,step越大則相反。

接下來用Golang的Benchmark效能測試來分別對step取不同的值進行壓測,來看看通過Benchmark執行Loop()函式而統計出來的幾種情況,最終消耗的時間差距為多少。首先建立loop_test.go檔案,實現一個製作陣列並且賦值初始化記憶體值的函式CreateSource(),程式碼如下:

package MyGolang

import "testing"

func CreateSource(len int) []int {
   nums := make([]int, 0, len)

   for i := 0 ; i < len; i++ {
      nums = append(nums, i)
   }

   return nums
}

其次實現一個Benchmark,製作一個長度為10000的陣列,這裡要注意的是建立完陣列後要執行b.ResetTimer()重置計時,去掉CreateSource()消耗的時間,step跨度為1的程式碼如下:

//第一篇/chapter3/MyGolang/loop_test.go

func BenchmarkLoopStep1(b *testing.B) {
   //製作源資料,長度為10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 1)
   }
}

Golang中的b.N表示Golang一次壓測最終迴圈的次數。BenchmarkLoopStep1()會將N次的總耗時時間除以N得到平均一次執行Loop()函式的耗時。因為要對比多個step的耗時差距,按照上述程式碼再依次實現step為2、3、4、5、6、12、16等Benchmark效能測試程式碼,如下:

func BenchmarkLoopStep2(b *testing.B) {
   //製作源資料,長度為10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 2)
   }
}

func BenchmarkLoopStep3(b *testing.B) {
   //製作源資料,長度為10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 3)
   }
}

func BenchmarkLoopStep4(b *testing.B) {
   //製作源資料,長度為10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 4)
   }
}

func BenchmarkLoopStep5(b *testing.B) {
   //製作源資料,長度為10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 5)
   }
}

func BenchmarkLoopStep6(b *testing.B) {
   //製作源資料,長度為10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 6)
   }
}

func BenchmarkLoopStep12(b *testing.B) {
   //製作源資料,長度為10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 12)
   }
}

func BenchmarkLoopStep16(b *testing.B) {
   //製作源資料,長度為10000
   src := CreateSource(10000)

   b.ResetTimer()
   for i:=0; i < b.N; i++ {
      Loop(src, 16)
   }
}

上述每個Benchmark都是相似的程式碼,只有step傳參不同,接下來通過執行下述指令來進行壓測,指令如下:

$ go test -bench=.  -count=3

其中“count=3”表示每個Benchmark要執行3次,這樣是更好驗證上述的結果。具體的執行結果如下:

goos: darwin
goarch: amd64
pkg: MyGolang
BenchmarkLoopStep1-12            366787      2792 ns/op
BenchmarkLoopStep1-12            432235      2787 ns/op
BenchmarkLoopStep1-12            428527      2849 ns/op
BenchmarkLoopStep2-12            374282      3282 ns/op
BenchmarkLoopStep2-12            363969      3263 ns/op
BenchmarkLoopStep2-12            361790      3315 ns/op
BenchmarkLoopStep3-12            308587      3760 ns/op
BenchmarkLoopStep3-12            311551      4369 ns/op
BenchmarkLoopStep3-12            289584      4622 ns/op
BenchmarkLoopStep4-12            275166      4921 ns/op
BenchmarkLoopStep4-12            264282      4504 ns/op
BenchmarkLoopStep4-12            286933      4869 ns/op
BenchmarkLoopStep5-12            223366      5609 ns/op
BenchmarkLoopStep5-12            202597      5655 ns/op
BenchmarkLoopStep5-12            214666      5623 ns/op
BenchmarkLoopStep6-12            187147      6344 ns/op
BenchmarkLoopStep6-12            177363      6397 ns/op
BenchmarkLoopStep6-12            185377      6333 ns/op
BenchmarkLoopStep12-12           126860      9660 ns/op
BenchmarkLoopStep12-12           127557      9741 ns/op
BenchmarkLoopStep12-12           126658      9492 ns/op
BenchmarkLoopStep16-12            95116     12754 ns/op
BenchmarkLoopStep16-12            95175     12591 ns/op
BenchmarkLoopStep16-12            92106     12533 ns/op
PASS
ok  MyGolang31.712s

對上述結果以第一行為例進行簡單的解讀:

(1)“BenchmarkLoopStep1-12”其中的“-12”表示GOMAXPROCS(執行緒數)為12,這個在此處不需要過度的關心。

(2)“366787”表示一共執行了366787次,即程式碼中b.N的值,這個值不是固定不變的。實際上是通過迴圈呼叫366787次Loop()函式得到的最後效能結果。

(3)“2792 ns/op”表示平均每次Loop()函式所消耗的時間是2792納秒。

通過上述結果可以看出,隨著Step引數的增加,記憶體訪問的區域性性就越差,那麼執行Loop()的效能也就越差,在Step為16和Step為1的結果來看,效能相差近4~5倍之間。

通過結果可以得出如果要設計出一個更加高效的程式,提高程式碼的區域性性訪問是非常有必要的程式效能優化手段之一。

思考 在Golang的GPM排程器模型中,為什麼一個G開闢的子G優先放在當前的本地G佇列中,而不是放在其他M上的本地P佇列中?GPM為何要滿足區域性性的排程設計?

4 如何用Golang語言實現記憶體管理和記憶體池設計

本節介紹自主實現一個記憶體管理模組都大致需要哪些基礎的開發和元件建設。接下來的一些程式碼不需要讀者去掌握,因為Golang已經給開發者提供的記憶體管理模式,開發者不需要關心Golang的記憶體分配情況。但是為了更好的理解Golang的記憶體管理模型,需要了解如果自己實現一套簡單的記憶體管理模組應該需要關注哪些點和需要實現哪些必要的模組和機制。

本節接下來的內容即是通過Golang自我實現一個記憶體管理模組和記憶體池的建設,該模組非企業級開發而是促進理解記憶體管理模型的教程型程式碼。

4.1 基於Cgo的記憶體C介面封裝

因為Golang語言已經內建的記憶體管理機制,所以如果用Golang原生的語法結構如Slice、String、Map等都會自動觸發Golang的記憶體管理機制。本案例為了介紹如何實現一個自我管理的記憶體模型,所以直接使用的C語言的malloc()、free()系統呼叫來開闢和釋放記憶體空間,使用C語言的memcpy()、memmove()等進行記憶體的拷貝和移動。至於如何封裝Golang語法的Malloc()、Free()、Memcpy()、Memmove()等函式,即是利用的Golang中的Cgo機制。

注意 Cgo提供了 Golang 和 C 語言相互呼叫的機制。可以通過 Cgo 用 Golang 呼叫 C 的介面,對於C++的介面可以用 C 包裝一下提供給 Golang 呼叫。被呼叫的 C 程式碼可以直接以原始碼形式提供或者打包靜態庫或動態庫在編譯時連結。
Cgo 的具體使用教程本章將不繼續詳細介紹,本章主要介紹下在記憶體管理設計所涉及到部分Cgo語法部分。

開始建立一個zmem/目錄,作為當前記憶體實現案例的專案名稱。在zmem/目錄下再建立c/資料夾,這裡用來實現通過Cgo來封裝的C語言記憶體管理介面。

在c/目錄下建立memory.go檔案,分別封裝的C語言記憶體介面程式碼如下:

//zmem/c/memory.go

package c

/*
#include <string.h>
#include <stdlib.h>
 */
import "C"
import "unsafe"

func Malloc(size int) unsafe.Pointer {
   return C.malloc(C.size_t(size))
}

func Free(data unsafe.Pointer) {
   C.free(data)
}

func Memmove(dest, src unsafe.Pointer, length int) {
   C.memmove(dest, src, C.size_t(length))
}

func Memcpy(dest unsafe.Pointer, src []byte, length int) {
   srcData := C.CBytes(src)
   C.memcpy(dest, srcData, C.size_t(length))
}

接下來分別介紹上述程式碼幾個需要注意的地方。

1.import“C”

代表Cgo模組的啟動,其中import “C”上面的全部註釋程式碼(中間不允許有空白行)均為C語言原生程式碼。因為在下述介面封裝中使用到了C語言的malloc()、free()、memmove()、memcpy()等函式,這些函式的宣告需要包含標頭檔案string.h和stdlib.h,所以在註釋部分新增了匯入這兩個標頭檔案的程式碼,並且通過import “C”匯入進來。

2.unsafe.Pointer

這裡以malloc()系統呼叫為例,通過man[5]手冊檢視malloc()函式的原型如下:

#include <stdlib.h>

void *malloc(size_t size);

函式malloc()形參是C語言中的size_t資料型別型別,那麼在Golang中使用對應的C型別是C.size_t,是的,一般的C基本型別只需要通過C包直接訪問即可。但是對於malloc()的返回值void來說,這是一個萬能指標,期功能用法類似Golang中的interface{},但是在語法上並不能將二者直接劃等號。而Golang給開發這提供了一個可以直接對等C中void的資料型別,就是unsafe.Pointer。unsafe.Pointer是Golang封裝好的可以比較自由訪問的指標型別,其含義和void萬能指標相似。在語法上,也可以直接將void型別資料賦值給unsafe.Pointer型別資料。

3.Golang與C的字串等型別轉換

在Cgo中Go的字串與Byte陣列都會轉換為C的char陣列,其中Golang的Cgo模組提供了幾個方法供開發者使用:

// Go字串轉換為C字串。C字串使用malloc分配,因此需要使用C.free以避免記憶體洩露
func C.CString(string) *C.char

// Go byte陣列轉換為C的陣列。使用malloc分配的空間,因此需要使用C.free避免記憶體洩漏
func C.CBytes([]byte) unsafe.Pointer

// C字串轉換為Go字串
func C.GoString(*C.char) string

// C字串轉換為Go字串,指定轉換長度
func C.GoStringN(*C.char, C.int) string

// C資料轉換為byte陣列,指定轉換的長度
func C.GoBytes(unsafe.Pointer, C.int) []byte

其中C.CBytes()方法可以將Golang的[]byte切片轉換成unsafe.Pointer型別。利用這個轉換功能,來分析一下是如何封裝memcpy()函式的:

func Memcpy(dest unsafe.Pointer, src []byte, length int) {
   srcData := C.CBytes(src)
   C.memcpy(dest, srcData, C.size_t(length))
}

新封裝的Memcpy()的第一個形參是任意指標型別,表示拷貝的目標地址,第二個形參是[]byte型別,表示被拷貝的源資料,第三個參數列示本次拷貝資料的長度。因為C語言中的memcpy()函式原型如下:

#include <string.h>

void *memcpy(void *dst, const void *src, size_t n);

對於src資料來源形參需要[]byte轉換為unsafe.Pointer,因此在呼叫C的介面是通過C.CBytes()轉換了一下。

Free()和Memmove()方法的封裝和上述一樣。Free()與Malloc()對應,Memmove()為移動一塊連續記憶體。

接下來將上述封裝做一個簡單的單元測試,在c/目錄下建立memory_test.go,實現程式碼如下:

package c_test

import (
   "zmem/c"
   "bytes"
   "encoding/binary"
   "fmt"
   "testing"
   "unsafe"
)

func IsLittleEndian() bool {
   var n int32 = 0x01020304

   //下面是為了將int32型別的指標轉換成byte型別的指標
   u := unsafe.Pointer(&n)
   pb := (*byte)(u)

   //取得pb位置對應的值
   b := *pb

   //由於b是byte型別,最多儲存8位,那麼只能取得開始的8位
   // 小端: 04 (03 02 01)
   // 大端: 01 (02 03 04)
   return (b == 0x04)
}

func IntToBytes(n uint32) []byte {
   x := int32(n)
   bytesBuffer := bytes.NewBuffer([]byte{})

   var order binary.ByteOrder
   if IsLittleEndian() {
      order = binary.LittleEndian
   } else {
      order = binary.BigEndian
   }
   binary.Write(bytesBuffer, order, x)

   return bytesBuffer.Bytes()
}

func TestMemoryC(t *testing.T) {
   data := c.Malloc(4)
   fmt.Printf(" data %+v, %T\n", data, data)
   myData := (*uint32)(data)
   *myData = 5
   fmt.Printf(" data %+v, %T\n", *myData, *myData)

   var a uint32 = 100
   c.Memcpy(data, IntToBytes(a), 4)
   fmt.Printf(" data %+v, %T\n", *myData, *myData)

   c.Free(data)
}

單元測試介面是TestMemoryC(),首先通過Malloc()開闢4個位元組記憶體,然後將這4個位元組賦值為5,列印結果看data的值是否是5。最後是將100通過Memcpy()拷貝給這4個位元組,看最後的結果是否是100,執行結果如下:

=== RUN   TestMemoryC
data 0x9d040a0, unsafe.Pointer
data 5, uint32
data 100, uint32
--- PASS: TestMemoryC (0.00s)
PASS

通過單元測試結果來看,目前的記憶體開闢和拷貝的相關介面可以正常使用,接下來就是基於這些介面來實現記憶體管理的模組實現。

4.2 基礎記憶體緩衝Buf實現

在zmem目錄下再建立mem資料夾,包mem模組作為記憶體管理相關程式碼的包名,然後再mem目下面建立buf.go,作為Buf的程式碼實現。檔案路徑結構如下:

zmem/
├── README.md
├── c/
│   ├── memory.go
│   └── memory_test.go
├── go.mod
└── mem/
└── buf.go

接下來定義一個Buf資料結構,具體的定義實現如下:

//zmem/mem/buf.go

package mem

import "unsafe"

type Buf struct {
   //如果存在多個buffer,是採用連結串列的形式連結起來
   Next *Buf
   //當前buffer的快取容量大小
   Capacity int
   //當前buffer有效資料長度
   length int
   //未處理資料的頭部位置索引
   head int
   //當前buf所儲存的資料地址
   data unsafe.Pointer
}

一個Buf記憶體緩衝包含如下成員屬性:

(1)Capacity,表示當前緩衝的容量大小,實則是底層記憶體分配的最大記憶體空間上限。

(2)length,當前緩衝區的有效資料長度,有效資料長度為使用者存入但又未訪問的剩餘資料長度。

(3)head,緩衝中未處理的頭部位置索引。

(4)data,是當前buf所儲存記憶體的首地址指標,這裡用的事unsafe.Pointer型別,表示data所存放的為基礎的虛擬記憶體地址。

(5)Next,是Buf型別的指標,指向下一個Buf地址。Buf與Buf之間的關係是一個連結串列結構。

一個Buf的資料記憶體結構佈局如圖13所示。

圖 13 Buf的資料結構佈局

Buf是採用連結串列的集合方式,每個Buf通過Next進行關聯,其中Data為指向底層開闢出來供使用者使用的記憶體。一個記憶體中有幾個刻度索引,記憶體首地址索引位置定義為0,Head為當前使用者應用有效資料的首地址索引,Length為有效資料尾地址索引,有效資料的長度為“Length-Head”。Capacity是開闢記憶體的尾地址索引,也表示當前Buf的可使用記憶體容量。

接下來來提供一個Buf的構造方法,具體程式碼如下:

//zmem/mem/buf.go

//構造,建立一個Buf物件
func NewBuf(size int) *Buf {
   return &Buf{
      Capacity: size,
      length: 0,
      head: 0,
      Next: nil,
      data : c.Malloc(size),
   }
}

NewBuf()接收一個size形參,用來表示開闢的記憶體空間長度。這裡呼叫封裝的c.Malloc()方法來申請size長度的記憶體空間,並且賦值給data。

Buf被初始化之後,需要給Buf賦予讓呼叫方傳入資料的介面,這裡允許一個Buf的記憶體可以賦予[]byte型別的源資料,方法名稱是SetBytes(),定義如下:

//zmem/mem/buf.go
//給一個Buf填充[]byte資料
func (b *Buf) SetBytes(src []byte) {
   c.Memcpy(unsafe.Pointer(uintptr(b.data)+uintptr(b.head)), src, len(src))
   b.length += len(src)
}

操作一共有兩個過程組成:

(1)將[]byte源資料src通過C介面的記憶體拷貝,給Buf的data賦值。這裡要注意的是被拷貝的data的起始地址是b.head。

(2)拷貝之後Buf的有效資料長度要相應的累加偏移,具體的過程如圖14所示。

圖 14 SetBytes記憶體操作

這裡要注意的是,拷貝的起始地址會基於data的基地址向右偏移head的長度,因為定義是從Head到Length是有效合法資料。對於unsafe.Pointer的地址偏移需要轉換為uintptr型別進行地址計算。

與SetBytes()對應的是GetBytes(),是從Buf的data中獲取資料,具體實現程式碼如下:

//zmem/mem/buf.go

//獲取一個Buf的資料,以[]byte形式展現
func (b *Buf) GetBytes() []byte {
   data := C.GoBytes(unsafe.Pointer(uintptr(b.data)+uintptr(b.head)), C.int(b.length))
   return data
}

其中C.GoBytes()是Cgo模組提供的將C資料轉換為byte陣列,並且指定轉換的長度。

取資料的起始地址依然是基於data進行head長度的偏移。

Buf還需要提供一個Copy()方法,用來將其他Buf緩衝物件直接複製拷貝到自身當中,且head、length等於對方完全一樣,具體實現的程式碼如下:

//zmem/mem/buf.go

//將其他Buf物件資料考本到自己中
func (b *Buf) Copy(other *Buf) {
   c.Memcpy(b.data, other.GetBytes(), other.length)
   b.head = 0
   b.length = other.length
}

接下來需要提供可以移動head的方法,其作用是縮小有效資料長度,當呼叫方已經使用了一部分資料之後,這部分資料可能會變成非法的非有效資料,那麼就需要將head向後偏移縮小有效資料的長度,Buf將提供一個名字叫Pop()的方法,具體定義如下:

//zmem/mem/buf.go

//處理長度為len的資料,移動head和修正length
func (b *Buf) Pop(len int) {
   if b.data == nil {
      fmt.Printf("pop data is nil")
      return
   }
   if len > b.length {
      fmt.Printf("pop len > length")
      return
   }
   b.length -= len
   b.head += len
}

一次Pop()操作,首先會判斷彈出合法有效資料的長度是否越界。然後對應的head向右偏移,length的有效長度對應做縮減,具體的流程如圖15所示。

圖 15 Pop記憶體操作的head與length偏移

因為呼叫方經常的獲取資料,然後呼叫Pop()縮減有效長度,那麼不出幾次,可能就會導致head越來越接近Capacity,也會導致有效資料之前的已經過期的非法資料越來越多。所以Buf需要提供一個Adjust()方法,來將有效資料的記憶體遷移至data基地址位置,覆蓋之前的已使用過的過期資料,將後續的空白可使用空間擴大。Adjust()的實現方法如下:

//zmem/mem/buf.go

//將已經處理過的資料,清空,將未處理的資料提前至資料首地址
func (b *Buf) Adjust() {
   if b.head != 0 {
      if (b.length != 0) {
         c.Memmove(b.data, unsafe.Pointer(uintptr(b.data) + uintptr(b.head)), b.length)
      }
      b.head = 0
   }
}

Adjust()呼叫之前封裝好的c.Memmove()方法,將有效資料記憶體平移至Buf的data基地地址,同時將head重置到0位置,具體的流程如圖16所示。

圖 16 Adjust操作的記憶體平移

Buf也要提供一個清空緩衝記憶體的方法Clear(),Clear()實現很簡單,只需要將幾個索引值清零即可,Clear()並不會以作業系統層面回收記憶體,因為Buf的是否回收,是否被重置等需要依賴BufPool記憶體池來管理,將在下一小結介紹記憶體池管理Buf的情況。為了降低系統記憶體的開闢和回收,Buf可能長期在記憶體池中存在。呼叫方只需要改變幾個地址索引值就可以達到記憶體的使用和回收。Clear()方法的實現如下:

//zmem/mem/buf.go

//清空資料
func (b *Buf) Clear() {
   b.length = 0
   b.head = 0
}
其他的提供的訪問head和length的方法如下:
func (b *Buf) Head() int {
   return b.head
}

func (b *Buf) Length() int {
   return b.length
}

現在Buf的基本功能已經實現完成了,接下來實現對Buf的管理記憶體池模組。

4.3 記憶體池設計與實現

一個Buf只是一次記憶體使用所需要存放資料的緩衝空間,為了方便多個Buf直接的申請與管理,則需要設計一個記憶體池來統一進行Buf的調配。

記憶體池的設計是預開闢記憶體,就是在首次申請建立記憶體池的時候,就將池子裡全部可以被使用的Buf記憶體空間集合一併申請開闢出來。呼叫方在申請記憶體的時候,是通過記憶體池來申請,記憶體池從Buf集合中選擇未被使用或佔用的Buf返回給呼叫方。呼叫方在使用完Buf

之後,也是將Buf退還給記憶體池。這樣呼叫方即使頻繁的申請和回收小空間的記憶體也不會出現頻繁的系統呼叫申請實體記憶體空間,降低了記憶體動態開闢的開銷成本,業務方的記憶體訪問速度也會有很大的提升。

下面來實現記憶體池BufPool,首先在zmem/mem/目錄下建立buf_pool.go檔案,在當前檔案來實現BufPool記憶體池的功能,BufPool的資料結構,程式碼如下所示:

//zmem/mem/buf_pool.go
package mem

import (
   "sync"
)

//記憶體管理池型別
type Pool map[int] *Buf

//Buf記憶體池
type BufPool struct {
   //所有buffer的一個map集合控制程式碼
   Pool Pool
   PoolLock sync.RWMutex

   //總buffer池的記憶體大小單位為KB
   TotalMem uint64
}

首先定義Pool資料型別,該型別表示管理全部Buf的Map集合,其中Key表示當前

一組Buf的Capacity容量,Value則是一個Buf連結串列。每個Key下面掛載著相同Capacity的Buf集合連結串列,其實是BufPool的成員屬性定義如下:

(1)Pool,當前記憶體池全部的Buf緩衝物件集合,是一個Map資料結構。

(2)PoolLock,對Map讀寫併發安全的讀寫鎖。

(3)TotalMem,當前BufPool所開闢記憶體池申請虛擬記憶體的總容量。

接下來提供BufPoll的初始化建構函式方法,BufPool作為記憶體池,全域性應該設計成唯一,所以採用單例模式設計,下面定義公共方法MemPool(),用來初始化並且獲取BufPoll單例物件,具體的實現方式如下:

//zmem/mem/buf_pool.go

//單例物件
var bufPoolInstance *BufPool
var once sync.Once

//獲取BufPool物件(單例模式)
func MemPool() *BufPool{
   once.Do(func() {
      bufPoolInstance = new(BufPool)
      bufPoolInstance.Pool = make(map[int]*Buf)
      bufPoolInstance.TotalMem = 0
      bufPoolInstance.prev = nil
      bufPoolInstance.initPool()
   })

   return bufPoolInstance
}

全域性遍歷指標bufPoolInstance作為指向BufPool單例例項的唯一指標,通過Golang標準庫提供sync.Once來做只執行依次的Do()方法,來初始化BufPool。在將BufPool成員均賦值完之後,最後通過initPool()方法來初始化記憶體池的記憶體申請佈局。

記憶體申請initPool()會將記憶體的分配結構如圖17所示。BufPool會預先將所有要管理的Buf按照記憶體刻度大小進行分組,如4KB的一組,16KB的一組等待。容量越小的Buf,所管理的Buf連結串列的數量越多,容量越大的Buf數量則越少。全部的Buf關係通過Map資料結構來管理,由於Buf本身是連結串列資料結構,所以每個Key所對應的Value只需要儲存頭結點Buf資訊即可,之後的Buf可以通過Buf的Next指標找到。

圖 17 BufPool記憶體池的記憶體管理佈局

BufPool的initPool()初始化記憶體方法的具體實現如下:

//zmem/mem/buf_pool.go

const (
   m4K int = 4096
   m16K int = 16384
   m64K int = 655535
   m256K int = 262144
   m1M int = 1048576
   m4M int = 4194304
   m8M int = 8388608
)

/*
     初始化記憶體池主要是預先開闢一定量的空間
  這裡BufPool是一個hash,每個key都是不同空間容量
  對應的value是一個Buf集合的連結串列

BufPool --> [m4K]  -- Buf-Buf-Buf-Buf...(BufList)
              [m16K] -- Buf-Buf-Buf-Buf...(BufList)
              [m64K] -- Buf-Buf-Buf-Buf...(BufList)
              [m256K]-- Buf-Buf-Buf-Buf...(BufList)
              [m1M] -- Buf-Buf-Buf-Buf...(BufList)
              [m4M] -- Buf-Buf-Buf-Buf...(BufList)
              [m8M] -- Buf-Buf-Buf-Buf...(BufList)
 */
func (bp *BufPool) initPool() {
   //----> 開闢4K buf 記憶體池
   // 4K的Buf 預先開闢5000個,約20MB供開發者使用
   bp.makeBufList(m4K, 5000)

   //----> 開闢16K buf 記憶體池
   //16K的Buf 預先開闢1000個,約16MB供開發者使用
   bp.makeBufList(m16K, 1000)

   //----> 開闢64K buf 記憶體池
   //64K的Buf 預先開闢500個,約32MB供開發者使用
   bp.makeBufList(m64K, 500)

   //----> 開闢256K buf 記憶體池
   //256K的Buf 預先開闢200個,約50MB供開發者使用
   bp.makeBufList(m256K, 200)

   //----> 開闢1M buf 記憶體池
   //1M的Buf 預先開闢50個,約50MB供開發者使用
   bp.makeBufList(m1M, 50)

   //----> 開闢4M buf 記憶體池
   //4M的Buf 預先開闢20個,約80MB供開發者使用
   bp.makeBufList(m4M, 20)

   //----> 開闢8M buf 記憶體池
   //8M的io_buf 預先開闢10個,約80MB供開發者使用
   bp.makeBufList(m8M, 10)
}

其中makeBufList()為每次初始化一種刻度容量的Buf連結串列,程式碼實現如下:

//zmem/mem/buf_pool.go

func (bp *BufPool) makeBufList(cap int, num int) {
   bp.Pool[cap] = NewBuf(cap)

   var prev *Buf
   prev = bp.Pool[cap]
   for i := 1; i < num; i ++ {
      prev.Next = NewBuf(cap)
      prev = prev.Next
   }
   bp.TotalMem += (uint64(cap)/1024) * uint64(num)
}

每次建立一行BufList之後,BubPool記憶體池的TotalMem就對應增加響應申請記憶體的容量,這個屬性就作為當前記憶體池已經從作業系統獲取的記憶體總容量為多少。

現在BufPool已經具備了申請首次初始化記憶體池的能力,還應該提供從BufPool獲取一個Buf記憶體的介面,也同時需要當呼叫方使用完後,再將記憶體退還給BufPool的介面。

1.獲取Buf

下面定義Alloc()方法來標識從BufPool中申請一個可用的Buf物件,具體的程式碼實現如下:

//zmem/mem/buf_pool.go

package mem

import (
   "errors"
   "fmt"
   "sync"
)

const (
   //總記憶體池最大限制單位是Kb 所以目前限制是 5GB
   EXTRA_MEM_LIMIT int = 5 * 1024 * 1024
)

/*
   開闢一個Buf
*/
func (bp *BufPool) Alloc(N int) (*Buf, error) {
   //1 找到N最接近哪hash 組
   var index int
   if N <= m4K {
      index = m4K
   } else if (N <= m16K) {
      index = m16K
   } else if (N <= m64K) {
      index = m64K
   } else if (N <= m256K) {
      index = m256K
   } else if (N <= m1M) {
      index = m1M
   } else if (N <= m4M) {
      index = m4M
   } else if (N <= m8M) {
      index = m8M
   } else {
      return nil, errors.New("Alloc size Too Large!");
   }

   //2 如果該組已經沒有,需要額外申請,那麼需要加鎖保護
   bp.PoolLock.Lock()
   if bp.Pool[index] == nil {
      if (bp.TotalMem + uint64(index/1024)) >= uint64(EXTRA_MEM_LIMIT) {
         errStr := fmt.Sprintf("already use too many memory!\n")
         return nil, errors.New(errStr)
      }

      newBuf := NewBuf(index)
      bp.TotalMem += uint64(index/1024)
      bp.PoolLock.Unlock()
      fmt.Printf("Alloc Mem Size: %d KB\n", newBuf.Capacity/1024)
      return newBuf, nil
   }

   //3 如果有該組有Buf記憶體存在,那麼得到一個Buf並返回,並且從pool中摘除該記憶體塊
   targetBuf := bp.Pool[index]
   bp.Pool[index] = targetBuf.Next
   bp.TotalMem -= uint64(index/1024)
   bp.PoolLock.Unlock()
   targetBuf.Next = nil
   fmt.Printf("Alloc Mem Size: %d KB\n", targetBuf.Capacity/1024)
   return targetBuf, nil
}

Alloc()函式有三個關鍵步驟:

(1)如果上層需要N個位元組的大小的空間,找到與N最接近的Buf連結串列集合,從當前Buf集合取出。

(2)如果該組已經沒有節點使用,可以額外申請總申請長度不能夠超過最大的限制大小 EXTRA_MEM_LIMIT。

(3)如果有該節點需要的記憶體塊,直接取出,並且將該記憶體塊從BufPool摘除。

2.退還Buf

定義Revert()方法為為退還使用後的Buf給BufPool記憶體池,具體的程式碼實現如下:

//當Alloc之後,當前Buf被使用完,需要重置這個Buf,需要將該buf放回pool中
func (bp *BufPool) Revert(buf *Buf) error {
   //每個buf的容量都是固定的在hash的key中取值
   index := buf.Capacity
   //重置buf中的內建位置指標
   buf.Clear()

   bp.PoolLock.Lock()
   //找到對應的hash組 buf首屆點地址
   if _, ok := bp.Pool[index]; !ok {
      errStr := fmt.Sprintf("Index %d not in BufPoll!\n", index)
      return errors.New(errStr)
   }

   //將buffer插回連結串列頭部
   buf.Next = bp.Pool[index]
   bp.Pool[index] = buf
   bp.TotalMem += uint64(index/1024)
   bp.PoolLock.Unlock()
   fmt.Printf("Revert Mem Size: %d KB\n",index/1024)

   return nil
}

Revert()會根據當前Buf的Capacity找到對應的Hash刻度,然後將Buf插入到連結串列的頭部,在插入之前通過Buf的Clear()將Buf的全部有效資料清空。

4.4 記憶體池的功能單元測試

接下來對上述介面做一些單元測試,在zmem/mem/目錄下建立buf_test.go檔案。

1.TestBufPoolSetGet

首先測試基本的SetBytes()和GetBytes()方法,單測程式碼編寫如下:

//zmem/mem/buf_test.go

package mem_test

import (
   "zmem/mem"
   "fmt"
   "testing"
)

func TestBufPoolSetGet(t *testing.T) {
   pool := mem.MemPool()

   buffer, err := pool.Alloc(1)
   if err != nil {
      fmt.Println("pool Alloc Error ", err)
      return
   }

   buffer.SetBytes([]byte("Aceld12345"))
   fmt.Printf("GetBytes = %+v, ToString = %s\n", buffer.GetBytes(), string(buffer.GetBytes()))
   buffer.Pop(4)
   fmt.Printf("GetBytes = %+v, ToString = %s\n", buffer.GetBytes(), string(buffer.GetBytes()))
}

單測用例是首先申請一個記憶體buffer,然後設定“Aceld12345”內容,然後輸出日誌,接下來彈出有效資料4個位元組,再列印buffer可以訪問的合法資料,執行單元測試程式碼,通過如下指令:

$ go test -run TestBufPoolSetGet
Alloc Mem Size: 4 KB
GetBytes = [65 99 101 108 100 49 50 51 52 53], ToString = Aceld12345
GetBytes = [100 49 50 51 52 53], ToString = d12345
PASS
ok      zmem/mem        0.010s

通過上述結果可得出通過Pop(4)之後,已經彈出了“Acel”前4個位元組資料。

2.TestBufPoolCopy

接下來測試Buf的Copy()賦值方法,具體的程式碼如下:

//zmem/mem/buf_test.go

package mem_test

import (
   "zmem/mem"
   "fmt"
   "testing"
)

func TestBufPoolCopy(t *testing.T) {
   pool := mem.MemPool()

   buffer, err := pool.Alloc(1)
   if err != nil {
      fmt.Println("pool Alloc Error ", err)
      return
   }

   buffer.SetBytes([]byte("Aceld12345"))
   fmt.Printf("Buffer GetBytes = %+v\n", string(buffer.GetBytes()))

   buffer2, err := pool.Alloc(1)
   if err != nil {
      fmt.Println("pool Alloc Error ", err)
      return
   }
   buffer2.Copy(buffer)
   fmt.Printf("Buffer2 GetBytes = %+v\n", string(buffer2.GetBytes()))
}

將buffer拷貝的buffer2中,看buffer存放的資料內容,執行單元測試指令和所得到的結果如下:

$ go test -run TestBufPoolCopy
Alloc Mem Size: 4 KB
Buffer GetBytes = Aceld12345
Alloc Mem Size: 4 KB
Buffer2 GetBytes = Aceld12345
PASS
ok      zmem/mem        0.008s

3.TestBufPoolAdjust

之後來針對Buf的Adjust()方法進行單元測試,相關程式碼如下:

//zmem/mem/buf_test.go

package mem_test

import (
   "zmem/mem"
   "fmt"
   "testing"
)

func TestBufPoolAdjust(t *testing.T) {
   pool := mem.MemPool()

   buffer, err := pool.Alloc(4096)
   if err != nil {
      fmt.Println("pool Alloc Error ", err)
      return
   }

   buffer.SetBytes([]byte("Aceld12345"))
   fmt.Printf("GetBytes = %+v, Head = %d, Length = %d\n", buffer.GetBytes(), buffer.Head(), buffer.Length())
   buffer.Pop(4)
   fmt.Printf("GetBytes = %+v, Head = %d, Length = %d\n", buffer.GetBytes(), buffer.Head(), buffer.Length())
   buffer.Adjust()
   fmt.Printf("GetBytes = %+v, Head = %d, Length = %d\n", buffer.GetBytes(), buffer.Head(), buffer.Length())
}

首先buffer被填充“Aceld12345”,然後列印Head索引和Length長度,然後通過Pop彈出有效資料4個位元組,繼續列印日誌,然後通過Adjust()重置Head,再輸出buffer資訊,通過下述指令執行單元測試和得到的結果如下:

$ go test -run TestBufPoolAdjust
Alloc Mem Size: 4 KB
GetBytes = [65 99 101 108 100 49 50 51 52 53], Head = 0, Length = 10
GetBytes = [100 49 50 51 52 53], Head = 4, Length = 6
GetBytes = [100 49 50 51 52 53], Head = 0, Length = 6
PASS
ok      zmem/mem        0.009s

可以看出第三次輸出的日誌Head已經重置為0,且GetBytes()得到的有效資料沒有改變。

4.5 記憶體管理應用介面

前面小結已經基本實現了一個簡單的記憶體池管理,但如果希望更方便的使用,則需要對Buf和BufPool再做一層封裝,這裡定義新資料結構Zbuf,對Buf的基本操作做已經封裝,使記憶體管理的介面更加友好,在zmem/mem/目錄下建立zbuf.go檔案,切定義資料型別Zbuf,具體程式碼如下:

//zmem/mem/zbuf.go

package mem

//應用層的buffer資料
type ZBuf struct {
   b *Buf
}

接下來定義Zbuf對外提供的一些使用方法。

1.Clear()方法

Zbuf的Clear()方法實則是將ZBuf中的Buf退還給BufPool,具體程式碼如下:

//zmem/mem/zbuf.go

//清空當前的ZBuf
func (zb *ZBuf) Clear() {
   if zb.b != nil {
      //將Buf重新放回到buf_pool中
      MemPool().Revert(zb.b)
      zb.b = nil
   }
}

在Buf的Clear()中呼叫了MemPool()的Revert()方法,回收了當前Zbuf中的Buf物件。

2.Pop()方法

Zbuf的Pop()方法對之前的Pop進行了一些安全性越界校驗,具體程式碼如下:

//zmem/mem/zbuf.go

//彈出已使用的有效長度
func (zb *ZBuf) Pop(len int) {
   if zb.b == nil || len > zb.b.Length() {
      return
   }

   zb.b.Pop(len)

   //當此時Buf的可用長度已經為0時,將Buf重新放回BufPool中
   if zb.b.Length() == 0 {
      MemPool().Revert(zb.b)
      zb.b = nil
   }
}

如果Buf在Pop()之後的有效資料長度為0,那麼就將當前Buf退還給BufPool。

3.Data()方法

Zbuf的Data()方法就是返回Buf的資料,程式碼如下:

//zmem/mem/zbuf.go

//獲取Buf中的資料
func (zb *ZBuf) Data() []byte {
   if zb.b == nil {
      return nil
   }
   return zb.b.GetBytes()
}

4.Adjust()方法

Zbuf的Adjust()方法的封裝也沒有任何改變:

//zmem/mem/zbuf.go

//重置緩衝區
func (zb *ZBuf) Adjust() {
   if zb.b != nil {
      zb.b.Adjust()
   }
}

5.Read()方法

Zbuf的Read()方法是將資料填充到Zbuf的Buf中。Read()方法是將被填充的資料作為形參[]byte傳遞進來。

//zmem/mem/zbuf.go

//讀取資料到Buf中
func (zb *ZBuf) Read(src []byte) (err error){
   if zb.b == nil {
      zb.b, err = MemPool().Alloc(len(src))
      if err != nil {
         fmt.Println("pool Alloc Error ", err)
      }
   } else {
      if zb.b.Head() != 0 {
         return nil
      }
      if zb.b.Capacity - zb.b.Length() < len(src) {
         //不夠存,重新從記憶體池申請
         newBuf, err := MemPool().Alloc(len(src)+zb.b.Length())
         if err != nil {
            return nil
         }
         //將之前的Buf拷貝到新申請的Buf中去
         newBuf.Copy(zb.b)
         //將之前的Buf回收到記憶體池中
         MemPool().Revert(zb.b)
         //新申請的Buf成為當前的ZBuf
         zb.b = newBuf
      }
   }

   //將內容寫進ZBuf緩衝中
   zb.b.SetBytes(src)

   return nil
}

如果當前Zbuf的Buf為空則會向BufPool中申請記憶體。如果傳遞的源資料超過的當前Buf所能承載的容量,那麼Zbuf會申請一個更大的Buf,將之前的已有的資料通過Copy()到新申請的Buf中,之後將之前的Buf退還給BufPool中。

6.其他可擴充方法等

上述的Read()方法代表Zbuf從引數獲取源資料,如果為了更方便的填充Zbuf,可以封裝類似介面,如Fd檔案描述符中讀取資料到Zbuf中、從檔案讀取資料到Zbuf中、從網路套接字讀取資料到Zbuf中等等,相關函式原型如下:

//zmem/mem/zbuf.go

//讀取資料從Fd檔案描述符中
func (zb *ZBuf) ReadFromFd(fd int) error {
   //...
   return nil
}

//將資料寫入Fd檔案描述符中
func (zb *ZBuf) WriteToFd(fd int) error {
   //...
   return nil
}

//讀取資料從檔案中
func (zb *ZBuf) ReadFromFile(path string) error {
   //...
   return nil
}

func (zb *ZBuf) WriteToFile(path string) error {
   //...
   return nil
}

//讀取資料從網路連線中
func (zb *ZBuf) ReadFromConn(conn net.Conn) error {
   //...
   return nil
}

func (zb *ZBuf) WriteToConn(conn net.Conn) error {
   //...
   return nil
}

這裡就不一一展開的,具體實現方式和Read()方法類似。這樣Zbuf就可以通過不同的媒介來填充Buf並且來使用,業務層只需要面向Zbuf就可以獲取資料,無需關心具體的IO層邏輯。

5 Golang記憶體管理之魂TCMalloc

在瞭解Golang的記憶體管理之前,一定要了解的基本申請記憶體模式,即TCMalloc(Thread Cache Malloc)。Golang的記憶體管理就是基於TCMalloc的核心思想來構建的。本節將介紹TCMalloc的基礎理念和結構。

5.1 TCMalloc

TCMalloc最大優勢就是每個執行緒都會獨立維護自己的記憶體池。在之前章節介紹的自定義實現的Golang記憶體池版BufPool實則是所有Goroutine或者所有執行緒共享的記憶體池,其關係如圖18所示。

圖 18 BufPool記憶體池與執行緒Thread的關係

這種記憶體池的設計缺點顯而易見,應用方全部的記憶體申請均需要和全域性的BufPool互動,為了執行緒的併發安全,那麼頻繁的BufPool的記憶體申請和退還需要加互斥和同步機制,影響了記憶體的使用的效能。

TCMalloc則是為每個Thread預分配一塊快取,每個Thread在申請記憶體時首先會先從這個快取區ThreadCache申請,且所有ThreadCache快取區還共享一個叫CentralCache的中心快取。這裡假設目前Golang的記憶體管理用的是原生TCMalloc模式,那麼執行緒與記憶體的關係將如圖19所示。

圖 19 TCMalloc記憶體池與執行緒Thread的關係

這樣做的好處其一是ThreadCache做為每個執行緒獨立的快取,能夠明顯的提高Thread獲取高命中的資料,其二是ThreadCache也是從堆空間一次性申請,即只觸發一次系統呼叫即可。每個ThreadCache還會共同訪問CentralCache,這個與BufPool的類似,但是設計更為精細一些。CentralCache是所有執行緒共享的快取,當ThreadCache的快取不足時,就會從CentralCache獲取,當ThreadCache的快取充足或者過多時,則會將記憶體退還給CentralCache。但是CentralCache由於共享,那麼訪問一定是需要加鎖的。ThreadCache作為執行緒獨立的第一互動記憶體,訪問無需加鎖,CentralCache則作為ThreadCache臨時補充快取。

TCMalloc的構造不僅於此,提供了ThreadCache和CentralCache可以解決小物件記憶體塊的申請,但是對於大塊記憶體Cache顯然是不適合的。 TCMalloc將記憶體分為三類,如表4所示。

表4 TCMalloc的記憶體分離
物件 容量
小物件 (0,256KB]
中物件 (256KB, 1MB]
大物件 (1MB, +∞)

所以為了解決中物件和大物件的記憶體申請,TCMalloc依然有一個全域性共享記憶體堆PageHeap,如圖20所示。

圖 20 TCMalloc中的PageHeap

PageHeap也是一次系統呼叫從虛擬記憶體中申請的,PageHeap很明顯是全域性的,所以訪問一定是要加鎖。其作用是當CentralCache沒有足夠記憶體時會從PageHeap取,當CentralCache記憶體過多或者充足,則將低命中記憶體塊退還PageHeap。如果Thread需要大物件申請超過的Cache容納的記憶體塊單元大小,也會直接從PageHeap獲取。

5.2 TCMalloc模型相關基礎結構

在瞭解TCMalloc的一些內部設計結構時,首要了解的是一些TCMalloc定義的基本名詞Page、Span和Size Class。

1.Page

TCMalloc中的Page與之前章節介紹作業系統對虛擬記憶體管理的MMU定義的物理頁有相似的定義,TCMalloc將虛擬記憶體空間劃分為多份同等大小的Page,每個Page預設是8KB。

對於TCMalloc來說,虛擬記憶體空間的全部記憶體都按照Page的容量分成均等份,並且給每份Page標記了ID編號,如圖21所示。

圖 21 TCMalloc將虛擬記憶體平均分層N份Page

將Page進行編號的好處是,可以根據任意記憶體的地址指標,進行固定演算法偏移計算來算出所在的Page。

2.Span

多個連續的Page稱之為是一個Span,其定義含義有作業系統的管理的頁表相似,Page和Span的關係如圖22所示。

圖 22 TCMalloc中Page與Span的關係

TCMalloc是以Span為單位向作業系統申請記憶體的。每個Span記錄了第一個起始Page的編號Start,和一共有多少個連續Page的數量Length。

為了方便Span和Span之間的管理,Span集合是以雙向連結串列的形式構建,如圖23所示。

圖 23 TCMalloc中Span儲存形式

3.Size Class

參考表3-3所示,在256KB以內的小物件,TCMalloc會將這些小物件集合劃分成多個記憶體刻度[6],同屬於一個刻度類別下的記憶體集合稱之為屬於一個Size Class。這與之前章節自定義實現的記憶體池,將Buf劃分多個刻度的BufList類似。

每個Size Class都對應一個大小比如8位元組、16位元組、32位元組等。在申請小物件記憶體的時候,TCMalloc會根據使用方申請的空間大小就近向上取最接近的一個Size Class的Span(由多個等空間的Page組成)記憶體塊返回給使用方。

如果將Size Class、Span、Page用一張圖來表示,則具體的抽象關係如圖24所示。

圖 24 TCMalloc中Size Class、Page、Span的結構關係

接下來剖析一下ThreadCache、CentralCache、PageHeap的記憶體管理結構。

5.3 ThreadCache

在TCMalloc中每個執行緒都會有一份單獨的快取,就是ThreadCache。ThreadCache中對於每個Size Class都會有一個對應的FreeList,FreeList表示當前快取中還有多少個空閒的記憶體可用,具體的結構佈局如圖25所示。

圖 25 TCMalloc中ThreadCache

使用方對於從TCMalloc申請的小物件,會直接從TreadCache獲取,實則是從FreeList中返回一個空閒的物件,如果對應的Size Class刻度下已經沒有空閒的Span可以被獲取了,則ThreadCache會從CentralCache中獲取。當使用方使用完記憶體之後,歸還也是直接歸還給當前的ThreadCache中對應刻度下的的FreeList中。

整條申請和歸還的流程是不需要加鎖的,因為ThreadCache為當前執行緒獨享,但如果ThreadCache不夠用,需要從CentralCache申請記憶體時,這個動作是需要加鎖的。不同Thread之間的ThreadCache是以雙向連結串列的結構進行關聯,是為了方便TCMalloc統計和管理。

5.4 CentralCache

CentralCache是各個執行緒共用的,所以與CentralCache獲取記憶體互動是需要加鎖的。CentralCache快取的Size Class和ThreadCache的一樣,這些快取都被放在CentralFreeList中,當ThreadCache中的某個Size Class刻度下的快取小物件不夠用,就會向CentralCache對應的Size Class刻度的CentralFreeList獲取,同樣的如果ThreadCache有多餘的快取物件也會退還給響應的CentralFreeList,流程和關係如圖26所示。

圖 26 TCMalloc中CentralCache

CentralCache與PageHeap的角色關係與ThreadCache與CentralCache的角色關係相似,當CentralCache出現Span不足時,會從PageHeap申請Span,以及將不再使用的Span退還給PageHeap。

5.5 PageHeap

PageHeap是提供CentralCache的記憶體來源。PageHead與CentralCache不同的是CentralCache是與ThreadCache佈局一模一樣的快取,主要是起到針對ThreadCache的一層二級快取作用,且只支援小物件記憶體分配。而PageHeap則是針對CentralCache的三級快取。彌補對於中物件記憶體和大物件記憶體的分配,PageHeap也是直接和作業系統虛擬記憶體銜接的一層快取,當找不到ThreadCache、CentralCache、PageHeap都找不到合適的Span,PageHeap則會呼叫作業系統記憶體申請系統呼叫函式來從虛擬記憶體的堆區中取出記憶體填充到PageHeap當中,具體的結構如圖27所示。

圖 27 TCMalloc中PageHeap

PageHeap內部的Span管理,採用兩種不同的方式,對於128個Page以內的Span申請,每個Page刻度都會用一個連結串列形式的快取來儲存。對於128個Page以上記憶體申請,PageHeap是以有序集合(C++標準庫STL中的Std::Set容器)來存放。

5.6 TCMalloc的小物件分配

上述已經將TCMalloc的幾種基礎結構介紹了,接下來總結一下TCMalloc針對小物件、中物件和大物件的分配流程。小物件分配流程如圖28所示。

圖 28 TCMalloc小物件分配流程

小物件為佔用記憶體小於等於256KB的記憶體,參考圖中的流程,下面將介紹詳細流程步驟:

(1)Thread使用者執行緒應用邏輯申請記憶體,當前Thread訪問對應的ThreadCache獲取記憶體,此過程不需要加鎖。

(2)ThreadCache的得到申請記憶體的SizeClass(一般向上取整,大於等於申請的記憶體大小),通過SizeClass索引去請求自身對應的FreeList。

(3)判斷得到的FreeList是否為非空。

(4)如果FreeList非空,則表示目前有對應記憶體空間供Thread使用,得到FreeList第一個空閒Span返回給Thread使用者邏輯,流程結束。

(5)如果FreeList為空,則表示目前沒有對應SizeClass的空閒Span可使用,請求CentralCache並告知CentralCache具體的SizeClass。

(6)CentralCache收到請求後,加鎖訪問CentralFreeList,根據SizeClass進行索引找到對應的CentralFreeList。

(7)判斷得到的CentralFreeList是否為非空。

(8)如果CentralFreeList非空,則表示目前有空閒的Span可使用。返回多個Span,將這些Span(除了第一個Span)放置ThreadCache的FreeList中,並且將第一個Span返回給Thread使用者邏輯,流程結束。

(9)如果CentralFreeList為空,則表示目前沒有可用是Span可使用,向PageHeap申請對應大小的Span。

(10)PageHeap得到CentralCache的申請,加鎖請求對應的Page刻度的Span連結串列。

(11)PageHeap將得到的Span根據本次流程請求的SizeClass大小為刻度進行拆分,分成N份SizeClass大小的Span返回給CentralCache,如果有多餘的Span則放回PageHeap對應Page的Span連結串列中。

(12)CentralCache得到對應的N個Span,新增至CentralFreeList中,跳轉至第(8)步。

綜上是TCMalloc一次申請小物件的全部詳細流程,接下來分析中物件的分配流程。

5.7 TCMalloc的中物件分配

中物件為大於256KB且小於等於1MB的記憶體。對於中物件申請分配的流程TCMalloc與處理小物件分配有一定的區別。對於中物件分配,Thread不再按照小物件的流程路徑向ThreadCache獲取,而是直接從PageHeap獲取,具體的流程如圖29所示。

圖 29 TCMalloc中物件分配流程

PageHeap將128個Page以內大小的Span定義為小Span,將128個Page以上大小的Span定義為大Span。由於一個Page為8KB,那麼128個Page即為1MB,所以對於中物件的申請,PageHeap均是按照小Span的申請流程,具體如下:

(1)Thread使用者邏輯層提交記憶體申請處理,如果本次申請記憶體超過256KB但不超過1MB則屬於中物件申請。TCMalloc將直接向PageHeap發起申請Span請求。

(2)PageHeap接收到申請後需要判斷本次申請是否屬於小Span(128個Page以內),如果是,則走小Span,即中物件申請流程,如果不是,則進入大物件申請流程,下一節介紹。

(3)PageHeap根據申請的Span在小Span的連結串列中向上取整,得到最適應的第K個Page刻度的Span連結串列。

(4)得到第K個Page連結串列刻度後,將K作為起始點,向下遍歷找到第一個非空連結串列,直至128個Page刻度位置,找到則停止,將停止處的非空Span連結串列作為提供此次返回的記憶體Span,將連結串列中的第一個Span取出。如果找不到非空連結串列,則當錯本次申請為大Span申請,則進入大物件申請流程。

(5)假設本次獲取到的Span由N個Page組成。PageHeap將N個Page的Span拆分成兩個Span,其中一個為K個Page組成的Span,作為本次記憶體申請的返回,給到Thread,另一個為N-K個Page組成的Span,重新插入到N-K個Page對應的Span連結串列中。

綜上是TCMalloc對於中物件分配的詳細流程。

5.8 TCMalloc的大物件分配

對於超過128個Page(即1MB)的記憶體分配則為大物件分配流程。大物件分配與中物件分配情況類似,Thread繞過ThreadCache和CentralCache,直接向PageHeap獲取。詳細的分配流程如圖30所示。

圖 30 TCMalloc大物件分配流程

進入大物件分配流程除了申請的Span大於128個Page之外,對於中物件分配如果找不到非空連結串列也會進入大物件分配流程,大物件分配的具體流程如下:

(1)Thread使用者邏輯層提交記憶體申請處理,如果本次申請記憶體超過1MB則屬於大物件申請。TCMalloc將直接向PageHeap發起申請Span 。

(2)PageHeap接收到申請後需要判斷本次申請是否屬於小Span(128個Page以內),如果是,則走小Span中物件申請流程(上一節已介紹),如果不是,則進入大物件申請流程。

(3)PageHeap根據Span的大小按照Page單元進行除法運算,向上取整,得到最接近Span的且大於Span的Page倍數K,此時的K應該是大於128。如果是從中物件流程分過來的(中物件申請流程可能沒有非空連結串列提供Span),則K值應該小於128。

(4)搜尋Large Span Set集合,找到不小於K個Page的最小Span(N個Page)。如果沒有找到合適的Span,則說明PageHeap已經無法滿足需求,則向作業系統虛擬記憶體的堆空間申請一堆記憶體,將申請到的記憶體安置在PageHeap的記憶體結構中,重新執行(3)步驟。

(5)將從Large Span Set集合得到的N個Page組成的Span拆分成兩個Span,K個Page的Span直接返回給Thread使用者邏輯,N-K個Span退還給PageHeap。其中如果N-K大於128則退還到Large Span Set集合中,如果N-K小於128,則退還到Page連結串列中。

綜上是TCMalloc對於大物件分配的詳細流程。

6 Golang堆記憶體管理

本章節將介紹Golang的記憶體管理模型,看本章節之前強烈建議讀者將上述章節均閱讀理解完成,更有助於理解Golang的記憶體管理機制。

6.1 Golang記憶體模型層級結構

Golang記憶體管理模型的邏輯層次全景圖,如圖31所示。

圖 31 Golang記憶體管理模組關係

Golang記憶體管理模型與TCMalloc的設計極其相似。基本輪廓和概念也幾乎相同,只是一些規則和流程存在差異,接下來分析一下Golang記憶體管理模型的基本層級模組組成概念。

6.2 Golang記憶體管理單元相關概念

Golang記憶體管理中依然保留TCMalloc中的Page、Span、Size Class等概念。

1.Page

與TCMalloc的Page一致。Golang記憶體管理模型延續了TCMalloc的概念,一個Page的大小依然是8KB。Page表示Golang記憶體管理與虛擬記憶體互動記憶體的最小單元。作業系統虛擬記憶體對於Golang來說,依然是劃分成等分的N個Page組成的一塊大記憶體公共池,如圖3.21所示。

2.mSpan

與TCMalloc中的Span一致。mSpan概念依然延續TCMalloc中的Span概念,在Golang中將Span的名稱改為mSpan,依然表示一組連續的Page。

3.Size Class相關

Golang記憶體管理針對Size Class對衡量記憶體的的概念又更加詳細了很多,這裡面介紹一些基礎的有關記憶體大小的名詞及演算法。

(1)Object Size,是隻協程應用邏輯一次向Golang記憶體申請的物件Object大小。Object是Golang記憶體管理模組針對記憶體管理更加細化的記憶體管理單元。一個Span在初始化時會被分成多個Object。比如Object Size是8B(8位元組)大小的Object,所屬的Span大小是8KB(8192位元組),那麼這個Span就會被平均分割成1024(8192/8=1024)個Object。邏輯層向Golang記憶體模型取記憶體,實則是分配一個Object出去。為了更好的讓讀者理解,這裡假設了幾個資料來標識Object Size 和Span的關係,如圖32所示。

圖 32 Object Size與Span的關係

上圖中的Num Of Object表示當前Span中一共存在多少個Object。

注意 Page是Golang記憶體管理與作業系統互動衡量記憶體容量的基本單元,Golang記憶體管理內部本身用來給物件儲存記憶體的基本單元是Object。

(2)Size Class,Golang記憶體管理中的Size Class與TCMalloc所表示的設計含義是一致的,都表示一塊記憶體的所屬規格或者刻度。Golang記憶體管理中的Size Class是針對Object Size來劃分記憶體的。也是劃分Object大小的級別。比如Object Size在1Byte8Byte之間的Object屬於Size Class 1級別,Object Size 在8B16Byte之間的屬於Size Class 2級別。

(3)Span Class,這個是Golang記憶體管理額外定義的規格屬性,是針對Span來進行劃分的,是Span大小的級別。一個Size Class會對應兩個Span Class,其中一個Span為存放需要GC掃描的物件(包含指標的物件),另一個Span為存放不需要GC掃描的物件(不包含指標的物件),具體Span Class與Size Class的邏輯結構關係如圖33所示。

圖 33 Span Class與Size Class的邏輯結構關係

其中Size Class和Span Class的對應關係計算方式可以參考Golang原始碼,如下:

//usr/local/go/src/runtime/mheap.go

type spanClass uint8 

//……(省略部分程式碼)

func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

//……(省略部分程式碼)

這裡makeSpanClass()函式為通過Size Class來得到對應的Span Class,其中第二個形參noscan表示當前物件是否需要GC掃描,不難看出來Span Class 和Size Class的對應關係公式如表3-5所示。

表5 TCMalloc的記憶體分離
物件 Size Class **** Span Class**對應公式**
需要GC掃描 Span Class = Size Class * 2 + 0
不需要GC掃描 Span Class = Size Class * 2 + 1

4.Size Class明細

如果再具體一些,則通過Golang的原始碼可以看到,Golang給記憶體池中固定劃分了66[7]個Size Class,這裡面列舉了詳細的Size Class和Object大小、存放Object數量,以及每個Size Class對應的Span記憶體大小關係,程式碼如下:

//usr/local/go/src/runtime/sizeclasses.go

package runtime

// 標題Title解釋:
// [class]: Size Class
// [bytes/obj]: Object Size,一次對外提供記憶體Object的大小
// [bytes/span]: 當前Object所對應Span的記憶體大小
// [objects]: 當前Span一共有多少個Object
// [tail wastre]: 為當前Span平均分層N份Object,會有多少記憶體浪費
// [max waste]: 當前Size Class最大可能浪費的空間所佔百分比

// class  bytes/obj  bytes/span  objects  tail waste  max waste
//     1          8        8192     1024           0        87.50%
//     2         16        8192      512           0        43.75%
//     3         32        8192      256           0        46.88%
//     4         48        8192      170          32        31.52%
//     5         64        8192      128           0        23.44%
//     6         80        8192      102          32        19.07%
//     7         96        8192       85          32        15.95%
//     8        112        8192       73          16        13.56%
//     9        128        8192       64           0        11.72%
//    10        144        8192       56         128        11.82%
//    11        160        8192       51          32        9.73%
//    12        176        8192       46          96        9.59%
//    13        192        8192       42         128        9.25%
//    14        208        8192       39          80        8.12%
//    15        224        8192       36         128        8.15%
//    16        240        8192       34          32        6.62%
//    17        256        8192       32           0        5.86%
//    18        288        8192       28         128        12.16%
//    19        320        8192       25         192        11.80%
//    20        352        8192       23          96        9.88%
//    21        384        8192       21         128        9.51%
//    22        416        8192       19         288        10.71%
//    23        448        8192       18         128        8.37%
//    24        480        8192       17          32        6.82%
//    25        512        8192       16           0        6.05%
//    26        576        8192       14         128        12.33%
//    27        640        8192       12         512        15.48%
//    28        704        8192       11         448        13.93%
//    29        768        8192       10         512        13.94%
//    30        896        8192        9         128        15.52%
//    31       1024        8192        8           0        12.40%
//    32       1152        8192        7         128        12.41%
//    33       1280        8192        6         512        15.55%
//    34       1408       16384       11         896        14.00%
//    35       1536        8192        5         512        14.00%
//    36       1792       16384        9         256        15.57%
//    37       2048        8192        4           0        12.45%
//    38       2304       16384        7         256       12.46%
//    39       2688        8192        3         128        15.59%
//    40       3072       24576        8           0        12.47%
//    41       3200       16384        5         384        6.22%
//    42       3456       24576        7         384        8.83%
//    43       4096        8192        2           0        15.60%
//    44       4864       24576        5         256        16.65%
//    45       5376       16384        3         256        10.92%
//    46       6144       24576        4           0        12.48%
//    47       6528       32768        5         128        6.23%
//    48       6784       40960        6         256        4.36%
//    49       6912       49152        7         768        3.37%
//    50       8192        8192        1           0        15.61%
//    51       9472       57344        6         512        14.28%
//    52       9728       49152        5         512        3.64%
//    53      10240       40960        4           0        4.99%
//    54      10880       32768        3         128        6.24%
//    55      12288       24576        2           0        11.45%
//    56      13568       40960        3         256        9.99%
//    57      14336       57344        4           0        5.35%
//    58      16384       16384        1           0        12.49%
//    59      18432       73728        4           0        11.11%
//    60      19072       57344        3         128        3.57%
//    61      20480       40960        2           0        6.87%
//    62      21760       65536        3         256        6.25%
//    63      24576       24576        1           0        11.45%
//    64      27264       81920        3         128        10.00%
//    65      28672       57344        2           0        4.91%
//    66      32768       32768        1           0        12.50%

下面分別解釋一下每一列的含義:

(1)Class列為Size Class規格級別。

(2)bytes/obj列為Object Size,即一次對外提供記憶體Object的大小(單位為Byte),可能有一定的浪費,比如業務邏輯層需要2B的資料,實則會定位到Size Class為1,返回一個Object即8B的記憶體空間。

(3)bytes/span列為當前Object所對應Span的記憶體大小(單位為Byte)。

(4)objects列為當前Span一共有多少個Object,該欄位是通過bytes/span和bytes/obj相除計算而來。

(5)tail waste列為當前Span平均分層N份Object,會有多少記憶體浪費,這個值是通過bytes/span對bytes/obj求餘得出,即span%obj。

(6)max waste列當前Size Class最大可能浪費的空間所佔百分比。這裡面最大的情況就是一個Object儲存的實際資料剛好是上一級Size Class的Object大小加上1B。當前Size Class的Object所儲存的真實資料物件都是這一種情況,這些全部空間的浪費再加上最後的tail waste就是max waste最大浪費的記憶體百分比,具體如圖34所示。

圖 34 Max Waste最大浪費空間計算公式

圖中以Size Class 為7的Span為例,通過原始碼runtime/sizeclasses.go的詳細Size Class資料可以得知具體Span細節如下:

// class  bytes/obj  bytes/span  objects  tail waste  max waste

// … …
//     6         80        8192      102          32        19.07%
//     7         96        8192       85          32        15.95%
// … …

從圖3.34可以看出,Size Class為7的Span如果每個Object均超過Size Class為7中的Object一個位元組。那麼就會導致Size Class為7的Span出現最大空間浪費情況。綜上可以得出計算最大浪費空間比例的演算法公式如下:

(本級Object Size – (上級Object Size + 1)*本級Object數量) / 本級Span Size

6.3 MCache

從概念來講MCache與TCMalloc的ThreadCache十分相似,訪問mcache依然不需要加鎖而是直接訪問,且MCache中依然儲存各種大小的Span。

雖然MCache與ThreadCache概念相似,二者還是存在一定的區別的,MCache是與Golang協程排程模型GPM中的P所繫結,而不是和執行緒繫結。因為Golang排程的GPM模型,真正可執行的執行緒M的數量與P的數量一致,即GOMAXPROCS個,所以MCache與P進行繫結更能節省記憶體空間使用,可以保證每個G使用MCache時不需要加鎖就可以獲取到記憶體。而TCMalloc中的ThreadCache隨著Thread的增多,ThreadCache的數量也就相對成正比增多,二者繫結關係的區別如圖35所示。

圖 35 ThreadCache與mcache的繫結關係區別

如果將圖35的mcache展開,來看mcache的內部構造,則具體的結構形式如圖36所示。

圖 36 MCache內部構造

協程邏輯層從mcache上獲取記憶體是不需要加鎖的,因為一個P只有一個M在其上執行,不可能出現競爭,由於沒有鎖限制,mcache則其到了加速記憶體分配。

MCache中每個Span Class都會對應一個MSpan,不同Span Class的MSpan的總體長度不同,參考runtime/sizeclasses.go的標準規定劃分。比如對於Span Class為4的MSpan來說,存放記憶體大小為1Page,即8KB。每個對外提供的Object大小為16B,共存放512個Object。其他Span Class的存放方式類似。當其中某個Span Class的MSpan已經沒有可提供的Object時,MCache則會向MCentral申請一個對應的MSpan。

在圖3.36中應該會發現,對於Span Class為0和1的,也就是對應Size Class為0的規格刻度記憶體,mcache實際上是沒有分配任何記憶體的。因為Golang記憶體管理對記憶體為0的資料申請做了特殊處理,如果申請的資料大小為0將直接返回一個固定記憶體地址,不會走Golang記憶體管理的正常邏輯,相關Golang原始碼如下:

//usr/local/go/src/runtime/malloc.go

// Al Allocate an object of size bytes.                                     
// Sm Small objects are allocated from the per-P cache's free lists.        
// La Large objects (> 32 kB) are allocated straight from the heap.         
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {                        
// ……(省略部分程式碼)

if size == 0 {
return unsafe.Pointer(&zerobase)
}

//……(省略部分程式碼)
}

上述程式碼可以看見,如果申請的size為0,則直接return一個固定地址zerobase。下面來測試一下有關0空間申請的情況,在Golang中如[0]int、 struct{}所需要大小均是0,這也是為什麼很多開發者在通過Channel做同步時,傳送一個struct{}資料,因為不會申請任何記憶體,能夠適當節省一部分記憶體空間,測試程式碼如下:

//第一篇/chapter3/MyGolang/zeroBase.go
package main

import (
"fmt"
)

func main() {
var (
//0記憶體物件
a struct{}
b [0]int

//100個0記憶體struct{}
c [100]struct{}

//100個0記憶體struct{},make申請形式
d = make([]struct{}, 100)
)

fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)
fmt.Printf("%p\n", &c[50])    //取任意元素
fmt.Printf("%p\n", &(d[50]))  //取任意元素
}

執行結果如下:

$ go run zeroBase.go 
0x11aac78
0x11aac78
0x11aac78
0x11aac78

從結果可以看出,全部的0記憶體物件分配,返回的都是一個固定的地址。

6.4 MCentral

MCentral與TCMalloc中的Central概念依然相似。向MCentral申請Span是同樣是需要加鎖的。當MCache中某個Size Class對應的Span被一次次Object被上層取走後,如果出現當前Size Class的Span空缺情況,MCache則會向MCentral申請對應的Span。Goroutine、MCache、MCentral、MHeap互相交換的記憶體單位是不同,具體如圖37所示。

圖 37 Golang記憶體管理各層級記憶體交換單位

其中協程邏輯層與MCache的記憶體交換單位是Object,MCache與MCentral的記憶體交換單位是Span,而MCentral與MHeap的記憶體交換單位是Page。

MCentral與TCMalloc中的Central不同的是MCentral針對每個Span Class級別有兩個Span連結串列,而TCMalloc中的Central只有一個。MCentral的內部構造如圖38所示。

圖 38 MCentral的內部構造

MCentral與MCCache不同的是,每個級別儲存的不是一個Span,而是一個Span List連結串列。與TCMalloc中的Central不同的是,MCentral每個級別都儲存了兩個Span List。

注意 圖38中MCentral是表示一層抽象的概念,實際上每個Span Class對應的記憶體資料結構是一個mcentral,即在MCentral這層資料管理中,實際上有Span Class個mcentral小記憶體管理單元。

1)NonEmpty Span List

表示還有可用空間的Span連結串列。連結串列中的所有Span都至少有1個空閒的Object空間。如果MCentral上游MCache退還Span,會將退還的Span加入到NonEmpty Span List連結串列中。

2)Empty Span List

表示沒有可用空間的Span連結串列。該連結串列上的Span都不確定否還有有空閒的Object空間。如果MCentral提供給一個Span給到上游MCache,那麼被提供的Span就會加入到Empty List連結串列中。

注意 在Golang 1.16版本之後,MCentral中的NonEmpty Span List 和 Empty Span List

均由連結串列管理改成集合管理,分別對應Partial Span Set 和 Full Span Set。雖然儲存的資料結構有變化,但是基本的作用和職責沒有區別。

下面是MCentral層級中其中一個Size Class級別的MCentral的定義Golang原始碼(V1.14版本):

//usr/local/go/src/runtime/mcentral.go  , Go V1.14

// Central list of free objects of a given size.
// go:notinheap
type mcentral struct {
lock      mutex      //申請MCentral記憶體分配時需要加的鎖

spanclass spanClass //當前哪個Size Class級別的

// list of spans with a free object, ie a nonempty free list
// 還有可用空間的Span 連結串列
nonempty  mSpanList 

// list of spans with no free objects (or cached in an mcache)
// 沒有可用空間的Span連結串列,或者當前連結串列裡的Span已經交給mcache
empty     mSpanList 

// nmalloc is the cumulative count of objects allocated from
// this mcentral, assuming all spans in mcaches are
// fully-allocated. Written atomically, read under STW.
// nmalloc是從該mcentral分配的物件的累積計數
// 假設mcaches中的所有跨度都已完全分配。
// 以原子方式書寫,在STW下閱讀。
nmalloc uint64
}

在GolangV1.16及之後版本(截止本書編寫最新時間)的相關MCentral結構程式碼如下:

//usr/local/go/src/runtime/mcentral.go  , Go V1.16+

//…

type mcentral struct {
// mcentral對應的spanClass
spanclass spanClass

partial  [2]spanSet // 維護全部空閒的Span集合
full     [2]spanSet // 維護存在非空閒的Span集合
}

//…

新版本的改進是將List變成了兩個Set集合,Partial集合與NonEmpty Span List責任類似,Full集合與Empty Span List責任類似。可以看見Partial和Full都是一個[2]spanSet型別,也就每個Partial和Full都各有兩個spanSet集合,這是為了給GC垃圾回收來使用的,其中一個集合是已掃描的,另一個集合是未掃描的。

6.5 MHeap

Golang記憶體管理的MHeap依然是繼承TCMalloc的PageHeap設計。MHeap的上游是MCentral,MCentral中的Span不夠時會向MHeap申請。MHeap的下游是作業系統,MHeap的記憶體不夠時會向作業系統的虛擬記憶體空間申請。訪問MHeap獲取記憶體依然是需要加鎖的。

MHeap是對記憶體塊的管理物件,是通過Page為記憶體單元進行管理。那麼用來詳細管理每一系列Page的結構稱之為一個HeapArena,它們的邏輯層級關係如圖39所示。

圖 39 MHeap內部邏輯層級構造

一個HeapArena佔用記憶體64MB[8],其中裡面的記憶體的是一個一個的mspan,當然最小單元依然是Page,圖中沒有表示出mspan,因為多個連續的page就是一個mspan。所有的HeapArena組成的集合是一個Arenas,也就是MHeap針對堆記憶體的管理。MHeap是Golang程式全域性唯一的所以訪問依然加鎖。圖中又出現了MCentral,因為MCentral本也屬於MHeap中的一部分。只不過會優先從MCentral獲取記憶體,如果沒有MCentral會從Arenas中的某個HeapArena獲取Page。

如果再詳細剖析MHeap裡面相關的資料結構和指標依賴關係,可以參考圖40,這裡不做過多解釋,如果更像詳細理解MHeap建議研讀原始碼/usr/local/go/src/runtime/mheap.go檔案。

圖 40 MHeap資料結構引用依賴

MHeap中HeapArena佔用了絕大部分的空間,其中每個HeapArean包含一個bitmap,其作用是用於標記當前這個HeapArena的記憶體使用情況。其主要是服務於GC垃圾回收模組,bitmap共有兩種標記,一個是標記對應地址中是否存在物件,一個是標記此物件是否被GC模組標記過,所以當前HeapArena中的所有Page均會被bitmap所標記。

ArenaHint為定址HeapArena的結構,其有三個成員:

(1)addr,為指向的對應HeapArena首地址。

(2)down,為當前的HeapArena是否可以擴容。

(3)next,指向下一個HeapArena所對應的ArenaHint首地址。

從圖3.40中可以看出,MCentral實際上就是隸屬於MHeap的一部分,從資料結構來看,每個Span Class對應一個MCentral,而之前在分析Golang記憶體管理中的邏輯分層中,是將這些MCentral集合統一歸類為MCentral層。

6.6 Tiny物件分配流程

在之前章節的表3-4中可以得到TCMalloc將物件分為了小物件、中物件、和大物件,而Golang記憶體管理將物件的分類進行了更細的一些劃分,具體的劃分割槽別對比如表6所示。

表6 Golang記憶體與TCMalloc對記憶體的分類對比
TCMalloc Golang
小物件 Tiny物件
中物件 小物件
大物件 大物件

針對Tiny微小物件的分配,實際上Golang做了比較特殊的處理,之前在介紹MCache的時候並沒有提及有關Tiny的儲存和分配問題,MCache中不僅儲存著各個Span Class級別的記憶體塊空間,還有一個比較特殊的Tiny儲存空間,如圖41所示。

圖 41 MCache中的Tiny空間

Tiny空間是從Size Class = 2(對應Span Class = 4 或5)中獲取一個16B的Object,作為Tiny物件的分配空間。對於Golang記憶體管理為什麼需要一個Tiny這樣的16B空間,原因是因為如果協程邏輯層申請的記憶體空間小於等於8B,那麼根據正常的Size Class匹配會匹配到Size Class = 1(對應Span Class = 2或3),所以像 int32、 byte、 bool 以及小字串等經常使用的Tiny微小物件,也都會使用從Size Class = 1申請的這8B的空間。但是類似bool或者1個位元組的byte,也都會各自獨享這8B的空間,進而導致有一定的記憶體空間浪費,如圖42所示。

圖 42 如果微小物件不存在Tiny空間中

可以看出來這樣當大量的使用微小物件可能會對Size Class = 1的Span造成大量的浪費。所以Golang記憶體管理決定儘量不使用Size Class = 1的Span,而是將申請的Object小於16B的申請統一歸類為Tiny物件申請。具體的申請流程如圖43所示。

圖 43 MCache中Tiny微小物件分配流程

MCache中對於Tiny微小物件的申請流程如下:

(1)P向MCache申請微小物件如一個Bool變數。如果申請的Object在Tiny物件的大小範圍則進入Tiny物件申請流程,否則進入小物件或大物件申請流程。

(2)判斷申請的Tiny物件是否包含指標,如果包含則進入小物件申請流程(不會放在Tiny緩衝區,因為需要GC走掃描等流程)。

(3)如果Tiny空間的16B沒有多餘的儲存容量,則從Size Class = 2(即Span Class = 4或5)的Span中獲取一個16B的Object放置Tiny緩衝區。

(4)將1B的Bool型別放置在16B的Tiny空間中,以位元組對齊的方式。

Tiny物件的申請也是達不到記憶體利用率100%的,就上述圖43為例,當前Tiny緩衝16B的記憶體利用率為,而如果不用Tiny微小物件的方式來儲存,那麼記憶體的佈局將如圖44所示。

圖 44 不用Tiny緩衝儲存情況

可以算出利用率為。Golang記憶體管理通過Tiny物件的處理,可以平均節省20%左右的記憶體。

6.7 小物件分配流程

上節已經介紹了分配在1B至16B的Tiny物件的分配流程,那麼對於物件在16B至32B的記憶體分配,Golang會採用小物件的分配流程。

分配小物件的標準流程是按照Span Class規格匹配的。在之前介紹MCache的內部構造已經介紹了,MCache一共有67份Size Class其中Size Class 為0的做了特殊的處理直接返回一個固定的地址。Span Class為Size Class的二倍,也就是從0至133共134個Span Class。

當協程邏輯層P主動申請一個小物件的時候,Golang記憶體管理的記憶體申請流程如圖45所示。

圖 45 Golang小物件記憶體分配流程

下面來分析一下具體的流程過程:

(1)首先協程邏輯層P向Golang記憶體管理申請一個物件所需的記憶體空間。

(2)MCache在接收到請求後,會根據物件所需的記憶體空間計算出具體的大小Size。

(3)判斷Size是否小於16B,如果小於16B則進入Tiny微物件申請流程,否則進入小物件申請流程。

(4)根據Size匹配對應的Size Class記憶體規格,再根據Size Class和該物件是否包含指標,來定位是從noscan Span Class 還是 scan Span Class獲取空間,沒有指標則鎖定noscan。

(5)在定位的Span Class中的Span取出一個Object返回給協程邏輯層P,P得到記憶體空間,流程結束。

(6)如果定位的Span Class中的Span所有的記憶體塊Object都被佔用,則MCache會向MCentral申請一個Span。

(7)MCentral收到記憶體申請後,優先從相對應的Span Class中的NonEmpty Span List(或Partial Set,Golang V1.16+)裡取出Span(多個Object組成),NonEmpty Span List沒有則從Empty List(或 Full Set Golang V1.16+)中取,返回給MCache。

(8)MCache得到MCentral返回的Span,補充到對應的Span Class中,之後再次執行第(5)步流程。

(9)如果Empty Span List(或Full Set)中沒有符合條件的Span,則MCentral會向MHeap申請記憶體。

(10)MHeap收到記憶體請求從其中一個HeapArena從取出一部分Pages返回給MCentral,當MHeap沒有足夠的記憶體時,MHeap會向作業系統申請記憶體,將申請的記憶體也儲存到HeapArena中的mspan中。MCentral將從MHeap獲取的由Pages組成的Span新增到對應的Span Class連結串列或集合中,作為新的補充,之後再次執行第(7)步。

(11)最後協程業務邏輯層得到該物件申請到的記憶體,流程結束。

6.8 大物件分配流程

小物件是在MCache中分配的,而大物件是直接從MHeap中分配。對於不滿足MCache分配範圍的物件,均是按照大物件分配流程處理。

大物件分配流程是協程邏輯層直接向MHeap申請物件所需要的適當Pages,從而繞過從MCaceh到MCentral的繁瑣申請記憶體流程,大物件的記憶體分配流程相對比較簡單,具體的流程如圖46所示。

圖 46 Golang大物件記憶體分配流程

下面來分析一下具體的大物件記憶體分配流程:

(1)協程邏輯層申請大物件所需的記憶體空間,如果超過32KB,則直接繞過MCache和MCentral直接向MHeap申請。

(2)MHeap根據物件所需的空間計算得到需要多少個Page。

(3)MHeap向Arenas中的HeapArena申請相對應的Pages。

(4)如果Arenas中沒有HeapA可提供合適的Pages記憶體,則向作業系統的虛擬記憶體申請,且填充至Arenas中。

(5)MHeap返回大物件的記憶體空間。

(6)協程邏輯層P得到記憶體,流程結束。

7 小結

本章從作業系統的虛擬記憶體申請到Golang記憶體模型進行的理論的推進和逐層剖析。通過本章的記憶體,可以瞭解到無論是作業系統虛擬記憶體管理,還是C++的TCMalloc、Golang記憶體模型,均有一個共同特點,就是分層的快取機制。針對不同的記憶體場景採用不同的獨特解決方式,提高區域性性邏輯和細微粒度記憶體的複用率。這也是程式設計的至高理念。


[1] PTE是Page Table Entry的縮寫,表示頁表條目。PTE是由一個有效位和N位地址欄位構成,能夠有效標識這個虛擬記憶體地址是否分配了實體記憶體。

[2]CPU每次訪問虛擬記憶體,虛擬地址都必須轉換為對應的實體地址。從概念上說,這個轉換需要遍歷頁表,頁表是三級頁表,就需要3次記憶體訪問。就是說,每次虛擬記憶體訪問都會導致4次實體記憶體訪問。簡單點說,如果一次虛擬記憶體訪問對應了4次實體記憶體訪問,肯定比1次物理訪問慢,這樣虛擬記憶體肯定不會發展起來。幸運的是,有一個聰明的做法解決了大部分問題:現代CPU使用一小塊關聯記憶體,用來快取最近訪問的虛擬頁的PTE。這塊記憶體稱為translation lookaside buffer(TLB),參考《IA-64 Linux Kernel: Design and Implementation

[3]一個虛擬地址VA(Virtual Address)= 虛擬頁號VPN + 虛擬頁偏移量VPO。

[4]一個實體地址PA(Physical Address)= 物理頁號PPN * 頁長度PageSize+ 物理頁號偏移PPO(Physical Page Offset)

[5] Man 手冊頁(Manua pages,縮寫man page)是在Linux作業系統線上軟體文件的一種普遍形式。內容包括計算機程式庫和系統呼叫等命令的幫助手冊。

[6] TCMalloc官方文件稱一共劃分88個size-classes,“Each small object size maps to one of approximately 88 allocatable size-classes”,參考《TCMalloc : Thread-Caching Malloc》gperftools.github.io/gperftools/tc...

[7]參考Golang 1.14版本,其中還有擴充套件到128個size class的對應關係,本書不詳細介紹,具體細節參考Golang原始碼/usr/local/go/src/runtime/sizeclasses.go檔案。

[8]在Linux64位作業系統上。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
劉丹冰Aceld

相關文章