Go 的 time.After 在 select 循环里内存泄漏:定时器堆积原理与 timer.Reset 正确姿势
Go 的 time.After 在 select 循环里内存泄漏:定时器堆积原理与 timer.Reset 正确姿势你写了一个经典的「干活 or 超时」循环,跑一段时间发现内存慢慢涨、GC 越来越频繁,pprof 一看全是time.Timer对象堆着不释放。代码看着人畜无害:for{select{casemsg:-ch:handle(msg)case-time.After(5*time.Second):// 看起来很自然,其实在漏log.Println(idle timeout)return}}这段代码在高频循环里就是内存泄漏源。这篇讲清楚它为什么漏、漏多严重,以及三种正确写法。time.After 到底做了什么time.After(d)不是一个轻量的语法糖,它每次调用都会新建一个 Timer,并向 runtime 的定时器堆里注册,返回这个 Timer 的 channel:funcAfter(d Duration)-chanTime{returnNewTimer(d).C// 每调一次,new 一个 Timer}关键在于:这个 Timer 直到 d 时间到、触发一次之后,才会被 runtime 释放。在它触发之前,它一直挂在定时器堆里,占着内存,GC 也回收不掉(runtime 还持有引用)。为什么 select 循环里会堆积回到开头的循环。假设ch每 100ms 来一条消息,循环非常活跃:for{select{casemsg:-ch:// 每 100ms 命中这里handle(msg)case-time.After(5*time.Second):return}}每次for转一圈,select都会重新求值所有 case,于是time.After(5*time.Second)每一圈都被调用一次,每一圈都 new 一个 5 秒的 Timer。而ch100ms 就来一条,select立刻走msg这个分支——那个刚建的 5 秒 Timer 根本没等到触发就被丢下,但它不会被回收,要在堆里躺满 5 秒才消失。算笔账:100ms 一圈,5 秒的 Timer 存活期,意味着任一时刻堆里同时挂着约50 个没用的 Timer。如果消息更密集、超时设得更长,堆积规模成倍放大。这就是 pprof 里那一片time.Timer的来源。核心结论:time.After适合只用一次的场景(比如函数里等一个一次性超时)。放进会反复执行的循环 case 里,就会不断制造无法及时回收的 Timer。正确写法一:把 Timer 提到循环外,手动 Reset复用同一个 Timer,每圈用Reset重置它,而不是每圈新建:timer:time.NewTimer(5*time.Second)defertimer.Stop()// 循环退出时释放,别忘for{// 每圈重置前,先安全地把 timer 停下并清空可能残留的信号if!timer.Stop(){select{case-timer.C:// 排掉已经触发但没被读走的那一次default:}}timer.Reset(5*time.Second)select{casemsg:-ch:handle(msg)case-timer.C:log.Println(idle timeout)return}}这里的Stop 排空 Reset三连是 Go 1.23 之前的标准姿势。为什么要先排空?因为Stop()返回false有两种可能:timer 已经触发过(信号还在timer.C里没被读),或者已经被 Stop 过。如果不排空就Reset,残留的旧信号会让下一轮select立刻误判为超时。注意:Stop和排空这段有个经典竞态——如果 timer 的 channel 已经被 select 的另一分支并发读走,default分支能避免阻塞。所以上面用了select{ case -timer.C: default: }而不是裸-timer.C,后者会在没有残留信号时永久阻塞。正确写法二:Go 1.23 直接 Reset,不用排空Go 1.23 改进了 Timer 的实现:Reset和Stop之后,channel 里不会再残留过期信号,那段繁琐的排空逻辑可以删掉了:// Go 1.23 及以后timer:time.NewTimer(5*time.Second)defertimer.Stop()for{timer.Reset(5*time.Second)// 1.23 直接 Reset,安全select{casemsg:-ch:handle(msg)case-timer.C:return}}如果你的项目已经上了 Go 1.23,优先用这个版本,心智负担小很多。但要确认团队构建用的 Go 版本——在老版本上这么写依然会踩残留信号的坑。正确写法三:超时语义是「整体截止」就用 context上面两种写法的超时是「每次循环都重置」的空闲超时。如果你要的其实是「整个循环最多跑 5 秒」这种总截止时间,别用 Timer,用context.WithTimeout更贴切也更省心:ctx,cancel:context.WithTimeout(context.Background(),5*time.Second)defercancel()// cancel 会释放底层 timer,一定要 deferfor{select{casemsg:-ch:handle(msg)case-ctx.Done():// 到点或被上游取消都会走这里log.Println(deadline:,ctx.Err())return}}context.WithTimeout内部只建一个 timer,cancel()负责释放它,不存在循环里反复 new 的问题。而且它天然支持从上游传递取消信号,比裸 Timer 更适合服务里的请求级超时。选哪个取决于语义:每次活动都续期用 Reset 方案,整体不超时用 context 方案。怎么确认自己漏没漏别靠肉眼看代码,用 pprof 抓一把 heap,搜time.相关对象:import_net/http/pprof// 程序里起一个: go http.ListenAndServe(localhost:6060, nil)# 跑一段时间后抓堆快照go tool pprof http://localhost:6060/debug/pprof/heap# 进去后输入 top,看有没有 time.NewTimer / runtime.addtimer 占大头(pprof)top如果time.NewTimer分配量随运行时间线性增长,基本就是某个循环里的time.After在漏。定位到具体行用list 函数名。小结time.After每次调用都 new 一个 Timer,触发前不会被回收;它只适合一次性超时。放进高频select循环的 case 里,会在每圈新建 Timer 并大量堆积,表现为内存缓涨、GC 变频。修法按语义选:空闲超时用「循环外NewTimerReset」;Go 1.23 可省掉排空;整体截止时间用context.WithTimeout。用Reset前在老版本 Go 上要先Stop 排空 channel,否则残留信号会误触发超时分支。记忆点:循环里别写case -time.After(d)——要么把 Timer 提到循环外 Reset,要么改用 context。