Resetting timers in Go

If you use Timer.Reset() in Go 1.22 or earlier, you may be doing it wrong. Even the book 100 Go Mistakes (which is usually right about Go nuances) got it wrong.

Let's see what the problem might be and how to work around it.

time.After

Using time.After() in a loop in Go ≤1.22 can lead to significant memory usage. Consider this example:

// go 1.22
type token struct{}

func consumer(ctx context.Context, in <-chan token) {
	const timeout = time.Hour
	for {
		select {
		case <-in:
			// do stuff
		case <-time.After(timeout):
			// log warning
		case <-ctx.Done():
			return
		}
	}
}

The consumer reads tokens from the input channel and alerts if a value does not appear in a channel after an hour.

Let's write a client that measures the memory usage after 100K channel sends:

// go 1.22
func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	tokens := make(chan token)
	go consumer(ctx, tokens)

	memBefore := getAlloc()

	for range 100000 {
		tokens <- token{}
	}

	memAfter := getAlloc()
	memUsed := memAfter - memBefore
	fmt.Printf("Memory used: %d KB\n", memUsed/1024)
}
Memory used: 20379 KB
What is getAlloc
// getAlloc returns the number of bytes of allocated
// heap objects (after garbage collection).
func getAlloc() uint64 {
    var m runtime.MemStats
    runtime.GC()
    runtime.ReadMemStats(&m)
    return m.Alloc
}

Behind the scenes, time.After creates a timer that is not freed until it expires. And since we are using a large timeout (one hour), the for loop essentially creates a miriad of timers that are not yet freed. These timers together use ≈20 MB of memory. This is obviously not what we want.

Incorrect solution

How about we create a single timer and reset it in each loop iteration? Seems reasonable. This is the solution suggested by 100 Go Mistakes:

// go 1.22
func consumer(ctx context.Context, in <-chan token) {
	const timeout = time.Hour
	timer := time.NewTimer(timeout)
	for {
		timer.Reset(timeout)
		select {
		case <-in:
			// do stuff
		case <-timer.C:
			// log warning
		case <-ctx.Done():
			return
		}
	}
}
Memory used: 0 KB

Since we are reusing the same timer instance and not creating new ones, the memory usage problem is solved. But the thing is, this is not the way to use the Reset method in Go ≤1.22.

Reset problem

Consider this example:

// go 1.22
func main() {
	const timeout = 10 * time.Millisecond
	t := time.NewTimer(timeout)
	time.Sleep(20 * time.Millisecond)

	start := time.Now()
	t.Reset(timeout)
	<-t.C
	fmt.Printf("Time elapsed: %dms\n", time.Since(start).Milliseconds())
	// expected: Time elapsed: 10ms
	// actual:   Time elapsed:  0ms
}
Time elapsed: 0ms

The timer t has a timeout of 10ms. So after we waited for 20ms, it has already expired and sent a value to the t.C channel. And since Reset does not drain the channel, <-t.C does not block and proceeds immediately. Also, since Reset restarted the timer, we'll see another value in t.C after 10ms.

This is not a minor issue. Let's see what happens if the "do stuff" branch in our consumer takes longer than the timer timeout:

// go 1.22
func consumer(ctx context.Context, in <-chan token) {
	const timeout = 10 * time.Millisecond
	timer := time.NewTimer(timeout)
	for {
		timer.Reset(timeout)
		select {
		case <-in:
			// do stuff
			time.Sleep(20 * time.Millisecond)
		case <-timer.C:
			panic("should not happen")
		case <-ctx.Done():
			return
		}
	}
}
panic: should not happen

goroutine 18 [running]:
main.consumer({0x4d3af0, 0xc000080050}, 0xc0000820c0)
	/sandbox/src/main.go:29 +0xf4
created by main.main in goroutine 1
	/sandbox/src/main.go:41 +0xb4 (exit status 2)

Since the timer channel is not drained, the timer branch is executed regardless of the Reset call, causing a panic.

Let me quote the Go stdlib documentation here:

For a Timer created with NewTimer, Reset should be invoked only on stopped or expired timers with drained channels.

It may not be very intuitive, but it's the only way to use Reset properly in Go ≤1.22.

1.23 fix

The reset problem is fixed in Go 1.23. Quoting the docs again:

The timer channel associated with a Timer is now unbuffered, with capacity 0. The main effect of this change is that Go now guarantees that for any call to a Reset (or Stop) method, no stale values prepared before that call will be sent or received after the call.

But if you look at the code, you'll see that the channel is in fact still buffered:

// As of Go 1.23, the channel is synchronous (unbuffered, capacity 0),
// eliminating the possibility of those stale values.
func NewTimer(d Duration) *Timer {
	c := make(chan Time, 1)
	t := (*Timer)(newTimer(when(d), 0, sendTime, c, syncTimer(c)))
	t.C = c
	return t
}

time/sleep.go

According to the commit message, the Go team left the timer channel buffered (contrary to what the code comment says). But they also hacked the chan type itself to return zero length and capacity for timer channels:

// in runtime/chan.go
func chanlen(c *hchan) int {
	if c == nil {
		return 0
	}
	async := debug.asynctimerchan.Load() != 0
	if c.timer != nil && async {
		c.timer.maybeRunChan()
	}
	if c.timer != nil && !async {
		// timer channels have a buffered implementation
		// but present to users as unbuffered, so that we can
		// undo sends without users noticing.
		return 0
	}
	return int(c.qcount)
}

runtime/chan.gocommit message

Seems like a dirty hack to me, but what do I know.

Pre-1.23 solution

While a simple Reset should do for 1.23+, in earlier versions we must ensure that the timer is ➊ either stopped or expired and ➋ has a drained channel. Let's write a helper function and use it in the consumer:

// resetTimer stops, drains and resets the timer.
func resetTimer(t *time.Timer, d time.Duration) {
	if !t.Stop() {
		select {
		case <-t.C:
		default:
		}
	}
	t.Reset(d)
}
// go 1.22
func consumer(ctx context.Context, in <-chan token) {
	const timeout = time.Hour
	timer := time.NewTimer(timeout)
	for {
		resetTimer(timer, timeout)
		select {
		case <-in:
			// do stuff
		case <-timer.C:
			// log warning
		case <-ctx.Done():
			return
		}
	}
}
Memory used: 0 KB

Now the consumer is guaranteed to work properly regardless of the timeout value and "do stuff" execution time.

// go 1.22
func main() {
	const timeout = 10 * time.Millisecond
	t := time.NewTimer(timeout)
	time.Sleep(20 * time.Millisecond)

	start := time.Now()
	resetTimer(t, timeout)
	<-t.C
	fmt.Printf("Time elapsed: %dms\n", time.Since(start).Milliseconds())
}
Time elapsed: 10ms

Post-1.23 solution

As of Go 1.23, an active (but unreferenced) timer can be freed by the garbage collector. So a time.After in a loop will not pile up memory:

// go 1.23
func consumer(ctx context.Context, in <-chan token) {
	const timeout = time.Hour
	for {
		select {
		case <-in:
			// do stuff
		case <-time.After(timeout):
			// log warning
		case <-ctx.Done():
			return
		}
	}
}
Memory used: 0 KB

It will still do a lot of allocations, of course. So you might prefer the NewTimer + Reset approach — it does not create new timers and therefore GC does not need to collect them.

// go 1.23
func consumer(ctx context.Context, in <-chan token) {
	const timeout = time.Hour
	timer := time.NewTimer(timeout)
	for {
		timer.Reset(timeout)
		select {
		case <-in:
			// do stuff
		case <-timer.C:
			// log warning
		case <-ctx.Done():
			return
		}
	}
}
Memory used: 0 KB

Here are both options (time.After vs timer.Reset) side by side:

BenchmarkAfter-8    24    49271620 ns/op    23201095 B/op    300012 allocs/op
BenchmarkReset-8    40    29428138 ns/op         652 B/op         8 allocs/op

The winner is clear.

time.AfterFunc

To make matters worse, time.AfterFunc also creates a timer, but a very different one. It has a nil C channel, so the Reset method works differently:

  • If the timer is still active (not stopped, not expired), Reset clears the timeout, effectively restarting the timer.
  • If the timer is already stopped or expired, Reset schedules a new function execution.
func main() {
	var start time.Time

	work := func() {
		fmt.Printf("work done after %dms\n", time.Since(start).Milliseconds())
	}

	// run work after 10 milliseconds
	timeout := 10 * time.Millisecond
	start = time.Now()  // ignore the data race for simplicity
	t := time.AfterFunc(timeout, work)

	// wait for 5 to 15 milliseconds
	delay := time.Duration(5+rand.Intn(11)) * time.Millisecond
	time.Sleep(delay)
	fmt.Printf("%dms has passed...\n", delay.Milliseconds())

	// Reset behavior depends on whether the timer has expired
	t.Reset(timeout)
	start = time.Now()

	time.Sleep(50*time.Millisecond)
}
8ms has passed...
work done after 10ms

If the timer has not expired, Reset clears the timeout:

8ms has passed...
work done after 10ms

If the timer has expired, Reset schedules a new function call:

work done after 10ms
13ms has passed...
work done after 10ms

Final thoughts

To reiterate:

  • Go ≤ 1.22: For a Timer created with NewTimer, Reset should only be called on stopped or expired timers with drained channels.
  • Go ≥ 1.23: For a Timer created with NewTimer, it's safe to call Reset on timers in any state (active, stopped or expired). No channel drain is required, since the timer channel is (sort of) no longer buffered.
  • For a Timer created with AfterFunc, Reset either reschedules the function (if the timer is still active) or schedules the function to run again (if the timer has stopped or expired).

Documentation [pre-1.23]Documentation [1.23+]100 Go Mistakes

Timers are not the most obvious thing in Go, are they?

──

P.S. Interactive examples in this post are powered by codapi — an open source tool I'm building. Use it to embed live code snippets into your product docs, online course or blog.

★ Subscribe to keep up with new posts.