Go语言循环变量传引用给goroutine致内存泄漏问题排查
问题背景
使用Go 1.19版本(darwin/amd64)运行以下代码后,通过go tool pprof http://localhost:6060/debug/pprof/heap做堆内存分析,发现func1存在明显内存泄漏。func1与func2的唯一区别是循环变量的捕获方式:func2用局部变量捕获循环变量,而func1直接传循环变量作为goroutine参数。移除全局maptMap后,两个函数均无内存泄漏。调换main中func1和func2的执行顺序后,func2也会出现在泄漏报告中,但func1仍占最大内存占比(51.32%),func2占19.33%。
问题复现代码
package main import ( "bytes" "log" "net/http" _ "net/http/pprof" "runtime" "strconv" "sync" ) type TestStruct struct { M []byte } var lkMap sync.Mutex var tMap map[string]*TestStruct const objectCount = 1024 * 100 func main() { tMap = make(map[string]*TestStruct) wg := &sync.WaitGroup{} wg.Add(objectCount) func1(wg) wg.Add(objectCount) func2(wg) wg.Wait() runtime.GC() log.Println(http.ListenAndServe("localhost:6060", nil)) } //go:noinline func func1(wg *sync.WaitGroup) { ts := []*TestStruct{} for i := 0; i < objectCount; i++ { t := &TestStruct{} ts = append(ts, t) lkMap.Lock() tMap[strconv.Itoa(i)] = t lkMap.Unlock() } for i, t := range ts { go func(t *TestStruct, idx int) { t.M = bytes.Repeat([]byte{byte(32)}, 1024) lkMap.Lock() delete(tMap, strconv.Itoa(idx)) lkMap.Unlock() wg.Done() }(t, i) //pass by reference } } //go:noinline func func2(wg *sync.WaitGroup) { ts := []*TestStruct{} for i := 0; i < objectCount; i++ { t := &TestStruct{} ts = append(ts, t) lkMap.Lock() tMap[strconv.Itoa(i+objectCount)] = t lkMap.Unlock() } for i, t := range ts { tmp := t //capture here idx := i go func() { tmp.M = bytes.Repeat([]byte{byte(32)}, 1024) lkMap.Lock() delete(tMap, strconv.Itoa(idx+objectCount)) lkMap.Unlock() wg.Done() }() } }
问题解答
这个内存泄漏确实和全局map的操作直接相关,核心是map对对象的引用延迟GC、goroutine异步执行,加上循环变量的处理方式共同作用的结果:
全局map是泄漏的必要条件
两个函数都会先把TestStruct指针存入tMap,再启动goroutine执行删除操作。goroutine是异步的,在delete完成前,tMap会一直持有对象的引用,导致GC无法回收这些对象。移除map后,对象仅被goroutine持有,goroutine退出后就能立即被GC,因此不会出现泄漏。func1的传参方式放大了泄漏程度
虽然func1通过传参避免了Go 1.19中range循环变量复用的问题,但每个goroutine的栈上会保留TestStruct指针的引用,加上tMap的引用,形成双重持有。Go的map在删除元素后,不会立即释放bucket内的内存槽(仅标记为可用,等待后续复用),这会进一步延迟对象的回收。相比之下,func2用局部变量捕获循环变量,goroutine对指针的引用更"轻量化",泄漏程度更低。执行顺序影响泄漏范围的原因
调换执行顺序后func2也出现泄漏,是因为调度顺序变化导致func2的goroutine执行delete的时间被推迟,map对对象的引用时间变长。而func1始终泄漏更严重,是因为传参方式导致的引用链更稳定,对象被持有的时间普遍更长。
总结:全局map的存在延长了对象的生命周期,func1的循环变量处理方式放大了这种延迟的影响,最终表现为明显的内存泄漏。
内容的提问来源于stack exchange,提问作者davyzhang

