為什么 Go 占用那么多的虛擬內(nèi)存?
本文轉(zhuǎn)載自微信公眾號「腦子進(jìn)煎魚了」,作者陳煎魚。轉(zhuǎn)載本文請聯(lián)系腦子進(jìn)煎魚了公眾號。
前段時間,某同學(xué)說某服務(wù)的容器因為超出內(nèi)存限制,不斷地重啟,問我們是不是有內(nèi)存泄露,趕緊排查,然后解決掉,省的出問題。
我們大為震驚,趕緊查看監(jiān)控+報警系統(tǒng)和性能分析,發(fā)現(xiàn)應(yīng)用指標(biāo)壓根就不高,不像有泄露的樣子。
問題到底是出在哪里了呢,我們進(jìn)入某個容器里查看了 top 的系統(tǒng)指標(biāo):
- PID VSZ RSS ... COMMAND
- 67459 2007m 136m ... ./eddycjy-server
看上去也沒什么大開銷的東西,就一個 Go 進(jìn)程?就這?
再定眼一看,某同學(xué)就說 VSZ 那么高,而某云上的容器內(nèi)存指標(biāo)居然恰好和 VSZ 的值相接近,因此就懷疑是不是 VSZ 所導(dǎo)致的,覺得存在一定的關(guān)聯(lián)關(guān)系。
這個猜測的結(jié)果到底是否正確呢?
基礎(chǔ)知識
本篇文章將主要圍繞 Go 進(jìn)程的 VSZ 來進(jìn)行剖析,看看到底它為什么那么 "高"。
第一節(jié)為前置的補充知識,大家可按順序閱讀。
什么是 VSZ
VSZ 是該進(jìn)程所能使用的虛擬內(nèi)存總大小,它包括進(jìn)程可以訪問的所有內(nèi)存,其中包括了被換出的內(nèi)存(Swap)、已分配但未使用的內(nèi)存以及來自共享庫的內(nèi)存。
為什么要虛擬內(nèi)存
在前面我們有了解到 VSZ 其實就是該進(jìn)程的虛擬內(nèi)存總大小,那如果我們想了解 VSZ 的話,那我們得先了解 “為什么要虛擬內(nèi)存?”。
本質(zhì)上來講,在一個系統(tǒng)中的進(jìn)程是與其他進(jìn)程共享 CPU 和主存資源的。
因此在現(xiàn)代的操作系統(tǒng)中,多進(jìn)程的使用非常的常見,如果太多的進(jìn)程需要太多的內(nèi)存,在沒有虛擬內(nèi)存的情況下,物理內(nèi)存很可能會不夠用,就會導(dǎo)致其中有些任務(wù)無法運行,更甚至?xí)霈F(xiàn)一些很奇怪的現(xiàn)象。
例如 “某一個進(jìn)程不小心寫了另一個進(jìn)程使用的內(nèi)存”,就會造成內(nèi)存破壞,因此虛擬內(nèi)存是非常重要的一個媒介。
虛擬內(nèi)存包含了什么
虛擬內(nèi)存,又分為:
- 內(nèi)核虛擬內(nèi)存。
- 進(jìn)程虛擬內(nèi)存。
每一個進(jìn)程的虛擬內(nèi)存都是獨立的, 內(nèi)部結(jié)構(gòu)如下圖所示。
在內(nèi)核虛擬內(nèi)存中,包含了內(nèi)核中的代碼和數(shù)據(jù)結(jié)構(gòu)。
內(nèi)核虛擬內(nèi)存中的某些區(qū)域會被映射到所有進(jìn)程共享的物理頁面中去,因此你會看到 ”內(nèi)核虛擬內(nèi)存“ 中實際上是包含了 ”物理內(nèi)存“ 的,它們兩者存在映射關(guān)系。
而從應(yīng)用場景上來講,每個進(jìn)程也會去共享內(nèi)核的代碼和全局?jǐn)?shù)據(jù)結(jié)構(gòu),因此就會被映射到所有進(jìn)程的物理頁面中去。
虛擬內(nèi)存的重要能力
為了更有效地管理內(nèi)存并且減少出錯,現(xiàn)代系統(tǒng)提供了一種對主存的抽象概念,也就是今天的主角,叫做虛擬內(nèi)存(VM)。
虛擬內(nèi)存是硬件異常、硬件地址翻譯、主存、磁盤文件和內(nèi)核軟件交互的地方,它為每個進(jìn)程提供了一個大的、一致的和私有的地址空間,虛擬內(nèi)存提供了三個重要的能力:
它將主存看成是一個存儲在磁盤上的地址空間的高速緩存,在主存中只保存活動區(qū)域,并根據(jù)需要在磁盤和主存之間來回傳送數(shù)據(jù),通過這種方式,它高效地使用了主存。
它為每個進(jìn)程提供了一致的地址空間,從而簡化了內(nèi)存管理。
它保護(hù)了每個進(jìn)程的地址空間不被其他進(jìn)程破壞。
小結(jié)
上面發(fā)散的可能比較多,簡單來講,對于本文我們重點關(guān)注這些知識點,如下:
- 虛擬內(nèi)存它是有各式各樣內(nèi)存交互的地方,它包含的不僅僅是 "自己",而在本文中,我們只需要關(guān)注 VSZ,也就是進(jìn)程虛擬內(nèi)存,它包含了你的代碼、數(shù)據(jù)、堆、棧段和共享庫。
- 虛擬內(nèi)存作為內(nèi)存保護(hù)的工具,能夠保證進(jìn)程之間的內(nèi)存空間獨立,不受其他進(jìn)程的影響,因此每一個進(jìn)程的 VSZ 大小都不一樣,互不影響。
- 虛擬內(nèi)存的存在,系統(tǒng)給各進(jìn)程分配的內(nèi)存之和是可以大于實際可用的物理內(nèi)存的,因此你也會發(fā)現(xiàn)你進(jìn)程的物理內(nèi)存總是比虛擬內(nèi)存低的多的多。
排查問題
在了解了基礎(chǔ)知識后,我們正式開始排查問題,第一步我們先編寫一個測試程序,看看沒有什么業(yè)務(wù)邏輯的 Go 程序,它初始的 VSZ 是怎么樣的。
測試
應(yīng)用代碼:
- func main() {
- r := gin.Default()
- r.GET("/ping", func(c *gin.Context) {
- c.JSON(200, gin.H{
- "message": "pong",
- })
- })
- r.Run(":8001")
- }
查看進(jìn)程情況:
- $ ps aux 67459
- USER PID %CPU %MEM VSZ RSS ...
- eddycjy 67459 0.0 0.0 4297048 960 ...
從結(jié)果上來看,VSZ 為 4297048K,也就是 4G 左右,咋一眼看過去還是挺嚇人的,明明沒有什么業(yè)務(wù)邏輯,但是為什么那么高呢,真是令人感到好奇。
確認(rèn)有沒有泄露
在未知的情況下,我們可以首先看下 runtime.MemStats 和 pprof,確定應(yīng)用到底有沒有泄露。不過我們這塊是演示程序,什么業(yè)務(wù)邏輯都沒有,因此可以確定和應(yīng)用沒有直接關(guān)系。
- # runtime.MemStats
- # Alloc = 1298568
- # TotalAlloc = 1298568
- # Sys = 71893240
- # Lookups = 0
- # Mallocs = 10013
- # Frees = 834
- # HeapAlloc = 1298568
- # HeapSys = 66551808
- # HeapIdle = 64012288
- # HeapInuse = 2539520
- # HeapReleased = 64012288
- # HeapObjects = 9179
- ...
Go FAQ
接著我第一反應(yīng)是去翻了 Go FAQ(因為看到過,有印象),其問題為 "Why does my Go process use so much virtual memory?",回答如下:
The Go memory allocator reserves a large region of virtual memory as an arena for allocations. This virtual memory is local to the specific Go process; the reservation does not deprive other processes of memory.
To find the amount of actual memory allocated to a Go process, use the Unix top command and consult the RES (Linux) or RSIZE (macOS) columns.
這個 FAQ 是在 2012 年 10 月 提交 的,這么多年了也沒有更進(jìn)一步的說明,再翻了 issues 和 forum,一些關(guān)閉掉的 issue 都指向了 FAQ,這顯然無法滿足我的求知欲,因此我繼續(xù)往下探索,看看里面到底都擺了些什么。
查看內(nèi)存映射
在上圖中,我們有提到進(jìn)程虛擬內(nèi)存,主要包含了你的代碼、數(shù)據(jù)、堆、棧段和共享庫,那初步懷疑是不是進(jìn)程做了什么內(nèi)存映射,導(dǎo)致了大量的內(nèi)存空間被保留呢,為了確定這一點,我們通過如下命令去排查:
- $ vmmap --wide 67459
- ...
- ==== Non-writable regions for process 67459
- REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
- __TEXT 00000001065ff000-000000010667b000 [ 496K 492K 0K 0K] r-x/rwx SM=COW /bin/zsh
- __LINKEDIT 0000000106687000-0000000106699000 [ 72K 44K 0K 0K] r--/rwx SM=COW /bin/zsh
- MALLOC metadata 000000010669b000-000000010669c000 [ 4K 4K 4K 0K] r--/rwx SM=COW DefaultMallocZone_0x10669b000 zone structure
- ...
- __TEXT 00007fff76c31000-00007fff76c5f000 [ 184K 168K 0K 0K] r-x/r-x SM=COW /usr/lib/system/libxpc.dylib
- __LINKEDIT 00007fffe7232000-00007ffff32cb000 [192.6M 17.4M 0K 0K] r--/r-- SM=COW dyld shared cache combined __LINKEDIT
- ...
- ==== Writable regions for process 67459
- REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
- __DATA 000000010667b000-0000000106682000 [ 28K 28K 28K 0K] rw-/rwx SM=COW /bin/zsh
- ...
- __DATA 0000000106716000-000000010671e000 [ 32K 28K 28K 4K] rw-/rwx SM=COW /usr/lib/zsh/5.3/zsh/zle.so
- __DATA 000000010671e000-000000010671f000 [ 4K 4K 4K 0K] rw-/rwx SM=COW /usr/lib/zsh/5.3/zsh/zle.so
- __DATA 0000000106745000-0000000106747000 [ 8K 8K 8K 0K] rw-/rwx SM=COW /usr/lib/zsh/5.3/zsh/complete.so
- __DATA 000000010675a000-000000010675b000 [ 4K 4K 4K 0K] rw-
- ...
這塊主要是利用 macOS 的 vmmap 命令去查看內(nèi)存映射情況,這樣就可以知道這個進(jìn)程的內(nèi)存映射情況,從輸出分析來看,這些關(guān)聯(lián)共享庫占用的空間并不大,導(dǎo)致 VSZ 過高的根本原因不在共享庫和二進(jìn)制文件上,但是并沒有發(fā)現(xiàn)大量保留內(nèi)存空間的行為,這是一個問題點。
注:若是 Linux 系統(tǒng),可使用 cat /proc/PID/maps 或 cat /proc/PID/smaps 查看。
查看系統(tǒng)調(diào)用
既然在內(nèi)存映射中,我們沒有明確的看到保留內(nèi)存空間的行為,那我們接下來看看該進(jìn)程的系統(tǒng)調(diào)用,確定一下它是否存在內(nèi)存操作的行為,如下:
- $ sudo dtruss -a ./awesomeProject
- ...
- 4374/0x206a2: 15620 6 3 mprotect(0x1BC4000, 0x1000, 0x0) = 0 0
- ...
- 4374/0x206a2: 15781 9 4 sysctl([CTL_HW, 3, 0, 0, 0, 0] (2), 0x7FFEEFBFFA64, 0x7FFEEFBFFA68, 0x0, 0x0) = 0 0
- 4374/0x206a2: 15783 3 1 sysctl([CTL_HW, 7, 0, 0, 0, 0] (2), 0x7FFEEFBFFA64, 0x7FFEEFBFFA68, 0x0, 0x0) = 0 0
- 4374/0x206a2: 15899 7 2 mmap(0x0, 0x40000, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x4000000 0
- 4374/0x206a2: 15930 3 1 mmap(0xC000000000, 0x4000000, 0x0, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0xC000000000 0
- 4374/0x206a2: 15934 4 2 mmap(0xC000000000, 0x4000000, 0x3, 0x1012, 0xFFFFFFFFFFFFFFFF, 0x0) = 0xC000000000 0
- 4374/0x206a2: 15936 2 0 mmap(0x0, 0x2000000, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x59B7000 0
- 4374/0x206a2: 15942 2 0 mmap(0x0, 0x210800, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x4040000 0
- 4374/0x206a2: 15947 2 0 mmap(0x0, 0x10000, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x1BD0000 0
- 4374/0x206a2: 15993 3 0 madvise(0xC000000000, 0x2000, 0x8) = 0 0
- 4374/0x206a2: 16004 2 0 mmap(0x0, 0x10000, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x1BE0000 0
- ...
在這小節(jié)中,我們通過 macOS 的 dtruss 命令監(jiān)聽并查看了運行這個程序所進(jìn)行的所有系統(tǒng)調(diào)用,發(fā)現(xiàn)了與內(nèi)存管理有一定關(guān)系的方法如下:
- mmap:創(chuàng)建一個新的虛擬內(nèi)存區(qū)域,但這里需要注意,就是當(dāng)系統(tǒng)調(diào)用 mmap 時,它只是從虛擬內(nèi)存中申請了一段空間出來,并不會去分配和映射真實的物理內(nèi)存,而當(dāng)你訪問這段空間的時候,才會在當(dāng)前時間真正的去分配物理內(nèi)存。那么對應(yīng)到我們實際應(yīng)用的進(jìn)程中,那就是 VSZ 的增長后,而該內(nèi)存空間又未正式使用的話,物理內(nèi)存是不會有增長的。
- madvise:提供有關(guān)使用內(nèi)存的建議,例如:MADV_NORMAL、MADV_RANDOM、MADV_SEQUENTIAL、MADV_WILLNEED、MADV_DONTNEED 等等。
- mprotect:設(shè)置內(nèi)存區(qū)域的保護(hù)情況,例如:PROT_NONE、PROT_READ、PROT_WRITE、PROT_EXEC、PROT_SEM、PROT_SAO、PROT_GROWSUP、PROT_GROWSDOWN 等等。
- sysctl:在內(nèi)核運行時動態(tài)地修改內(nèi)核的運行參數(shù)。
在此比較可疑的是 mmap 方法,它在 dtruss 的最終統(tǒng)計中一共調(diào)用了 10 余次,我們可以相信它在 Go Runtime 的時候進(jìn)行了大量的虛擬內(nèi)存申請。
我們再接著往下看,看看到底是在什么階段進(jìn)行了虛擬內(nèi)存空間的申請。
注:若是 Linux 系統(tǒng),可使用 strace 命令。
查看 Go Runtime
啟動流程
通過上述的分析,我們可以知道在 Go 程序啟動的時候 VSZ 就已經(jīng)不低了,并且確定不是共享庫等的原因,且程序在啟動時系統(tǒng)調(diào)用確實存在 mmap 等方法的調(diào)用。
那么我們可以充分懷疑 Go 在初始化階段就保留了該內(nèi)存空間。那我們第一步要做的就是查看一下 Go 的引導(dǎo)啟動流程,看看是在哪里申請的。
引導(dǎo)過程如下:
- graph TD
- A(rt0_darwin_amd64.s:8<br/>_rt0_amd64_darwin) -->|JMP| B(asm_amd64.s:15<br/>_rt0_amd64)
- B --> |JMP|C(asm_amd64.s:87<br/>runtime-rt0_go)
- C --> D(runtime1.go:60<br/>runtime-args)
- D --> E(os_darwin.go:50<br/>runtime-osinit)
- E --> F(proc.go:472<br/>runtime-schedinit)
- F --> G(proc.go:3236<br/>runtime-newproc)
- G --> H(proc.go:1170<br/>runtime-mstart)
- H --> I(在新創(chuàng)建的 p 和 m 上運行 runtime-main)
- runtime-osinit:獲取 CPU 核心數(shù)。
- runtime-schedinit:初始化程序運行環(huán)境(包括棧、內(nèi)存分配器、垃圾回收、P等)。
- runtime-newproc:創(chuàng)建一個新的 G 和 綁定 runtime.main。
- runtime-mstart:啟動線程 M。
注:來自@曹大的 《Go 程序的啟動流程》和@全成的 《Go 程序是怎樣跑起來的》,推薦大家閱讀。
初始化運行環(huán)境
顯然,我們要研究的是 runtime 里的 schedinit 方法,如下:
- func schedinit() {
- ...
- stackinit()
- mallocinit()
- mcommoninit(_g_.m)
- cpuinit() // must run before alginit
- alginit() // maps must not be used before this call
- modulesinit() // provides activeModules
- typelinksinit() // uses maps, activeModules
- itabsinit() // uses activeModules
- msigsave(_g_.m)
- initSigmask = _g_.m.sigmask
- goargs()
- goenvs()
- parsedebugvars()
- gcinit()
- ...
- }
從用途來看,非常明顯, mallocinit 方法會進(jìn)行內(nèi)存分配器的初始化,我們繼續(xù)往下看。
初始化內(nèi)存分配器
mallocinit
接下來我們正式的分析一下 mallocinit 方法,在引導(dǎo)流程中, mallocinit 主要承擔(dān) Go 程序的內(nèi)存分配器的初始化動作,而今天主要是針對虛擬內(nèi)存地址這塊進(jìn)行拆解,如下:
- func mallocinit() {
- ...
- if sys.PtrSize == 8 {
- for i := 0x7f; i >= 0; i-- {
- var p uintptr
- switch {
- case GOARCH == "arm64" && GOOS == "darwin":
- p = uintptr(i)<<40 | uintptrMask&(0x0013<<28)
- case GOARCH == "arm64":
- p = uintptr(i)<<40 | uintptrMask&(0x0040<<32)
- case GOOS == "aix":
- if i == 0 {
- continue
- }
- p = uintptr(i)<<40 | uintptrMask&(0xa0<<52)
- case raceenabled:
- ...
- default:
- p = uintptr(i)<<40 | uintptrMask&(0x00c0<<32)
- }
- hint := (*arenaHint)(mheap_.arenaHintAlloc.alloc())
- hint.addr = p
- hint.next, mheap_.arenaHints = mheap_.arenaHints, hint
- }
- } else {
- ...
- }
- }
- 判斷當(dāng)前是 64 位還是 32 位的系統(tǒng)。
- 從 0x7fc000000000~0x1c000000000 開始設(shè)置保留地址。
- 判斷當(dāng)前 GOARCH、GOOS 或是否開啟了競態(tài)檢查,根據(jù)不同的情況申請不同大小的連續(xù)內(nèi)存地址,而這里的 p 是即將要要申請的連續(xù)內(nèi)存地址的開始地址。
- 保存剛剛計算的 arena 的信息到 arenaHint 中。
可能會有小伙伴問,為什么要判斷是 32 位還是 64 位的系統(tǒng),這是因為不同位數(shù)的虛擬內(nèi)存的尋址范圍是不同的,因此要進(jìn)行區(qū)分,否則會出現(xiàn)高位的虛擬內(nèi)存映射問題。而在申請保留空間時,我們會經(jīng)常提到 arenaHint 結(jié)構(gòu)體,它是 arenaHints鏈表里的一個節(jié)點,結(jié)構(gòu)如下:
- type arenaHint struct {
- addr uintptr
- down bool
- next *arenaHint
- }
- addr:arena 的起始地址
- down:是否最后一個 arena
- next:下一個 arenaHint 的指針地址
那么這里瘋狂提到的 arena 又是什么東西呢,這其實是 Go 的內(nèi)存管理中的概念,Go Runtime 會把申請的虛擬內(nèi)存分為三個大塊,如下:
image
- spans:記錄 arena 區(qū)域頁號和 mspan 的映射關(guān)系。
- bitmap:標(biāo)識 arena 的使用情況,在功能上來講,會用于標(biāo)識 arena 的哪些空間地址已經(jīng)保存了對象。
- arean:arean 其實就是 Go 的堆區(qū),是由 mheap 進(jìn)行管理的,它的 MaxMem 是 512GB-1。而在功能上來講,Go 會在初始化的時候申請一段連續(xù)的虛擬內(nèi)存空間地址到 arean 保留下來,在真正需要申請堆上的空間時再從 arean 中取出來處理,這時候就會轉(zhuǎn)變?yōu)槲锢韮?nèi)存了。
在這里的話,你需要理解 arean 區(qū)域在 Go 內(nèi)存里的作用就可以了。
mmap
我們剛剛通過上述的分析,已經(jīng)知道 mallocinit 的用途了,但是你可能還是會有疑惑,就是我們之前所看到的 mmap 系統(tǒng)調(diào)用,和它又有什么關(guān)系呢,怎么就關(guān)聯(lián)到一起了,接下來我們先一起來看看更下層的代碼,如下:
- func sysAlloc(n uintptr, sysStat *uint64) unsafe.Pointer {
- p, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
- ...
- mSysStatInc(sysStat, n)
- return p
- }
- func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
- p, err := mmap(v, n, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
- ...
- }
- func sysMap(v unsafe.Pointer, n uintptr, sysStat *uint64) {
- ...
- munmap(v, n)
- p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0)
- ...
- }
在 Go Runtime 中存在著一系列的系統(tǒng)級內(nèi)存調(diào)用方法,本文涉及的主要如下:
- sysAlloc:從 OS 系統(tǒng)上申請清零后的內(nèi)存空間,調(diào)用參數(shù)是 _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE,得到的結(jié)果需進(jìn)行內(nèi)存對齊。
- sysReserve:從 OS 系統(tǒng)中保留內(nèi)存的地址空間,這時候還沒有分配物理內(nèi)存,調(diào)用參數(shù)是 _PROT_NONE, _MAP_ANON|_MAP_PRIVATE,得到的結(jié)果需進(jìn)行內(nèi)存對齊。
- sysMap:通知 OS 系統(tǒng)我們要使用已經(jīng)保留了的內(nèi)存空間,調(diào)用參數(shù)是 _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE。
看上去好像很有道理的樣子,但是 mallocinit 方法在初始化時,到底是在哪里涉及了 mmap 方法呢,表面看不出來,如下:
- for i := 0x7f; i >= 0; i-- {
- ...
- hint := (*arenaHint)(mheap_.arenaHintAlloc.alloc())
- hint.addr = p
- hint.next, mheap_.arenaHints = mheap_.arenaHints, hint
- }
實際上在調(diào)用 mheap_.arenaHintAlloc.alloc() 時,調(diào)用的是 mheap 下的 sysAlloc 方法,而 sysAlloc 又會與 mmap 方法產(chǎn)生調(diào)用關(guān)系,并且這個方法與常規(guī)的 sysAlloc 還不大一樣,如下:
- var mheap_ mheap
- ...
- func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
- ...
- for h.arenaHints != nil {
- hint := h.arenaHints
- p := hint.addr
- if hint.down {
- p -= n
- }
- if p+n < p {
- v = nil
- } else if arenaIndex(p+n-1) >= 1<<arenaBits {
- v = nil
- } else {
- v = sysReserve(unsafe.Pointer(p), n)
- }
- ...
- }
你可以驚喜的發(fā)現(xiàn) mheap.sysAlloc 里其實有調(diào)用 sysReserve 方法,而 sysReserve 方法又正正是從 OS 系統(tǒng)中保留內(nèi)存的地址空間的特定方法,是不是很驚喜,一切似乎都串起來了。
小結(jié)
在本節(jié)中,我們先寫了一個測試程序,然后根據(jù)非常規(guī)的排查思路進(jìn)行了一步步的跟蹤懷疑,整體流程如下:
- 通過 top 或 ps 等命令,查看進(jìn)程運行情況,分析基礎(chǔ)指標(biāo)。
- 通過 pprof 或 runtime.MemStats 等工具鏈查看應(yīng)用運行情況,分析應(yīng)用層面是否有泄露或者哪兒高。
- 通過 vmmap 命令,查看進(jìn)程的內(nèi)存映射情況,分析是不是進(jìn)程虛擬空間內(nèi)的某個區(qū)域比較高,例如:共享庫等。
- 通過 dtruss 命令,查看程序的系統(tǒng)調(diào)用情況,分析可能出現(xiàn)的一些特殊行為,例如:在分析中我們發(fā)現(xiàn) mmap 方法調(diào)用的比例是比較高的,那我們有充分的理由懷疑 Go 在啟動時就進(jìn)行了大量的內(nèi)存空間保留。
- 通過上述的分析,確定可能是在哪個環(huán)節(jié)申請了那么多的內(nèi)存空間后,再到 Go Runtime 中去做進(jìn)一步的源碼分析,因為源碼面前,了無秘密,沒必要靠猜。
從結(jié)論上而言,VSZ(進(jìn)程虛擬內(nèi)存大小)與共享庫等沒有太大的關(guān)系,主要與 Go Runtime 存在直接關(guān)聯(lián),也就是在前圖中表示的運行時堆(malloc)。轉(zhuǎn)換到 Go Runtime 里,就是在 mallocinit 這個內(nèi)存分配器的初始化階段里進(jìn)行了一定量的虛擬空間的保留。
而保留虛擬內(nèi)存空間時,受什么影響,又是一個哲學(xué)問題。從源碼上來看,主要如下:
- 受不同的 OS 系統(tǒng)架構(gòu)(GOARCH/GOOS)和位數(shù)(32/64 位)的影響。
- 受內(nèi)存對齊的影響,計算回來的內(nèi)存空間大小是需要經(jīng)過對齊才會進(jìn)行保留。
總結(jié)
我們通過一步步地分析,講解了 Go 會在哪里,又會受什么因素,去調(diào)用了什么方法保留了那么多的虛擬內(nèi)存空間,但是我們肯定會憂心進(jìn)程虛擬內(nèi)存(VSZ)高,會不會存在問題呢,我分析如下:
- VSZ 并不意味著你真正使用了那些物理內(nèi)存,因此是不需要擔(dān)心的。
- VSZ 并不會給 GC 帶來壓力,GC 管理的是進(jìn)程實際使用的物理內(nèi)存,而 VSZ 在你實際使用它之前,它并沒有過多的代價。
- VSZ 基本都是不可訪問的內(nèi)存映射,也就是它并沒有內(nèi)存的訪問權(quán)限(不允許讀、寫和執(zhí)行)。
思考
看到這里舒一口氣,因為 Go VSZ 的高,并不會對我們產(chǎn)生什么非常實質(zhì)性的問題,但是又仔細(xì)一想,為什么 Go 要申請那么多的虛擬內(nèi)存呢?
總體考慮如下:
- Go 的設(shè)計是考慮到 arena 和 bitmap 的后續(xù)使用,先提早保留了整個內(nèi)存地址空間。
- Go Runtime 和應(yīng)用的逐步使用,肯定也會開始實際的申請和使用內(nèi)存,這時候 arena 和 bitmap 的內(nèi)存分配器就只需要將事先申請好的內(nèi)存地址空間保留更改為實際可用的物理內(nèi)存就好了,這樣子可以極大的提高效能。
參考
High virtual memory allocation by golang
GO MEMORY MANAGEMENT
GoBigVirtualSize
GoProgramMemoryUse
曹大的 Go 程序的啟動流程
全成大佬的 Go 程序是怎樣跑起來的
歐神的 go-under-the-hood