Go 的 goroutine 泄漏:几种最常见的写法
goroutine 泄漏不像内存泄漏那样慢慢涨,它可能在一个循环里几分钟就堆到几万个,然后进程被 OOM 或者调度器拖垮。更麻烦的是它不会报错,只会让程序越来越慢。
这篇列几种最常见的泄漏写法,以及怎么在代码里提前避开。
一、没有退出条件的 for
go func() {
for {
doSomething()
time.Sleep(time.Second)
}
}()
这段代码的问题不是 for,是它没有任何机会停下来。如果这个 goroutine 是随某个对象创建的,对象销毁了它还在跑,每创建一次就泄漏一个。
要给它两条出路:一个 stop channel,或者一个 context:
go func() {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
doSomething()
}
}
}()
select 里必须有 ctx.Done() 这一支,否则取消信号传不进来。这不是格式问题,是唯一能让它退出的路径。
二、往 channel 发数据,但没人接
ch := make(chan int) // 无缓冲
go func() { ch <- 1 }()
// 忘了读,或者提前 return 了
无缓冲 channel 的发送会阻塞,直到有人接收。如果接收方因为别的原因退出了,发送方就永远卡在那里。
有缓冲的也一样,只是能多撑几个:
ch := make(chan int, 1)
go func() { ch <- 1; ch <- 2 }() // 第二个就阻塞了
稳妥的写法是发送时也 select 一个取消分支:
select {
case ch <- v:
case <-ctx.Done():
return
}
或者干脆用带缓冲、容量明确的 channel,并保证接收方一定会读到关闭。
三、Ticker 忘了 Stop
t := time.NewTicker(time.Second)
// 用完没有 t.Stop()
Ticker 内部是一个独立的时间轮,不 Stop 就一直活着,还会持续往 channel 里发时间。短命进程无所谓,常驻服务里每个请求创建一个 Ticker 就是灾难。
习惯写法:
t := time.NewTicker(time.Second)
defer t.Stop()
time.After 也有类似问题:它在定时器触发前不会被回收。循环里反复 time.After 会堆积大量定时器,这种情况用 Ticker 或者带 ctx 的 time.NewTimer。
四、HTTP 响应体没关
resp, err := http.Get(url)
if err != nil {
return err
}
// 忘了 resp.Body.Close()
不关 Body 不只是句柄泄漏,还会让底层连接无法复用,连接池很快耗尽,上层看到的是一堆超时。
正确写法是拿到响应立刻 defer 关闭,并且读完再关:
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
如果只关心状态码不关心内容,也要读完再丢弃,否则连接会被直接断掉而不是复用。
五、怎么发现泄漏
最简单的办法是在测试或压测里数一下 goroutine 数量:
before := runtime.NumGoroutine()
doSomething()
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutine 变化:", runtime.NumGoroutine()-before)
如果重复执行几百次,数字一直往上涨,基本可以确定有泄漏。
更细的可以用 pprof:
import _ "net/http/pprof"
然后访问 /debug/pprof/goroutine?debug=2,能看到每个 goroutine 卡在哪一行。卡在 chan send 或 chan receive 的,多半是第二类问题。
六、小结
四条最常见的泄漏:
1. for 没有退出条件; 2. 往没人接收的 channel 发数据; 3. Ticker 忘记 Stop; 4. HTTP Body 忘记 Close。
它们共同点都是「某个东西一直在等」。写 goroutine 的时候顺手问一句:它什么时候退出?答不出来的,基本上就是漏了。
- 点赞
- 收藏
- 关注作者
评论(0)