前段时间,某同学说某服务的容器因为超出内存限制,不断地重启,问我们是不是有内存泄露,赶紧排查,然后解决掉,省得出问题。我们大为震惊,赶紧查看监控+报警系统和性能分析,发现应用指标压根就不高,不像有泄露的样子。,那么问题是出在哪里了呢,我们进入某个容器里查看了一下 top 系统指标,结果如下:,从结果上来看,也没什么大开销的东西,主要就一个 Go 进程,一看,某同学就说 VSZ 那么高,而某云上的容器内存指标居然恰好和 VSZ 的值相接近,因此某同学就怀疑是不是 VSZ 所导致的,觉得存在一定的关联关系。,而从最终的结论上来讲,上述的表述是不全对的,那么在今天,本篇文章将主要围绕 Go 进程的 VSZ 来进行剖析,看看到底它为什么那么强大 “高”,而在正式开始分析前,第一节为前置的补充知识,大家可按顺序阅读。,VSZ 是该进程所能使用的虚拟内存总大小,它包括进程可以访问的所有内存,其中包括了被换出的内存(Swap)、已分配但未使用的内存以及来自共享库的内存。,在前面我们有了解到 VSZ 其实就是该进程的虚拟内存总大小,那如果我们想了解 VSZ 的话,那我们得先了解 “为什么要虚拟内存?”。,本质上来讲,在一个系统中的进程是与其他进程共享 CPU 和主存资源的,而在现代的操作系统中,多进程的使用非常的常见,那么如果太多的进程需要太多的内存,那么在没有虚拟内存的情况下,物理内存很可能会不够用,就会导致其中有些任务无法运行,更甚至会出现一些很奇怪的现象,例如 “某一个进程不小心写了另一个进程使用的内存”,就会造成内存破坏,因此虚拟内存是非常重要的一个媒介。,而虚拟内存,又分为内核虚拟内存和进程虚拟内存,每一个进程的虚拟内存都是独立的, 呈现如上图所示。,这里也补充说明一下,在内核虚拟内存中,是包含了内核中的代码和数据结构,而内核虚拟内存中的某些区域会被映射到所有进程共享的物理页面中去,因此你会看到 ”内核虚拟内存“ 实际上是包含了 ”物理内存“ ,它们两者存在映射关系。而在应用场景上来讲,每个进程也会去共享内核的代码和全局数据结构,因此就会被映射到所有进程的物理页面中去。,为了更有效地管理内存并且减少出错,现代系统提供了一种对主存的抽象概念,也就是今天的主角,叫做虚拟内存(VM),虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件交互的地方,它为每个进程提供了一个大的、一致的和私有的地址空间,虚拟内存提供了三个重要的能力:,上面发散的可能比较多,简单来讲,对于本文我们重点关注这些知识点,如下:,在了解了基础知识后,我们正式开始排查问题,第一步我们先编写一个测试程序,看看没有什么业务逻辑的 Go 程序,它初始的 VSZ 是怎么样的。,应用代码:,查看进程情况:,从结果上来看,VSZ 为 4297048K,也就是 4G 左右,咋一眼看过去还是挺吓人的,明明没有什么业务逻辑,但是为什么那么高呢,真是令人感到好奇。,在未知的情况下,我们可以首先看下 runtime.MemStats 和 pprof,确定应用到底有没有泄露。不过我们这块是演示程序,什么业务逻辑都没有,因此可以确定和应用没有直接关系。,我们有提到进程虚拟内存,主要包含了你的代码、数据、堆、栈段和共享库,那初步怀疑是不是进程做了什么内存映射,导致了大量的内存空间被保留呢,为了确定这一点,我们通过如下命令去排查:,这块主要是利用 macOS 的 vmmap 命令去查看内存映射情况,这样就可以知道这个进程的内存映射情况,从输出分析来看,这些关联共享库占用的空间并不大,导致 VSZ 过高的根本原因不在共享库和二进制文件上,但是并没有发现大量保留内存空间的行为,这是一个问题点。,注:若是 Linux 系统,可使用 cat /proc/PID/maps 或 cat /proc/PID/smaps 查看。,既然在内存映射中,我们没有明确地看到保留内存空间的行为,那我们接下来看看该进程的系统调用,确定一下它是否存在内存操作的行为,如下:,在这小节中,我们通过 macOS 的 dtruss 命令监听并查看了运行这个程序所进行的所有系统调用,发现了与内存管理有一定关系的方法如下:,在此比较可疑的是 mmap 方法,它在 dtruss 最终统计中一共调用了 10 余次,我们可以相信它在 Go Runtime 的时候进行了大量的虚拟内存申请,我们再接着往下看,看看到底是在什么阶段进行了虚拟内存空间的申请。,注:若是 Linux 系统,可使用 strace 命令。,通过上述的分析,我们可以知道在 Go 程序启动的时候 VSZ 就已经不低了,并且确定不是共享库等的原因,且程序在启动时系统调用确实存在 mmap 等方法的调用,那么我们可以充分怀疑 Go 在初始化阶段就保留了该内存空间。那我们第一步要做的就是查看一下 Go 的引导启动流程,看看是在哪里申请的,引导过程如下:,显然,我们要研究的是 runtime 里的 schedinit 方法,如下:,从用途来看,非常明显, mallocinit 方法会进行内存分配器的初始化,我们继续往下看。,接下来我们正式的分析一下 mallocinit 方法,在引导流程中, mallocinit 主要承担 Go 程序的内存分配器的初始化动作,而今天主要是针对虚拟内存地址这块进行拆解,如下:,可能会有小伙伴问,为什么要判断是 32 位还是 64 位的系统,这是因为不同位数的虚拟内存的寻址范围是不同的,因此要进行区分,否则会出现高位的虚拟内存映射问题。而在申请保留空间时,我们会经常提到 arenaHint 结构体,它是 arenaHints链表里的一个节点,结构如下:,那么这里疯狂提到的 arena 又是什么东西呢,这其实是 Go 的内存管理中的概念,Go Runtime 会把申请的虚拟内存分为三个大块,如下:,在这里的话,你需要理解 arean 区域在 Go 内存里的作用就可以了。,我们刚刚通过上述的分析,已经知道 mallocinit 的用途了,但是你可能还是会有疑惑,就是我们之前所看到的 mmap 系统调用,和它又有什么关系呢,怎么就关联到一起了,接下来我们先一起来看看更下层的代码,如下:,在 Go Runtime 中存在着一系列的系统级内存调用方法,本文涉及的主要如下:,看上去好像很有道理的样子,但是 mallocinit 方法在初始化时,到底是在哪里涉及了 mmap 方法呢,表面看不出来,如下:,实际上在调用 mheap_.arenaHintAlloc.alloc() 时,调用的是 mheap 下的 sysAlloc 方法,而 sysAlloc 又会与 mmap 方法产生调用关系,并且这个方法与常规的 sysAlloc 还不大一样,如下:,你可以惊喜的发现 mheap.sysAlloc 里其实有调用 sysReserve 方法,而 sysReserve 方法又正正是从 OS 系统中保留内存的地址空间的特定方法,是不是很惊喜,一切似乎都串起来了。,在本节中,我们先写了一个测试程序,然后根据非常规的排查思路进行了一步步的跟踪怀疑,整体流程如下:,从结论上而言,VSZ(进程虚拟内存大小)与共享库等没有太大的关系,主要与 Go Runtime 存在直接关联,也就是在前图中表示的运行时堆(malloc)。转换到 Go Runtime 里,就是在 mallocinit 这个内存分配器的初始化阶段里进行了一定量的虚拟空间的保留。,而保留虚拟内存空间时,受什么影响,又是一个哲学问题。从源码上来看,主要如下:,我们通过一步步地分析,讲解了 Go 会在哪里,又会受什么因素,去调用了什么方法保留了那么多的虚拟内存空间,但是我们肯定会忧心进程虚拟内存(VSZ)高,会不会存在问题呢,我分析如下:,看到这里舒一口气,因为 Go VSZ 的高,并不会对我们产生什么非常实质性的问题,但是又仔细一想,为什么 Go 要申请那么多的虚拟内存呢,到底有啥用呢,考虑如下:Go 的设计是考虑到 arena 和 bitmap 的后续使用,先提早保留了整个内存地址空间。 然后随着 Go Runtime 和应用的逐步使用,肯定也会开始实际的申请和使用内存,这时候 arena 和 bitmap 的内存分配器就只需要将事先申请好的内存地址空间保留更改为实际可用的物理内存就好了,这样子可以极大的提高效能。,
© 版权声明
文章版权归作者所有,未经允许请勿转载。