time.Ticker, time.After and timers: what leaks, and what changed in Go 1.23?
Question 464HardGo 1.22 to 1.25
A time.Ticker fires until you call Stop(); always defer t.Stop(). Before Go 1.23, an unstopped Ticker/Timer was never garbage-collected, and time.After inside a hot select loop allocated a timer per iteration that lived until it fired — a memory "leak" for long durations.
func poll(ctx context.Context, every time.Duration, fn func()) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
fn()
}
}
}
// Idle timeout that resets on each message: reuse one timer.
func consume(msgs <-chan string) {
idle := time.NewTimer(30 * time.Second)
defer idle.Stop()
for {
select {
case m, ok := <-msgs:
if !ok {
return
}
fmt.Println(m)
idle.Reset(30 * time.Second) // Go 1.23+: safe, no stale value
case <-idle.C:
fmt.Println("idle, exiting")
return
}
}
}
Go 1.23 changes (module go 1.23+): unreferenced timers/tickers are GC-eligible even if not stopped, and timer channels are synchronous (unbuffered), so after Stop/Reset no stale tick is received — the old "drain the channel before Reset" dance is unnecessary. (Unchanged: tickers have always dropped ticks for slow receivers rather than queueing them.) GODEBUG=asynctimerchan=1 restores the old buffered-channel behavior. Still call Stop for clarity and to stop work promptly.
More on Standard Library, HTTP & Systems Design in Go
- Q462How does log/slog work? Discuss handlers, attributes, LogValuer, and performance.
- Q463What does this print? Explain the monotonic clock and time.Time comparison.
- Q465What does this print? (time.Duration arithmetic gotchas)
- Q466Explain the laws of reflection and settability. What does this print?
- Q467Use reflection to build a simple struct-tag validator (`validate:"required,max=10"`).
- Q468Design a per-client rate limiter for an HTTP API. Implement a token bucket.