Interesting bits

Interesting bits

Thoughts on maths, cs and tech.

02 Jul 2023

Memory Leak Issue with Go's time.After Inside Loops

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Memory Usage:
Alloc = 2 MiB   TotalAlloc = 2 MiB      Sys = 16 MiB    NumGC = 1
Memory Usage:
Alloc = 5 MiB   TotalAlloc = 5 MiB      Sys = 25 MiB    NumGC = 2
Memory Usage:
Alloc = 8 MiB   TotalAlloc = 8 MiB      Sys = 25 MiB    NumGC = 2
Memory Usage:
Alloc = 10 MiB  TotalAlloc = 11 MiB     Sys = 30 MiB    NumGC = 3
Memory Usage:
Alloc = 13 MiB  TotalAlloc = 14 MiB     Sys = 34 MiB    NumGC = 3
Memory Usage:
Alloc = 15 MiB  TotalAlloc = 17 MiB     Sys = 34 MiB    NumGC = 3
Memory Usage:
Alloc = 17 MiB  TotalAlloc = 20 MiB     Sys = 38 MiB    NumGC = 4

Alternative 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Memory Usage:
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 11 MiB    NumGC = 0
Memory Usage:
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 11 MiB    NumGC = 0
Memory Usage:
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 11 MiB    NumGC = 0
Memory Usage:
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 11 MiB    NumGC = 0
Memory Usage:
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 11 MiB    NumGC = 0

While 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.