一个 Go 服务跑了 47 天,内存从 800MB 涨到 6.2GB,K8s OOMKilled。重启后又开始稳定地往上爬,大约每天 +100MB。这种症状只有一个解释:goroutine 泄漏。本文把我们这两年遇到的 5 个真实泄漏场景写下来,每个都附最小复现代码和修法。
泄漏的本质
Go 的 goroutine 不是 OS 线程,每个起步 2KB 栈,可以并发跑几百万个。但「轻量」不等于「免费」—— 每个活着的 goroutine 都至少占 2-8KB 内存,持有它引用的所有对象。如果一个 goroutine 启动后永远不会退出,就成了泄漏。
怎么判断有泄漏?用 pprof:
// 在 main 里加这一行,启动 6060 端口的 pprof
import _ "net/http/pprof"
import "net/http"
func main() {
go func() {
_ = http.ListenAndServe(":6060", nil)
}()
// 你的业务代码...
}
# 抓 goroutine 快照
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
# 看总数
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -3
# goroutine profile: total 38421 ← 38000+ 就明显泄漏了
# 按调用栈分组
go tool pprof -top http://localhost:6060/debug/pprof/goroutine
# Showing nodes accounting for 38241, 99.5% of 38421 total
# flat flat% sum% cum cum%
# 35921 93.5% 93.5% 35921 93.5% example.com/svc.fanoutWorker
# 1893 4.9% 98.4% 1893 4.9% example.com/svc.kafkaConsumer.poll
# ...
看哪个调用栈占比最高,基本就锁定泄漏点。下面 5 个场景按出现频率排序。
场景 1:channel 写没人读(最常见)
真实代码,简化版:
// 一个发邮件的辅助函数,异步发,不阻塞主流程
func SendEmailAsync(to, subject, body string) {
resultCh := make(chan error, 1) // 错!没人读的 channel
go func() {
err := smtpClient.Send(to, subject, body)
resultCh <- err // 写完没人读,但因为是 buffered=1 不会卡死
// 但 goroutine 不会退出,因为函数没结束
// 等等,这看起来会退出?...
}()
// 主流程立刻返回,resultCh 没人读
// 但 goroutine 因为已经写入并完成,会正常退出
// 看起来没事?
}
这段代码表面没事,但实际是另一种泄漏模式 —— resultCh 这个 channel 占着内存,GC 不回收(它没被显式 close 也没人持有写引用)。如果 SendEmailAsync 调用频繁,内存慢慢涨。
更隐蔽的泄漏是这种:
// 真实场景:fan-out 模式,聚合多个下游响应
func aggregateAll(ctx context.Context, urls []string) ([]string, error) {
resultCh := make(chan string) // 无缓冲
for _, url := range urls {
go func(u string) {
data := fetchSlow(u) // 可能很慢
resultCh <- data // 如果没人读,goroutine 永久阻塞!
}(url)
}
results := make([]string, 0, len(urls))
timeout := time.After(2 * time.Second)
for i := 0; i < len(urls); i++ {
select {
case r := <-resultCh:
results = append(results, r)
case <-timeout:
return results, errors.New("timeout") // 直接返回,后续 fetchSlow 的 goroutine 全部卡在写 channel
}
}
return results, nil
}
第 8 行起的 goroutine 在写 resultCh,但当 timeout 触发 return 后,主函数不再读 channel。还没完成 fetchSlow 的 goroutine 会卡在 resultCh <- data 永远阻塞 —— 这才是经典的 goroutine 泄漏。
修法 1:用 buffered channel,确保写不会阻塞:
resultCh := make(chan string, len(urls)) // 缓冲 = urls 长度
// 即使主流程不读,goroutine 也能完成写入并退出
修法 2:goroutine 监听 context,主流程超时时 cancel:
func aggregateAll(parent context.Context, urls []string) ([]string, error) {
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel() // 关键:函数退出时取消所有 goroutine
resultCh := make(chan string, len(urls))
for _, url := range urls {
go func(u string) {
// 给 fetch 也传 ctx,这样 HTTP 请求会被取消
data, err := fetchWithCtx(ctx, u)
if err != nil {
return // ctx 取消时 fetchWithCtx 立刻返回
}
select {
case resultCh <- data:
case <-ctx.Done():
return // 防止 buffered 满了还阻塞
}
}(url)
}
results := make([]string, 0, len(urls))
for i := 0; i < len(urls); i++ {
select {
case r := <-resultCh:
results = append(results, r)
case <-ctx.Done():
return results, ctx.Err()
}
}
return results, nil
}
场景 2:for-select 缺少退出分支
第二常见的泄漏:有一个 worker 监听 channel 处理任务,但永远没人告诉它什么时候停。
// 错:worker 永远不退出
func StartWorker(jobCh <-chan Job) {
go func() {
for {
select {
case j := <-jobCh:
process(j)
// 缺少 ctx.Done() 分支,这个 goroutine 永生
}
}
}()
}
// 对:用 context 控制生命周期
func StartWorker(ctx context.Context, jobCh <-chan Job) {
go func() {
for {
select {
case j, ok := <-jobCh:
if !ok {
return // channel 关了就退
}
process(j)
case <-ctx.Done():
return // ctx 取消就退
}
}
}()
}
修复策略很简单:每个长跑 goroutine 都要有明确的退出条件 —— context 取消或 channel close,两条路径。
场景 3:HTTP body 没读完没 Close
这个泄漏不是 goroutine 直接泄漏,而是 HTTP 连接没释放,间接导致 transport 的 goroutine 增多。
// 错:resp.Body 没读完就只 Close
resp, err := http.Get(url)
if err != nil { return err }
if resp.StatusCode != 200 {
resp.Body.Close() // 关了,但 body 里还有 byte 没读
return errors.New("bad status")
}
// 错:仅判断了 status,实际响应 body 没读完
// 连接不能复用,会留在 transport 的等待队列里
// 对:不管什么情况,都把 body 读完再 close
resp, err := http.Get(url)
if err != nil { return err }
defer func() {
io.Copy(io.Discard, resp.Body) // 读完丢弃
resp.Body.Close()
}()
if resp.StatusCode != 200 {
return errors.New("bad status")
}
Go 的 http.Transport 在 body 没读完时无法复用连接(它不知道还有多少字节没拿走)。每次发请求都新建连接,每个连接对应几个内部 goroutine。久了 goroutine 数飙升,内存涨上来。
场景 4:time.After 在 select 里(被坑过两次)
// 错:每次循环都创建新的 timer,旧的不会立即释放
for {
select {
case msg := <-msgCh:
process(msg)
case <-time.After(5 * time.Second):
log.Warn("idle 5s")
}
}
time.After 返回一个 channel,内部启动一个 timer goroutine。如果 msgCh 高频有消息,每次循环都创建新的 timer,旧 timer 直到 5 秒后才能被 GC。高 QPS 下短时间内积累大量 timer goroutine。
// 对:复用一个 timer
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case msg := <-msgCh:
process(msg)
if !timer.Stop() {
<-timer.C
}
timer.Reset(5 * time.Second)
case <-timer.C:
log.Warn("idle 5s")
timer.Reset(5 * time.Second)
}
}
Go 1.23 之前这是必备技巧。1.23 起 time.After 被 GC 友好化了,这个坑减小,但养成复用 timer 的习惯不亏。
场景 5:WaitGroup.Wait 卡死
典型场景:启动一组 goroutine,某个 goroutine panic 没 recover,Add/Done 不匹配,主流程 Wait 永远不返回。
// 错:goroutine panic 时 Done 不会被调用
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
process(it) // 如果这里 panic,defer 会执行 Done,看起来没事
// 但如果是 goroutine 没起来呢?
}(item)
}
wg.Wait() // 大多数情况能等到
// 真实坑:Add 和 go 之间被取消
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
var wg sync.WaitGroup
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err() // 注意:可能 wg.Add(1) 已经调用但 go 没起来!
default:
}
wg.Add(1)
go func(it Item) {
defer wg.Done()
// 业务逻辑
}(item)
}
wg.Wait()
修复办法 1:把 Add 放在 goroutine 内部之外的同一处:
// 用 errgroup 替代:它自带 panic recover 和取消传播
import "golang.org/x/sync/errgroup"
func processAll(ctx context.Context, items []Item) error {
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(10) // 限制并发数
for _, it := range items {
it := it // 1.22 之前要这一行,避免闭包共享变量
g.Go(func() error {
select {
case <-gctx.Done():
return gctx.Err()
default:
return process(gctx, it)
}
})
}
return g.Wait()
}
errgroup 比 sync.WaitGroup 在大多数场景下更好用,它自带:
- panic recover(避免 goroutine 异常退出导致 WaitGroup 卡住)
- 第一个错误就取消所有子 goroutine
- 并发数限制(SetLimit)
排查 goroutine 泄漏的 5 步法
- 打开 pprof:
import _ "net/http/pprof" - 记录基线:服务启动后跑 5 分钟,curl /debug/pprof/goroutine?debug=1 看正常值(通常 50-200)
- 对比快照:1 小时后再抓一次,看哪个调用栈数量翻了
- 找最大的几栈:
go tool pprof -top看 top 5 - 读代码找根因:大多是 channel 写没人读,或 for-select 缺退出
# 一键脚本
HOST=localhost:6060
echo "=== Goroutine count ==="
curl -s http://$HOST/debug/pprof/goroutine?debug=1 | head -1
echo "=== Top 10 stacks ==="
go tool pprof -top -nodecount=10 http://$HOST/debug/pprof/goroutine 2>/dev/null
echo "=== Heap top 10 ==="
go tool pprof -top -nodecount=10 http://$HOST/debug/pprof/heap 2>/dev/null
echo "=== Blocked goroutines (looking for 'goroutine X [chan send' or '[select') ==="
curl -s http://$HOST/debug/pprof/goroutine?debug=2 | grep -E '^goroutine.*\[(chan|select)' | head -20
预防比修复更重要
把这 3 条加到团队 Go 代码 review checklist:
- 每个
go func()都问:它什么时候退出? 答不出来的拒绝合并。 - 每个 channel 写都问:写不进去会怎样? 是 buffered 不会阻塞,还是有 select default,还是有 ctx.Done?
- HTTP / DB / Stream 资源,defer Close 是基础,defer 读完 body 也是基础。
这 3 条做到位,我们厂 Go 服务的 goroutine 泄漏案例从「每月 2-3 次」降到「半年遇到 1 次」。最近一次还是因为引入了新的第三方库,库自己有泄漏。
Goroutine 泄漏不像内存泄漏那么显眼,因为单个 goroutine 占用小。但累积到几万、几十万,内存就会以肉眼可见的速度涨,直到某天 OOMKilled。预防它的成本远低于排查它。
—— 别看了 · 2026