一、什么是协程泄漏?

想象一下你家的水龙头没关紧,水一直滴滴答答流着。虽然每一滴看起来不多,但时间一长,水费账单就会让你头疼。协程泄漏也是类似的道理——程序里启动的协程没有正确关闭,它们会像没关紧的水龙头一样持续占用内存和CPU资源,最终拖垮整个服务。

在Go语言中,协程(goroutine)是轻量级线程,启动成本很低,但这并不意味着可以随意创建而不管理。尤其是在长期运行的服务中,比如Web服务器或后台任务处理器,协程泄漏会导致内存耗尽、响应变慢甚至服务崩溃。

二、协程泄漏的常见场景

1. 未正确关闭的channel

// 技术栈: Golang  
func leakyFunction() {
    ch := make(chan int)
    go func() {
        val := <-ch  // 这里会一直阻塞,因为没人往ch发送数据
        fmt.Println(val)
    }()
    // 函数结束,但上面的协程还在等ch的数据,永远不会退出
}

问题分析

  • 这个协程会一直卡在val := <-ch,因为ch没有数据传入,也没有被关闭。
  • 如果leakyFunction()被频繁调用,就会有大量协程堆积,造成泄漏。

2. 无限循环的协程

// 技术栈: Golang  
func infiniteWorker() {
    for {
        // 做一些任务...
        time.Sleep(1 * time.Second)
    }
    // 这个协程永远不会退出
}

问题分析

  • 如果这个协程是某个任务的一部分,而调用者没有控制它的生命周期,它就会一直运行。
  • 即使主程序退出,这类协程也可能继续存在(比如在main函数中直接启动)。

3. 未处理的context取消

// 技术栈: Golang  
func fetchData(ctx context.Context) {
    go func() {
        // 模拟一个长时间运行的HTTP请求
        time.Sleep(10 * time.Second)
        fmt.Println("请求完成")
    }()
    // 如果ctx被取消,这个协程仍然会运行完10秒
}

问题分析

  • 虽然Go推荐用context控制协程生命周期,但如果协程内部不检查ctx.Done()context的取消就无效。
  • 在微服务架构中,这类问题会导致大量无用请求继续占用资源。

三、如何检测协程泄漏?

1. 使用runtime.NumGoroutine()

// 技术栈: Golang  
func main() {
    start := runtime.NumGoroutine()
    leakyFunction()
    time.Sleep(1 * time.Second) // 给泄漏的协程一点时间启动
    end := runtime.NumGoroutine()
    fmt.Printf("协程泄漏数量: %d\n", end - start)
}

适用场景

  • 简单测试某个函数是否泄漏协程。
  • 不适合生产环境长期监控。

2. 结合pprof工具

// 技术栈: Golang  
import _ "net/http/pprof"

func main() {
    go func() {
        log.Println(http.ListenAndServe(":6060", nil))
    }()
    // 其他业务代码...
}

使用方法

  1. 访问http://localhost:6060/debug/pprof/goroutine?debug=1查看实时协程堆栈。
  2. go tool pprof http://localhost:6060/debug/pprof/goroutine生成分析报告。

优点

  • 能精确定位哪些代码创建了泄漏的协程。
  • 适合生产环境诊断问题。

四、预防协程泄漏的最佳实践

1. 始终用context控制生命周期

// 技术栈: Golang  
func safeFetch(ctx context.Context) error {
    ch := make(chan error)
    go func() {
        // 模拟耗时操作
        time.Sleep(5 * time.Second)
        ch <- nil
    }()

    select {
    case <-ctx.Done():  // 如果ctx被取消,立即退出
        return ctx.Err()
    case err := <-ch:   // 正常完成
        return err
    }
}

关键点

  • 所有可能阻塞的操作都应该监听ctx.Done()
  • 适用于HTTP请求、数据库查询等I/O操作。

2. 使用sync.WaitGroup等待协程退出

// 技术栈: Golang  
func batchProcess(items []string) {
    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)
        go func(i string) {
            defer wg.Done() // 确保协程结束时通知WaitGroup
            processItem(i)
        }(item)
    }
    wg.Wait() // 阻塞直到所有协程完成
}

适用场景

  • 需要等待一组协程全部完成的场景。
  • 比用time.Sleep等待更可靠。

3. 设置协程超时

// 技术栈: Golang  
func withTimeout() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    go func() {
        select {
        case <-ctx.Done():
            fmt.Println("协程超时退出")
            return
        case <-time.After(5 * time.Second): // 模拟超时任务
            fmt.Println("任务完成")
        }
    }()
}

关键点

  • 即使协程没有自然结束,超时机制也会强制回收资源。
  • 特别适合调用外部服务或不确定耗时的操作。

五、总结

协程泄漏就像程序里的"慢性病",初期可能看不出影响,但随着时间推移会导致严重问题。通过本文的示例和分析,我们可以总结出几个关键原则:

  1. 永远不要假设协程会自己退出——必须主动管理生命周期。
  2. context是你的好朋友——几乎所有涉及I/O的协程都应该用它控制超时和取消。
  3. 工具链很重要——pprof和runtime包能帮你快速定位泄漏点。

在长期运行的服务中,良好的协程管理习惯比事后调试更重要。就像定期检查家里的水管一样,养成预防性编程的习惯,才能让服务稳定运行。