1. 起因
服务跑了几天之后内存就开始缓慢上涨,一开始怀疑是内存泄漏,加了 pprof 之后发现堆内存其实还好,倒是 goroutine 数量一路涨到几万个不回落,才反应过来是 goroutine 泄漏。记录一下排查过程和几个常见的坑。
2. 先用 pprof 确认 goroutine 数量异常
服务里加上:
1 | import _ "net/http/pprof" |
然后用:
1 | go tool pprof http://localhost:6060/debug/pprof/goroutine |
进去之后用 top 命令能看到哪个函数创建的 goroutine 数量最多。当时排出来是一个消费 channel 的 worker 函数,数量随时间线性增长,基本可以确定是它在泄漏。
3. 坑一:channel 没人消费,goroutine 永远阻塞在 send 上
1 | func process(items []Item) { |
这段代码的问题是:起了 len(items) 个 goroutine 往同一个无缓冲 channel 发数据,但外层只读了一次就返回,剩下的 goroutine 全部永远阻塞在 resultCh <- doWork(it) 这一行,既不会被回收,也不会报错,就那么一直挂着占内存。
修法是要么把 channel 加缓冲区,要么把所有结果都读完:
1 | resultCh := make(chan Result, len(items)) |
4. 坑二:忘记给长时间运行的 goroutine 传 context 做退出信号
1 | func startWorker() { |
这种没有退出条件的 for {} 循环,只要程序不停止,goroutine 就永远存在。问题是如果这个 worker 是某个请求处理过程中动态创建的(比如每来一个 HTTP 请求就起一个),请求结束后这个 goroutine 却没人通知它退出,日积月累就会越攒越多。
加上 context 让它能被取消:
1 | func startWorker(ctx context.Context) { |
请求级别起的 goroutine,一定要绑定这个请求的 context,请求结束 context 被取消,goroutine 才能跟着退出。
5. 坑三:time.After 在循环里用,定时器不会被及时回收
1 | for { |
time.After 每次调用都会创建一个新的 time.Timer,如果 taskCh 一直有数据、循环转得很快,那么每一轮循环里创建的 timer 在到期之前是不会被 GC 回收的,短时间内会有大量 timer 堆积。虽然这个不是 goroutine 泄漏,但排查现场经常和 goroutine 泄漏混在一起看,一并记一下。换成复用的 time.NewTimer 加 Reset 能避免这个问题:
1 | timer := time.NewTimer(5 * time.Second) |
6. 小结
goroutine 泄漏的共同特征就是:起了 goroutine,但没有一条明确的、一定会被触发的退出路径。写 go func(){}() 之前多问一句——这个 goroutine 什么时候、被什么条件终止?如果答不上来,大概率就是在埋雷。pprof 的 goroutine profile 是排查这类问题最直接的工具,比靠猜靠谱得多。