圖解 Go 內存管理系列(1)

這篇博客是我在維爾紐斯的 Go Meetup[1] 演講的總結。如果你在維爾紐斯並且喜歡 Go 語言,歡迎加入我們並考慮作演講

在這篇博文中我們將要探索 Go 語言的內存管理,首先讓我們來思考以下的這個小程序:

func main() {
    http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
    })

    http.ListenAndServe(":8080", nil)
}

編譯並且運行:

go build main.go
./main

接着我們通過 ps 命令觀察這個正在運行的程序:

ps -u --pid 16609
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
povilasv 16609 0.0 0.0 388496 5236 pts/9 Sl+ 17:21 0:00 ./main

我們發現,這個程序居然耗掉了 379.39M 虛擬內存,實際使用內存爲 5.11M。這有點兒誇張吧,爲什麼會用掉 380M 虛擬內存?

一點小提示:

虛擬內存大小 (VSZ) 是進程可以訪問的所有內存,包括換出的內存、分配但未使用的內存和共享庫中的內存。(stackoverflow 上有很好的解釋。)

駐留集大小(RSS)是進程在實際內存中的內存頁數乘以內存頁大小,這裏不包括換出的內存頁(譯者注:包含共享庫佔用的內存)。

在深入研究這個問題之前,讓我們先介紹一些計算機架構和內存管理的基礎知識。

內存的基本知識

維基百科 [2] 對 RAM 的定義如下:

隨機訪問存儲器(RAM /ræm/)是一種計算機存儲設備,用於存儲當前被使用的數據和機器碼。隨機訪問內存設備允許在幾乎相同的時間內讀取或寫入數據項,而不管數據在內存中的物理位置如何。

我們可以將物理內存看作是一個槽 / 單元的數組,其中槽可以容納 8 個位信息 1。每個內存槽都有一個地址,在你的程序中你會告訴 CPU:“喂,CPU,你能在地址 0 處的內存中取出那個字節的信息嗎?”,或者 “喂,CPU,你能把這個字節的信息放在內存爲地址 1 的地方嗎?”。

物理內存

由於計算機通常要運行多個任務,所以直接從物理內存中讀寫是並不明智。想象一下,編寫一個程序是一個很容易的事情,它會從內存中讀取所有的東西 (包括你的密碼),或者編寫一個程序,它會在不同的程序的內存地址中寫入內容。那將是很荒唐的事情。

因此,除了使用實際物理內存去處理任務我們還有_虛擬內存_的概念。當你的程序運行時,它只看到它的內存,它認爲它獨佔了內存 2。另外,程序中存儲的內存字節也不可能都放在 RAM 中。如果不經常訪問特定的內存塊,操作系統可能會將一些內存塊放入較慢的存儲空間 (比如磁盤),從而節省寶貴的 RAM。操作系統甚至不會承認對你的程序是這樣操作的,但實際上,我們知道操作系統確實是那樣運作的。

虛擬內存 -> 物理內存

虛擬內存可以使用基於 CPU 體系結構和操作系統的段或頁表來實現。我不會詳細講段,因爲頁表更常見,但你可以在附錄 3 中讀到更多關於段的內容。

在_分頁虛擬內存_中,我們將虛擬內存劃分爲塊,稱爲_頁_。頁的大小可以根據硬件的不同而有所不同,但是頁的大小通常是 4-64 KB,此外,通常還能夠使用從 2MB 到 1GB 的巨大的頁。分塊很有用,因爲單獨管理每個內存槽需要更多的內存,而且會降低計算機的性能。

爲了實現分頁虛擬內存,計算機通常有一個稱爲_內存管理單元 (MMU)4 的芯片,它位於 CPU 和內存之間。MMU 在一個名爲_頁表_的表 (它存儲在內存中) 中保存了從虛擬地址到物理地址的映射,其中每頁包含一個_頁表項 (PTE)。MMU 還有一個物理緩存_旁路轉換緩衝 (TLB)_,用來存儲最近從虛擬內存到物理內存的轉換。大致是這樣的:

虛擬內存到物理內存轉換

因此,假設操作系統決定將一些虛擬內存頁放入磁盤,程序會嘗試訪問它。此過程如下所示:

  1. CPU 發出訪問虛擬地址的命令,MMU 在其頁面表中檢查它並禁止訪問,因爲沒有爲該虛擬頁面分配物理 RAM。

  2. 然後 MMU 向 CPU 發送頁錯誤。

  3. 然後,操作系統通過查找 RAM 的備用內存塊(稱爲幀)並設置新的 PTE 來映射它來處理頁錯誤。

  4. 如果沒有 RAM 是空閒的,它可以使用一些替換算法選擇現有頁面,並將其保存到磁盤(此過程稱爲分頁)。

  5. 對於一些內存管理單元,還可能出現頁表入口不足的情況,在這種情況下,操作系統必須爲新的映射釋放一個表入口。

操作系統通常管理多個應用程序(進程),因此整個內存管理位如下所示:

內存管理位

每個進程都有一個線性虛擬地址空間,地址從 0 到最大值。虛擬地址空間不需要是連續的,因此並非所有這些虛擬地址實際上都用於存儲數據,並且它們不佔用 RAM 或磁盤中的空間。很酷的一點是,真實內存的同一幀可以支持屬於多個進程的多個虛擬頁面。通常就是這種情況,虛擬內存佔用 GNU C 庫代碼(libc),如果使用 go build 進行編譯,則默認包含該代碼。你可以通過添加 ldflags 參數來設置編譯時不帶 libc 的代碼 5:

go build -ldflags '-libgcc=none'

以上敘述是關於什麼是內存,以及如何使用硬件和操作系統相互通信來實現內存的概述。現在讓我們看看在操作系統中發生了什麼,當你嘗試運行你的程序時,程序將如何分配內存。

操作系統相關

爲了運行程序,操作系統有一個模塊,它負責加載程序和所需要的庫,稱爲程序加載器。在 Linux 系統中,你可以通過 execve() 系統調用來調用你的程序加載器。

當加載程序運行時,它會進行一下步驟 6:

  1. 驗證程序映像 (權限、內存需求等);

  2. 將程序映像從磁盤複製到主存儲器中;

  3. 傳遞堆棧上的命令行參數;

  4. 初始化寄存器(如棧指針);

加載完成後,操作系統通過將控制權傳遞給加載的程序代碼來啓動程序(執行跳轉指令到程序的入口點(_start))。

那麼什麼是程序呢?

我們通常用 Go 語言等高級語言編寫程序,這些語言被編譯成可執行的機器代碼文件或不可執行的機器代碼目標文件(庫)。這些可執行或不可執行的目標文件通常採用容器格式,例如可執行文件和可鏈接格式 [3](ELF)(通常在 Linux 中),可執行文件 [4](通常在 Windows 中)。但有時候,你並不能用你喜歡的 Go 語言來編寫所有程序。在這種情況下,一種選擇是手工製作你自己的 ELF 二進制文件並將機器代碼放入正確的 ELF 結構中。另一種選擇是用匯編語言開發一個程序,該程序在與機器代碼指令更緊密地聯繫,同時仍然是便於人們閱讀的。

目標文件是直接在處理器上執行的程序的二進制表示。這些目標文件不僅包含機器代碼,還包含有關應用程序的元數據,如操作系統體系結構,調試信息。目標文件還攜帶應用程序數據,如全局變量或常量。通常,目標文件由以下段(section)組成,如:.text(可執行代碼).data(全局變量).rodata(全局常量) 等 7。

我在 linux(Ubuntu) 系統上把程序編譯成可執行和可鏈接形式的文件(也就是執行 go build 命令後的輸出文件)8。在 Go 語言中,我們可以輕鬆編寫一個讀取 ELF 可執行文件的程序,因爲 Go 語言在標準庫中有一個 debug/elf 包。以下是一個例子:

package main

import (
    "debug/elf"
    "log"
)

func main() {
    f, err := elf.Open("main")

    if err != nil {
        log.Fatal(err)
    }

    for _, section := range f.Sections {
        log.Println(section)
    }
}

輸出如下:

2018/05/06 14:26:08 &{{ SHT_NULL 0x0 0 0 0 0 0 0 0 0} 0xc4200803f0 0xc4200803f0 0 0}
2018/05/06 14:26:08 &{{.text SHT_PROGBITS SHF_ALLOC+SHF_EXECINSTR 4198400 4096 3373637 0 0 16 0 3373637} 0xc420080420 0xc420080420 0 0}
2018/05/06 14:26:08 &{{.plt SHT_PROGBITS SHF_ALLOC+SHF_EXECINSTR 7572064 3377760 560 0 0 16 16 560} 0xc420080450 0xc420080450 0 0}
2018/05/06 14:26:08 &{{.rodata SHT_PROGBITS SHF_ALLOC 7573504 3379200 1227675 0 0 32 0 1227675} 0xc420080480 0xc420080480 0 0}
2018/05/06 14:26:08 &{{.rela SHT_RELA SHF_ALLOC 8801184 4606880 24 11 0 8 24 24} 0xc4200804b0 0xc4200804b0 0 0}
2018/05/06 14:26:08 &{{.rela.plt SHT_RELA SHF_ALLOC 8801208 4606904 816 11 2 8 24 816} 0xc4200804e0 0xc4200804e0 0 0}
2018/05/06 14:26:08 &{{.gnu.version SHT_GNU_VERSYM SHF_ALLOC 8802048 4607744 78 11 0 2 2 78} 0xc420080510 0xc420080510 0 0}
2018/05/06 14:26:08 &{{.gnu.version_r SHT_GNU_VERNEED SHF_ALLOC 8802144 4607840 112 10 2 8 0 112} 0xc420080540 0xc420080540 0 0}
2018/05/06 14:26:08 &{{.hash SHT_HASH SHF_ALLOC 8802272 4607968 192 11 0 8 4 192} 0xc420080570 0xc420080570 0 0}
2018/05/06 14:26:08 &{{.shstrtab SHT_STRTAB 0x0 0 4608160 375 0 0 1 0 375} 0xc4200805a0 0xc4200805a0 0 0}
2018/05/06 14:26:08 &{{.dynstr SHT_STRTAB SHF_ALLOC 8802848 4608544 594 0 0 1 0 594} 0xc4200805d0 0xc4200805d0 0 0}
2018/05/06 14:26:08 &{{.dynsym SHT_DYNSYM SHF_ALLOC 8803456 4609152 936 10 0 8 24 936} 0xc420080600 0xc420080600 0 0}
2018/05/06 14:26:08 &{{.typelink SHT_PROGBITS SHF_ALLOC 8804416 4610112 12904 0 0 32 0 12904} 0xc420080630 0xc420080630 0 0}
2018/05/06 14:26:08 &{{.itablink SHT_PROGBITS SHF_ALLOC 8817320 4623016 3176 0 0 8 0 3176} 0xc420080660 0xc420080660 0 0}
2018/05/06 14:26:08 &{{.gosymtab SHT_PROGBITS SHF_ALLOC 8820496 4626192 0 0 0 1 0 0} 0xc420080690 0xc420080690 0 0}
2018/05/06 14:26:08 &{{.gopclntab SHT_PROGBITS SHF_ALLOC 8820512 4626208 1694491 0 0 32 0 1694491} 0xc4200806c0 0xc4200806c0 0 0}
2018/05/06 14:26:08 &{{.got.plt SHT_PROGBITS SHF_WRITE+SHF_ALLOC 10518528 6324224 296 0 0 8 8 296} 0xc4200806f0 0xc4200806f0 0 0}
...
2018/05/06 14:26:08 &{{.dynamic SHT_DYNAMIC SHF_WRITE+SHF_ALLOC 10518848 6324544 304 10 0 8 16 304} 0xc420080720 0xc420080720 0 0}
2018/05/06 14:26:08 &{{.got SHT_PROGBITS SHF_WRITE+SHF_ALLOC 10519152 6324848 8 0 0 8 8 8} 0xc420080750 0xc420080750 0 0}
2018/05/06 14:26:08 &{{.noptrdata SHT_PROGBITS SHF_WRITE+SHF_ALLOC 10519168 6324864 183489 0 0 32 0 183489} 0xc420080780 0xc420080780 0 0}
2018/05/06 14:26:08 &{{.data SHT_PROGBITS SHF_WRITE+SHF_ALLOC 10702688 6508384 46736 0 0 32 0 46736} 0xc4200807b0 0xc4200807b0 0 0}
2018/05/06 14:26:08 &{{.bss SHT_NOBITS SHF_WRITE+SHF_ALLOC 10749440 6555136 127016 0 0 32 0 127016} 0xc4200807e0 0xc4200807e0 0 0}
2018/05/06 14:26:08 &{{.noptrbss SHT_NOBITS SHF_WRITE+SHF_ALLOC 10876480 6682176 12984 0 0 32 0 12984} 0xc420080810 0xc420080810 0 0}
2018/05/06 14:26:08 &{{.tbss SHT_NOBITS SHF_WRITE+SHF_ALLOC+SHF_TLS 0 0 8 0 0 8 0 8} 0xc420080840 0xc420080840 0 0}
2018/05/06 14:26:08 &{{.debug_abbrev SHT_PROGBITS 0x0 10891264 6557696 437 0 0 1 0 437} 0xc420080870 0xc420080870 0 0}
2018/05/06 14:26:08 &{{.debug_line SHT_PROGBITS 0x0 10891701 6558133 350698 0 0 1 0 350698} 0xc4200808a0 0xc4200808a0 0 0}
2018/05/06 14:26:08 &{{.debug_frame SHT_PROGBITS 0x0 11242399 6908831 381068 0 0 1 0 381068} 0xc4200808d0 0xc4200808d0 0 0}
2018/05/06 14:26:08 &{{.debug_pubnames SHT_PROGBITS 0x0 11623467 7289899 121435 0 0 1 0 121435} 0xc420080900 0xc420080900 0 0}
2018/05/06 14:26:08 &{{.debug_pubtypes SHT_PROGBITS 0x0 11744902 7411334 225106 0 0 1 0 225106} 0xc420080930 0xc420080930 0 0}
2018/05/06 14:26:08 &{{.debug_gdb_scripts SHT_PROGBITS 0x0 11970008 7636440 53 0 0 1 0 53} 0xc420080960 0xc420080960 0 0}
2018/05/06 14:26:08 &{{.debug_info SHT_PROGBITS 0x0 11970061 7636493 1847750 0 0 1 0 1847750} 0xc420080990 0xc420080990 0 0}
2018/05/06 14:26:08 &{{.debug_ranges SHT_PROGBITS 0x0 13817811 9484243 167568 0 0 1 0 167568} 0xc4200809c0 0xc4200809c0 0 0}
2018/05/06 14:26:08 &{{.interp SHT_PROGBITS SHF_ALLOC 4198372 4068 28 0 0 1 0 28} 0xc4200809f0 0xc4200809f0 0 0}
2018/05/06 14:26:08 &{{.note.go.buildid SHT_NOTE SHF_ALLOC 4198272 3968 100 0 0 4 0 100} 0xc420080a20 0xc420080a20 0 0}
2018/05/06 14:26:08 &{{.symtab SHT_SYMTAB 0x0 0 9654272 290112 35 377 8 24 290112} 0xc420080a50 0xc420080a50 0 0}
2018/05/06 14:26:08 &{{.strtab SHT_STRTAB 0x0 0 9944384 446735 0 0 1 0 446735} 0xc420080a80 0xc420080a80 0 0}

你也可以通過一些 Linux 工具來查看 ELF 文件信息,如:size --format=sysv mainreadelf -l main(這裏的 main 是指輸出的二進制文件)。

顯而易見,可執行文件只是具有某種預定義格式的文件。通常,可執行格式具有段,這些段是在運行映像之前映射的數據內存。下面是 segment 的一個常見視圖,流程如下:

segment

_文本段_包含程序指令、字面量和靜態常量。

_數據段_是程序的工作存儲器。它可以由 exec 預分配和預加載,進程可以擴展或收縮它。

_堆棧段_包含一個程序堆棧。它隨着堆棧的增長而增長,但是當堆棧收縮時它不會收縮。

堆區域通常從 .bss.data 段的末尾開始,並從那裏增長到更大的地址。

我們來看看進程如何分配內存。

Libc 手冊解釋是 9,程序可以使用 exec 系列函數和編程方式以兩種主要方式進行分配。exec  調用程序加載器來啓動程序,從而爲進程創建虛擬地址空間,將程序加載進內存並運行它。常用的編程方式有:

要動態分配內存,你有幾個選擇。其中一個選項是調用操作系統(syscall 或通過 libc)。操作系統提供各種功能,如:

我認爲 Go 語言的運行時只使用 mmapmadvisemunmapsbrk,並且它們都是在操作系統下通過彙編或者 cgo 直接調用的,也就是說它不會調用 libc10。這些內存分配是低級別的,通常程序員不使用它們。更常見的是使用 libc 的 malloc 系列函數,當你向系統申請 n 個字節的內存時,libc 將爲你分配內存。同時,你不需要這些內存的時候,要調用 free 來釋放這些內存。

以下是一個 C 語言使用 malloc 函數的基礎示例:

#include /* printf, scanf, NULL */
#include /* malloc, free, rand */
int main (){
    int i,n;
    char * buffer;

    printf ("How long do you want the string? ");
    scanf ("%d"&i);

    buffer = (char*) malloc (i+1);
    if (buffer==NULL) exit (1);

    for (n=0; n<i; n++)
        buffer[n]=rand()%26+'a';
    buffer[i]='\0';

    printf ("Random string: %s\n",buffer);
    free (buffer);

    return 0;
}

這個例子說明了動態分配數據的需要,因爲我們要求用戶輸入字符串長度,然後根據它分配字節並生成隨機字符串。另外,請注意對 free() 的顯式調用。

內存分配器

由於 Go 語言不使用 malloc 來獲取內存,而是直接操作系統申請(通過 mmap),它必須自己實現內存分配和釋放(就像 malloc 一樣)。Go 語言的內存分配器最初基於 TCMalloc:Thread-Caching Malloc[5]。

以下是一些關於 TCMalloc 的有趣事實:

TCMalloc 還減少了多線程程序的鎖爭用:

TCMalloc

TCMalloc 性能背後的祕密在於它使用線程本地緩存來存儲一些預先分配的內存 “對象”,以便從線程本地緩存 11 中滿足小分配。一旦線程本地緩存耗盡空間,內存對象就會從中心數據結構移動到線程本地緩存。

中心數據結構 -> 線程本地緩存

TCMalloc 對小對象 (大小 <= 32K) 分配的處理與大對象不同。使用頁級分配器直接從中心堆分配大型對象。同時,小對象被映射到大約 170 個可分配大小類中的一個。

大小類

以下是它如何適用於小對象:

當給一個小對象分配內存時:

  1. 我們將其大小映射到相應的大小等級。

  2. 查看當前線程的線程緩存中的相應空閒列表。

  3. 如果空閒列表不爲空,我們從列表中刪除第一個對象並將其返回。

如果沒有空閒列表

  1. 我們從這個 size-class 的中心空閒列表中獲取一些對象 (中心空閒列表由所有線程共享)。

  2. 將它們放在線程本地空閒列表中。

  3. 將一個新獲取的對象返回給應用程序。

如果中心空閒列表也是空的:

  1. 我們從中央頁面分配器分配了一系列頁面。

  2. 將 run 分解爲這個 size-class 的一組對象。

  3. 將新對象放在中央自由列表中。

  4. 與前面一樣,將這些對象中的一些移動到線程本地自由列表中。

大對象 (大小爲> 32K) 四捨五入到一個頁面大小(4K),由一箇中心頁面堆處理。中心頁面堆又是一個自由列表數組:

中心頁面堆

對於 i <256,第 k 個項是由 k 個頁組成的運行的空閒列表。第 256 項是長度> = 256 頁的運行的空閒列表。

以下描述了它如何適用於大型對象的:

滿足 k 頁的分配:

  1. 我們查看 k-th 列表。

  2. 如果這個空閒列表是空的,我們會查看下一個空閒列表,等等。

  3. 所示。最後,如果有必要,我們會查看最後一個免費列表。

  4. 所示。如果失敗,我們將從系統中獲取內存。

  5. 如果對 k 個頁面的分配通過運行長度爲 > k 的頁面來滿足,則運行的其餘部分將重新插入到頁面堆中的適當空閒列表中。

內存是根據連續頁面的運行來管理的,這些頁面稱爲 Spans(這很重要,因爲 Go 語言耶穌根據 Spans 來管理內存的)。

在 TCMalloc 中,span 可以是 assigned,或 free

span

在這個例子中,span 1 佔 2 頁,span 2 佔 4 頁,span 3 佔 1 頁。可以使用按頁碼索引的中心數組來查找頁面所屬的跨度。

Go 語言的內存分配器

Go 語言的內存分配器與 TCMalloc 類似,它在頁運行(spans/mspan 對象)中工作,使用線程局部緩存並根據大小劃分分配。跨度是 8K 或更大的連續內存區域。在 runtime/mheap.go 中你可以看到有一個名爲 mspn 的結構體。Spans 有 3 種類型:

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

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

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

當分配發生時,我們將對象映射到 3 個大小的類:對於小於 16 字節的對象的極小類,對於達到 32 kB 的對象的小類,以及對於其他對象的大類。小的分配大小被四捨五入到大約 70 個大小的類中的一個,每個類都有它自己的恰好大小的自由對象集。我在 runtime/malloc.go 中發現了一些有趣的註釋: 小分配器的主要目標是小字符串和獨立轉義變量。

在 json 基準測試中,分配器將分配數量減少了大約 12%,並將堆大小減少了大約 20%。微型分配器將幾個微小的分配請求組合成一個 16 字節的單個內存塊。當所有子對象都無法訪問時,將釋放生成的內存塊。子對象不能有指針。

下面描述極小對象是如何工作的:

當分配極小對象:

  1. 查看這個 P 的 mcache 中對應的小槽對象。

  2. 根據新對象的大小,將現有子對象的大小 (如果存在的話) 四捨五入爲 8、4 或 2 個字節。

  3. 如果對象與現有的子對象相匹配,將其放在那裏。

如果它不適合小塊:

  1. 看看這個 P 的 mcache 對應的 mspan。

  2. 從 mcache 獲得一個新的 mspan。

  3. 掃描 mspan 的空閒位圖找到一個自由插槽。

  4. 如果有一個空閒的槽,分配它並將它用作一個新的小槽對象。(這一切都不需要鎖。)

如果 mspan 的列表爲空:

  1. 從 mheap 中獲取要用於 mspan 的一系列頁。

如果 mheap 爲空或沒有足夠大的頁面運行:

  1. 從操作系統分配一組新的頁 (至少 1MB)。

  2. 分配大量的頁會平攤與操作系統對話的成本。

對於小對象,它非常相似,但我們跳過了第一部分 *:

當分配小對象:

  1. 四捨五入到一個小類的大小。

  2. 看看這個 P 的 mcache 對應的 mspan。

  3. 掃描 mspan 的空閒位圖找到一個自由插槽。

  4. 如果有空閒的槽,分配它。(這一切都不需要鎖。)

如果 mspan 沒有空閒插槽:

  1. mcentral 提供的具有空閒空間的所需大小類的 mspan 列表中獲得一個新的 mspan。

  2. 獲得整個跨度將分攤鎖定 mcentral 的成本。

如果 mspan 的列表爲空:

  1. 從 mheap 中獲取要用於 mspan 的一系列頁。

如果 mheap 爲空或沒有足夠大的頁面運行:

  1. 從操作系統分配一組新的頁 (至少 1MB)。

  2. 分配大量的頁會平攤與操作系統對話的成本。

分配和釋放一個大對象直接使用 mheap,繞過了 mcache 和 mcentral。mheap 的管理類似於 TCMalloc,我們有一個空閒的列表數組。大的對象被四捨五入到頁面大小 (8K),我們在一個由 k 個頁組成的空閒列表中查找第 k 個項,如果它是空的,我們就繼續下去。清楚緩存並重復操作,直到第 128 個數組。如果我們沒有在 127 頁中找到空白頁,我們就會在剩下的大頁 (mspan.freelarge 子段) 中尋找一個跨度,如果這個跨度失敗,我們就會從操作系統中獲取。

這就是在深入研究代碼運行時之後的內存分配,通過發掘後發現,MemStats 對我來說更有意義。你可以查看大小類的所有報告,可以查看實現內存管理 (如 MCache、MSpan) 的多少 bytes 對象,等等。

回到問題中來

爲了讓你對文章開始提出的問題還有印象,我們再描述一下問題:

func main() {
    http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
    })

    http.ListenAndServe(":8080", nil)
}
go build main.go
./main
ps -u --pid 16609
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
povilasv 16609 0.0 0.0 388496 5236 pts/9 Sl+ 17:21 0:00 ./main

這裏使用了大約 380 MiB 虛擬內存。

這是運行時引起的麼?

讓我們用程序來讀取 memstats 的信息:

func main() {
        http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
    })

    Go func() {
        for {
            var m runtime.MemStats
            runtime.ReadMemStats(&m)

            log.Println(float64(m.Sys) / 1024 / 1024)
            log.Println(float64(m.HeapAlloc) / 1024 / 1024)
            time.Sleep(10 * time.Second)
        }
    }()

    http.ListenAndServe(":8080", nil)
}

注意:

不,看起來不像:

2018/05/08 18:00:34 4.064689636230469
2018/05/08 18:00:34 0.5109481811523438

這個問題屬於正常現象?

讓我們測試以下這個 C 程序:

#include /* printf, scanf, NULL */

int main (){
    int i,n;
    printf ("Enter a number:");
    scanf ("%d"&i);

    return 0;
}

(譯者注:編譯,運行)

gcc main.c
./a.out

不對,C 程序只花了 10Mb:

ps -u --pid 25074

USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
povilasv 25074 0.0 0.0 10832 908 pts/6 S+ 17:48 0:00 ./a.out

讓我們試着看看 /proc

cat /proc/30376/status
Name: main
State: S (sleeping)
Pid: 30376
...
FDSize: 64
VmPeak: 386576 kB
VmSize: 386576 kB
VmLck: 0 kB
VmPin: 0 kB
VmHWM: 5116 kB
VmRSS: 5116 kB
RssAnon: 972 kB
RssFile: 4144 kB
RssShmem: 0 kB
VmData: 44936 kB
VmStk: 136 kB
VmExe: 2104 kB
VmLib: 2252 kB
VmPTE: 132 kB
VmSwap: 0 kB
HugetlbPages: 0 kB
CoreDumping: 0
Threads: 6

由於段大小正常,沒有幫助,只有 VmSize 的值比較大。

讓我們看看 /proc/maps

cat /proc/31086/maps

結果如下:

00400000-0060e000 r-xp 00000000 fd:01 1217120 /main
0060e000-007e5000 r--p 0020e000 fd:01 1217120 /main
007e5000-0081b000 rw-p 003e5000 fd:01 1217120 /main
0081b000-0083d000 rw-p 00000000 00:00 0
0275d000-0277e000 rw-p 00000000 00:00 0 [heap]
c000000000-c000001000 rw-p 00000000 00:00 0
c41fff0000-c420200000 rw-p 00000000 00:00 0
7face8000000-7face8021000 rw-p 00000000 00:00 0
7face8021000-7facec000000 ---p 00000000 00:00 0
7facec000000-7facec021000 rw-p 00000000 00:00 0
...
7facf4021000-7facf8000000 ---p 00000000 00:00 0
7facf8000000-7facf8021000 rw-p 00000000 00:00 0
7facf8021000-7facfc000000 ---p 00000000 00:00 0
7facfd323000-7facfd324000 ---p 00000000 00:00 0
7facfd324000-7facfdb24000 rw-p 00000000 00:00 0
7facfdb24000-7facfdb25000 ---p 00000000 00:00 0
...
7facfeb27000-7facff327000 rw-p 00000000 00:00 0
7facff327000-7facff328000 ---p 00000000 00:00 0
7facff328000-7facffb28000 rw-p 00000000 00:00 0
7fddc2798000-7fddc2f98000 rw-p 00000000 00:00 0
...
7fddc2f98000-7fddc2f9b000 r-xp 00000000 fd:01 2363785 libdl-2.27.so
...
7fddc319c000-7fddc3383000 r-xp 00000000 fd:01 2363779 libc-2.27.so
...
7fddc3587000-7fddc3589000 rw-p 001eb000 fd:01 2363779 libc-2.27.so
7fddc3589000-7fddc358d000 rw-p 00000000 00:00 0
7fddc358d000-7fddc35a7000 r-xp 00000000 fd:01 2363826 libpthread-2.27.so
...
7fddc37a8000-7fddc37ac000 rw-p 00000000 00:00 0
7fddc37ac000-7fddc37b2000 r-xp 00000000 fd:01 724559 libgtk3-nocsd.so.0
...
7fddc39b2000-7fddc39b3000 rw-p 00006000 fd:01 724559 libgtk3-nocsd.so.0
7fddc39b3000-7fddc39da000 r-xp 00000000 fd:01 2363771 ld-2.27.so
7fddc3af4000-7fddc3bb8000 rw-p 00000000 00:00 0
7fddc3bda000-7fddc3bdb000 r--p 00027000 fd:01 2363771 ld-2.27.so
....
7fddc3bdc000-7fddc3bdd000 rw-p 00000000 00:00 0
7fff472e9000-7fff4730b000 rw-p 00000000 00:00 0 [stack]
7fff473a7000-7fff473aa000 r--p 00000000 00:00 0 [vvar]
7fff473aa000-7fff473ac000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 [vsyscall]

把所有這些地址加起來可能會留下相同的結果,大約是 380Mb。我懶得總結。但它很有趣,向右滾動你會看到 libc 和其他共享庫映射到你的進程。

讓我們嘗試另一個簡單點兒的程序

func main() {
    Go func() {
        for {
            var m runtime.MemStats
            runtime.ReadMemStats(&m)

            log.Println(float64(m.Sys) / 1024 / 1024)
            log.Println(float64(m.HeapAlloc) / 1024 / 1024)
            time.Sleep(10 * time.Second)
        }
    }()

    fmt.Println("hello")
    time.Sleep(1 * time.Hour)
}

編譯運行:

go build main.go
./main
$ ps -u --pid 3642

USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
povilasv 3642 0.0 0.0 4900 948 pts/10 Sl+ 09:07 0:00 ./main

恩,是不是很有意思?這個程序只花了 4Mb (RSS)。

未完待續。。。

感謝你閱讀本文。一如既往的,我期待你的評論。同時請不要破壞我在評論中的搜索😀


via: https://povilasv.me/go-memory-management/

作者:Povilas[6] 譯者:7Ethan[7] 校對:polaris1119[8]

本文由 GCTT[9] 原創編譯,Go 中文網 [10] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

參考資料

[1]

Go Meetup: https://www.meetup.com/Vilnius-Golang/events/249897910/

[2]

維基百科: https://en.wikipedia.org/wiki/Random-access_memory

[3]

可執行文件和可鏈接格式: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format

[4]

可執行文件: https://en.wikipedia.org/wiki/Portable_Executable

[5]

Thread-Caching Malloc: http://goog-perftools.sourceforge.net/doc/tcmalloc.html

[6]

Povilas: https://povilasv.me/author/versockas/

[7]

7Ethan: https://github.com/7Ethan

[8]

polaris1119: https://github.com/polaris1119

[9]

GCTT: https://github.com/studygolang/GCTT

[10]

Go 中文網: https://studygolang.com/

福利

我爲大家整理了一份從入門到進階的 Go 學習資料禮包,包含學習建議:入門看什麼,進階看什麼。關注公衆號 「polarisxu」,回覆 ebook 獲取;還可以回覆「進羣」,和數萬 Gopher 交流學習。

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