Update for Go 1.23 and later: Go’s timer implementation now allows unreferenced, unstopped timers to be garbage-collected. The behaviour demonstrated below applies to older Go semantics, or when those semantics are restored with
GODEBUG=asynctimerchan=1. See the Go 1.23 timer channel changes.
Problem
Suppose you want to write a function that either handles an event from a channel or logs a warning if no event is received within a specific time frame.
Initial Solution with time.After
We can use time.After to create a timer inside a for loop. Because of the surrounding for loop, this timer gets reset on each iteration on receiving successful event or if it fires.
func consumerWithTimeAfter(ch <-chan string) { for { select { case event := <-ch: handle(event) case <-time.After(1000 * time.Second): // Because of the surrounding for loop, this timer gets reset // on each iteration on receiving successful event log.Println("warning: no messages received") } }}But this solution has a memory-leak issue. The timer created by time.After is not garbage collected until it fires. This means that the timer is never garbage collected because it is reset on each iteration. This is a known issue with time.After and is documented in the time package.
Following snippet helps in understanding the issue:
func bToMb(b uint64) uint64 { return b / 1024 / 1024}
func getMemory() { var memStats runtime.MemStats runtime.ReadMemStats(&memStats)
fmt.Println("Memory Usage:") fmt.Printf("Alloc = %v MiB", bToMb(memStats.Alloc)) fmt.Printf("\tTotalAlloc = %v MiB", bToMb(memStats.TotalAlloc)) fmt.Printf("\tSys = %v MiB", bToMb(memStats.Sys)) fmt.Printf("\tNumGC = %v\n", memStats.NumGC)}func main() {
ch := make(chan string)
go consumerWithTimeAfter(ch)
// Collect memory statistics ticker := time.NewTicker(5 * time.Second) go func() { for { select { case <-ticker.C: getMemory() } } }()
// Simulate events events := []string{"one", "two", "three"} ticker2 := time.NewTicker(30 * time.Second) for { select { case <-ticker2.C: break default: ch <- events[0] ch <- events[1] ch <- events[2] time.Sleep(1 * time.Millisecond) } }
}Running the above snippet shows that the memory usage keeps increasing.
Memory Usage:Alloc = 2 MiB TotalAlloc = 2 MiB Sys = 16 MiB NumGC = 1Memory Usage:Alloc = 5 MiB TotalAlloc = 5 MiB Sys = 25 MiB NumGC = 2Memory Usage:Alloc = 8 MiB TotalAlloc = 8 MiB Sys = 25 MiB NumGC = 2Memory Usage:Alloc = 10 MiB TotalAlloc = 11 MiB Sys = 30 MiB NumGC = 3Memory Usage:Alloc = 13 MiB TotalAlloc = 14 MiB Sys = 34 MiB NumGC = 3Memory Usage:Alloc = 15 MiB TotalAlloc = 17 MiB Sys = 34 MiB NumGC = 3Memory Usage:Alloc = 17 MiB TotalAlloc = 20 MiB Sys = 38 MiB NumGC = 4Alternative Solution Using time.NewTimer
Instead of using time.After, we can use time.NewTimer and reset it on each iteration. This way we do not create a new timer channel at each iteration.
func consumerWithResetTimer(ch <-chan string) { timer := time.NewTimer(1000 * time.Second) for { timer.Reset(1000 * time.Second) select { case event := <-ch: handle(event) case <-timer.C: log.Println("warning: no messages received") } }Replacing consumerAfter with consumerWithResetTimer in the above snippet shows that the memory usage is constant.
Memory Usage:Alloc = 0 MiB TotalAlloc = 0 MiB Sys = 11 MiB NumGC = 0Memory Usage:Alloc = 0 MiB TotalAlloc = 0 MiB Sys = 11 MiB NumGC = 0Memory Usage:Alloc = 0 MiB TotalAlloc = 0 MiB Sys = 11 MiB NumGC = 0Memory Usage:Alloc = 0 MiB TotalAlloc = 0 MiB Sys = 11 MiB NumGC = 0Memory Usage:Alloc = 0 MiB TotalAlloc = 0 MiB Sys = 11 MiB NumGC = 0While time.After is convenient, it’s important to understand its behavior to avoid memory leaks, especially in long-running loops. Using time.NewTimer and manually resetting the timer is more efficient in such cases.