前段时间,某同学说某服务的容器因为超出内存限制,不断地重启,问我们是不是有内存泄露,赶紧排查,然后解决掉,省的出问题。我们大为震惊,赶紧查看监控+报警系统和性能分析,发现应用指标压根就不高,不像有泄露的样子。
那么问题是出在哪里了呢,我们进入某个容器里查看了 top
的系统指标,结果如下:
PID VSZ RSS ... COMMAND 67459 2007m 136m ... ./eddycjy-server
从结果上来看,也没什么大开销的东西,主要就一个 Go 进程,一看,某同学就说 VSZ 那么高,而某云上的容器内存指标居然恰好和 VSZ 的值相接近,因此某同学就怀疑是不是 VSZ 所导致的,觉得存在一定的关联关系。
而从最终的结论上来讲,上述的表述是不全对的,那么在今天,本篇文章将主要围绕 Go 进程的 VSZ 来进行剖析,看看到底它为什么那么 "高",而在正式开始分析前,第一节为前置的补充知识,大家可按顺序阅读。
VSZ 是该进程所能使用的虚拟内存总大小,它包括进程可以访问的所有内存,其中包括了被换出的内存(Swap)、已分配但未使用的内存以及来自共享库的内存。
在前面我们有了解到 VSZ 其实就是该进程的虚拟内存总大小,那如果我们想了解 VSZ 的话,那我们得先了解 “为什么要虚拟内存?”。
本质上来讲,在一个系统中的进程是与其他进程共享 CPU 和主存资源的,而在现代的操作系统中,多进程的使用非常的常见,那么如果太多的进程需要太多的内存,那么在没有虚拟内存的情况下,物理内存很可能会不够用,就会导致其中有些任务无法运行,更甚至会出现一些很奇怪的现象,例如 “某一个进程不小心写了另一个进程使用的内存”,就会造成内存破坏,因此虚拟内存是非常重要的一个媒介。
而虚拟内存,又分为内核虚拟内存和进程虚拟内存,每一个进程的虚拟内存都是独立的, 呈现如上图所示。
这里也补充说明一下,在内核虚拟内存中,是包含了内核中的代码和数据结构,而内核虚拟内存中的某些区域会被映射到所有进程共享的物理页面中去,因此你会看到 ”内核虚拟内存“ 中实际上是包含了 ”物理内存“ 的,它们两者存在映射关系。而在应用场景上来讲,每个进程也会去共享内核的代码和全局数据结构,因此就会被映射到所有进程的物理页面中去。
为了更有效地管理内存并且减少出错,现代系统提供了一种对主存的抽象概念,也就是今天的主角,叫做虚拟内存(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 语言、微服务架构和奇怪的系统设计,欢迎大家关注我的公众号和我进行交流和沟通。
最好的关系是互相成就,各位的点赞就是煎鱼创作的最大动力,感谢支持。