背景提要
分享一个 GC 相关的踩坑实践。公司线上某组件内存资源泄漏,偶发 oom 。通过 Go 的 pprof 排查,很快速定位到泄漏的数据结构 A ,结构 A 的相关资源是通过 Go 的 Finalizer 机制来释放的。但诡异的来了,对照着代码审视了多次之后,大家一致断定,这段代码绝对没有泄漏的问题。但是,事实胜于雄辩,现实就是泄漏就在此处。想不通。。。
几天之后,问题的转机来自于另一个毫不相关的地方,我们发现了一个卡住的协程。最开始并不在意,因为虽然卡住是异常的,但是泄漏的地点差了十万八千里,两者毫不相关。所以刚开始是忽略的。
后来实在是想不开,闲来无事,把这个异常点拿来看,才发现一点点线索。这个卡住的协程是一个结构体 B 的释放过程,和 A 一样也是 Go 的 Finalizer 机制。我们踩的坑就于此有关,很典型,出人意料,所以分享给大家。先复习一下 Finalizer 机制。
什么是 Go 的 Finalizer 机制?
那么什么是 Finalizer 机制呢?这个就必须要再提一嘴 Go 的 GC 机制了。这个是 Go 比较有特色的机制。在 Go 里程序员负责申请内存,Go 的 runtime 的 GC 机制负责回收。
在这个过程,Go 语言还提供了一个 Finalizer 机制,允许程序员在申请的时候指定一个回调函数,在 GC 回收到这个结构体内存的时候,Go 会自动调用一次这个回调函数。
这个非常实用的一个技巧,在文章《编程思考:对象生命周期的问题》里有分享。主要是比较安全的解决掉对象声明周期的问题。因为程序员自己来管理资源的释放,那很可能出 bug ,比如在有人用的时候调用释放。通过 Finalizer 机制,则能保证一定是无人引用的结构体内存,才会执行回调。
举个例子:
上面的例子,给结构体 TestStruct 的释放设置了一个 Finalizer 回调函数。然后在主动调用 runtime.GC 来快速回收,童鞋可以体验一下。
Finalizer 这里竟然有个坑?
Finalizer 很好用这是事实,但 Finalizer 机制也有限制条件,在官网上有如下声明:
A single goroutine runs all finalizers for a program, sequentially. If a finalizer must run for a long time, it should do so by starting a new goroutine.
来自 https://golang.google.cn/pkg/runtime/#SetFinalizer ,什么意思?
说得是,Go 的 runtime 是用一个单 goroutine 来执行所有的 Finalizer 回调,还是串行化的。
划重点:一旦执行某个 Finalizer 出了问题,可能会影响到全局的 Finalizer 回调函数的执行。
原来如此!!
我们这次就是精准踩坑。在释放 B 结构体的时候,调用了一个 Finalizer 回调,然后把协程卡死了。导致后续所有的 Finalizer 回调都执行不了,比如 A 的 Finalizer 就无法执行,从而导致资源的泄漏和各种的异常。
举个例子:
这里创建了一个极简的例子,A,B, C 实例都设置了 Finalizer 回调,故意让其中一个阻塞住,会影响到剩下的 Finalizer 的执行。
总结
- Go 提供的 Finalizer 机制,让程序员创建的时候注册回调函数,能很好的帮助程序员解决资源安全释放的问题;
- Finalizer 的执行是全局单协程,且串行化执行的。所以可能会因为某一次的卡住导致全局的失效,切记;
- 排查内存问题的时候,pprof 看现场很明确,但是根因可能是看似毫不相关的旮旯角落,有时候要把思维跳出来排查;