一个 time.Timer 在运行时里是什么?占多少内存、会不会让创建变慢、到点之后多久能醒?这篇用分配计数、通道状态、唤醒误差和大量定时器下的创建时间回答,并读 runtime/time.go 里决定定时器放不放进堆的那一行。
time.NewTimer(1h)+Stop:每次 3 次分配 time.AfterFunc(1h, f)+Stop:每次 1 次分配 time.NewTicker(1h)+Stop:每次 3 次分配 time.Sleep(1µs):每次 0 次分配 保留 200000 个 time.NewTimer(1h):堆增加 49.6 MB,平均每个 248 字节(含存放指针的切片 8 字节) 再保留 200000 个 time.AfterFunc(1h):堆又增加 27.7 MB,平均每个 139 字节
NewTimer 与 NewTicker 每次分配 3 次,AfterFunc 只要 1 次(它没有通道),time.Sleep 不分配。保留 20 万个时,平均 248 字节与 139 字节;这里的堆数字是 GC 后的 HeapAlloc 差值,包含切片里指针的 8 字节。
NewTimer(1h):cap(t.C)=0 len(t.C)=0 NewTimer(1ms) 到期 20ms 后、还没人读:len(t.C)=0 cap(t.C)=0 Reset(1h) 返回 true;Reset 之后立刻读 t.C:没有旧值(非阻塞读走了 default) 到期未读的定时器 Stop() 返回 true Stop 之后读 t.C:没有旧值
Go 1.23 起定时器通道容量为 0:到期了但没人读时 len 与 cap 都是 0。这时 Reset 与 Stop 仍返回 true(文档:没读过且定时器在运行,Stop 一定返回 true),之后读不到旧值。所以老写法里 Stop 返回 false 就多读一次 t.C 来排空的做法,在 1.23 之后不再需要。
time.Sleep( 10µs) 超时(实际耗时 − 请求):最小 2 µs / 中位 7 µs / 最大 76 µs(200 次) time.Sleep( 100µs) 超时(实际耗时 − 请求):最小 29 µs / 中位 29 µs / 最大 36 µs(200 次) time.Sleep( 1ms) 超时(实际耗时 − 请求):最小 203 µs / 中位 261 µs / 最大 336 µs(200 次) time.Sleep( 10ms) 超时(实际耗时 − 请求):最小 40 µs / 中位 2030 µs / 最大 2237 µs(200 次) NewTimer( 100µs) 并 <-t.C 超时:最小 24 µs / 中位 33 µs / 最大 46 µs(200 次) NewTimer( 1ms) 并 <-t.C 超时:最小 60 µs / 中位 263 µs / 最大 322 µs(200 次)
macOS 上 Sleep(100µs) 中位多睡 29 µs,Sleep(1ms) 多睡约 260 µs,Timer 与 Sleep 几乎一样。Sleep(10ms) 中位多睡约 2 ms,我没有查原因(可能与系统定时器粒度有关,是猜测,没有验证)。需要微秒级精度的程序不能依赖它。
AfterFunc 已有 1000 个:创建+Stop 一个新的 98 ns AfterFunc 已有 100000 个:创建+Stop 一个新的 109 ns AfterFunc 已有 1000000 个:创建+Stop 一个新的 195 ns NewTimer(无人等待) 已有 1000 个:创建+Stop 一个新的 174 ns NewTimer(无人等待) 已有 100000 个:创建+Stop 一个新的 215 ns NewTimer(无人等待) 已有 1000000 个:创建+Stop 一个新的 195 ns NewTimer(有 goroutine 阻塞在 t.C) 已有 1000 个:创建+Stop 一个新的 215 ns NewTimer(有 goroutine 阻塞在 t.C) 已有 100000 个:创建+Stop 一个新的 315 ns NewTimer(有 goroutine 阻塞在 t.C) 已有 1000000 个:创建+Stop 一个新的 112 ns
在已有 1000 到 100 万个待触发定时器时再创建并停止一个新的,耗时在 100–300 ns 之间,没有稳定的增长趋势;重复运行时同一格的数字会波动。这个测法只有一次测量每格,我不能据此断言“与数量无关”,只能说本机上没观察到随数量变慢。
time.go:135-144
// A timers is a per-P set of timers.
type timers struct {
// mu protects timers; timers are per-P, but the scheduler can
// access the timers of another P, so we have to lock.
mu mutex
// heap is the set of timers, ordered by heap[i].when.
// Must hold lock to access.
heap []timerWhen
time.go:653-658
// needsAdd reports whether t needs to be added to a timers heap.
// t must be locked.
func (t *timer) needsAdd() bool {
assertLockHeld(&t.mu)
need := t.state&timerHeaped == 0 && t.when > 0 && (!t.isChan || t.blocked > 0)
if need {
go doc time.Timer.Stop(节选):
For a chan-based timer created with NewTimer(d), as of Go 1.23, any receive
from t.C after Stop has returned is guaranteed to block rather than receive
a stale time value from before the Stop; if the program has not received
from t.C already and the timer is running, Stop is guaranteed to return
true. Before Go 1.23, the only safe way to use Stop was insert an extra
<-t.C if Stop returned false to drain a potential stale value. See the
go doc time.NewTimer(节选):
after at least duration d.
Before Go 1.23, the garbage collector did not recover timers that had not
yet expired or been stopped, so code often immediately deferred t.Stop
after calling NewTimer, to make the timer recoverable when it was no longer
needed. As of Go 1.23, the garbage collector can recover unreferenced
timers, even if they haven't expired or been stopped. The Stop method is no
longer necessary to help the garbage collector. (Code may of course still一是定时器堆是每个 P 一个(timers 的注释),二是 needsAdd 里 (!t.isChan || t.blocked > 0):通道型定时器没有 goroutine 阻塞在它上面时不需要放进堆,所以没人等的 NewTimer 不会拖慢运行时,也能被 GC 回收(NewTimer 文档)。