本文源码基线为 Go 1.26.2、Linux/amd64。Go 1.23 重写了 Timer Channel 的可回收性与 Stop/Reset 保证;阅读旧版本分析时必须先确认版本边界。
Timer 不是一个独立后台 goroutine 加一张全局表。Go 1.26 把真实时间 Timer 组织在每个 P 的四叉最小堆中,由调度器和 netpoll 共同决定睡到何时、何时执行到期回调。
它同时服务 time.Sleep、time.Timer、time.Ticker、AfterFunc 和网络 Deadline。难点不只是“按时间排序”,而是并发 Stop/Reset、跨 P 修改、周期跳期、Channel 旧值以及 GC 可达性。
time包如何进入runtime
time 包通过 runtime 提供的私有入口创建和修改 Timer:
TEXT
time.Sleep -> runtime.timeSleep
time.NewTimer -> runtime.newTimer(sendTime, channel)
Timer.Stop -> runtime.stopTimer
Timer.Reset -> runtime.resetTimer
time.AfterFunc -> runtime.newTimer(goFunc, nil)
net deadline -> runtime timer callbacktime.NewTimer 的源码仍写 make(chan Time, 1)。默认模式下 runtime 借助 hchan.timer 对外呈现同步、容量为零的语义;设置 GODEBUG=asynctimerchan=1 才恢复 Go 1.23 以前的异步 Timer Channel 行为。因此不能只看 make 参数推断 cap(t.C) 和 Stop/Reset 语义。
timer对象
timer 的核心字段可以分成四组:
TEXT
并发状态
mu, astate, state
时间与回调
when, period, f, arg, seq
堆归属
ts *timers
Channel协议
isChan, blocked, sendLock, isSendingwhen 是下一次触发的单调时间,零表示 inactive;period > 0 表示周期 Timer。回调统一为 f(arg, seq, delay),其中 delay 是实际执行相对计划时刻的延迟。
state 受 Timer 锁保护,astate 是供无锁观察的副本。主要标志包括 timerHeaped、timerModified、timerZombie。ts 指向拥有它的 Timer 集合;对象可能被其他 P 修改,所以“每 P 所有”不代表无需锁。
每P的timers
timers 位于 P 中,主要保存:
TEXT
mu heap锁
heap 四叉最小堆
len 原子可见长度
zombies 已停止但仍留在堆中的数量
minWhenHeap 堆中已同步条目的最早时间
minWhenModified 尚未调整条目的最早时间每 P 一组堆分散了插入和触发竞争。Timer 可能由别的 P Stop/Reset,调度器也会查看非当前 P 的 timer,因此 timers.mu 与每个 timer.mu 都不可省略。
堆是四叉而不是二叉:父节点索引为 (i-1)/4,四个孩子从 4*i+1 开始。siftUp 和 siftDown 维持最小 when 位于根部。
创建与入堆
newTimer 初始化 timeTimer、回调和关联 Channel,再调用 modify。需要入堆时,maybeAdd 通过 acquirem 固定当前 M/P,避免在读取当前 P 与实际加锁插入之间被抢占到另一个 P。
TEXT
timer.modify
-> needsAdd?
-> acquirem,确定当前P
-> lock P.timers
-> cleanHead
-> lock timer并复查needsAdd
-> addHeap
-> 必要时wakeNetPolleraddHeap 会确保 netpoll 已初始化。原因不是 Timer 依赖网络事件,而是 runtime 用同一个 poller 进行带 deadline 的线程睡眠与提前唤醒;新 Timer 若早于当前休眠期限,必须唤醒 poller 重算等待时间。
调度器如何发现到期Timer
调度器在 findRunnable 等路径检查本地 P 的 Timer,并把最早唤醒时间合并进 pollUntil。没有 runnable G 时,M 可以在 netpoll 中睡到:
TEXT
min(网络事件到达, 最早Timer到期, 被其他M显式唤醒)timers.check 先通过原子维护的 wakeTime 做无锁快速判断。若到期或 zombie 比例过高,才获取堆锁、调整 modified 项并循环运行根节点。
timers.run 只检查堆顶:先同步 modified/zombie 状态;若未到期返回下一时间;若已到期进入 unlockAndRun。最小堆保证无需扫描整个集合。
sysmon 和死锁检查使用 timeSleepUntil 汇总所有 P 的最早时间。Timer 的存在意味着“当前没有 runnable G”不一定是死锁。
到期回调如何执行
unlockAndRun 在 system stack 上准备下一状态,临时释放 Timer 集合锁后调用回调,再恢复锁约束。不能持有全堆锁执行任意回调,否则一个慢回调会阻塞同一 P 上所有 Timer 操作。
一次性 Timer 到期后 when=0,标为 zombie 并从堆中延迟清除。周期 Timer 计算下一次时刻:
TEXT
delay = now - when
next = when + period * (1 + delay/period)这个公式基于原计划时刻推进,并直接跳过已经错过的周期,不会为了“补发”而连续执行所有历史 tick。time.Ticker 的接收方仍可能观察到丢 tick 或合并后的节奏。
Stop为何采用延迟删除
timer.stop 可能操作属于另一个 P 的 Timer。直接从对方堆中删除需要同时处理跨 P 锁和元素位置,因此实现把已在堆中的 Timer 标记为 timerModified|timerZombie,增加 zombies,并令 when=0。
拥有该堆的 P 之后在 adjust、cleanHead 或处理根节点时删除 zombie。当本地堆 zombie 数超过长度四分之一,check 会强制清理,防止大量 Stop 让堆持续膨胀。
延迟删除降低跨 P 修改成本,但需要 astate、minWhenModified 和 zombie 计数让调度器不会睡过新的最早期限。
Reset如何跨P修改
timer.modify 更新 when/period/f/arg/seq。若 Timer 已在堆中,它只标记 modified,并用原子 minWhenModified 发布一个可能更早的 deadline;堆顺序由拥有 P 稍后调整。
若新 deadline 早于 poller 当前等待时间,wakeNetPoller(when) 会唤醒休眠 M。若 Timer 此前不在堆且当前需要被调度,maybeAdd 将它放入当前 P 的堆。
Reset 的返回值表示调用前是否 active,而不是回调是否已经执行完。对 AfterFunc,旧回调可能已在另一个 goroutine 中运行,Reset 返回 false 后安排的新回调可能与它并发;需要完成性时必须由应用额外同步。
Go 1.23后的同步Channel语义
从 Go 1.23 起,Timer.Stop 返回后,后续接收不会得到停止前遗留值;Timer.Reset 返回后,后续接收不会得到旧配置的值。runtime 用以下机制实现该保证:
hchan.timer把 Channel 操作连接到 Timer。sendLock串行 Channel 发送与 Stop/Reset。seq标识配置代次,过时回调在真正发送前重新校验。isSending标识一次性 Timer 正在准备发送,帮助计算 Stop/Reset 返回值。timerchandrain在锁序允许时清除旧的缓冲值。
在 unlockAndRun 中,回调准备发送前记录 seq 并增加 isSending;真正调用 sendTime 前获取 sendLock 再比较代次。并发 Stop/Reset 会先改变 seq,使旧发送变成空操作。
这就是“底层容量 1、公开同步语义”并存的原因。chanlen/chancap 对默认 Timer Channel 也做特殊处理,对用户呈现容量 0。
GODEBUG=asynctimerchan=1 恢复旧式缓冲 Channel、不可回收的未到期 Timer 和旧 Stop/Reset 规则。它是迁移开关,不应作为新代码的正常依赖。
Go 1.26 还保留 asynctimerchan=2 作为 runtime 调试模式:它使用 runtime 的新实现并保留 Timer 可回收性,但对外呈现异步 Channel 语义。排查问题时必须记录具体取值,不能把所有非零模式都当成完全相同的旧实现。
Timer Channel按需入堆
默认同步模式下,没有 goroutine 阻塞接收的 Channel Timer 不一定需要留在堆中。blockTimerChan 增加 blocked 并在需要时入堆;unblockTimerChan 减少计数,最后一个 waiter 离开时可把 Timer 标为 zombie。
maybeRunChan 允许 Channel receive 或 select 在发现 Timer 已到期时就地推动回调。这既支持同步语义,也让不再可达、无人等待的 Channel Timer 能被 GC 回收。
Ticker如何处理慢接收者
NewTicker 与 Timer 共享 timeTimer 布局,把 period 设置为间隔并复用 sendTime 的非阻塞发送。若接收者落后,Channel 中已有未消费值时新的 tick 会被丢弃;runtime 的周期公式同时跳过已经错过的时间点,所以 Ticker 不会累积一个无限补发队列。
Ticker.Stop 只令 Timer inactive,不关闭 C,避免并发接收者把关闭产生的零值误认为一次 tick。Ticker.Reset 要求正 duration,并从 Reset 时刻重新计算下一次到期。
Sleep为何复用G上的Timer
timeSleep 为当前 G 延迟初始化并复用一个 timer,回调为 goroutineReady。真实时间路径不是“先 reset 再 park”,而是把 resetForSleep 作为 park commit 回调:只有 G 已经提交等待后才激活 Timer,避免极短 Sleep 在 G 尚未 park 时就先触发唤醒。internal/synctest 的 fake-time Timer 是例外,因为虚拟时间在 G park 前不会前进,可以先 reset 再 park。
TEXT
time.Sleep
-> 取得/初始化 gp.timer
-> 记录 gp.sleepWhen
-> gopark(resetForSleep)
-> G提交为waiting
-> reset timer到sleepWhen
-> timer到期调用goroutineReady(gp)
-> G回到runnable队列因此 Sleep 不占用一个 OS 线程,也不需要为每次调用创建业务 goroutine。复用 G 上的 Timer 还减少重复分配。
AfterFunc为何不同
AfterFunc 不创建 Channel,runtime 到期回调最终执行 goFunc,由它启动新的 goroutine 调用用户函数。Timer 调度路径不会直接在 system stack 上运行任意用户函数。
Stop 只阻止尚未开始的回调,不等待已经启动的函数;Reset 安排的后续执行也可能与先前已经启动的函数并发,需要调用方自己协调。这与 Channel Timer 的“无旧值”保证是两种不同问题。
网络Deadline复用同一机制
internal/poll 为读写 deadline 安装 runtime Timer,回调最终调用 netpollDeadline,再由 netpollunblock 将等待 G 置为 ready。deadline 序号用于识别已被更新或取消的旧回调,思想上与 Timer Channel 的 seq 相同:异步执行前必须验证自己仍代表当前配置。
所以一次带 deadline 的网络等待会同时经过:pollDesc 状态机、runtime Timer、平台 netpoll 和 GMP 调度器。第六篇解释 fd 就绪路径,本文补上“时间到期”这一半。
常见误解
- Timer 精度不等于实时保证。到期只表示可以执行,实际回调还受调度、STW 和系统负载影响。
- 每个 Timer 不对应一个 goroutine;大量未触发 Timer 主要是对象与堆维护成本。
Ticker不补发所有错过 tick;周期计算会跳过历史区间。- Go 1.23 以后通常无需为 GC 回收而专门 Stop,但业务取消仍应 Stop。
AfterFunc.Stop返回 false 不表示回调已经结束。- 旧文章中的 Stop 后 drain 模式只适用于旧语义或兼容开关,不能不分版本照搬。
源码阅读顺序
time.NewTimer/Stop/Reset/AfterFunctimer与timersnewTimer与addHeapstop与modifycheck、run与unlockAndRunmaybeRunChan/blockTimerChanfindRunnable
观测与验证
BASH
GODEBUG=schedtrace=1000,scheddetail=1 go test ./...
GODEBUG=asynctimerchan=1 go test ./...
go test -trace=trace.out ./...
go tool trace trace.out
go test -run '^$' -bench Timer -benchmem ./...对比默认模式与 asynctimerchan=1 时,应记录 cap(t.C)、Stop/Reset 后是否可能收到旧值、未引用 Timer 的内存回收和大量并发 Reset 的 contention。不要用 time.Sleep 测量亚毫秒级硬实时精度;它验证的是“不早于 deadline”与调度延迟分布。
延伸阅读
- time包文档
- Go语言设计与实现:Timer:适合了解旧实现与演进,Go 1.23 之后的 Channel 语义以当前源码为准。
- Go 1.23 Timer Channel Changes
- runtime/time.go
系列导航
- 上一篇:Runtime信号量与同步原语
- 当前:Go Runtime(十):Timer实现
- 回到第一篇:运行时架构与程序启动
- 原始长文