前段时间,某同窗说某服务的容器由于超出内存限制,不断地重启,问咱们是否是有内存泄露,赶忙排查,而后解决掉,省的出问题。咱们大为震惊,赶忙查看监控+报警系统和性能分析,发现应用指标压根就不高,不像有泄露的样子。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 语言、微服务架构和奇怪的系统设计,欢迎你们关注个人公众号和我进行交流和沟通。
最好的关系是互相成就,各位的点赞就是煎鱼创做的最大动力,感谢支持。