bigywater.tech
Go · 62

定时器的实现:time.Timer 与 Ticker 在运行时里是什么

一个 time.Timer 在运行时里是什么?占多少内存、会不会让创建变慢、到点之后多久能醒?这篇用分配计数、通道状态、唤醒误差和大量定时器下的创建时间回答,并读 runtime/time.go 里决定定时器放不放进堆的那一行。

3 次 / 1 次
NewTimer 与 AfterFunc 创建并停止一次的分配次数
248 / 139 字节
保留 20 万个时,每个 NewTimer 和 AfterFunc 占的堆
0
通道型定时器 t.C 的 cap 与 len,到期没人读也是 0

成本

实测 · 本机执行输出
  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 文档)。

分步演示

定时器的实现
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。分配次数用 testing.AllocsPerRun(1000 次);堆占用 = 创建 20 万个并保留后 runtime.GC() 再读 HeapAlloc 的差;唤醒误差每种 200 次;大量定时器的创建耗时每格一次 20 万次平均,前后两次完整运行的数字不同(文中用第二次,第一次的范围已在步骤里注明);读过的源码:runtime/time.go 的 timers 结构、needsAdd,以及 go doc time.Timer.Stop / time.NewTimer 的文字
    • 没有验证:Linux 上的唤醒误差、10ms 睡眠多出 2 ms 的原因、定时器堆的具体算法(四叉堆等细节没有读)、定时器在多个 P 之间迁移的过程、Ticker 的内部差别(下一篇专门讲泄漏)、大量定时器下 O(log n) 是否真的存在(本测法下看不出)
    京ICP备2026067502号-1京公网安备11010502063487号