0%

go 语言 pprof heap profile 实现机制

最近学习了 go 语言内置的 heap profile 实现机制,就像一个精致机械手表。

问题

首先,go 语言有自动的垃圾回收,已经很大程度的降低了程序员的心智负担。
需要的时候,直接申请使用即可,不需要手动的显示释放内存。
不过呢,也还是会经常碰到内存泄漏,内存占用高的问题。

作为内存分析工具,主要是需要解释两个问题:

  1. 内存从哪里申请来的
  2. 又是为什么没有被释放

go 内置的 pprof heap profile 则可以很好的回答第一个问题。

ps: 问题二,通常可以根据问题一的答案,再结合看代码是可以搞清楚的,不过有些复杂的场景下,也还是需要借助工具来分析定位,这个我们以后有空再说。

解决办法

通常情况下,我们并不会关心那些已经被 GC 回收掉的临时内存,而只关心还没有被回收的内存。
所以,我们需要追踪内存分配和释放这两个动作。

采样

因为内存申请是程序运行过程中一个比较高频的行为,为了减少开销,一个有效的方法是采样,仅仅追踪部分行为。
go 默认是每分配 512KB 内存采样一次,可以通过环境变量 memprofilerate 来修改。

实现方式就是,在每次 malloc 的时候,维护一个计数器,累计申请的内存达到阈值即进行采样,并开始下一个计数周期。

采样的时候,需要记录以下几个信息:

  1. 当前调用栈。注意并不是最终可见的文本调用栈,仅需要记录每层栈帧的 PC 值。
  2. 申请的大小。这里会是一个统计周期内的累计值。

每个调用栈,会创建一个 memory Record,也就是源码中的 mp 对象,进行归类。

并且会为本次采样的内存块,记录一个 special(且 special 会关联到 mp),会在 GC sweep 处理 finalizer 的阶段,处理这个内存块的释放逻辑。
此时的处理逻辑也就非常简单了,直接在关联的 mp 中统计已经释放的内存大小即可,O(1) 操作,非常干净。

周期

因为内存分配是会持续发生的事件,而内存回收又是另外一个独立的 GC 周期,这个时候精准卡点就显得非常重要了。

go 中的每个 mp,并不是全局的实时统计,而是会分为好几个区域,按照 GC 周期的进行,依序往前推进(是的,就像机械手表那样精巧)

  1. active,这是最终汇总统计的,通过 pprof heap profile 读取的数据,通常就是 active 里的汇总数据
  2. feature[3],这里是统计正在发生的,长度为 3 的一个数组。

推进器则是 cycle 计数器,在 GC mark 完成,start the world 之前,cycle 会 +1。

结合长度为 3 的 feature 数组:

  1. feature[(c+2)%3]: malloc 时记录到这个位置,表示正在申请的。
  2. feature[(c+1)%3]: sweep 时从位置去统计 free,表示正在 sweep 或者将要被 sweep。
  3. feature[c%3]: sweep 开始之前,将这个 c 中的值,累加到 active 中,表示已经经过一个 GC 周期 sweep 的。

这样的方式,非常的精准,和 GC 周期完美的结合起来了。
active 中统计是:从程序启动开始,到某一次 GC 周期完成之后,还没有释放的内存。
至于具体是哪个 GC 周期,就是说不好了,可能最多会落后两个 GC 周期。

用途

了解这些是有啥用呢,当然是为了更好的分析内存问题。

有些时候,内存并不会持续增长,而只是突增一下又恢复了,我们需要一个非常精准的方式,拿到这一次突增的的原因。
这时我们需要这样一种分析思路,基于 GC 周期的内存 profile,当然,这也是 pprof heap profile 思路的延续。

突增的危害

通常情况下,内存突增一下又恢复,并不是什么大问题,只是短时间的让 GC 变得更活跃。
但是,这种异常的波动,也不可简单忽略

  1. 内存突增,意味着内存申请/回收变得频繁,可能是有非预期的大批内存申请,造成瞬间的响应时间变长。
  2. 推高 GC goal,也就是下一次 GC 的阈值会变高,如果内存相对紧张,会导致 OOM。

解决办法

代码已经提交到 MOSN 社区的 holmes:
https://github.com/mosn/holmes/pull/54

具体的做法是:

  1. 在每次 GC Mark 结束之后,检查本次 GC 之后的依然 live 内存量,是否有突增。这个值基本就决定了下一次的 GC goal。
  2. 如果有突增,就获取当前 active 的 heap profile。注意:此时 profile 中的数据,并不包含突增的内存。
  3. 并且,在下一次 GC 完成之后,再一次获取 active 的 heap profile。此时 profile 中的数据,就多了刚才突增的内存。

我们需要获取两次 heap profile,使用 pprof 的 base 功能,就可以精准地获取突增的那一个 GC 周期,到底新增了什么内存。

ps: 当然 GC goal 被推高的问题,也还有另外的办法来缓解/解决,也就是动态的 SetGCPercent,这里也暂且不表。

案例

上面的 PR 中也有一个测试案例:
https://github.com/mosn/holmes/blob/15e2b9bedf130993d3a4e835290b2065278e062f/example/gcheap/README.md

描述的是这样一个场景:

  1. 长期存活内存基本保持在 10MB
  2. /rand,表示正常的接口,申请的内存很快能被回收,这个接口一直被频繁的调用
  3. /spike,表示异常的接口,突然会申请 10MB 内存,并且保持一段时间之后才释放,这个接口偶尔有调用

具体的过程就不再重复描述了,最终我们可以看到通过 holmes 的 gcHeap 检查,可以精准地抓到 /spike 这个偶尔调用的异常接口的现场。

最后

go 语言的 pprof heap profile 是很强大的基础能力,对于那种持续泄漏的场景,我们只需要取两个点的 profile 就可以分析出来。
但是,对于非持续的内存增长毛刺,则需要我们充分理解它的工作机制,才能精准地抓到问题现场。

另外,还有一个中内存 “突增”,突然申请了大量临时内存,并且能立马被 GC 回收掉,并不会导致 GC goal 被推高。
这种情况,其实危险要相对小一点,坏处就是 GC 频率变高了。

或者,压根就不是 “突增”,就是 GC 频率一直很快,一直有大量的临时内存申请。

这种情况,其实也可以借助 heap profile 来精准区分,按照上面的分析,我们只需要获取 feature[c+2] 中的数据即可。
那个就是新增内存的来源,我们可以根据这个来分析哪些临时内存是可以复用的,也是一个很有效的优化方法。

只不过,目前的 go runtime 中并没有一个很好的 callback 来实现精准的读取 feature[c+2]
而且看起来也不是很有必要的样子,后面有需要的话,可以给 go 提个 proposal,听听 rsc 大佬的意见。