1. 程式人生 > >技術分享:記憶體管理

技術分享:記憶體管理

導讀:如何寫一篇技術文章

  1. 確定目標讀者群體
  2. 從問題出發,帶著問題一步一步引出要講解的內容
  3. 基於問題用邏輯推演匯出內容
    1. 要有邏輯
  4. 斐波那契原則
    1. 層層遞進

ps: 來自知乎問答


正文

本次分享的主題是:記憶體管理。 首先講下為什麼做這次分享,之前自己看過很多東西,但是呢,由於工作中沒有用到,看完後呢就都忘了,就拿redis來說,相信很多人都看過《redis設計與實現》這本書,記得當時自己也一邊看書一邊看原始碼,但是現在也不記得什麼了。所以呢,我就想有什麼方法能夠讓自己更好的掌握理解所學的知識,即使這些內容在工作中暫時用不到,一個很好的辦法就是做測驗,即我們經常那一些問題來問自己,檢驗自己對內容的理解程度,所以本次分享我會先提問,然後為了回答這個問題,一步一步的給出本次分享的內容,當然一是由於記憶體管理主題太大。二是因為自己所知道的知識也有限,不可能面面俱到,因為自己也不知道自己不知道什麼。只能後續不斷補充完善記憶體管理的內容。

記憶體分配

先來講記憶體分配,我們先看下面的一小段程式碼:

func main() {
	http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
	})
    fmt.Printf("pid: %d\n", os.Getpid())
	http.ListenAndServe(":8080", nil)
}
複製程式碼

功能非常簡單,就是在8080埠啟動了一個http服務,我們編譯並且執行起來

go build main.go
./main
> pid: 3240424
複製程式碼

通過ps命令檢視程序詳情(mac 下ps)

我們重點看兩個指標: VSZ、RSS. VSZ 是 Virtual Memory Size 虛擬記憶體大小的縮寫, RSS 是 Resident Set Size 縮寫,代表了程式實際使用實體記憶體。 這就很奇怪了,我們看到程式虛擬記憶體佔用了約213.69MB,實體記憶體佔有了5.30MB,那問題來了:

為什麼虛擬記憶體比實體記憶體多這麼多? 為了回答上這個問題,我們先介紹虛擬記憶體。


虛擬記憶體

要講虛擬記憶體,我們首先從馮諾依曼體系說起,馮諾依曼體系將計算機主要分為了:cpu、記憶體、IO 這三部分,其中

我們先回答一個問題:可執行程式是怎麼能夠執行的?我們日常開發中go build main.go; ./main 當我們執行main的時候,為什麼程式能夠被執行?

首先用高階語言編寫的程式要經過預處理、編譯、彙編、連結,然後產生可執行的檔案,只有經過連結的檔案才能夠被執行,我們可以看下線上可執行檔案的型別:

file output/bin/xx.api
output/bin/xx.api: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not stripped
複製程式碼

裡面幾個關鍵字 ELF、dynamically linked、ld-linux-x86-64.so.2,我們分別來解釋下。 首先 ELF 是 Linux下的一種可執行程式的型別,我們可以 man elf 檢視具體的說明,

通讀完manual後可以知道elf檔案開始是一個elf頭,然後是program header或者section table,這兩個頭描述了餘下檔案的內容。

先來看 elf 頭,我們可以通過 readelf -h output/bin/x.api 檢視

elf頭的具體含義是定義在 elf.h 中,裡面有個Elf64_Ehdr結構,每個欄位的含義可以看手冊。 在64位機器上elf header大小是64位元組,我們可以通過命令 hexdump -C output/bin/xx.api |head -n 10來檢視資料,然後跟實際情況對比,借用一張網上圖片:

更多關於elf的介紹可以看部落格Introduction to the ELF Format

介紹完elf header,下面就是非常重要的兩個概念:

  • program header
  • Section header

我們知道程式要經過預處理、編譯、彙編、連結四個步驟才能成為可執行檔案,其中彙編是將彙編檔案轉換為機器可執行的指令,裡面還有一個非常重要的工作就是將檔案按語義分段儲存,常見的一些section就是程式碼段,資料段,debug段等,那為什麼我們要按不同功能分段呢? 以下面的程式碼為例子:

int printf(const char *format, ...);

void func1(int i) {
    printf("%d\n", i);
}

int main() {
    static int static_var = 85;
    static int static_var2;

    int a = 1;
    int b;

    func1(static_var + static_var2 + a + b);

    return a;
}
複製程式碼

上面程式碼中我們聲明瞭printf,但是沒有定義它,我們在連結階段需要重定位printf這個符號,為了能夠方便去別的檔案查詢printf這個符號,自然檔案需要有個匯出符號表,這樣子方便別的檔案進行符號查詢,所以我們為了更好的劃分程式功能,同時也方便連結時進行查詢,debug資訊讀取等,就有了Section header ,我們可以通過 readelf -S output/bin/xx.api來檢視Section header

Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
  I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)
複製程式碼

上圖我們可以看到雖然程式分為很多段,但是好多段的許可權都是相同的,我們先記住這一點。

最後我們再來介紹下program header。現在我們已經有了可執行檔案了,而且通過檔案的開頭的64位元組呢,我們能校驗這個檔案是否確實是elf格式的,校驗通過後呢,我們為了能夠執行檔案,自然就需要將檔案載入進記憶體了,只有載入進來程式才能夠執行。那問題就是如何載入程式了?

我們先通過readelf -l output/bin/xx.api來檢視程式的program header

其中第3個 segment 是由 .text, .interp 等section組成,這個劃分的原則就是按照相同許可權的section合併。 另外我們可以看到每段欄位的含義是:

  • offset:在檔案中的偏移
  • virtAddr:虛擬地址空間,即程式載入進來後,在程序的地址空間中的地址
  • fileSize: segment 在檔案中佔用大大小
  • memSize: segment 載入進記憶體中佔用的虛擬空間大小

到這,我們總結下 program header 和 Section header

  • Segment program header 執行檢視,即被裝載近記憶體中,地址空間分佈
  • Section header 則是連結檢視,elf在儲存的時候是按照 section儲存的,然後裝載的時候,為了減少記憶體碎片,將相同許可權的section合併為一個Segment裝載進來,一個Segment基本可以對應一個vma。

上圖是關於 section 和 segment的關係圖,圖片來自 Interfacing with ELF files 再來一個關於可執行程式的靜態檢視和記憶體中的動態檢視

圖片來自 Executable and Linkable Format 101

TLB

現在我們在重新整理下思路:

我們通過ps命令看到程式的虛擬記憶體比實體記憶體佔用很多,為了回答為什麼會存在這個現象,我們先得知道虛擬記憶體是啥,於是我們介紹了一個可執行程式是在磁碟上的靜態檢視:可執行程式按照不同功能劃分為不同的section,當程式被載入進記憶體的時候,不同的section按照相同的許可權組成一個segment被分配到程式的虛擬地址空間中。

現在我們已經提到了虛擬地址空間,每個程式都會有一個自己的虛擬地址空間,為什麼每個程式都會有獨立的地址空間呢?

首先程式執行其實是cpu在一行一行執行指令,cpu需要有地址才能去讀取記憶體,然後再執行,這個地址最早呢就是實體記憶體地址,這就意味著所有程式連結的時候,都必須要指定彼此不同的實體地址,不然會載入進入記憶體後彼此覆蓋,這個苛刻的要求顯然隨著程式越來越多是不可能的,於是呢,就有了虛擬地址空間一說,即每個程式都有自己的虛擬地址空間,然後cpu看到的是虛擬地址空間,但是實體記憶體肯定是需要實體地址才能訪問的,所有就有個中介軟體將虛擬地址轉換為實體地址。

evernotecid://684F00FC-2900-4AF6-B7AA-D9B72CB9AC48/appyinxiangcom/5364228/ENResource/p14937

上圖是一個記憶體管理的硬體結構,圖片來自 Virtual Memory, Paging, and Swapping 整個一個翻譯過程是cpu進行虛擬地址定址,此時有個MMU:記憶體管理單元 memory management unit 負責將虛擬地址轉換為實體地址,由於cpu的速度和實體記憶體之間速度存在差距大(大概200倍差),所以會有一個TLB: 翻譯後背緩衝器(Translation Lookaside Buffer)專門來快取這個對映關係,然後這個對映關係在實現上呢,需要用到頁表,當發現虛擬地址還沒有分配實體地址空間的時候,會觸發缺頁中斷,此時會去檢視這段虛擬地址對應到檔案內容是啥,將其載入到記憶體中,在頁表中建立起對映關係後,程式就可以繼續執行了。

針對上面描述的這個過程,我們來回答幾個問題:

  1. 頁表是什麼,以及為什麼要使用頁表?
  2. TLB中快取的是什麼?

我們先來回答為什麼需要頁表? 我們現在的目標是要建立虛擬地址和實體地址之間的對映關係,而記憶體一般我們可以將其看成是一個大陣列,陣列中每個元素大小1位元組,那就意味著1G記憶體的物理空間我們就需要4G對映關係,即一個關係我們就需要4位元組,簡單表示就是

var maps [4*1024*1024*1024]int32
// 下標就是虛擬地址,值就是實體地址
複製程式碼

即4G記憶體對映我們卻需要16G來儲存對映關係。這顯然不可能,於是我們需要對實體記憶體進行大力度的劃分,一般在32位機器時代,我們將實體記憶體按頁劃分,每頁大小為4K,為了方便,我們假設虛擬地址也是按頁劃分,此時4G被劃分為了1M個頁,需要4M來儲存這個對映關係,4M記憶體也就是需要1000個頁。此時即使實體記憶體有4G,光儲存程序的頁表,我們就只能同時執行1000個。所以我們就採用多級頁表的方案,下圖是2級示例:

圖片來自 TLB and Pagewalk Coherence in x86 Processors 如果我們按照4M劃分虛擬地址,則第一個對映關係只需要1K個項,即4k記憶體,一個物理頁就可以了。


上面我們回答了我們為什麼需要頁表,下面我們回答TLB中快取的是什麼? 首先我們看一張圖: evernotecid://684F00FC-2900-4AF6-B7AA-D9B72CB9AC48/appyinxiangcom/5364228/ENResource/p14939

圖片來自 CPU Cache Flushing Fallacy 從上圖可以看到記憶體訪問速度是cpu的60多倍,因此如果每次做虛擬地址到實體地址的轉換都要訪問主存,顯然速度是無法忍受的,因此我們就有了TLB作為cache加快訪問。 我們可以通過命令 cpuid來檢視tlb資訊,

cpuid -1
L1 TLB/cache information: 2M/4M pages & L1 TLB (0x80000005/eax):
      instruction # entries     = 0xff (255)
      instruction associativity = 0x1 (1)
      data # entries            = 0xff (255)
      data associativity        = 0x1 (1)
L1 TLB/cache information: 4K pages & L1 TLB (0x80000005/ebx):
  instruction # entries     = 0xff (255)
  instruction associativity = 0x1 (1)
  data # entries            = 0xff (255)
  data associativity        = 0x1 (1)
L2 TLB/cache information: 2M/4M pages & L2 TLB (0x80000006/eax):
      instruction # entries     = 0x0 (0)
      instruction associativity = L2 off (0)
      data # entries            = 0x0 (0)
      data associativity        = L2 off (0)
L2 TLB/cache information: 4K pages & L2 TLB (0x80000006/ebx):
  instruction # entries     = 0x200 (512)
  instruction associativity = 4-way (4)
  data # entries            = 0x200 (512)
  data associativity        = 4-way (4)
複製程式碼

一個線上觀看地址Cache Organization on Your Machine 上面我們可以看到TLB也像cache一樣分為L1和L2,L1 cache如果快取大頁2M/4M有256個項,快取4k小頁也是256項,L2 cache只能快取4k小頁512個。另外TLB也像cache一樣分為 instruction-TLB (ITLB) 和 data-TLB (DTLB)。

所以現在我們知道了由於cpu和記憶體之間速度存在巨大差異,如果每次地址轉換都需要訪問記憶體,肯定效能會下降,所以TLB中快取的就是虛擬地址到實體地址關係,一個示意圖如下:

現在我們知道了頁表是用來儲存虛擬地址和實體地址對映關係的,也知道了為了加速轉換過程,TLB作為快取記憶體儲存了這個對映關係,但是我們想下,之前我們說4G空間按4K頁劃分的話,就會有1K個表項,之前我們通過cpuid命令檢視的時候,發現即使是TLB 2也只有512個項,那意味著著必須要做一個1024 -> 512的對映,這個怎麼做呢?還有一個問題,之前我們看TLB資訊的時候有個叫 instruction associativity = 4-way (4) 的概念,這個是什麼意思?

我們想我們現在4G的虛擬地址被分為了1K份,每份是4M大小,那32位地址的話,就是前10位是頁編號,後22位是頁內偏移,但是我們現在只有256個TLB表項,那最簡單的就是比較所有的表項,看256個表項中是否存在虛擬頁號,這就需要在同一個時刻同時比較256行,隨著這個數字的增加,會越來越難,所以我們就有了另一種方法。

我們將10位的虛擬頁號的後8位用來選擇256個表項,用高2位來比較是否是當前行

evernotecid://684F00FC-2900-4AF6-B7AA-D9B72CB9AC48/appyinxiangcom/5364228/ENResource/p14943

一個示意圖 額外補充一個問題:我們為什麼不用高位來選擇組,低位來做tag比較呢? evernotecid://684F00FC-2900-4AF6-B7AA-D9B72CB9AC48/appyinxiangcom/5364228/ENResource/p14944
圖片來自《深入理解計算機系統》 原因很容易理解,如果我們採用高位來選擇組,但是由於每組只有一個,意味著相鄰的組對映到了同一行cache,程式的區域性性不好。

然後此處 associativity = 4-way 意味著有4組,即2位來選擇組,剩餘的位做tag比較,更詳細的內容可以看《深入理解計算機系統》第六章,寫的非常詳細。


小結

總結下,目前我們的內容:

  • 首先我們這次分享的主題是記憶體管理,會按照記憶體分配和垃圾回收兩大塊內容
  • 對於第一部分記憶體分配呢,我們從一個簡單程式執執行起來後虛擬記憶體和實體記憶體佔用非常大這個現象出發,想來回來為什麼
  • 為了回答為什麼會出現虛擬記憶體遠大於實體記憶體的現象,我們先得知道什麼是虛擬記憶體
  • 為了解釋虛擬記憶體,我們引出了可執行程式兩個檢視:
    • 靜態檢視:在磁碟上格式是elf,按照程式功能分section儲存
    • 動態檢視:載入進記憶體的時候,會將section許可權相同的進行合併,分配到同一個虛擬地址空間中
  • 在上面提到程序動態檢視的時候,我們嘗試回答為什麼每個程式都會有自己的虛擬地址空間
    • 隔離:每個程式都可以在連結階段自己分配程式執行地址
    • 安全性:每個程式只能當訪問自己的地址空間內容
  • 因為每個程式都有自己的虛擬地址空間,但是實際機器執行的指令和資料都需要儲存在實體記憶體中,這就需要對虛擬地址->實體地址進行翻譯
  • 翻譯時候,為了儲存虛擬地址和實體地址的對應關係,我們有了頁表,而為了減少頁表佔用的空間,我們有了多級頁表
  • 由於cpu和記憶體速度之間的巨大差異,我們不可能每次都到記憶體中讀取頁表,所有有了TLB,而TLB本質上是一個cache,由於cache容量小於所有的對應關係的,就需要解決:
    • 快速查詢對應關係是否在TLB中
    • 當TLB滿的時候,進行淘汰
    • 保證TLB中資料和記憶體資料一致性
    • 。。。。
    • 以上這些問題本次沒有具體展開,後續補上 mark。更新文章地址

現在我們有了上面的這些知識後,我們在整體上來描述一下在日常開發中我們執行go build main.go; ./main 時候都發生了什麼,為什麼程式能夠被執行?

  1. 由於我們在是在bash中執行這個命令的,所有bash會首先通過fork建立一個新的程序出來
  2. 新程序通過execve系統呼叫指定執行elf檔案,即main檔案
  3. execve通過系統呼叫 sys_execve 進入核心態
  4. 核心呼叫鏈路:sys_execve -> do_execve -> search_binary_handle -> load_elf_binary
  5. 主要過程首先讀取頭128字確定檔案格式,然後選擇合適的裝載程式將程式載入進記憶體
    1. 此處載入進記憶體只是讀取program header,分配了虛擬地址空間,建立虛擬地址空間與可執行檔案的對映關係
    2. 此處建立虛擬空間和可執行檔案的對映關係就是為了在缺頁異常的時候能夠正確載入內容進來
    3. 發生缺頁異常的時候,分配物理頁,然後從磁碟將檔案載入進來,這個時候才會真正佔用實體記憶體
  6. 最後,我們將cpu指令暫存器設定為可執行檔案的入口地址。程式就開始執行了。。。。

通過以上的這些內容,相信可以非常輕鬆的回答開始的問題了,每個程式被載入的時候,分配了虛擬空間地址,但是隻有真正訪問這些地址的時候,才會觸發缺頁中斷,分配實體記憶體。 ps: cat /proc/21121/maps; cat /proc/21121/smaps可以檢視程序詳細的地址空間。

應用層記憶體管理

以上我們介紹了虛擬記憶體的概念,知道一個程式要執行,要經過層層步驟載入到記憶體中,才能被執行,上面介紹的這些內容我們如果將記憶體管理進行分層的話,應該是屬於核心層和硬體層(TLB,cache)的內容,先來一張圖: evernotecid://684F00FC-2900-4AF6-B7AA-D9B72CB9AC48/appyinxiangcom/5364228/ENResource/p14945

圖片來自《奔跑吧linux核心》 ps:這一本書也是待讀的書單,但是暫時沒找到工作中應用場景,可能先當做一本工具書了。 我們之前的內容是講了非常少的一點核心層和硬體層的記憶體管理,現在我們要講下應用層的記憶體管理,首先我們得知道,為什麼核心有了記憶體管理,我們在應用層還需要做記憶體管理。

  1. 為了減少系統呼叫(brk,mmap)
  2. 作業系統不知道如何對記憶體進行復用,一旦程序申請記憶體後,這塊記憶體就被程序佔用了,只要它不釋放,我們再也不能分配給別人
  3. 應用層自己做記憶體管理,可以更好的複用記憶體,同時能夠和垃圾回收器配合,更好的管理記憶體

我們來看常見語言的記憶體管理實現:

  • C:malloc,free
  • C++:new,delete
  • Go:逃逸分析和垃圾回收

動手專案:如何自己實現個最小的malloc。

注意:以下內容,由於自己站的高度問題,不保證完全的正確。如果錯誤請不吝指出。


我們自己去設計記憶體分配器的時候,衡量的重要的指標是:

  • 吞吐率
  • 記憶體利用率

吞吐率指的是記憶體分配器每秒能夠處理的請求數(包括分配和回收),記憶體利用率則是儘可能減少內部碎片和外部碎片。 ps:

  • 內部碎片是已經被分配出去的的記憶體空間大於請求所需的記憶體空間。
  • 外部碎片是指還沒有分配出去,但是由於大小太小而無法分配給申請空間的新程序的記憶體空間空閒塊。

下面我們來看下GO語言的記憶體分配器設計, 我們會分2部分來介紹:

  • tcmalloc
  • go語言allocator實現

tcmalloc

tcmalloc大名鼎鼎,對於tcmalloc的分析網上內容很多,此處就不多說了,大家自行Google。此處推薦一篇文章圖解 TCMalloc,一圖勝千言。 此處只提下為啥tcmalloc那麼好。

  • 通過 thread-local-cache 分散了鎖競爭(一把鎖變多把鎖)
  • 有借有還,當有連續記憶體不用的時候,還給核心

ps:在64位機器將記憶體還給作業系統非常有意思,釋放記憶體的時候不釋放 VA,把 VA 掛在 Heap 上,只是向作業系統提個建議,某一段 VA 暫時不用,可以解除 VA 和 PA 的 mmu 對映,作業系統可能會觸發兩種行為,第一種行為作業系統實體記憶體的確不夠用有大量的換入換出操作,就解除,第二種作業系統覺得實體記憶體挺大的,就擱那,但是知道了這段 PA 空間不可用了,在 Heap 中並沒有把 VA 釋放掉,下次分配正好用到當時解除的 VA,有可能會引發兩種行為,第一種是 PA 對映沒有解除直接拿過來用,第二種是 PA 被解除掉了會引發作業系統缺頁異常,作業系統就會補上這段實體記憶體,這個過程對使用者空間來說是不可見的,這樣在使用者空間覺得這段記憶體根本沒有釋放過,因為使用者空間看到的永遠是 VA,VA 上某段記憶體可能存在也可能不存在,它是否存在對使用者邏輯來說根本不關心,這地方實際上是作業系統來管理,作業系統通過 mmu 建立對映,這會造成 64 位 VA 地址空間非常的大,只要申請就不釋放,下次重複使用,只不過重複使用會補上。現在記憶體管理在 64 位下簡單的多,無非 VA 用就用了,就不釋放。Windows 作業系統沒有建議解除,只能說全部釋放掉。

go allocator

此處也是推薦材料 Go's Memory Allocator - Overview GopherCon 2018 - Allocator Wrestling 此處只說 go的記憶體分配是從tcmalloc發展而來,基本思想一致, 下面是一個簡圖: evernotecid://684F00FC-2900-4AF6-B7AA-D9B72CB9AC48/appyinxiangcom/5364228/ENResource/p15012

go 垃圾回收

好資料推薦 Golang's Realtime GC in Theory and Practice Getting to Go: The Journey of Go's Garbage Collector

調優

上面介紹這麼多後,我們來介紹一些實際開發中,應該怎麼優化我們自己程式的例子。這部分後續再寫一篇了專門介紹的

參考

Go Memory Management CppCon 2017: Bob Steagall “How to Write a Custom Allocator Go's Memory Allocator - Overview Golang's Realtime GC in Theory and Practice GopherCon 2018 - Allocator Wrestling Golang's Realtime GC in Theory and Practice Getting to Go: The Journey of Go's Garbage Collector