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 对应的函数没有返回,它就仍然存在。函数没有返回,通常是因为它卡在以下几类状态中:
- 等待 channel 发送或接收
- 等待互斥锁、读写锁、条件变量
- 阻塞在网络读写、数据库调用、系统调用
- 运行在没有退出条件的死循环中
- 等待某个永远不会到来的信号
从调度器视角看,这些 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 后,重点看以下内容:
- 大量重复栈:如果许多 goroutine 都停在同一行代码,通常意味着同一类泄漏在批量发生
- 阻塞位置:观察是卡在 channel send、channel recv、select、mutex、IO wait 还是 syscall
- 创建路径:有些 dump 会显示创建来源,可以帮助你找到谁启动了这些 goroutine
- 是否和业务路径一致:如果请求已经结束,但仍存在对应后台 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"
这个流程对应线上排查的最小闭环:
- 先通过
runtime.NumGoroutine()发现异常增长 - 再通过 goroutine dump 确认阻塞类型和代码位置
- 最后回到代码修复退出机制
9. 预防 Goroutine 泄漏的编码规范
排查很重要,但更重要的是预防。下面这部分建议直接作为团队编码规范的一部分。
9.1 每个 goroutine 都必须有明确退出路径
启动 goroutine 前,先问自己两个问题:
- 它什么时候退出?
- 谁负责通知它退出?
如果这两个问题答不出来,这段代码大概率有风险。
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 泄漏时,可以按这份清单快速推进:
- 查看
runtime.NumGoroutine()是否持续增长 - 对比发布前后、流量变化前后指标趋势
- 抓取
/debug/pprof/goroutine?debug=2 - 查找重复出现最多的阻塞栈
- 判断阻塞类型:channel、锁、I/O、死循环还是 pipeline 中断
- 回到源码分析:谁创建、谁退出、谁取消、谁关闭
- 修复退出机制、超时和取消逻辑
- 重新压测,确认 goroutine 数量能回落
- 增加监控和告警,防止同类问题再次发生
11. 总结
Goroutine 泄漏的难点,不在于“Go 协程很难用”,而在于它太轻量,导致大家容易忽略生命周期管理。只要 goroutine 没有退出,就意味着资源还在被占用。一个、两个泄漏可能没有感觉,但一旦在高频请求路径上发生,就会迅速积累为稳定性事故。
在治理这类问题时,建议建立三个层次的能力:
- 设计层:每个 goroutine 都有退出条件
- 编码层:使用 context、超时、关闭协议和 WaitGroup 管理生命周期
- 运维层:用
runtime.NumGoroutine()做趋势监控,用 pprof 做现场定位
真正有效的方式,不是等线上故障后再抓 dump,而是在写代码时就把“退出机制”当成并发设计的一部分。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!