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
- Incorrect solution
- Reset problem
- 1.23 fix
- Pre-1.23 solution
- Post-1.23 solution
- time.AfterFunc
- Final thoughts
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
}
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.go • commit 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 withNewTimer
,Reset
should only be called on stopped or expired timers with drained channels. - Go ≥ 1.23: For a
Timer
created withNewTimer
, it's safe to callReset
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 withAfterFunc
,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.