Is time.After in a loop a memory leak? What changed in Go 1.23?
Question 362MediumGo 1.22 to 1.25
func consume(ch <-chan Event) {
for {
select {
case e := <-ch:
handle(e)
case <-time.After(time.Minute):
return // idle timeout
}
}
}
Before Go 1.23: every iteration created a timer that stayed in the runtime's timer heap until it fired. At 100k events per second that meant millions of live timers for a whole minute, which is a large temporary leak. The same was true of time.NewTicker tickers that were never stopped, and those leaked forever.
Go 1.23 and later, when the main module declares go 1.23 or newer:
- Timers and tickers that nothing references can be garbage-collected even if they were never stopped or have not fired.
- Timer channels are now synchronous (unbuffered), so after
ResetorStopno stale value is ever received. The oldif !t.Stop() { <-t.C }drain idiom is no longer needed.
Good practice is still to reuse a single timer in hot loops, because each time.After call still allocates:
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case e := <-ch:
handle(e)
t.Reset(time.Minute)
case <-t.C:
return
}
}
What the interviewer is looking for: you know the behaviour depends on the go version in go.mod, which can be overridden with GODEBUG=asynctimerchan=1.
More on Memory, GC & Runtime Internals
- Q360Do Go maps shrink after you delete entries? How do you reclaim the memory?
- Q361How do goroutine leaks happen, and how do you detect them?
- Q363How do you find a memory leak in production with pprof?
- Q364How does GOMAXPROCS interact with containers, and how does it affect GC?
- Q365What are the runtime representations of Go's built-in types? What does this print on 64-bit?
- Q366What is bounds-check elimination and how do you help the compiler do it?