You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Go语言循环变量传引用给goroutine致内存泄漏问题排查

Go代码内存泄漏与全局Map的关联分析

问题背景

使用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异步执行,加上循环变量的处理方式共同作用的结果:

  1. 全局map是泄漏的必要条件
    两个函数都会先把TestStruct指针存入tMap,再启动goroutine执行删除操作。goroutine是异步的,在delete完成前,tMap会一直持有对象的引用,导致GC无法回收这些对象。移除map后,对象仅被goroutine持有,goroutine退出后就能立即被GC,因此不会出现泄漏。

  2. func1的传参方式放大了泄漏程度
    虽然func1通过传参避免了Go 1.19中range循环变量复用的问题,但每个goroutine的栈上会保留TestStruct指针的引用,加上tMap的引用,形成双重持有。Go的map在删除元素后,不会立即释放bucket内的内存槽(仅标记为可用,等待后续复用),这会进一步延迟对象的回收。相比之下,func2用局部变量捕获循环变量,goroutine对指针的引用更"轻量化",泄漏程度更低。

  3. 执行顺序影响泄漏范围的原因
    调换执行顺序后func2也出现泄漏,是因为调度顺序变化导致func2的goroutine执行delete的时间被推迟,map对对象的引用时间变长。而func1始终泄漏更严重,是因为传参方式导致的引用链更稳定,对象被持有的时间普遍更长。

总结:全局map的存在延长了对象的生命周期,func1的循环变量处理方式放大了这种延迟的影响,最终表现为明显的内存泄漏。

内容的提问来源于stack exchange,提问作者davyzhang

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.17 16:35:28