Go 是如何精緻得進行內存管理?

前言

Go 語言拋棄 C/C++ 中的開發者管理內存的方式,實現了主動申請與主動釋放管理,增加了逃逸分析和垃圾回收,將開發者從內存管理中釋放出來。

所以我們在日常編寫代碼的時候不需要精通內存的管理,它確實很複雜。但是另一方面,如果你掌握了 Go 內存管理的基本概念和知識點,可以讓你寫出更高質量的,更壓榨機器性能的代碼;另外,還能幫助你更快更精準得定位 Bug,快速解決問題。所以,作爲進階的 Go 開發,瞭解掌握 Go 的內存管理還是很有必要的。

相關背景

存儲金字塔

馮 · 諾依曼計算機體系中的存儲器,是用於存儲程序和數據的。現代計算機系統中,一般都是採用 “CPU 寄存器 - CPU 高速緩存 - 內存 - 硬盤” 的存儲器結構。自上而下容量逐漸增大,速度逐漸減慢,單位價格逐漸降低。

(圖片來自於網絡)

1、CPU 寄存器:存儲 CPU 正在使用的數據或指令。

2、CPU 高速緩存:存儲 CPU 近期要用到的數據和指令。

3、內存:存儲正在運行或者將要運行的程序和數據。

4、硬盤:存儲暫時不使用或者不能直接使用的程序和數據

虛擬內存

(圖片來自於網絡)

物理內存:是指實際通過物理內存而獲得的內存空間。

虛擬內存:與物理內存相反, 是指根據系統需要從硬盤中虛擬的劃出一部分存儲空間。

虛擬內存技術就是對內存的一種抽象,有了這層抽象之後,程序運行進程的總大小可以超過實際可用的物理內存大小。每個進程都有自己的獨立虛擬地址空間,然後通過 CPU 和 MMU 把虛擬內存地址轉換爲實際物理地址。

TCMalloc

TCMalloc 全稱是 Thread Cache Malloc, 是 google 爲 C 語言開發的內存分配算法,是 Go 內存分配的起源。

TCMalloc 內存分配算法的核心思想是把內存分爲多級管理,從而降低鎖的粒度,它將可用的堆內存採用二級分配的方式進行管理,每個線程都會自行維護一個獨立的線程內存池,進行內存分配時優先從該線程內存池中分配, 當線程內存池不足時纔會向全局內存池申請,以避免不同線程對全局內存池的頻繁競爭 , 進一步的降低了內存併發訪問的粒度。

Go 的內存分配算法是基於 TCMalloc 內存分配算法實現的,借鑑了 TCmalloc 的思想。

幾個重要概念

Page: 操作系統對內存的管理同樣是以頁爲單位,但 TCMalloc 中的 Page 和操作系統的中頁是倍數關係,x64 下 Page 大小爲 8KB。

Span:一組連續的 Page 被叫做 Span,是 TCMalloc 內存管理的基本單位,有不同大小的 Span,比如 2 個 Page 大的 Span,16 個 Page 大的 Span。

ThreadCache:每個線程各自的 Cache,每個 ThreadCache 包含多個不同規格的 Span 鏈表,叫做 SpanList,內存分配的時候,可以根據要分配的內存大小,快速選擇不同大小的 SpanList,在 SpanList 上選擇合適的 Span,每個線程都有自己的 ThreadCache,所以 ThreadCache 是無鎖訪問的。

CentralCache:中心 Cache,所有線程共享的 Cache,也是保存的 SpanList,數量和 ThreadCache 中數量相同,當 ThreadCache 中內存不足時,可以從 CentralCache 中獲取,當 ThreadCache 中內存太多時,可以放回 CentralCache,由於 CentralCache 是線程共享的,所以它的訪問需要加鎖。

PageHeap:堆內存的抽象,同樣當 CentealCache 中內存太多或太少時,都可從 PageHeap 中放回或獲取,同樣,PageHeap 的訪問也是需要加鎖的。

管理分配

核心思想

Go 在程序啓動的時候,會分配一塊連續的內存(虛擬的地址空間,還沒有真正地分配內存),切成小塊後自己進行管理,對內存的分配遵循以下思想。

  1. 每次從操作系統申請一大塊內存, 以減少系統調用。

  2. 將申請到的大塊內存按照特定大小預先切分成小塊, 構成鏈表。

  3. 爲對象分配內存時, 只需從大小合適的鏈表提取一個小塊即可。

  4. 回收對象內存時, 將該小塊內存重新歸還到原鏈表, 以便複用。

  5. 如閒置內存過多, 則嘗試歸還部分內存給操作系統, 降低整體開銷。

內存管理由 mcache、mcentral、mheap 組成一個三級管理結構,本質上都是對 mspan 的管理,三者用於不同的目的來共同配合管理所有 mspan。

mspan

mspan 是 Go 中內存管理的基本單元,是由一片連續的 8kB 的 page 組成的內存塊。但是小對象和大對象分配的位置不用,大對象在 mheap 上分配,mheap 向操作系統申請新內存時,是向虛擬內存申請;小對象使用 mcache 的 tiny 分配器分配。

一組連續的 Page 組成 1 個 Span,go 把內存分爲 67 個大小不同的 span,並且大小是不固定的。

源碼文件 src/runtime/sizeclasses.go 對 67 種 span 的定義(源碼版本爲 go-1.17.1, 本文下所有源碼展示均爲此版本)

延伸擴展:67 種定義列表裏面有一列的名稱叫做 "max waste", 代表的是這個 span 下可能出現的最大內存浪費比例。舉個例子解釋,看第 4 個規格的情況:

class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
    4         32        8192      256           0     21.88%         32

4 的對象最小內存長度爲 25 字節(因爲小於 25 的只會申請 3 或 3 以下的,要不到 4),所以如果每個 object 都被 25 字節的對象申請,此時內存浪費最大,對應浪費率爲:(32-25)/32 = 21.88%

再通過觀察整個列表可以看到,"max waste" 一列並非線性遞減的,熟悉 Linux 的同學應該猜到原因了,沒錯,這個設計跟大名鼎鼎的夥伴算法是非常相似的。

夥伴算法(buddy 算法),就是將內存分成若干塊,然後以最適合的方式滿足程序內存需求的一種內存管理算法,夥伴算法是儘可能地在提高內存利用率的同時減少內存碎片。但是算法中,一個很小的塊往往會阻礙一個大塊的合併,一個系統中,對內存塊的分配,大小是隨機的,一片內存中僅一個小的內存塊沒有釋放,旁邊兩個大的就不能合併,這也是造成上面現象的根因。(完整解讀夥伴算法需要非常大的篇幅和難度,本文就不展開了,文章最後有參考鏈接,讀者可自行研究)

回來主題,上面說到的 Spans 有 3 種類型:

空閒 - span,沒有對象,可以釋放回操作系統,或重用於堆分配,或重用於堆棧內存。

正在使用 - span,至少有一個堆對象,可能有更多的空間。

棧 - span,用於 goroutine 堆棧。此跨度可以存在於堆棧中或堆中,但不能同時存在。

源碼文件 src/runtime/mheap.go 對 mspan 結構體的定義

// mspan結構體部分關鍵屬性說明
type mspan struct {
  next *mspan     // 鏈表後向指針
  prev *mspan     // 鏈表前向指針
  list *mSpanList // 雙端隊列的head(已無實際用途)
  startAddr uintptr // span起始位置的地址指針
  npages    uintptr // 可供分配的頁數
  ...
  manualFreeList gclinkptr // 在mSpanManual的空閒對象
  allocCache uint64  // 在freeindex處的allocBits的緩存
  ...
  allocBits  *gcBits // 標記span中的elem哪些是被使用的,哪些是未被使用的
  gcmarkBits *gcBits // 標記span中的elem哪些是被標記的,哪些是未被標記的
  speciallock mutex  // 互斥鎖
}

管理組件說明

內存管理器由 mcache, mcentral, mheap3 種組件構成:三級管理結構是爲了方便對 span 進行管理,加速對 span 對象的訪問和分配,這三個結構在 runtime 中分別有對應的 mcache.go、mcentral.go、mheap.go 文件。

mcache:保存的是各種大小的 Span,並按 Span class 分類,小對象直接從 mcache 分配內存,它起到了緩存的作用,並且可以無鎖訪問 Go 中是每個 P 擁有 1 個 mcache。

mcentral:是所有線程共享的緩存,需要加鎖訪問,它按 Span class 對 Span 分類,串聯成鏈表,當 mcache 的某個級別 Span 的內存被分配光時,它會向 mcentral 申請 1 個當前級別的 Span。

mheap:是堆內存的抽象,把從 OS(系統)申請出的內存頁組織成 Span,並保存起來。當 mcentral 的 Span 不夠用時會向 mheap 申請,mheap 的 Span 不夠用時會向 OS 申請,向 OS 的內存申請是按頁來的。

通俗的理解:mcache, mcentral, mheap 就是對 ThreadCache, CentralCache, PageHeap 的繼承沿用和基於 go 體系的優化處理版本。

分配流程

Go 的內存分配器在分配對象時,根據對象的大小,分成三類:小對象(<=16B)、一般對象(>16B && <=32KB)、大對象(>32KB)。

源碼文件 src/runtime/malloc.go 根據分配對象的大小選擇對應的空間申請

大體上的分配流程:

  1. 32KB 的對象,直接從 mheap 上分配。

  2. <=16B 的對象使用 mcache 的 tiny 分配器分配。

  3. 16B && <=32KB 的對象,首先計算對象的規格大小,然後使用 mcache 中相應規格大小的 mspan 分配。

A. 如果 mcache 沒有相應規格大小的 mspan,則向 mcentral 申請。

B. 如果 mcentral 沒有相應規格大小的 mspan,則向 mheap 申請。

C. 如果 mheap 中也沒有合適大小的 mspan,則向 OS 申請。

源碼文件 src/runtime/mheap.go 內存分配初始化過程

小結

Go 內存管理源自 TCMalloc,優秀作品源於繼承和優化(在這裏我自己想到了一句話:如果說我比別人看得更遠些,那是因爲我站在了巨人的肩上 -- 牛頓)。但它比 TCMalloc 還多了 2 件東西:逃逸分析(後面篇幅會提及)和垃圾回收。

總結一下它在底層設計上着重用到的 2 個重要觀念:

  1. 使用緩存提高效率:在存儲的整個體系中到處可見緩存的思想,利用緩存減少了系統調用的次數,降低了鎖的粒度、減少加鎖的次數,提高了管理效率。

  2. 以空間換時間:空間換時間是一種常用的性能優化思想,數據庫的索引 / 許多數據結構的本質就是空間換時間。

關聯知識點

逃逸分析

Go 堆內存所使用的內存頁 (page) 與 goroutine 的棧所使用的內存頁是交織在一起的,帶 GC(垃圾回收)功能的 GO 語言會對位於堆上的對象進行自動管理。當某個對象不可達時,即沒有其對象引用它時,它將會被回收並被重用(三色標記)。

但 GC 是會給程序帶來性能損耗的,尤其是當堆內存上有大量待掃描的堆內存對象時,將會給 GC 帶來過大的壓力,從而消耗更多的計算和存儲資源。於是開發者們都想盡量減少在堆上的內存分配,可以在棧上分配的變量儘量留在棧上。

逃逸分析(escape analysis)就是在程序編譯階段根據程序代碼中的數據流,對代碼中哪些變量需要在棧上分配,哪些變量需要在堆上分配進行靜態分析的方法。

分析準則:逃逸分析是在編譯器完成的,也就是隻存在於編譯階段;如果變量在函數外部沒有引用,則優先放到棧中;如果變量在函數外部存在引用,則必定放在堆中。

命令:go build -gcflags '-m -m -l' xxx.go

內存對齊

CPU 訪問內存時,並不是逐個字節訪問,而是以字長爲單位訪問。這樣是爲了是減少 CPU 訪問內存的次數,提升 CPU 訪問內存的吞吐量。如果訪問對象在內存的存儲空間是對齊的話,CPU 讀取一次即可,否則就要讀取兩次甚至多次,如下圖清晰可見。

對齊規則:第一個成員在與結構體變量偏移量爲 0 的地址處;其他成員變量要對齊到對齊數的整數倍的地址處;結構體總大小爲最大對齊數的整數倍

下圖是不同類型的對齊係數和佔用字節數

所以我們在日常編碼過程中,要儘量對結構體的變量類型做針對性的順序調整,以符合對齊原則。

One More Thing

介紹完了 Go 的情況,最後來簡單看下其他語言的,作者本人對 Java 不太熟悉,就不摻和了,就用最簡單的描述來講一下相對熟悉的 php 和 python。

php:php 也是有一個基本的分配單元叫 chunk,chunk 分配了 512 個 page,page 的大小爲 4KB。內存分配模式也是有 3 種,small(小於等於 3KB),large(大於 3KB 小於等於 2MB-4KB 內存),huge(大於 2MB-4KB 內存),GC 機制是引用計數方式,對堆區 zend_mm_heap 的管控就相對非常隨意了(純個人心得理解)。

python:py 最大的特色是有個內存池,可以減少內存碎片化,提高執行效率。回收機制也是引用計數,但是它有標記 / 清除和分代回收兩個輔助功能。

綜合 3 種語言對比,可以看到既有共同交集的地方,也有各自的私有屬性特色,各自的管理分配方式用到自己的語言環境下都能發揮最大的作用和效率。這也驗證了一句至高的哲學:方案設計或者架構理念,是沒有最優秀最完美的,但是會有最適合最貼近使用場景的。

參考資料

go 內存分配器                 

https://zhuanlan.zhihu.com/p/410317967

一文搞懂 Go 逃逸分析       

https://zhuanlan.zhihu.com/p/386769009

Linux 內存管理夥伴算法   

https://www.cnblogs.com/alantu2018/p/8527821.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sjBbpT7tGNYiPFi1qKy-Xg