前段時間,某同窗說某服務的容器由於超出內存限制,不斷地重啓,問咱們是否是有內存泄露,趕忙排查,而後解決掉,省的出問題。咱們大爲震驚,趕忙查看監控+報警系統和性能分析,發現應用指標壓根就不高,不像有泄露的樣子。git
那麼問題是出在哪裏了呢,咱們進入某個容器裏查看了 top
的系統指標,結果以下:github
PID VSZ RSS ... COMMAND 67459 2007m 136m ... ./eddycjy-server
從結果上來看,也沒什麼大開銷的東西,主要就一個 Go 進程,一看,某同窗就說 VSZ 那麼高,而某雲上的容器內存指標竟然剛好和 VSZ 的值相接近,所以某同窗就懷疑是否是 VSZ 所致使的,以爲存在必定的關聯關係。golang
而從最終的結論上來說,上述的表述是不全對的,那麼在今天,本篇文章將主要圍繞 Go 進程的 VSZ 來進行剖析,看看到底它爲何那麼 "高",而在正式開始分析前,第一節爲前置的補充知識,你們可按順序閱讀。shell
VSZ 是該進程所能使用的虛擬內存總大小,它包括進程能夠訪問的全部內存,其中包括了被換出的內存(Swap)、已分配但未使用的內存以及來自共享庫的內存。緩存
在前面咱們有了解到 VSZ 其實就是該進程的虛擬內存總大小,那若是咱們想了解 VSZ 的話,那咱們得先了解 「爲何要虛擬內存?」。數據結構
本質上來說,在一個系統中的進程是與其餘進程共享 CPU 和主存資源的,而在現代的操做系統中,多進程的使用很是的常見,那麼若是太多的進程須要太多的內存,那麼在沒有虛擬內存的狀況下,物理內存極可能會不夠用,就會致使其中有些任務沒法運行,更甚至會出現一些很奇怪的現象,例如 「某一個進程不當心寫了另外一個進程使用的內存」,就會形成內存破壞,所以虛擬內存是很是重要的一個媒介。架構
而虛擬內存,又分爲內核虛擬內存和進程虛擬內存,每個進程的虛擬內存都是獨立的, 呈現如上圖所示。ide
這裏也補充說明一下,在內核虛擬內存中,是包含了內核中的代碼和數據結構,而內核虛擬內存中的某些區域會被映射到全部進程共享的物理頁面中去,所以你會看到 」內核虛擬內存「 中其實是包含了 」物理內存「 的,它們二者存在映射關係。而在應用場景上來說,每一個進程也會去共享內核的代碼和全局數據結構,所以就會被映射到全部進程的物理頁面中去。微服務
爲了更有效地管理內存而且減小出錯,現代系統提供了一種對主存的抽象概念,也就是今天的主角,叫作虛擬內存(VM),虛擬內存是硬件異常、硬件地址翻譯、主存、磁盤文件和內核軟件交互的地方,它爲每一個進程提供了一個大的、一致的和私有的地址空間,虛擬內存提供了三個重要的能力:工具
上面發散的可能比較多,簡單來說,對於本文咱們重點關注這些知識點,以下:
在瞭解了基礎知識後,咱們正式開始排查問題,第一步咱們先編寫一個測試程序,看看沒有什麼業務邏輯的 Go 程序,它初始的 VSZ 是怎麼樣的。
應用代碼:
func main() { r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ "message": "pong", }) }) r.Run(":8001") }
查看進程狀況:
$ ps aux 67459 USER PID %CPU %MEM VSZ RSS ... eddycjy 67459 0.0 0.0 4297048 960 ...
從結果上來看,VSZ 爲 4297048K,也就是 4G 左右,咋一眼看過去仍是挺嚇人的,明明沒有什麼業務邏輯,可是爲何那麼高呢,真是使人感到好奇。
在未知的狀況下,咱們能夠首先看下 runtime.MemStats
和 pprof
,肯定應用到底有沒有泄露。不過咱們這塊是演示程序,什麼業務邏輯都沒有,所以能夠肯定和應用沒有直接關係。
# 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(由於看到過,有印象),其問題爲 "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 月 提交 的,這麼多年了也沒有更進一步的說明,再翻了 issues 和 forum,一些關閉掉的 issue 都指向了 FAQ,這顯然沒法知足個人求知慾,所以我繼續往下探索,看看裏面到底都擺了些什麼。
在上圖中,咱們有提到進程虛擬內存,主要包含了你的代碼、數據、堆、棧段和共享庫,那初步懷疑是否是進程作了什麼內存映射,致使了大量的內存空間被保留呢,爲了肯定這一點,咱們經過以下命令去排查:
$ 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
命令去查看內存映射狀況,這樣就能夠知道這個進程的內存映射狀況,從輸出分析來看,這些關聯共享庫佔用的空間並不大,致使 VSZ 太高的根本緣由不在共享庫和二進制文件上,可是並無發現大量保留內存空間的行爲,這是一個問題點。
注:如果 Linux 系統,可以使用 cat /proc/PID/maps
或 cat /proc/PID/smaps
查看。
既然在內存映射中,咱們沒有明確的看到保留內存空間的行爲,那咱們接下來看看該進程的系統調用,肯定一下它是否存在內存操做的行爲,以下:
$ 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 ...
在這小節中,咱們經過 macOS 的 dtruss
命令監聽並查看了運行這個程序所進行的全部系統調用,發現了與內存管理有必定關係的方法以下:
在此比較可疑的是 mmap
方法,它在 dtruss
的最終統計中一共調用了 10 餘次,咱們能夠相信它在 Go Runtime 的時候進行了大量的虛擬內存申請,咱們再接着往下看,看看究竟是在什麼階段進行了虛擬內存空間的申請。
注:如果 Linux 系統,可以使用 strace
命令。
經過上述的分析,咱們能夠知道在 Go 程序啓動的時候 VSZ 就已經不低了,而且肯定不是共享庫等的緣由,且程序在啓動時系統調用確實存在 mmap
等方法的調用,那麼咱們能夠充分懷疑 Go 在初始化階段就保留了該內存空間。那咱們第一步要作的就是查看一下 Go 的引導啓動流程,看看是在哪裏申請的,引導過程以下:
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(在新建立的 p 和 m 上運行 runtime-main)
注:來自@曹大的 《Go 程序的啓動流程》和@全成的 《Go 程序是怎樣跑起來的》,推薦你們閱讀。
顯然,咱們要研究的是 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
方法會進行內存分配器的初始化,咱們繼續往下看。
接下來咱們正式的分析一下 mallocinit
方法,在引導流程中, mallocinit
主要承擔 Go 程序的內存分配器的初始化動做,而今天主要是針對虛擬內存地址這塊進行拆解,以下:
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 { ... } }
GOARCH
、GOOS
或是否開啓了競態檢查,根據不一樣的狀況申請不一樣大小的連續內存地址,而這裏的 p
是即將要要申請的連續內存地址的開始地址。arenaHint
中。可能會有小夥伴問,爲何要判斷是 32 位仍是 64 位的系統,這是由於不一樣位數的虛擬內存的尋址範圍是不一樣的,所以要進行區分,不然會出現高位的虛擬內存映射問題。而在申請保留空間時,咱們會常常提到 arenaHint
結構體,它是 arenaHints
鏈表裏的一個節點,結構以下:
type arenaHint struct { addr uintptr down bool next *arenaHint }
arena
的起始地址arena
arenaHint
的指針地址那麼這裏瘋狂提到的 arena
又是什麼東西呢,這實際上是 Go 的內存管理中的概念,Go Runtime 會把申請的虛擬內存分爲三個大塊,以下:
在這裏的話,你須要理解 arean 區域在 Go 內存裏的做用就能夠了。
咱們剛剛經過上述的分析,已經知道 mallocinit
的用途了,可是你可能仍是會有疑惑,就是咱們以前所看到的 mmap
系統調用,和它又有什麼關係呢,怎麼就關聯到一塊兒了,接下來咱們先一塊兒來看看更下層的代碼,以下:
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 中存在着一系列的系統級內存調用方法,本文涉及的主要以下:
_PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE
,獲得的結果需進行內存對齊。_PROT_NONE, _MAP_ANON|_MAP_PRIVATE
,獲得的結果需進行內存對齊。_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 }
實際上在調用 mheap_.arenaHintAlloc.alloc()
時,調用的是 mheap
下的 sysAlloc
方法,而 sysAlloc
又會與 mmap
方法產生調用關係,而且這個方法與常規的 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) } ... }
你能夠驚喜的發現 mheap.sysAlloc
裏其實有調用 sysReserve
方法,而 sysReserve
方法又正正是從 OS 系統中保留內存的地址空間的特定方法,是否是很驚喜,一切彷佛都串起來了。
在本節中,咱們先寫了一個測試程序,而後根據很是規的排查思路進行了一步步的跟蹤懷疑,總體流程以下:
top
或 ps
等命令,查看進程運行狀況,分析基礎指標。pprof
或 runtime.MemStats
等工具鏈查看應用運行狀況,分析應用層面是否有泄露或者哪兒高。vmmap
命令,查看進程的內存映射狀況,分析是否是進程虛擬空間內的某個區域比較高,例如:共享庫等。dtruss
命令,查看程序的系統調用狀況,分析可能出現的一些特殊行爲,例如:在分析中咱們發現 mmap
方法調用的比例是比較高的,那咱們有充分的理由懷疑 Go 在啓動時就進行了大量的內存空間保留。從結論上而言,VSZ(進程虛擬內存大小)與共享庫等沒有太大的關係,主要與 Go Runtime 存在直接關聯,也就是在前圖中表示的運行時堆(malloc)。轉換到 Go Runtime 裏,就是在 mallocinit
這個內存分配器的初始化階段裏進行了必定量的虛擬空間的保留。
而保留虛擬內存空間時,受什麼影響,又是一個哲學問題。從源碼上來看,主要以下:
咱們經過一步步地分析,講解了 Go 會在哪裏,又會受什麼因素,去調用了什麼方法保留了那麼多的虛擬內存空間,可是咱們確定會憂心進程虛擬內存(VSZ)高,會不會存在問題呢,我分析以下:
看到這裏舒一口氣,由於 Go VSZ 的高,並不會對咱們產生什麼很是實質性的問題,可是又仔細一想,爲何 Go 要申請那麼多的虛擬內存呢?
整體考慮以下:
arena
和 bitmap
的後續使用,先提前保留了整個內存地址空間。arena
和 bitmap
的內存分配器就只須要將事先申請好的內存地址空間保留更改成實際可用的物理內存就行了,這樣子能夠極大的提升效能。分享 Go 語言、微服務架構和奇怪的系統設計,歡迎你們關注個人公衆號和我進行交流和溝通。
最好的關係是互相成就,各位的點贊就是煎魚創做的最大動力,感謝支持。