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
Stopno longer leaks them. It is still good practice to call it. - The timer channel is now unbuffered (capacity 0). After
ResetorStopreturns, no stale value can be received, so the oldif !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
- Q175Implement a bounded, cancellable fan-out that returns the first error and does not leak goroutines.
- Q176Is creating a goroutine per request (e.g., in net/http) a good design? What are the risks?
- Q178What are the scheduling implications of runtime.Goexit, blocking in init, and unbuffered sends from many goroutines?
- Q179When are the arguments of a go statement evaluated? What does this print?
- Q180What does the Go memory model guarantee about starting and ending a goroutine?
- Q181A CPU profile shows lots of time in runtime.findRunnable, stealWork, futex and usleep. What is going on and how do you fix it?