关于后端:为什么-Go-占用那么多的虚拟内存

76次阅读

共计 10535 个字符,预计需要花费 27 分钟才能阅读完成。

前段时间,某同学说某服务的容器因为超出内存限度,一直地重启,问咱们是不是有内存泄露,连忙排查,而后解决掉,省的出问题。咱们大为震惊,连忙查看监控 + 报警零碎和性能剖析,发现利用指标压根就不高,不像有泄露的样子。

那么问题是出在哪里了呢,咱们进入某个容器里查看了 top 的零碎指标,后果如下:

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

从后果上来看,也没什么大开销的货色,次要就一个 Go 过程,一看,某同学就说 VSZ 那么高,而某云上的容器内存指标竟然恰好和 VSZ 的值相靠近,因而某同学就狐疑是不是 VSZ 所导致的,感觉存在肯定的关联关系。

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

基础知识

什么是 VSZ

VSZ 是该过程所能应用的虚拟内存总大小,它包含过程能够拜访的所有内存,其中包含了被换出的内存(Swap)、已调配但未应用的内存以及来自共享库的内存。

为什么要虚拟内存

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

实质上来讲,在一个零碎中的过程是与其余过程共享 CPU 和主存资源的,而在古代的操作系统中,多过程的应用十分的常见,那么如果太多的过程须要太多的内存,那么在没有虚拟内存的状况下,物理内存很可能会不够用,就会导致其中有些工作无奈运行,更甚至会呈现一些很奇怪的景象,例如“某一个过程不小心写了另一个过程应用的内存”,就会造成内存毁坏,因而虚拟内存是十分重要的一个媒介。

虚拟内存蕴含了什么

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

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

虚拟内存的重要能力

为了更无效地治理内存并且缩小出错,古代零碎提供了一种对主存的抽象概念,也就是明天的配角,叫做虚拟内存(VM),虚拟内存是硬件异样、硬件地址翻译、主存、磁盘文件和内核软件交互的中央,它为每个过程提供了一个大的、统一的和公有的地址空间,虚拟内存提供了三个重要的能力:

  1. 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保留流动区域,并依据须要在磁盘和主存之间来回传送数据,通过这种形式,它高效地应用了主存。
  2. 它为每个过程提供了统一的地址空间,从而简化了内存治理。
  3. 它爱护了每个过程的地址空间不被其余过程毁坏。

小结

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

  • 虚拟内存它是有各式各样内存交互的中央,它蕴含的不仅仅是 “ 本人 ”,而在本文中,咱们只须要关注 VSZ,也就是过程虚拟内存,它蕴含了你的代码、数据、堆、栈段和共享库
  • 虚拟内存作为内存保护的工具,可能保障过程之间的内存空间独立,不受其余过程的影响,因而每一个过程的 VSZ 大小都不一样,互不影响。
  • 虚拟内存的存在,零碎给各过程调配的内存之和是能够大于理论可用的物理内存的,因而你也会发现你过程的物理内存总是比虚拟内存低的多的多。

排查问题

在理解了基础知识后,咱们正式开始排查问题,第一步咱们先编写一个测试程序,看看没有什么业务逻辑的 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.MemStatspprof,确定利用到底有没有泄露。不过咱们这块是演示程序,什么业务逻辑都没有,因而能够确定和利用没有间接关系。

# 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

接着我第一反馈是去翻了 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/mapscat /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:创立一个新的虚拟内存区域,但这里须要留神,就是当零碎调用 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:在内核运行时动静地批改内核的运行参数。

在此比拟可疑的是 mmap 办法,它在 dtruss 的最终统计中一共调用了 10 余次,咱们能够置信它在 Go Runtime 的时候进行了大量的虚拟内存申请,咱们再接着往下看,看看到底是在什么阶段进行了虚拟内存空间的申请。

注:若是 Linux 零碎,可应用 strace 命令。

查看 Go Runtime

启动流程

通过上述的剖析,咱们能够晓得在 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)
  • runtime-osinit:获取 CPU 外围数。
  • runtime-schedinit:初始化程序运行环境(包含栈、内存分配器、垃圾回收、P 等)。
  • runtime-newproc:创立一个新的 G 和 绑定 runtime.main。
  • runtime-mstart:启动线程 M。

注:来自 @曹大的《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 办法,在疏导流程中,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 {...}
}
  • 判断以后是 64 位还是 32 位的零碎。
  • 从 0x7fc000000000~0x1c000000000 开始设置保留地址。
  • 判断以后 GOARCHGOOS 或是否开启了竞态查看,依据不同的状况申请不同大小的间断内存地址,而这里的 p 是行将要要申请的间断内存地址的开始地址。
  • 保留刚刚计算的 arena 的信息到 arenaHint 中。

可能会有小伙伴问,为什么要判断是 32 位还是 64 位的零碎,这是因为不同位数的虚拟内存的寻址范畴是不同的,因而要进行辨别,否则会呈现高位的虚拟内存映射问题。而在申请保留空间时,咱们会常常提到 arenaHint 构造体,它是 arenaHints 链表里的一个节点,构造如下:

type arenaHint struct {
    addr uintptr
    down bool
    next *arenaHint
}
  • addr:arena 的起始地址
  • down:是否最初一个 arena
  • next:下一个 arenaHint 的指针地址

那么这里疯狂提到的 arena 又是什么货色呢,这其实是 Go 的内存治理中的概念,Go Runtime 会把申请的虚拟内存分为三个大块,如下:

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

在这里的话,你须要了解 arean 区域在 Go 内存里的作用就能够了。

mmap

咱们刚刚通过上述的剖析,曾经晓得 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 中存在着一系列的零碎级内存调用办法,本文波及的次要如下:

  • sysAlloc:从 OS 零碎上申请清零后的内存空间,调用参数是 _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE,失去的后果需进行内存对齐。
  • sysReserve:从 OS 零碎中保留内存的地址空间,这时候还没有调配物理内存,调用参数是 _PROT_NONE, _MAP_ANON|_MAP_PRIVATE,失去的后果需进行内存对齐。
  • sysMap:告诉 OS 零碎咱们要应用曾经保留了的内存空间,调用参数是 _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 零碎中保留内存的地址空间的特定办法,是不是很惊喜,所有仿佛都串起来了。

小结

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

  • 通过 topps 等命令,查看过程运行状况,剖析根底指标。
  • 通过 pprofruntime.MemStats 等工具链查看利用运行状况,剖析利用层面是否有泄露或者哪儿高。
  • 通过 vmmap 命令,查看过程的内存映射状况,剖析是不是过程虚拟空间内的某个区域比拟高,例如:共享库等。
  • 通过 dtruss 命令,查看程序的零碎调用状况,剖析可能呈现的一些非凡行为,例如:在剖析中咱们发现 mmap 办法调用的比例是比拟高的,那咱们有充沛的理由狐疑 Go 在启动时就进行了大量的内存空间保留。
  • 通过上述的剖析,确定可能是在哪个环节申请了那么多的内存空间后,再到 Go Runtime 中去做进一步的源码剖析,因为源码背后,了无机密,没必要靠猜。

从论断上而言,VSZ(过程虚拟内存大小)与共享库等没有太大的关系,次要与 Go Runtime 存在间接关联,也就是在前图中示意的运行时堆(malloc)。转换到 Go Runtime 里,就是在 mallocinit 这个内存分配器的初始化阶段里进行了一定量的虚拟空间的保留。

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

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

总结

咱们通过一步步地剖析,解说了 Go 会在哪里,又会受什么因素,去调用了什么办法保留了那么多的虚拟内存空间,然而咱们必定会忧心过程虚拟内存(VSZ)高,会不会存在问题呢,我剖析如下:

  • VSZ 并不意味着你真正应用了那些物理内存,因而是不须要放心的。
  • VSZ 并不会给 GC 带来压力,GC 治理的是过程理论应用的物理内存,而 VSZ 在你理论应用它之前,它并没有过多的代价。
  • VSZ 根本都是不可拜访的内存映射,也就是它并没有内存的拜访权限(不容许读、写和执行)。

思考

看到这里舒一口气,因为 Go VSZ 的高,并不会对咱们产生什么十分实质性的问题,然而又认真一想,为什么 Go 要申请那么多的虚拟内存呢?

总体思考如下:

  • Go 的设计是思考到 arenabitmap 的后续应用,先提前保留了整个内存地址空间。
  • 随着 Go Runtime 和利用的逐渐应用,必定也会开始理论的申请和应用内存,这时候 arenabitmap 的内存分配器就只须要将当时申请好的内存地址空间保留更改为理论可用的物理内存就好了,这样子能够极大的进步效力。

我的公众号

分享 Go 语言、微服务架构和奇怪的零碎设计,欢送大家关注我的公众号和我进行交换和沟通。

最好的关系是相互成就 ,各位的 点赞 就是煎鱼创作的最大能源,感激反对。

正文完
 0