Go 应用内存占用太多,让排查?(VSZ篇)

前段时间,某同学说某服务的容器因为超出内存限制,不断地重启,问我们是不是有内存泄露,赶紧排查,然后解决掉,省的出问题。我们大为震惊,赶紧查看监控+报警系统和性能分析,发现应用指标压根就不高,不像有泄露的样子。

top
  1. PID VSZ RSS ... COMMAND
  2. 67459 2007m 136m ... ./eddycjy-server

从结果上来看,也没什么大开销的东西,主要就一个 Go 进程,一看,某同学就说 VSZ 那么高,而某云上的容器内存指标居然恰好和 VSZ 的值相接近,因此某同学就怀疑是不是 VSZ 所导致的,觉得存在一定的关联关系。

而从最终的结论上来讲,上述的表述是不全对的,那么在今天,本篇文章将主要围绕 Go 进程的 VSZ 来进行剖析,看看到底它为什么那么 “高”,而在正式开始分析前,第一节为前置的补充知识,大家可按顺序阅读。

基础知识

什么是 VSZ

VSZ 是该进程所能使用的虚拟内存总大小,它包括进程可以访问的所有内存,其中包括了被换出的内存(Swap)、已分配但未使用的内存以及来自共享库的内存。

为什么要虚拟内存

在前面我们有了解到 VSZ 其实就是该进程的虚拟内存总大小,那如果我们想了解 VSZ 的话,那我们得先了解 “为什么要虚拟内存?”

本质上来讲,在一个系统中的进程是与其他进程共享 CPU 和主存资源的,而在现代的操作系统中,多进程的使用非常的常见,那么如果太多的进程需要太多的内存,那么在没有虚拟内存的情况下,物理内存很可能会不够用,就会导致其中有些任务无法运行,更甚至会出现一些很奇怪的现象,例如 “某一个进程不小心写了另一个进程使用的内存”,就会造成内存破坏,因此虚拟内存是非常重要的一个媒介。

虚拟内存包含了什么

image

而虚拟内存,又分为内核虚拟内存和进程虚拟内存,每一个进程的虚拟内存都是独立的, 呈现如上图所示。

这里也补充说明一下,在内核虚拟内存中,是包含了内核中的代码和数据结构,而内核虚拟内存中的某些区域会被映射到所有进程共享的物理页面中去,因此你会看到 ”内核虚拟内存“ 中实际上是包含了 ”物理内存“ 的,它们两者存在映射关系。而在应用场景上来讲,每个进程也会去共享内核的代码和全局数据结构,因此就会被映射到所有进程的物理页面中去。

image

虚拟内存的重要能力

为了更有效地管理内存并且减少出错,现代系统提供了一种对主存的抽象概念,也就是今天的主角,叫做虚拟内存(VM),虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件交互的地方,它为每个进程提供了一个大的、一致的和私有的地址空间,虚拟内存提供了三个重要的能力:

  1. 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。
  2. 它为每个进程提供了一致的地址空间,从而简化了内存管理。
  3. 它保护了每个进程的地址空间不被其他进程破坏。

小结

上面发散的可能比较多,简单来讲,对于本文我们重点关注这些知识点,如下:

  • 虚拟内存它是有各式各样内存交互的地方,它包含的不仅仅是 “自己”,而在本文中,我们只需要关注 VSZ,也就是进程虚拟内存,它包含了你的代码、数据、堆、栈段和共享库
  • 虚拟内存作为内存保护的工具,能够保证进程之间的内存空间独立,不受其他进程的影响,因此每一个进程的 VSZ 大小都不一样,互不影响。
  • 虚拟内存的存在,系统给各进程分配的内存之和是可以大于实际可用的物理内存的,因此你也会发现你进程的物理内存总是比虚拟内存低的多的多。

排查问题

在了解了基础知识后,我们正式开始排查问题,第一步我们先编写一个测试程序,看看没有什么业务逻辑的 Go 程序,它初始的 VSZ 是怎么样的。

测试

应用代码:

  1. func main() {
  2. r := gin.Default()
  3. r.GET("/ping", func(c *gin.Context) {
  4. c.JSON(200, gin.H{
  5. "message": "pong",
  6. })
  7. })
  8. r.Run(":8001")
  9. }

查看进程情况:

  1. $ ps aux 67459
  2. USER PID %CPU %MEM VSZ RSS ...
  3. eddycjy 67459 0.0 0.0 4297048 960 ...

从结果上来看,VSZ 为 4297048K,也就是 4G 左右,咋一眼看过去还是挺吓人的,明明没有什么业务逻辑,但是为什么那么高呢,真是令人感到好奇。

确认有没有泄露

runtime.MemStatspprof
  1. # runtime.MemStats
  2. # Alloc = 1298568
  3. # TotalAlloc = 1298568
  4. # Sys = 71893240
  5. # Lookups = 0
  6. # Mallocs = 10013
  7. # Frees = 834
  8. # HeapAlloc = 1298568
  9. # HeapSys = 66551808
  10. # HeapIdle = 64012288
  11. # HeapInuse = 2539520
  12. # HeapReleased = 64012288
  13. # HeapObjects = 9179
  14. ...

Go FAQ

接着我第一反应是去翻了 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,这显然无法满足我的求知欲,因此我继续往下探索,看看里面到底都摆了些什么。

查看内存映射

在上图中,我们有提到进程虚拟内存,主要包含了你的代码、数据、堆、栈段和共享库,那初步怀疑是不是进程做了什么内存映射,导致了大量的内存空间被保留呢,为了确定这一点,我们通过如下命令去排查:

  1. $ vmmap --wide 67459
  2. ...
  3. ==== Non-writable regions for process 67459
  4. REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
  5. __TEXT 00000001065ff000-000000010667b000 [ 496K 492K 0K 0K] r-x/rwx SM=COW /bin/zsh
  6. __LINKEDIT 0000000106687000-0000000106699000 [ 72K 44K 0K 0K] r--/rwx SM=COW /bin/zsh
  7. MALLOC metadata 000000010669b000-000000010669c000 [ 4K 4K 4K 0K] r--/rwx SM=COW DefaultMallocZone_0x10669b000 zone structure
  8. ...
  9. __TEXT 00007fff76c31000-00007fff76c5f000 [ 184K 168K 0K 0K] r-x/r-x SM=COW /usr/lib/system/libxpc.dylib
  10. __LINKEDIT 00007fffe7232000-00007ffff32cb000 [192.6M 17.4M 0K 0K] r--/r-- SM=COW dyld shared cache combined __LINKEDIT
  11. ...
  12. ==== Writable regions for process 67459
  13. REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
  14. __DATA 000000010667b000-0000000106682000 [ 28K 28K 28K 0K] rw-/rwx SM=COW /bin/zsh
  15. ...
  16. __DATA 0000000106716000-000000010671e000 [ 32K 28K 28K 4K] rw-/rwx SM=COW /usr/lib/zsh/5.3/zsh/zle.so
  17. __DATA 000000010671e000-000000010671f000 [ 4K 4K 4K 0K] rw-/rwx SM=COW /usr/lib/zsh/5.3/zsh/zle.so
  18. __DATA 0000000106745000-0000000106747000 [ 8K 8K 8K 0K] rw-/rwx SM=COW /usr/lib/zsh/5.3/zsh/complete.so
  19. __DATA 000000010675a000-000000010675b000 [ 4K 4K 4K 0K] rw-
  20. ...
vmmap
cat /proc/PID/mapscat /proc/PID/smaps

查看系统调用

既然在内存映射中,我们没有明确的看到保留内存空间的行为,那我们接下来看看该进程的系统调用,确定一下它是否存在内存操作的行为,如下:

  1. $ sudo dtruss -a ./awesomeProject
  2. ...
  3. 4374/0x206a2: 15620 6 3 mprotect(0x1BC4000, 0x1000, 0x0) = 0 0
  4. ...
  5. 4374/0x206a2: 15781 9 4 sysctl([CTL_HW, 3, 0, 0, 0, 0] (2), 0x7FFEEFBFFA64, 0x7FFEEFBFFA68, 0x0, 0x0) = 0 0
  6. 4374/0x206a2: 15783 3 1 sysctl([CTL_HW, 7, 0, 0, 0, 0] (2), 0x7FFEEFBFFA64, 0x7FFEEFBFFA68, 0x0, 0x0) = 0 0
  7. 4374/0x206a2: 15899 7 2 mmap(0x0, 0x40000, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x4000000 0
  8. 4374/0x206a2: 15930 3 1 mmap(0xC000000000, 0x4000000, 0x0, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0xC000000000 0
  9. 4374/0x206a2: 15934 4 2 mmap(0xC000000000, 0x4000000, 0x3, 0x1012, 0xFFFFFFFFFFFFFFFF, 0x0) = 0xC000000000 0
  10. 4374/0x206a2: 15936 2 0 mmap(0x0, 0x2000000, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x59B7000 0
  11. 4374/0x206a2: 15942 2 0 mmap(0x0, 0x210800, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x4040000 0
  12. 4374/0x206a2: 15947 2 0 mmap(0x0, 0x10000, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x1BD0000 0
  13. 4374/0x206a2: 15993 3 0 madvise(0xC000000000, 0x2000, 0x8) = 0 0
  14. 4374/0x206a2: 16004 2 0 mmap(0x0, 0x10000, 0x3, 0x1002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x1BE0000 0
  15. ...
dtruss
  • mmap:创建一个新的虚拟内存区域,但这里需要注意,就是当系统调用 mmap 时,它只是从虚拟内存中申请了一段空间出来,并不会去分配和映射真实的物理内存,而当你访问这段空间的时候,才会在当前时间真正的去分配物理内存。那么对应到我们实际应用的进程中,那就是 VSZ 的增长后,而该内存空间又未正式使用的话,物理内存是不会有增长的。
  • madvise:提供有关使用内存的建议,例如:MADV_NORMAL、MADV_RANDOM、MADV_SEQUENTIAL、MADV_WILLNEED、MADV_DONTNEED 等等。
  • mprotect:设置内存区域的保护情况,例如:PROT_NONE、PROT_READ、PROT_WRITE、PROT_EXEC、PROT_SEM、PROT_SAO、PROT_GROWSUP、PROT_GROWSDOWN 等等。
  • sysctl:在内核运行时动态地修改内核的运行参数。
mmapdtruss
strace

查看 Go Runtime

启动流程

mmap
  1. graph TD
  2. A(rt0_darwin_amd64.s:8<br/>_rt0_amd64_darwin) -->|JMP| B(asm_amd64.s:15<br/>_rt0_amd64)
  3. B --> |JMP|C(asm_amd64.s:87<br/>runtime-rt0_go)
  4. C --> D(runtime1.go:60<br/>runtime-args)
  5. D --> E(os_darwin.go:50<br/>runtime-osinit)
  6. E --> F(proc.go:472<br/>runtime-schedinit)
  7. F --> G(proc.go:3236<br/>runtime-newproc)
  8. G --> H(proc.go:1170<br/>runtime-mstart)
  9. H --> I(在新创建的 p 和 m 上运行 runtime-main)
  • runtime-osinit:获取 CPU 核心数。
  • runtime-schedinit:初始化程序运行环境(包括栈、内存分配器、垃圾回收、P等)。
  • runtime-newproc:创建一个新的 G 和 绑定 runtime.main。
  • runtime-mstart:启动线程 M。

注:来自@曹大的 《Go 程序的启动流程》和@全成的 《Go 程序是怎样跑起来的》,推荐大家阅读。

初始化运行环境

schedinit
  1. func schedinit() {
  2. ...
  3. stackinit()
  4. mallocinit()
  5. mcommoninit(_g_.m)
  6. cpuinit() // must run before alginit
  7. alginit() // maps must not be used before this call
  8. modulesinit() // provides activeModules
  9. typelinksinit() // uses maps, activeModules
  10. itabsinit() // uses activeModules
  11. msigsave(_g_.m)
  12. initSigmask = _g_.m.sigmask
  13. goargs()
  14. goenvs()
  15. parsedebugvars()
  16. gcinit()
  17. ...
  18. }
mallocinit

初始化内存分配器

mallocinit
mallocinitmallocinit
  1. func mallocinit() {
  2. ...
  3. if sys.PtrSize == 8 {
  4. for i := 0x7f; i >= 0; i-- {
  5. var p uintptr
  6. switch {
  7. case GOARCH == "arm64" && GOOS == "darwin":
  8. p = uintptr(i)<<40 | uintptrMask&(0x0013<<28)
  9. case GOARCH == "arm64":
  10. p = uintptr(i)<<40 | uintptrMask&(0x0040<<32)
  11. case GOOS == "aix":
  12. if i == 0 {
  13. continue
  14. }
  15. p = uintptr(i)<<40 | uintptrMask&(0xa0<<52)
  16. case raceenabled:
  17. ...
  18. default:
  19. p = uintptr(i)<<40 | uintptrMask&(0x00c0<<32)
  20. }
  21. hint := (*arenaHint)(mheap_.arenaHintAlloc.alloc())
  22. hint.addr = p
  23. hint.next, mheap_.arenaHints = mheap_.arenaHints, hint
  24. }
  25. } else {
  26. ...
  27. }
  28. }
GOARCHGOOSparenaHint
arenaHintarenaHints
  1. type arenaHint struct {
  2. addr uintptr
  3. down bool
  4. next *arenaHint
  5. }
arenaarenaarenaHint
arena

image

  • spans:记录 arena 区域页号和 mspan 的映射关系。
  • bitmap:标识 arena 的使用情况,在功能上来讲,会用于标识 arena 的哪些空间地址已经保存了对象。
  • arean:arean 其实就是 Go 的堆区,是由 mheap 进行管理的,它的 MaxMem 是 512GB-1。而在功能上来讲,Go 会在初始化的时候申请一段连续的虚拟内存空间地址到 arean 保留下来,在真正需要申请堆上的空间时再从 arean 中取出来处理,这时候就会转变为物理内存了。

在这里的话,你需要理解 arean 区域在 Go 内存里的作用就可以了。

mmap
mallocinitmmap
  1. func sysAlloc(n uintptr, sysStat *uint64) unsafe.Pointer {
  2. p, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
  3. ...
  4. mSysStatInc(sysStat, n)
  5. return p
  6. }
  7. func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
  8. p, err := mmap(v, n, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
  9. ...
  10. }
  11. func sysMap(v unsafe.Pointer, n uintptr, sysStat *uint64) {
  12. ...
  13. munmap(v, n)
  14. p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0)
  15. ...
  16. }

在 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
mallocinitmmap
  1. for i := 0x7f; i >= 0; i-- {
  2. ...
  3. hint := (*arenaHint)(mheap_.arenaHintAlloc.alloc())
  4. hint.addr = p
  5. hint.next, mheap_.arenaHints = mheap_.arenaHints, hint
  6. }
mheap_.arenaHintAlloc.alloc()mheapsysAllocsysAllocmmapsysAlloc
  1. var mheap_ mheap
  2. ...
  3. func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
  4. ...
  5. for h.arenaHints != nil {
  6. hint := h.arenaHints
  7. p := hint.addr
  8. if hint.down {
  9. p -= n
  10. }
  11. if p+n < p {
  12. v = nil
  13. } else if arenaIndex(p+n-1) >= 1<<arenaBits {
  14. v = nil
  15. } else {
  16. v = sysReserve(unsafe.Pointer(p), n)
  17. }
  18. ...
  19. }
mheap.sysAllocsysReservesysReserve

小结

在本节中,我们先写了一个测试程序,然后根据非常规的排查思路进行了一步步的跟踪怀疑,整体流程如下:

toppspprofruntime.MemStatsvmmapdtrussmmap
mallocinit

而保留虚拟内存空间时,受什么影响,又是一个哲学问题。从源码上来看,主要如下:

  • 受不同的 OS 系统架构(GOARCH/GOOS)和位数(32/64 位)的影响。
  • 受内存对齐的影响,计算回来的内存空间大小是需要经过对齐才会进行保留。

总结

我们通过一步步地分析,讲解了 Go 会在哪里,又会受什么因素,去调用了什么方法保留了那么多的虚拟内存空间,但是我们肯定会忧心进程虚拟内存(VSZ)高,会不会存在问题呢,我分析如下:

  • VSZ 并不意味着你真正使用了那些物理内存,因此是不需要担心的。
  • VSZ 并不会给 GC 带来压力,GC 管理的是进程实际使用的物理内存,而 VSZ 在你实际使用它之前,它并没有过多的代价。
  • VSZ 基本都是不可访问的内存映射,也就是它并没有内存的访问权限(不允许读、写和执行)。
arenabitmaparenabitmap

参考