Go

How are timers handled by the scheduler, and what changed with time.Timer in Go 1.23?

Question 177HardGo 1.22 to 1.25

Since Go 1.14, timers live in a 4-ary heap per P. Before that, a single global timer goroutine handled them. The scheduler runs expired timers inside schedule() and findRunnable (checkTimers). Idle Ps can steal timers, and the netpoller's blocking wait uses the next timer deadline as its timeout. A time.Sleep therefore parks the G without using a thread.

Go 1.23 (when go.mod says go 1.23 or later) changed two behaviours:

  • Timers and Tickers that nothing references can be garbage collected even if not stopped. Forgetting Stop no longer leaks them. It is still good practice to call it.
  • The timer channel is now unbuffered (capacity 0). After Reset or Stop returns, no stale value can be received, so the old if !t.Stop() { <-t.C } drain idiom is no longer needed and can even block.
t := time.NewTimer(time.Second)
defer t.Stop()
select {
case <-t.C:
	fmt.Println("timeout")
case v := <-work:
	fmt.Println(v)
}

Gotcha: calling time.After in a hot select loop allocates a new timer each iteration. Since 1.23 those timers are collected, but the allocation still costs. Reuse one timer with Reset.

More on Goroutines & the Scheduler

All 35 Goroutines & the Scheduler questions