整理了5个Goroutine泄漏的Code Review检查点


上周线上服务内存暴涨,排查了3小时发现是Goroutine泄漏。后来定位到是一个channel没关闭,导致goroutine一直阻塞。Code Review时如果能提前发现这些问题,能省去很多半夜起床排查的麻烦。整理了5个必查点,建议收藏对照。


检查点1:Channel是否关闭

问题:向已关闭的channel发送数据会panic,但没关闭的channel会导致goroutine永远阻塞在接收端。

// ❌ 问题代码
func process(ch chan int) {
    go func() {
        for val := range ch {
            fmt.Println(val)
        }
        fmt.Println("goroutine退出") // 这行永远不会执行
    }()
    // 函数退出了,但ch没关闭,上面的goroutine永远阻塞
}

修复:确保发送方关闭channel,或者使用context控制生命周期。

// ✅ 正确写法
func process(ctx context.Context, ch chan int) {
    go func() {
        defer fmt.Println("goroutine退出")
        for {
            select {
            case val := <-ch:
                fmt.Println(val)
            case <-ctx.Done():
                return // 收到取消信号,优雅退出
            }
        }
    }()
}

检查口诀range ch 的地方,ch谁来关闭?


检查点2:WaitGroup是否Done

问题wg.Add()wg.Done() 数量不匹配,导致 wg.Wait() 永远阻塞。

// ❌ 问题代码
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        if shouldSkip() {
            return // 这里直接return,wg.Done()没执行!
        }
        doWork()
        wg.Done()
    }()
}
wg.Wait() // 永远阻塞在这里

修复defer wg.Done() 放在函数开头,确保任何退出路径都会执行。

// ✅ 正确写法
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done() // 放这里,任何return都会执行
        if shouldSkip() {
            return
        }
        doWork()
    }()
}
wg.Wait()

检查口诀defer wg.Done() 是否紧跟在 wg.Add() 后面?


检查点3:无限循环是否有退出条件

问题:后台任务用了裸的 for {},没有监听取消信号,goroutine跑到程序结束。

// ❌ 问题代码
func startBackgroundTask() {
    go func() {
        for {
            doSomething()
            time.Sleep(time.Second)
        }
        // 没有退出条件,这个goroutine永远跑下去
    }()
}

修复:用 select 监听 ctx.Done(),收到取消信号立即退出。

// ✅ 正确写法
func startBackgroundTask(ctx context.Context) {
    go func() {
        ticker := time.NewTicker(time.Second)
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                fmt.Println("后台任务收到取消信号,退出")
                return
            case <-ticker.C:
                doSomething()
            }
        }
    }()
}

检查口诀for 循环里有没有 select { case <-ctx.Done() }


检查点4:HTTP请求是否设置了Timeout

问题http.Client 默认没有超时,如果服务端不响应,连接会一直挂着。

// ❌ 问题代码
client := &http.Client{}
resp, err := client.Get("https://api.example.com/data")
// 默认无超时,如果api.example.com不响应,这里永远等下去

修复:必须设置 Timeout,包括连接超时和读取超时。

// ✅ 正确写法
client := &http.Client{
    Timeout: 10 * time.Second, // 总超时
}
// 或者更精细的控制
client := &http.Client{
    Transport: &http.Transport{
        DialTimeout: 5 * time.Second,    // 连接超时
        ReadTimeout: 5 * time.Second,    // 读取超时
    },
    Timeout: 10 * time.Second,
}
resp, err := client.Get("https://api.example.com/data")

检查口诀:每个 http.Client 有没有 Timeout 字段?


检查点5:数据库连接是否归还

问题sql.Rowssql.Txsql.Stmt 忘记 Close,连接池被打满,新请求拿不到连接。

// ❌ 问题代码
func getUsers(db *sql.DB) error {
    rows, err := db.Query("SELECT * FROM users")
    if err != nil {
        return err
    }
    // 忘记 rows.Close(),这个连接一直被占用
    
    for rows.Next() {
        // 处理数据
    }
    return nil
}

修复defer rows.Close() 紧跟在错误检查后面。

// ✅ 正确写法
func getUsers(db *sql.DB) error {
    rows, err := db.Query("SELECT * FROM users")
    if err != nil {
        return err
    }
    defer rows.Close() // 确保归还连接
    
    for rows.Next() {
        // 处理数据
    }
    return rows.Err() // 别忘了检查遍历错误
}

检查口诀:数据库操作后有没有 defer rows.Close()


总结

这5个检查点覆盖了80%的Goroutine泄漏场景:

检查点 关键词
Channel 谁来关闭?
WaitGroup defer Done?
无限循环 ctx.Done()?
HTTP请求 Timeout?
数据库连接 defer Close()?

建议保存下来,Code Review时对着看。如果你曾经因为漏掉某个检查点导致线上问题,欢迎在评论区分享,让其他人也避避坑。


参考

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo