Go 定时器底层实现:per-P 四叉堆、sendTime 投递与 Stop/Reset 陷阱


引言:一个不止考语法的题

「time.Timer 和 time.Ticker 有什么区别?」「定时器到期后,回调是谁执行的?」「Stop() 之后为什么 channel 还能读到值(或读不到)?」——定时器题表面考 API,实际考你对 Go 运行时调度的理解。它和 select 原理、GMP 调度、goroutine 泄漏 是一条线上的考点。本文所有源码细节均对照 go1.26.0 官方源码核实。

一、先分清三个角色

API 语义 通道
NewTimer(d) 延迟 d 后发一次当前时间 无缓冲(Go 1.23 起)
NewTicker(d) 每过 d 循环发一次 1 元素缓冲
AfterFunc(d, f) 延迟 d 后在本 goroutine 上下文之外执行 f 无通道

time.After(d) 只是 NewTimer(d).C 的语法糖,坑在循环 select 里用(见第四节)。

二、time 包层:通道语义的微妙差异

两个结构体其实共享同一份运行时实现(newTimer 同时分配两者,靠 initTimer/initTicker 字段区分,转换靠 unsafe):

type Ticker struct {
	C          <-chan Time // 注意:单向接收通道
	initTicker bool
}

差异全在 NewTicker 的一句注释里——「给 channel 一个 1 元素缓冲,消费跟不上就丢 tick」;而 NewTimer 的通道自 Go 1.23 起是无缓冲的。运行时投递用的是同一个函数 sendTime:

func sendTime(c any, seq uintptr, delta int64) {
	select {
	case c.(chan Time) <- Now().Add(Duration(-delta)): // 非阻塞投递
	default: // 没人接收就丢弃
	}
}

default 分支是点睛之笔:超时值永远塞不进无人接收的通道。由此推出两个面试必考点:

  1. Timer 的通道读不到「陈年货」(Go 1.23 以前 buffered channel 里可能残留过期的超时值,1.23 起无缓冲,语义干净了);
  2. Ticker 消费慢只会丢 tick,不会阻塞运行时——所以心跳、限流打点这类场景用 Ticker 永远不会反压调度器。

另一个 1.23 的重要变化:未被引用的 Timer/Ticker 现在可以被 GC 回收,不需要再靠 defer t.Stop() 帮助 GC。注意这说的是 GC 层面,Stop() 在逻辑上该不该调是另一回事(Ticker 不 Stop 会永远滴答)。

三、runtime 层:per-P 定时器堆与四叉堆

老版本(Go 1.13 及更早)所有定时器挂在一个全局堆上,一把大锁,goroutine 一多就竞争惨烈。Go 1.14 起定时器下放到每个 P,go1.26.0 的 src/runtime/time.go 里每个 P 持有一个 timers:

type timers struct {
	mu     mutex          // 保护堆,但调度器会跨 P 访问,所以要锁
	heap   []timerWhen    // 按 when 排序的堆
	len    atomic.Uint32  // len(heap) 的原子副本,免锁快速判断
	zombies atomic.Int32  // 堆中待删除(Stop 但未物理移除)的数量
	minWhenHeap     atomic.Int64 // 堆顶时间,wakeTime 用它免锁判断
	minWhenModified atomic.Int64 // 被修改 timer 的最早时间下界
}

三个设计细节值得背:

  1. 四叉堆:const timerHeapN = 4,堆按到期时间排序,siftupTimer/siftdownTimer 维护。N 叉堆比二叉堆层数浅,CPU 缓存更友好——定时器是高频操作,每一层比较都值钱。
  2. len 的原子副本:调度器判断「这个 P 有没有定时器」时不用拿锁,原子读 len 即可;真有货再进慢路径。
  3. 惰性删除(zombies):Stop() 不立刻把 timer 从堆里摘出来(堆调整有代价),只打标记并累加 zombies,攒多了或堆顶操作时在 clearDeletedTimers 里批量清理。

到期之后谁执行 sendTime?调度循环在 checkTimers → runtimer 中处理本 P 到期的 timer,执行其函数;若最近的 timer 还很远,则用它计算 netpoll 的唤醒时间,线程可以安心睡眠到那一刻。定时器回调不是另起线程跑的,而是借调度循环的便车,这也是为什么定时精度是毫秒级而不是实时的。

四、面试陷阱与正确姿势

陷阱 1:循环 select 里用 time.After。每次迭代都 new 一个 Timer,旧 Timer 要么等触发要么等 GC(1.23 起可被回收,但 churn 仍在)。高频循环应复用 Timer:

// 正确:一个 Timer 用到天荒地老
timer := time.NewTimer(interval)
defer timer.Stop()
for {
	select {
	case v := <-ch:
		handle(v)
		timer.Reset(interval) // 收到数据就重置超时
	case <-timer.C:
		// 超时分支
		timer.Reset(interval)
	}
}

注意 Go 1.23 起通道无缓冲且投递是 select-default,不再需要 if !t.Stop() { <-t.C } 的清空套路,那行代码反而会阻塞——这是老教程最容易带出的坏习惯。

陷阱 2:Stop 不 close channel。官方注释明确:Stop 后不再发值,但通道保持打开,防止并发读取方把「关闭」误读成一次 tick。所以:

t := time.NewTimer(time.Second)
t.Stop()
// <-t.C 会永远阻塞!Stop 只保证不再发,不保证已有值可读

陷阱 3:Ticker 忘 Stop = goroutine 泄漏。Stop 不是为 GC,是为逻辑:不 Stop 的 Ticker 会一直滴答,配合 for-select 常驻 goroutine 就是泄漏(排查方法见 第 17 期)。正确退出姿势:

ticker := time.NewTicker(500 * time.Millisecond)
go func() {
	for {
		select {
		case t := <-ticker.C:
			fmt.Println("tick:", t.Unix())
		case <-ctx.Done(): // 用 context 驱动退出
			ticker.Stop()
			return
		}
	}
}()

陷阱 4:把 Ticker 当定时炸弹用。NewTicker(0) 会直接 panic(non-positive interval for NewTicker),而 time.Tick(0) 返回 nil 通道——后者是静默型 bug,select 里永远轮不到它。

五、面试速答清单

  1. Timer 和 Ticker 区别? 单次 vs 周期;通道一个无缓冲一个 1 缓冲,消费慢时 Timer 丢值、Ticker 丢 tick。
  2. 底层数据结构? 每个 P 一个定时器集合,四叉最小堆(timerHeapN=4)按到期时间排序。
  3. 到期谁执行回调? 调度循环 checkTimers/runtimer 顺手执行,不是独立定时线程。
  4. Stop 返回什么? bool,表示是否成功阻止了未触发的 timer;通道不 close,读到关闭语义要靠自己封装。
  5. 1.23 改了什么? 通道无缓冲、无引用即可 GC 回收,不再需 defer Stop 辅助 GC,也不需排空通道。
  6. 为什么 per-P? 消除全局锁竞争,调度器判断「有无定时器」走原子读免锁快路径。

版本说明

  • 本文涉及的源码细节(timers 结构体、四叉堆 timerHeapN = 4、sendTime 的 select-default 投递、Timer 通道无缓冲、GC 可回收未引用定时器等)对照 go1.26.0 的 src/runtime/time.go、src/time/sleep.go、src/time/tick.go 逐行核实(2026-11-01 核对,源码:github.com/golang/go,tag go1.26.0)。
  • Timer/Ticker 通道与 GC 行为变化发生在 Go 1.23(官方源码注释明确标注);定时器下放 per-P 自 Go 1.14 起,均无更晚版本变动。
  • 本文示例仅使用标准库,不依赖第三方模块。

本文是「Go语言面试题」系列第 34 篇。相关阅读:《深入剖析 Go select:多路复用背后的设计与实现原理》、《Go context 包详解:并发编程中的取消控制与超时管理》、《Goroutine 泄漏检测实战》、《Go 调度器 GMP 模型详解》。全系列目录见 《Go语言面试题总目录》。

🛒 站长推荐(京东联盟)

以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo · 隐私政策