0%

go 语言内存指标

go 提供了很多的内存指标,多了就容易分不清楚,本文解读几个容易让人迷糊的指标。

内存管理机制

磨刀不误砍柴工,我们先了解下 go 如何管理内存的。

首先,go 是 runtime 自己管理内存的,并没有依赖 libc 这种内存分配器。
具体而言,就是 runtime 自己直接用 mmap 从操作系统申请内存,供自己使用,没有中间商赚差价。

不过,也只是这种中间商角色,go runtime 自己来干了。
因为从操作系统申请内存,都是按页来申请,go 中的内存对象可以是任意大小的。
所以,go runtime 搞了个 mheap 数据结构,来维护所有申请到的内存,还搞了 mcentral, mcache, span 好多级来管理。

为了分配不同大小的内存对象,go 分了 67 个级别的 span,每个级别的 span 对应一个 size,可以理解为这个 size 的内存池。
分配内存的时候,从 size 最小且合适的 span 中分配一个出来。
是的,这种搞法是会浪费一些内存,不过可以比较好的解决,内存碎片的问题。

其他细节这里就先不展开了,简单而言,通常 C 程序用的 libc 中的内存分配器的活,go runtime 也自己干了。

与操作系统交互

当现有的 span 没有空闲的内存块时,go runtime 会通过 mmap 从操作系统申请内存。

但是,有意思的是,go runtime 释放内存的时候,并不是用 munmap,而是用的 madvise
其中区别是,munmap 是将整个地址空间都还给系统,madvise 并不会将地址空间归还,而只是给系统一个建议(advise),说这个地址空间对应的物理内存,go runtime 暂时不用了,系统可以释放了,但是虚拟地址空间还是留着的。
下一次 go runtime 需要的时候,再通过 madvise 让系统为这个地址空间准备物理内存。

这里有个小插曲,go 1.12 版本,将 madvise 的参数,也就是给系统的建议,从默认 MADV_DONTNEED 改成了默认 MADV_FREE
比起 MADV_DONTNEEDMADV_FREE 会更激进一些,操作系统并不会立马释放物理内存,而只是在物理内存比较紧张的时候,再真的释放。
这么改的目的是优化,可以减少下一次需要的时候,会触发的 pagefault 行为。

只是,这个副作用也比较明显的,没有释放的物理内存还是归属于 go 程序,所以 RSS 也算在 go 程序的头上,这个对于指标理解会造成很大的困扰,也更容易造成 OOM

所以,后面又改回了默认 MADV_FREE,具体可见这个 CL:https://go-review.googlesource.com/c/go/+/267100/

内存指标

回到主题,go runtime 提供了很多的内存指标,文档也写得挺清楚的。
https://pkg.go.dev/runtime#MemStats

选几个常用的指标,做些个人直白解读:

HeapReleased

已经释放给系统,只保留了地址空间的内存大小

HeapIdle

处于空闲状态的 span 的内存大小

注意,这里包含了 HeapReleased
HeapIdle - HeapReleased,就是 go runtime 目前预留的空闲内存池,
分配内存的时候,会优先从这里找合适的。

HeapInuse

处于被使用的 span 的内存大小

需要注意的是,如果 span 里有一个在使用的 GC object,整个 span 也处于 inuse 状态。
对象 Inused,包括:真实 alive(被 GC root 引用);新申请还没有被 markmark 标记死亡,但是还没有 sweep 掉。

HeapSys

总共从系统申请的内存地址大小
注意并不是 RSS,有两种可能使得这个值与 RSS 不一样:

  1. 申请了地址空间,但是没有读写,系统并不会分配真实物理内存
  2. 物理内存释放给系统了,但是地址空间还是保留的,也就是 HeapReleased.

当然,还有不在 heap 管理的 RSS

HeapAlloc

GC 管理的对象的大小

注意,是 object 粒度,不是 spanHeapInuse - HeapAlloc,也就是 Inused span 中还剩余的空间内存块。
这个语义跟 Inused 类似,除了包含活对象,还包括 mark 标记死亡,但是还没 sweep 调用的。
不过,如果 sweep 掉了,也就不算在内了,即使内存还没有还给系统。

StackInuse

被当做 stack 正在使用的 span 的大小

没有 StackIdle,因为 stackheap,其实都是通过 mheap 那一套机制管理的,Idle 的 span,都算在 HeapIdle 里面了。

总结

go 舍弃了 libc 这类内存分配管理的中间商,自己重新撸一套,确实可以看有一些新花样出来(不只提到的这些,还有很多新花样绑定在 go 自己的内存分配管理实现上了),并不是简单的重复造轮子。

至于指标的含义,确实是指标太多了,每个都记住了估计是很难的。
个人的做法,理解清楚原理,搞清楚几个常用的,没记住的,用的时候再翻文档吧。