Is time.After in a select loop a leak? What changed in Go 1.23 timers?
Question 250MediumGo 1.22 to 1.25
for {
select {
case msg := <-msgs:
handle(msg)
case <-time.After(time.Minute): // new timer every iteration
log.Println("idle")
}
}
Before Go 1.23: every iteration created a timer that could not be garbage-collected until it fired. With a busy msgs channel this piled up thousands of one-minute timers, a well-known memory leak. Stop/Reset also had subtle rules about draining the channel.
Go 1.23 (when go.mod is 1.23 or higher) changed two things:
- Unreferenced timers and tickers can be collected even when they have not fired or been stopped, so this pattern no longer leaks.
- Timer channels became synchronous (unbuffered). After
ResetorStop, no stale value can be received, and the old "drain before Reset" idiom is no longer needed.
Allocating a timer per iteration is still wasteful in hot loops. Reuse one timer:
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case msg := <-msgs:
handle(msg)
t.Reset(time.Minute) // 1.23+: safe without draining
case <-t.C:
log.Println("idle")
t.Reset(time.Minute)
case <-ctx.Done():
return
}
}More on Concurrency Patterns & sync
- Q248Implement graceful shutdown for an HTTP server with background workers.
- Q249Which newer context features matter for concurrent code: WithCancelCause, AfterFunc, WithoutCancel?
- Q251What happens here, and when does the runtime NOT detect a deadlock?
- Q252Design a thread-safe loading cache. Why is naive double-checked locking with a plain flag wrong in Go?
- Q253Channels or mutexes: how do you decide? ("Share memory by communicating")
- Q254How do you test concurrent and time-dependent code deterministically? (testing/synctest)