1. 起因

服务跑了几天之后内存就开始缓慢上涨,一开始怀疑是内存泄漏,加了 pprof 之后发现堆内存其实还好,倒是 goroutine 数量一路涨到几万个不回落,才反应过来是 goroutine 泄漏。记录一下排查过程和几个常见的坑。

2. 先用 pprof 确认 goroutine 数量异常

服务里加上:

1
2
3
4
5
import _ "net/http/pprof"

go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()

然后用:

1
go tool pprof http://localhost:6060/debug/pprof/goroutine

进去之后用 top 命令能看到哪个函数创建的 goroutine 数量最多。当时排出来是一个消费 channel 的 worker 函数,数量随时间线性增长,基本可以确定是它在泄漏。

3. 坑一:channel 没人消费,goroutine 永远阻塞在 send 上

1
2
3
4
5
6
7
8
9
10
11
12
13
func process(items []Item) {
resultCh := make(chan Result)

for _, item := range items {
go func(it Item) {
resultCh <- doWork(it) // 如果没人读,这里会一直阻塞
}(item)
}

// 只读了一个结果就返回了
result := <-resultCh
handle(result)
}

这段代码的问题是:起了 len(items) 个 goroutine 往同一个无缓冲 channel 发数据,但外层只读了一次就返回,剩下的 goroutine 全部永远阻塞在 resultCh <- doWork(it) 这一行,既不会被回收,也不会报错,就那么一直挂着占内存。

修法是要么把 channel 加缓冲区,要么把所有结果都读完:

1
2
3
4
5
6
resultCh := make(chan Result, len(items))
// ...
for range items {
result := <-resultCh
handle(result)
}

4. 坑二:忘记给长时间运行的 goroutine 传 context 做退出信号

1
2
3
4
5
6
7
8
func startWorker() {
go func() {
for {
task := <-taskCh
doTask(task)
}
}()
}

这种没有退出条件的 for {} 循环,只要程序不停止,goroutine 就永远存在。问题是如果这个 worker 是某个请求处理过程中动态创建的(比如每来一个 HTTP 请求就起一个),请求结束后这个 goroutine 却没人通知它退出,日积月累就会越攒越多。

加上 context 让它能被取消:

1
2
3
4
5
6
7
8
9
10
11
12
func startWorker(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return
case task := <-taskCh:
doTask(task)
}
}
}()
}

请求级别起的 goroutine,一定要绑定这个请求的 context,请求结束 context 被取消,goroutine 才能跟着退出。

5. 坑三:time.After 在循环里用,定时器不会被及时回收

1
2
3
4
5
6
7
8
for {
select {
case <-taskCh:
doTask()
case <-time.After(5 * time.Second): // 每次循环都创建一个新的 timer
log.Println("timeout")
}
}

time.After 每次调用都会创建一个新的 time.Timer,如果 taskCh 一直有数据、循环转得很快,那么每一轮循环里创建的 timer 在到期之前是不会被 GC 回收的,短时间内会有大量 timer 堆积。虽然这个不是 goroutine 泄漏,但排查现场经常和 goroutine 泄漏混在一起看,一并记一下。换成复用的 time.NewTimerReset 能避免这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()

for {
select {
case <-taskCh:
doTask()
if !timer.Stop() {
<-timer.C
}
timer.Reset(5 * time.Second)
case <-timer.C:
log.Println("timeout")
}
}

6. 小结

goroutine 泄漏的共同特征就是:起了 goroutine,但没有一条明确的、一定会被触发的退出路径。写 go func(){}() 之前多问一句——这个 goroutine 什么时候、被什么条件终止?如果答不上来,大概率就是在埋雷。pprof 的 goroutine profile 是排查这类问题最直接的工具,比靠猜靠谱得多。