返回首页

Golang横向:06 Goroutine 泄漏排查与预防

Goroutine 是 Go 并发模型中最常用、也最容易被滥用的能力之一。它的创建成本很低,调度也由运行时负责,因此很多工程师会下意识地“顺手起一个 goroutine”。问题在于:goroutine 不是用完即焚的语法糖,而是需要被正确回收的运行时资源。一旦 goroutine 长时间阻塞、无法退出,或者数量持续增长,就会形成 Goroutine 泄漏。

Goroutine 泄漏不会像内存越界那样立即崩溃,但它会持续吞噬系统资源:栈空间、调度器开销、连接、锁、文件句柄,以及下游依赖的处理能力。在线上环境里,这类问题往往表现为接口偶发变慢、内存持续上升、线程数增长、CPU 调度异常,最终演变为稳定性故障。

本文将围绕 Goroutine 泄漏的本质、检测方法、典型模式、修复方式和预防规范展开,给出完整且可运行的 Go 代码示例,帮助你建立一套系统化的排查思路。

1. 什么是 Goroutine 泄漏

从本质上说,Goroutine 泄漏指的是:某些 goroutine 已经失去业务价值,但依然存活,且无法被及时回收

这类 goroutine 往往有几个共同特征:

  • 一直阻塞在 channel 收发、锁等待、I/O、系统调用等位置
  • 被创建后缺少退出条件
  • 上游逻辑已经结束,但后台 goroutine 仍未停止
  • 在循环、定时任务、重试逻辑中不断累积

需要特别注意的是,Goroutine 泄漏并不等价于“Goroutine 数量多”。

  • 如果系统本身是高并发模型,goroutine 多不一定异常
  • 如果 goroutine 数量随着请求结束能回落,通常不是泄漏
  • 真正的泄漏特征,是 goroutine 数量持续增长且不回收

2. Goroutine 泄漏的本质

理解泄漏,先要理解 goroutine 为什么会“活着”。

只要 goroutine 对应的函数没有返回,它就仍然存在。函数没有返回,通常是因为它卡在以下几类状态中:

  1. 等待 channel 发送或接收
  2. 等待互斥锁、读写锁、条件变量
  3. 阻塞在网络读写、数据库调用、系统调用
  4. 运行在没有退出条件的死循环中
  5. 等待某个永远不会到来的信号

从调度器视角看,这些 goroutine 虽然不一定一直占用 CPU,但它们仍然会占据运行时资源。随着数量累积,调度、内存、句柄和依赖资源都会受到影响。

3. 常见原因与触发场景

下面是工程实践中最常见的 Goroutine 泄漏原因。

3.1 Channel 阻塞

最典型的一类问题,是 channel 的发送方或接收方永远等不到对端。

例如:

  • 向无缓冲 channel 发送数据,但没有接收者
  • 从 channel 读取数据,但没有发送者
  • channel 未关闭,消费者却用 for range 一直等待
  • 下游提前退出,导致上游发送永久阻塞

3.2 死锁或锁等待

多个 goroutine 竞争锁,如果某个锁没有正确释放,或者加锁顺序不一致,就可能导致 goroutine 长时间阻塞,表现为类似泄漏的问题。

3.3 循环未退出

后台任务、消费者、watcher、轮询器、重试器,常常写成 for {}。如果没有退出信号,这些 goroutine 在业务结束后仍会一直运行。

3.4 I/O 或外部依赖无超时

例如 HTTP 请求、RPC 调用、数据库查询、消息消费等,如果没有超时和取消机制,远程依赖一旦卡死,goroutine 就会一直挂住。

3.5 启动了 goroutine,但没人负责回收

很多泄漏来自“随手起 goroutine”:

  • 请求进来时起一个后台 goroutine
  • 定时任务里每次轮询都起新的 goroutine
  • 重试逻辑里不断 go func()
  • 忘记绑定 context,或者没有 stop 机制

3.6 Pipeline 中断

生产者-消费者模型里,如果某一段提前返回,前后游协程没有协同退出,剩余 goroutine 就会永久阻塞。

4. 快速识别泄漏的外部信号

线上没有第一时间拿到代码时,可以先观察系统症状。

现象 可能含义
runtime.NumGoroutine() 持续上升且不回落 高概率存在 goroutine 泄漏
内存缓慢上涨 goroutine 栈和关联对象持续存活
请求超时、吞吐下降 阻塞 goroutine 累积影响处理能力
连接数、句柄数异常 泄漏 goroutine 持有外部资源
pprof 中 goroutine profile 大量重复栈 某种阻塞模式正在批量发生

这些现象不能单独定性,但足以提示你优先从 goroutine 泄漏方向排查。

5. 使用 pprof 检测 Goroutine 泄漏

pprof 是 Go 线上问题定位的核心工具之一。要排查 goroutine 泄漏,最常用的是 goroutine profile。

5.1 集成 net/http/pprof

下面是一个完整可运行的示例。该程序故意制造 goroutine 泄漏:每次访问 /leak,都会启动一个 goroutine 往无人接收的 channel 发送数据,从而永久阻塞。

package main

import (
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof"
    "runtime"
    "time"
)

func leakHandler(w http.ResponseWriter, r *http.Request) {
    ch := make(chan int)

    go func() {
        ch <- 1 // 永久阻塞:没有接收者
    }()

    fmt.Fprintf(w, "goroutine leak triggered, current goroutines=%d\n", runtime.NumGoroutine())
}

func countHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "goroutines=%d\n", runtime.NumGoroutine())
}

func main() {
    go func() {
        for {
            log.Printf("[monitor] goroutines=%d", runtime.NumGoroutine())
            time.Sleep(2 * time.Second)
        }
    }()

    http.HandleFunc("/leak", leakHandler)
    http.HandleFunc("/count", countHandler)

    log.Println("server started at :6060")
    log.Println("pprof endpoint: http://127.0.0.1:6060/debug/pprof/")

    if err := http.ListenAndServe(":6060", nil); err != nil {
        log.Fatal(err)
    }
}

运行方式:

go run main.go

在另一个终端里连续访问几次:

for i in {1..20}; do curl "http://127.0.0.1:6060/leak"; done

查看 goroutine 数量:

curl "http://127.0.0.1:6060/count"

你会发现 goroutine 数量持续增长。

5.2 获取 goroutine profile

访问:

go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine

如果希望直接看文本堆栈,也可以:

curl "http://127.0.0.1:6060/debug/pprof/goroutine?debug=2"

debug=2 会输出更完整的 goroutine dump,非常适合定位“大家都卡在哪”。

5.3 如何分析 goroutine dump

拿到 goroutine dump 后,重点看以下内容:

  1. 大量重复栈:如果许多 goroutine 都停在同一行代码,通常意味着同一类泄漏在批量发生
  2. 阻塞位置:观察是卡在 channel send、channel recv、select、mutex、IO wait 还是 syscall
  3. 创建路径:有些 dump 会显示创建来源,可以帮助你找到谁启动了这些 goroutine
  4. 是否和业务路径一致:如果请求已经结束,但仍存在对应后台 goroutine,说明回收机制可能缺失

例如,针对上面的示例,你很可能看到类似信息:

goroutine 34 [chan send]:
main.leakHandler.func1()
    /path/to/main.go:16 +0x25
created by main.leakHandler in goroutine 18
    /path/to/main.go:15 +0x85

这段信息非常关键:

  • goroutine 34 [chan send]:该 goroutine 阻塞在 channel 发送
  • main.go:16:阻塞点在第 16 行
  • created by:是谁创建了它

只要有大量类似栈反复出现,基本就能锁定泄漏点。

5.4 实战排查思路

用 pprof 排查 goroutine 泄漏时,建议按照下面的顺序:

步骤 操作 目的
1 观察 runtime.NumGoroutine() 趋势 判断是否存在持续增长
2 抓取 /debug/pprof/goroutine?debug=2 查看全部 goroutine 栈
3 统计重复栈 找出最集中的阻塞模式
4 回到源码定位对应代码 分析退出条件、channel 配对、锁释放、超时机制
5 修复后再次压测和对比 验证 goroutine 数量是否回落

6. 使用 runtime.NumGoroutine 做监控

runtime.NumGoroutine() 是非常轻量、也非常实用的运行时指标。它不能直接告诉你“哪段代码泄漏了”,但非常适合做趋势监控和告警。

6.1 基础用法

package main

import (
    "log"
    "runtime"
    "time"
)

func main() {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        log.Printf("goroutines=%d", runtime.NumGoroutine())
    }
}

6.2 如何看这个指标

不要只看单点数值,而要关注趋势:

  • 正常情况:流量波动时 goroutine 数量上下浮动,但会回落
  • 异常情况:goroutine 数量与请求量无关地持续增长,且长时间不下降

在服务治理中,推荐把该指标接入监控系统,并结合以下信息一起看:

  • QPS
  • 错误率
  • 平均响应时间和 P99
  • 内存使用量
  • 文件句柄数或连接池使用情况

6.3 一个简化的阈值告警思路

下面的示例展示了如何做一个简单的 goroutine 增长监控器:

package main

import (
    "log"
    "runtime"
    "time"
)

func main() {
    baseline := runtime.NumGoroutine()
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        current := runtime.NumGoroutine()
        growth := current - baseline

        log.Printf("baseline=%d current=%d growth=%d", baseline, current, growth)

        if growth > 100 {
            log.Printf("[WARN] goroutine count increased too much")
        }
    }
}

实际生产环境中,阈值通常应按服务基线、流量规模和模块特性来配置,不建议直接照搬固定值。

7. 常见泄漏模式与修复示例

下面通过几个高频案例,展示泄漏是如何产生的,以及应如何修复。

7.1 模式一:发送方阻塞,channel 无人接收

错误示例

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    go func() {
        fmt.Println("sending...")
        ch <- 1 // 永久阻塞
        fmt.Println("sent")
    }()

    time.Sleep(3 * time.Second)
    fmt.Println("main exit")
}

这个程序中,子 goroutine 会永久阻塞在 ch <- 1

修复示例 1:保证有接收者

package main

import "fmt"

func main() {
    ch := make(chan int)

    go func() {
        ch <- 1
    }()

    v := <-ch
    fmt.Println("received:", v)
}

修复示例 2:通过 select 增加超时或取消

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    go func() {
        select {
        case ch <- 1:
            fmt.Println("send success")
        case <-ctx.Done():
            fmt.Println("send canceled:", ctx.Err())
            return
        }
    }()

    time.Sleep(2 * time.Second)
}

7.2 模式二:接收方阻塞,channel 永远没有数据

错误示例

package main

func main() {
    ch := make(chan int)

    go func() {
        <-ch // 永久等待
    }()

    select {}
}

修复示例

package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context, ch <-chan int) {
    select {
    case v := <-ch:
        fmt.Println("received:", v)
    case <-ctx.Done():
        fmt.Println("worker exit:", ctx.Err())
    }
}

func main() {
    ch := make(chan int)
    ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
    defer cancel()

    go worker(ctx, ch)

    time.Sleep(2 * time.Second)
}

7.3 模式三:for range channel 但 channel 永不关闭

错误示例

package main

import "fmt"

func main() {
    ch := make(chan int)

    go func() {
        for v := range ch {
            fmt.Println(v)
        }
    }()

    ch <- 1
    ch <- 2

    select {}
}

消费者在 for range ch 中等待 channel 关闭,但生产者从未关闭 ch,于是 goroutine 一直不退出。

修复示例

package main

import "fmt"

func main() {
    ch := make(chan int)
    done := make(chan struct{})

    go func() {
        defer close(done)
        for v := range ch {
            fmt.Println("consume:", v)
        }
        fmt.Println("consumer exit")
    }()

    ch <- 1
    ch <- 2
    close(ch)

    <-done
}

7.4 模式四:后台循环没有退出机制

错误示例

package main

import (
    "fmt"
    "time"
)

func startWorker() {
    go func() {
        for {
            fmt.Println("working...")
            time.Sleep(1 * time.Second)
        }
    }()
}

func main() {
    startWorker()
    time.Sleep(3 * time.Second)
}

在真实服务中,这类代码常见于 watcher、重试器、轮询器、缓存刷新任务。只要没有退出机制,服务组件反复初始化时就可能不断叠加 goroutine。

修复示例

package main

import (
    "context"
    "fmt"
    "time"
)

func startWorker(ctx context.Context) {
    go func() {
        ticker := time.NewTicker(1 * time.Second)
        defer ticker.Stop()

        for {
            select {
            case <-ticker.C:
                fmt.Println("working...")
            case <-ctx.Done():
                fmt.Println("worker exit:", ctx.Err())
                return
            }
        }
    }()
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    startWorker(ctx)

    time.Sleep(4 * time.Second)
}

7.5 模式五:HTTP 调用没有超时,goroutine 卡在外部依赖

错误示例

package main

import (
    "log"
    "net/http"
)

func main() {
    go func() {
        resp, err := http.Get("https://10.255.255.1") // 示例地址,可能长时间卡住
        if err != nil {
            log.Println("request failed:", err)
            return
        }
        defer resp.Body.Close()
    }()

    select {}
}

修复示例

package main

import (
    "context"
    "log"
    "net/http"
    "time"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    client := &http.Client{
        Timeout: 3 * time.Second,
    }

    resp, err := client.Do(req)
    if err != nil {
        log.Println("request failed:", err)
        return
    }
    defer resp.Body.Close()

    log.Println("request success, status:", resp.Status)
}

这里的关键点是:

  • 请求本身绑定 context
  • 客户端设置 Timeout
  • 对外部依赖永远不要无限等待

7.6 模式六:Pipeline 中某一段提前退出

错误示例

package main

import "fmt"

func producer(out chan<- int) {
    for i := 0; i < 10; i++ {
        out <- i
    }
    close(out)
}

func consumer(in <-chan int) {
    for v := range in {
        fmt.Println("consume:", v)
        if v == 2 {
            return // 提前退出
        }
    }
}

func main() {
    ch := make(chan int)
    go producer(ch)
    go consumer(ch)

    select {}
}

这个例子里,consumer 在收到 2 后提前返回,producer 后续再发送时就可能永久阻塞。

修复示例

package main

import (
    "context"
    "fmt"
    "sync"
)

func producer(ctx context.Context, out chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    defer close(out)

    for i := 0; i < 10; i++ {
        select {
        case out <- i:
        case <-ctx.Done():
            fmt.Println("producer exit")
            return
        }
    }
}

func consumer(ctx context.Context, cancel context.CancelFunc, in <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()

    for {
        select {
        case v, ok := <-in:
            if !ok {
                return
            }
            fmt.Println("consume:", v)
            if v == 2 {
                cancel()
                return
            }
        case <-ctx.Done():
            return
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    ch := make(chan int)
    var wg sync.WaitGroup

    wg.Add(2)
    go producer(ctx, ch, &wg)
    go consumer(ctx, cancel, ch, &wg)

    wg.Wait()
}

这个修复版的重点是:当某一段提前结束时,其他 goroutine 也能感知并退出

8. 一个小型示例:用 pprof + NumGoroutine 联合定位泄漏

在真实服务中,最常用的方法不是单独依赖某一个工具,而是把监控和剖析结合起来。

下面这个例子模拟一个“泄漏型服务”:

  • /work 会制造泄漏
  • /metrics 返回当前 goroutine 数量
  • /debug/pprof/ 用于抓取 goroutine dump
package main

import (
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof"
    "runtime"
    "time"
)

func startLeakyTask() {
    ch := make(chan struct{})

    go func() {
        <-ch // 永久阻塞
    }()
}

func workHandler(w http.ResponseWriter, r *http.Request) {
    startLeakyTask()
    fmt.Fprintf(w, "ok, goroutines=%d\n", runtime.NumGoroutine())
}

func metricsHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "goroutines %d\n", runtime.NumGoroutine())
}

func main() {
    go func() {
        for {
            log.Printf("goroutines=%d", runtime.NumGoroutine())
            time.Sleep(5 * time.Second)
        }
    }()

    http.HandleFunc("/work", workHandler)
    http.HandleFunc("/metrics", metricsHandler)

    log.Println("listen on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

你可以这样验证:

go run main.go
for i in {1..50}; do curl "http://127.0.0.1:8080/work"; done
curl "http://127.0.0.1:8080/metrics"
curl "http://127.0.0.1:8080/debug/pprof/goroutine?debug=2"

这个流程对应线上排查的最小闭环:

  1. 先通过 runtime.NumGoroutine() 发现异常增长
  2. 再通过 goroutine dump 确认阻塞类型和代码位置
  3. 最后回到代码修复退出机制

9. 预防 Goroutine 泄漏的编码规范

排查很重要,但更重要的是预防。下面这部分建议直接作为团队编码规范的一部分。

9.1 每个 goroutine 都必须有明确退出路径

启动 goroutine 前,先问自己两个问题:

  1. 它什么时候退出?
  2. 谁负责通知它退出?

如果这两个问题答不出来,这段代码大概率有风险。

9.2 优先使用 context.Context 传递取消信号

对请求链路、后台任务、pipeline、worker 池等场景,context 都应作为一等公民。不要只考虑“怎么启动”,更要考虑“怎么停止”。

9.3 Channel 的发送和接收必须成对设计

对于 channel,需要提前明确:

  • 谁发送
  • 谁接收
  • 谁关闭
  • 提前退出时如何通知对端

如果这些角色不清晰,泄漏风险会非常高。

9.4 for {}for range 必须配套退出条件

下面两类代码要格外警惕:

  • 无限循环 for {}
  • for range ch

这两种写法都很常见,但必须明确退出机制,例如:

  • ctx.Done()
  • stop channel
  • ticker 停止
  • channel 关闭
  • 上下游取消联动

9.5 所有 I/O 操作都要设置超时

包括但不限于:

  • HTTP/RPC 调用
  • 数据库查询
  • 消息队列消费
  • 文件或网络读写

没有超时的 I/O,本质上就是把 goroutine 的生命周期交给不确定的外部世界。

9.6 避免在请求路径中随意 go func()

很多线上泄漏都来自于这种习惯:

go func() {
    doSomething()
}()

这类代码看起来简洁,但如果没有:

  • context
  • 错误处理
  • 回收机制
  • 并发边界

就很容易变成稳定性隐患。

9.7 对后台协程使用 WaitGroup 或生命周期管理机制

服务关闭、模块重载、任务停止时,要能显式等待 goroutine 退出,而不是靠“进程结束时一并带走”。

9.8 把 goroutine 数量纳入可观测性体系

建议至少做到:

规范项 建议
指标采集 采集 runtime.NumGoroutine()
基线管理 明确服务平稳期 goroutine 数量范围
异常告警 对持续增长设置告警
故障排查 默认暴露 pprof(注意鉴权和访问控制)
发布验证 上线后关注 goroutine 数量是否异常抬升

10. 一份可执行的排查清单

当你怀疑服务存在 goroutine 泄漏时,可以按这份清单快速推进:

  1. 查看 runtime.NumGoroutine() 是否持续增长
  2. 对比发布前后、流量变化前后指标趋势
  3. 抓取 /debug/pprof/goroutine?debug=2
  4. 查找重复出现最多的阻塞栈
  5. 判断阻塞类型:channel、锁、I/O、死循环还是 pipeline 中断
  6. 回到源码分析:谁创建、谁退出、谁取消、谁关闭
  7. 修复退出机制、超时和取消逻辑
  8. 重新压测,确认 goroutine 数量能回落
  9. 增加监控和告警,防止同类问题再次发生

11. 总结

Goroutine 泄漏的难点,不在于“Go 协程很难用”,而在于它太轻量,导致大家容易忽略生命周期管理。只要 goroutine 没有退出,就意味着资源还在被占用。一个、两个泄漏可能没有感觉,但一旦在高频请求路径上发生,就会迅速积累为稳定性事故。

在治理这类问题时,建议建立三个层次的能力:

  • 设计层:每个 goroutine 都有退出条件
  • 编码层:使用 context、超时、关闭协议和 WaitGroup 管理生命周期
  • 运维层:用 runtime.NumGoroutine() 做趋势监控,用 pprof 做现场定位

真正有效的方式,不是等线上故障后再抓 dump,而是在写代码时就把“退出机制”当成并发设计的一部分。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang横向:05 GitOps 实践

下一篇

Golang横向:07 内存暴涨与 GC 调优