slice 类型内存泄露的逻辑
Go 101 总结了几个可能导致内存泄露的场景:
https://gfw.go101.org/article/memory-leaking.html
goroutine 阻塞在 channel,time.Ticker 不使用但未 stop,以及 for 循环里用 defer 导致泄露,这三个场景其实已经比较常见了,这里就不说了。
我们来看看子切片截取为什么会导致内存泄露。
因为 Go 是一门带 GC 的语言,虽然官方宣传 GC stw 在 1ms 以内,但服务的延迟并不仅仅取决于 stw,用户的 goroutine 如果触发了堆上分配内存的操作,也很有可能进入到协助标记流程(newobject->mallocgc->gcAssist),这个“协助标记”也会导致相应的用户 goroutine 延迟大幅度上升。我们总会听到有人吹 stw 1ms 以内没有影响,这其实是一个相当不负责的结论。目前 Go 的实现只是把 GC 的成本进行了均摊,例如标记成本有一部分被均摊给了用户的 goroutine。
GC 优化在高并发的场景下是一定有必要的。通过减少堆上创建的对象来降低标记的压力,一方面可以节省 GC 整体使用的 CPU,最终也就大幅缩减了用户服务的延迟,那些说不用优化的笑笑就好。
在优化 GC 时,最直接的思路就是对创建对象进行复用,官方提供了 sync.Pool 来帮助用户对他们的应用对象进行复用。这里看 Go 的基本类型:map 和 slice。
sync.Pool 在复用对象时,需要我们在 Get 或 Put 时对对象进行清空操作,这个清空是由用户完成的,对于一个 slice 来说,清空操作很简单:sl := sl[:0]
。但对于一个 map 来说,就没这么简单了。虽然官方对清空 map 也进行了一些优化:这里 和 这里。不过显然 map 依然不是一个适合复用的结构。
因为 slice 相比 map 要容易复用,在性能敏感的场景,只要能用 slice 来代替 map 就都换成了 slice + sync.Pool 来进行复用,fasthttp 里有很多这方面的实践,之前也有人做了比较好的总结,参考 这里 和 这里。
嗯,既然知道了 slice 很方便复用,大家都喜欢它,来看看为什么 slice 的复用可能会造成内存泄露。
下面这段程序是这位 朋友提供的 demo 的改版:
package main
import (
"fmt"
"runtime"
"time"
)
type P struct {
Age int
}
func getPartOfSlice() []*P {
var s = make([]*P, 0, 10000)
for i := 0; i < 10000; i++ {
var p = &P{i}
runtime.SetFinalizer(p, func(x *P) { println("gc happen on p", x) })
s = append(s, p)
}
return s[100:101]
}
func main() {
var k = getPartOfSlice()
// type 1
// print then gc
//fmt.Println(k[0])
//runtime.GC()
// type 2
// gc then print
runtime.GC()
fmt.Println(k[0])
time.Sleep(time.Hour)
}
type 1 表示在 runtime.GC 的时候,已经没有代码持有 k 的引用了,而 type 2 则表示在发生 GC 时,k 依然被持有。
显而易见,只要你持有子切片的某个对象,大切片被截掉的那些元素就是没有办法进行回收的。大概是下面这样:
在引用 slice 时要特别小心,因为有些 slice 的大小是动态生成的(比如可能依赖外部参数),所以也可能 99.99% 的 slice 大小在 <10,但只要有几个大 slice 就导致你的应用程序占用内存大幅增加,如果 slice 的大小依赖于用户输入,甚至会导致发生偶发的 OOM。
这个坑踩的人很多,比如:
获得了“官方自己都会踩的 Go 语言”坑认证。
如果你的 slice 大小是用户输入决定的,在往 pool 里放的时候,应该提前判断一下 slice 的容量,否则即使能够复用,也始终有一部分内存空间是浪费掉的。