17 定时器与时间:四叉堆 timer、time.After 的坑
开篇提问
先看一段非常常见的代码,然后请你判断它有没有问题:
func process(ctx context.Context) {
for {
select {
case <-time.After(1 * time.Second):
// 每秒做点事
doSomething()
case <-ctx.Done():
return
}
}
}这段代码,用 time.After 在 select 里实现”每秒执行一次”。看起来没问题,对吧?
但实际上,它在高负载下会悄悄累积大量未释放的定时器,造成内存和 CPU 泄漏。你能看出来问题在哪吗?
提示:想想 time.After 到底创建了什么,以及这个循环每一轮都在干什么。
这一章,我们拆开 Go 的定时器实现,讲清楚四叉堆、timer 的演进,以及几个经典的”定时器坑”。
子主题一:定时器要解决什么问题
Go 的定时器,本质要解决两个需求:
- 定时触发:让一个 goroutine 在指定时间后醒来(
time.Sleep、time.After、time.Tick)。 - 高效管理大量定时器:一个程序可能有成千上万个 goroutine 各自设了超时、定时任务,运行时必须高效地管理”谁先到期、到期了叫醒谁”。
朴素的实现是”每个定时器一个线程,睡到点再醒”——线程数量爆炸,显然不行。所以需要一个集中式的定时器管理结构,把海量定时器统一管理,用少量资源就能”盯住”它们。
Go 的答案是:一个(或少数)专门的 timer 协程 + 一个高效的数据结构(四叉堆)。
子主题二:四叉堆——为什么是”四叉”而不是”二叉”
管理定时器的核心操作是:
- 插入一个新定时器(设个超时);
- 删除一个定时器(超时取消了);
- 取最早到期的那个(看谁先到期,好去睡到那个时间点)。
这三个操作,天然适合用堆(heap):堆顶永远是最早到期的定时器,插入/删除是 O(log n),取堆顶是 O(1)。
但 Go 用的是四叉堆(4-ary heap),而不是最常见的二叉堆。为什么?
因为现代 CPU 有多级缓存,数据按”缓存行(cache line,通常 64 字节)“加载。四叉堆的每个节点有 4 个子节点,树更矮(同样数量的元素,四叉堆的高度约为二叉堆的一半),意味着:
- 从根到叶子经过的层数更少,缓存未命中更少;
- 虽然每层要比较 4 个子节点(比二叉的 2 个多),但比较是 CPU 寄存器操作、极快,而缓存未命中是内存访问、极慢——用”多一点比较”换”少一点缓存未命中”,总体更快。
这是一个非常经典的”缓存友好的数据结构设计”思想:在现代 CPU 上,内存访问的代价远高于计算,所以”矮而宽”的树(四叉堆、B 树)往往比”高而窄”的树(二叉树)更快,即使它们的理论复杂度相同甚至更差一点。
子主题三:timer 的演进——从全局锁到无锁多堆
Go 的 timer 实现,也经历了一次经典演进:
早期:全局 timer 堆 + 全局锁。 所有 goroutine 的定时器都塞进一个全局四叉堆,操作时要加一把全局锁。问题很明显:定时器操作频繁(每个超时、每个 time.After 都要动这个堆),全局锁竞争激烈,高并发下成为瓶颈。
后来:每个 P 一个 timer 堆(无锁)。 还记得调度器里那个 P(逻辑处理器)吗?Go 把 timer 堆拆分到每个 P 上——每个 P 维护自己的四叉堆,goroutine 创建定时器时,优先塞进当前 P 的堆,不需要加锁(P 独占)。
这和第三章”mcache 挂在 P 上”、第七章”runqueue 挂在 P 上”,是完全同一套设计思想:把会竞争的共享结构,按”稳定的执行单元 P”拆开,让绝大多数操作在”无锁的本地”完成。
你发现了吗?这一条思想,贯穿了 Go 的分配器、调度器、定时器三处。这绝不是巧合——这是 Go 运行时最核心的一条设计原则:用 P 作为”无锁本地化”的边界,把全局竞争摊薄到局部无锁。 学到这一章,你应该能自己总结出这条原则了。
子主题四:time.After 的坑——定时器泄漏
现在回到开篇那道题。
time.After(d) 的实现是:创建一个定时器,d 时间后向返回的 channel 发一个时间值。 关键点在于:这个定时器,在到期之前会一直占着 timer 堆里的位置。
看开篇的循环:
for {
select {
case <-time.After(1 * time.Second): // 每轮循环都创建一个新定时器!
doSomething()
case <-ctx.Done():
return
}
}问题在哪?每一轮循环,time.After 都会创建一个新的定时器。 如果 ctx.Done() 或 doSomething 让循环提前进入下一轮,或者循环频率很高,那么:
- 每个
time.After创建的定时器,要等 1 秒才到期、才被释放; - 如果循环 1 秒内转了很多轮(比如
ctx.Done()频繁触发、或 doSomething 很快又回到 select),就会同时存在大量未到期的定时器; - 这些定时器堆积在 timer 堆里,内存和 CPU 都泄漏(堆越来越大,插入删除越来越慢)。
更隐蔽的是,time.After 创建的定时器不能被取消(你拿不到那个定时器去 stop 它),所以一旦创建,就只能等它到期。
正确做法是用 time.Ticker(或 time.NewTimer + Reset),复用一个定时器,而不是每轮新建:
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doSomething()
case <-ctx.Done():
return
}
}time.Ticker 只创建一个定时器,循环里复用同一个 channel,不会有定时器堆积。
这个坑之所以经典,是因为它看起来完全无害,甚至”写得很标准”,但高负载下会悄悄拖垮系统——它教你一个教训:凡是”每轮循环新建资源”的写法,都要问一句”这个资源会被及时释放吗”。
子主题五:定时器的其他经典坑
坑一:time.Sleep 不能被取消。 一个 goroutine time.Sleep(10*time.Minute),你没法从外部把它叫醒(除非整个 goroutine 被调度器抢占/GC 打断,但它还是会继续睡)。所以”需要可取消的等待”,要用 select { case <-ctx.Done(); case <-time.After(d) } 而不是 Sleep。
坑二:time.Tick(不是 Ticker)也会泄漏。 time.Tick 返回一个 channel,但它创建的定时器无法 Stop(函数签名就不给你句柄)。一旦用 time.Tick,那个定时器就永久存在。所以官方推荐用 time.NewTicker(能 Stop)。
坑三:timer 的精度。 Go 的 timer 精度受运行时调度影响,不是纳秒级精确的。time.Sleep(1ms) 实际可能睡 1.2ms、2ms。别拿它做精确计时(精确计时用单调时钟、time.Since 等)。
坑四:单调时钟 vs 墙上时钟。 time.Now() 包含”墙上时钟”(会被系统调时间影响)和”单调时钟”(单调递增,不受调时影响)。测量时间间隔必须用单调时钟(time.Since(t) 自动用单调时钟),否则系统校时会让你的耗时测量变成负数或超大值。这是一个隐蔽但致命的坑。
子主题六:把四叉堆拆开看——索引公式、siftUp、deleteMin,以及”删不掉”的定时器
先反问一句:堆你肯定写过,但你能不查资料直接说出来——一个 d 叉堆里,第 i 个节点的父节点是谁、子节点是谁?
Go 的源码里就一个常量:const timerHeapN = 4(runtime/time.go)。于是全部索引运算只围绕它展开:
- 父节点下标:
p = (i-1) / timerHeapN,即(i-1)/4 - 子节点下标:
i*4+1、i*4+2、i*4+3、i*4+4
就这么简单。整个堆是一个连续的 slice:heap []timerWhen,而 timerWhen 是两个字段的结构体:
type timerWhen struct {
timer *timer
when int64
}注意这个设计的味道:堆里存的是 when 的一份拷贝,不是”去 timer 结构体里读 when”。先记住这件事,下一节它会成为关键。
插入(siftUp)。新定时器 append 到数组末尾,然后往上浮。但 Go 的实现不是教科书里的”父子两两交换”,而是一个叫**空洞法(hole drilling)**的写法:
tw := heap[i]
when := tw.when
for i > 0 {
p := int(uint(i-1) / timerHeapN) // parent
if when >= heap[p].when {
break
}
heap[i] = heap[p] // 父节点往下搬,i 变成"空洞"
i = p
}
heap[i] = tw // 最后一次性落位朴素交换每上一层要做 3 次赋值(t=a; a=b; b=t),空洞法只要 1 次。层数越多省得越多。这不是炫技——这是热路径上的标准动作:定时器插入删除是百万 QPS 系统里每秒发生几十万次的操作,省下来的赋值次数是真金白银。
取堆顶:heap[0],O(1),白送。这也是整个设计的核心诉求——调度器每秒要问无数次”下一个该醒的几点”。
删除堆顶(deleteMin):把最后一个元素搬到根上,然后 siftDown。
last := len(ts.heap) - 1
if last > 0 {
ts.heap[0] = ts.heap[last]
}
ts.heap[last] = timerWhen{} // 别忘了把指针置空,否则 GC 会误以为还活着
ts.heap = ts.heap[:last]
ts.siftDown(0)那个 ts.heap[last] = timerWhen{} 值得单独看一眼:缩容不清尾,就会造成”看不见的内存泄漏”——数组长度已经减一,但底层数组还拽着一个 *timer 指针,GC 顺着这条指针链把整棵对象图都留住。写 Go 的人,这个动作应该刻进肌肉记忆。
siftDown 才是四叉堆的精髓:
for {
leftChild := i*timerHeapN + 1
if leftChild >= n { break }
w := when
c := -1
for j, tw := range heap[leftChild:min(leftChild+timerHeapN, n)] {
if tw.when < w {
w = tw.when
c = leftChild + j
}
}
if c < 0 { break }
heap[i] = heap[c]
i = c
}它在一层的 4 个兄弟里挑最小的,一次下沉一层。注意那个 range 切片:heap[leftChild : leftChild+4]——4 个兄弟在内存里是紧挨着的。
现在,最难的一个问题:怎么删除”任意一个”定时器?
你的第一反应是”堆里删任意元素嘛,找到位置、和尾部交换、调整”。但 Go 根本不做这件事。看 timer.stop() 的注释:
It may be on some other P, so we can’t actually remove it from the timers heap. We can only mark it as stopped.
于是 Go 用的是惰性删除(lazy deletion)+ 墓碑(zombie):
- Stop/Reset 一个已在堆里的 timer:只打上
timerModified/timerZombie标记,ts.zombies计数 +1,不从堆里摘; - 真正摘除发生在下一次
ts.adjust()里。而adjust不是每次都跑,源码里的触发条件是:zombies > len(heap)/4(且必须是本地 P)——墓碑超过四分之一才清理一次。
为什么这么设计?两个原因,都很硬:
- 跨 P 问题:timer 属于创建它的那个 P 的堆,但调用
Stop()的 goroutine 可能正跑在另一个 P 上。要真删,就得去锁别的 P 的timers.mu——锁竞争立刻回来了,per-P 无锁本地化就白做了。标记一下就走,多干净。 - 找不到位置:堆里存的是
timerWhen(when的拷贝),timer结构体本身没有”我在堆里的下标”这个字段。要删除就得先 O(n) 扫描找自己在哪儿——那还不如别删。
这就是真实工程里的取舍:“删除”这个操作被彻底转化成了”打标记 + 批量延后清理”。用一个 zombies 计数器和一个 1/4 的阈值,把 O(log n) 的删除摊薄成几乎为零。
再问你一句:删除变慢了吗?没有。清理变晚了吗?是的——一个被 Stop 的 timer,只要堆里墓碑还不够多,它就还会占着堆的一个位置。这就是”Stop 了但内存没立刻降下来”的根源。以后有人问你”我 Stop 了 Ticker,为什么 pprof 里 timer 还那么多”,你现在能答了。
子主题七:把”矮而宽”翻译成缓存行的账
上一节留了个悬念:为什么堆里要存 when 的拷贝,而不是存 *timer 然后比较 heap[i].timer.when?
答案:让 sift 过程完全不追指针。
算一笔账。64 位平台上 timerWhen 是 16 字节(8 字节指针 + 8 字节 int64)。而主流 CPU 的缓存行是 64 字节。于是:
4 个
timerWhen= 64 字节 = 恰好一个缓存行。
而 timerHeapN 恰好是 4。也就是说,siftDown 里那句 heap[leftChild : leftChild+4] 要读的”4 个兄弟节点”,在内存里是连续 64 字节,正好填进一条缓存行。
这就是全部秘密。往下推:
- 如果堆里存的是
*timer,sift 每比较一次就要解引用一次指针,跳到堆内存上某个随机地址去读when。timer结构体本身不小(有 mutex、有 8 个字段),而且每个 timer 是独立分配的,地址毫无局部性可言。于是每下沉一层 = 一次几乎必然的 cache miss。 - 存
when拷贝之后,整个 siftDown 过程只在这条紧凑的 16 字节/元素数组上滑动,一次 miss 顺带把 4 个兄弟全取回来(硬件预取器就是干这个的)。
这是数据布局里最经典的一招:冷热分离。热的比较键(when)打包进连续数组,冷的完整对象(timer)放外面用指针挂着。B+ 树的非叶子节点只存键不存行数据,是同一招;列式存储把一列单独存,也是同一招。
现在回到”为什么是四叉不是二叉”。把所有代价摊开:
| 二叉堆 | 四叉堆 | |
|---|---|---|
| 树高 | log₂n | log₄n = log₂n / 2 |
| 每层比较次数 | 1 次(两个孩子比一下) | 3 次(四个孩子找最小) |
| 总比较次数 | log₂n | 1.5·log₂n |
| 每层缓存未命中 | 1 次(两个孩子只占 32 字节,但层多一倍) | 1 次(4 个孩子恰好一行,全取回来了) |
| 总缓存未命中 | log₂n 次 | log₂n / 2 次 |
看明白了吗?四叉堆用”1.5 倍的比较”换了”一半的缓存未命中”。
而这两者的代价根本不在一个量级上:
- 一次整数比较:寄存器里的 ALU 操作,亚纳秒到一纳秒,而且 CPU 有超标量和乱序执行,4 个数的 min 可以并行算完;
- 一次缓存未命中:L2 命中十几纳秒,L3 几十纳秒,真的去访问主存就是上百纳秒——够做几百次整数比较了。
所以这笔交易血赚。
给你一个狠一点的生活类比:
二叉堆像一个 8 层的写字楼,你每层只要敲 1 扇门就能问到路,但楼层之间你得坐电梯——电梯慢得要死。四叉堆像一个 4 层的裙楼,你每层要敲 4 扇门,但你可以走楼梯,一步就到。
敲一扇门要 1 秒,坐一次电梯要 300 秒。 你是愿意敲 8 扇门坐 8 次电梯,还是敲 16 扇门坐 4 次电梯?
这就是”矮而宽”在数据密集型结构上的普适性。同一个理由,B 树(几百个分支因子)击败了二叉搜索树成为数据库索引的事实标准;同样的理由,Go 的四叉堆击败了二叉堆。在现代 CPU 上,“少跳内存地址”比”少做几次比较”重要得多。
顺带补两个 Go 源码里的小心思,都是工程经验的结晶:
initHeap是 O(n) 而不是 O(n log n)。从最后一个非叶子节点往前逐个siftDown,这是 Floyd 建堆法。为什么不一个个插入?因为每次siftDown的成本和该节点的高度成正比,而堆里绝大多数节点都在底层(高度小),所以总和收敛到 O(n)。badTimer()里 throw 而不是 panic。源码注释写得很清楚:如果程序有 data race 把 timer 数据结构搞坏了,直接 panic 会在持锁状态下抛出”panic holding locks”这种莫名其妙的信息。所以这里手动检查下标,越界就throw("timer data corruption")。对运行时来说,“给出一条可读的错误信息”本身就是设计目标。
子主题八:timer 与调度器——到期那一刻,到底是谁把 goroutine 叫醒的
这一节是整个定时器话题里最容易想错的地方。
先抛问题:time.Sleep(1s) 睡下去以后,1 秒钟到了,会发生什么?
绝大多数人的直觉是”有个内核定时器到点发个信号,Go 运行时收到信号把 goroutine 唤醒”。错了。Go 里没有”定时器中断”这回事。
Go 的做法是主动轮询:定时器到期这件事,只有在调度路径上被顺手检查时才会被发现。源码里有三条检查路径:
路径一:findrunnable 的开头。 每次调度循环找可运行的 goroutine 时,第一件事就是
now, pollUntil, _ := pp.timers.check(0)check 会:取 wakeTime()(最早到期时间);没到点就直接返回 pollUntil(下一个到期时间,留给后面用);到点了就循环 ts.run(now) 把到期 timer 一个个跑掉。
路径二:stealWork 里偷别的 P 的定时器。 一个 P 自己没活干时,会去别的 P 那里偷 goroutine;偷之前先试”偷定时器”——p2.timers.check(now)。源码注释明确说:这是唯一一处会持有别的 P 的 timers 锁的地方,而且只在最后一轮偷一次(因为 now 在这轮循环里不变,重复检查没意义)。
这也是”timer 拆到每个 P”必须付出的代价:P 之间做了本地化,就得有人负责跨 P 兜底。如果某个 P 上的 goroutine 全阻塞了,它自己永远不会再进调度循环,那它的定时器就永远不会被检查——所以必须有偷定时器的机制。
路径三:sysmon 监控线程。 当所有 P 都空闲(sched.npidle == gomaxprocs)时,sysmon 调 timeSleepUntil() 扫一遍所有 P 的 wakeTime(),取最小值,据此决定自己睡多久。sysmon 的睡眠策略也很有意思:从 20 微秒起步,空闲轮次超过 50 就开始翻倍,上限 10 毫秒。所以一个完全空闲的 Go 程序不会真的”零开销地睡到定时器到期”,它是被 sysmon 以 10ms 为上限周期性唤醒的。
问你一个问题:既然 sysmon 最多只睡 10ms,那 Go 定时器的精度上限是不是就是 10ms?
不是。因为只要有一个 P 还在跑,路径一和路径二就在工作,定时器到期会被立刻发现。sysmon 的 10ms 只影响”整个程序都睡着了”的场景——而那种场景下,定时器到期本来也没人要响应得快。
那”跑一个到期的 timer”具体干什么? 分三种,看 f 是谁:
| 类型 | f | 唤醒方式 |
|---|---|---|
time.Sleep | goroutineReady | 直接 goready(gp),把 G 塞回 runq |
channel timer(After/NewTimer/Ticker) | sendTime | 向 channel 发一个 time.Time,由 channel 收发逻辑唤醒接收方 |
AfterFunc(d, f) | goFunc | go arg.(func())(),新起一个 goroutine 跑 f |
注意 AfterFunc 那一行:goFunc 的实现就是一句 go f()。所以 AfterFunc 的回调是在一个新 goroutine 里跑的,不是在当前 P 的 timer 处理路径里同步跑的。为什么必须这样?因为 f 是用户代码,它可能阻塞、可能 panic、可能做任何事——如果在 timer 处理路径上同步跑,整个 P 的定时器就全卡住了。源码注释对 timer 的 f 有明确要求:f must be a well-behaved function and not block。运行时把这条约束外包给了 “go 出去” 这个动作。
这里有一个源码里藏得很深、但极有教育价值的竞态案例。 看 timeSleep:
func timeSleep(ns int64) {
...
gp.sleepWhen = when
gopark(resetForSleep, nil, waitReasonSleep, traceBlockSleep, 1)
}
// resetForSleep is called after the goroutine is parked for timeSleep.
// We can't call timer.reset in timeSleep itself because if this is a short
// sleep and there are many goroutines then the P can wind up running the
// timer function, goroutineReady, before the goroutine has been parked.
func resetForSleep(gp *g, _ unsafe.Pointer) bool {
gp.timer.reset(gp.sleepWhen, 0)
return true
}看明白了吗?设定时器这件事,被推迟到了 gopark 之后。 因为如果 Sleep 的时间极短(比如 Sleep(1ns)),在 timeSleep 里设完定时器、还没来得及 gopark,这个 P 就可能已经转去跑调度循环、发现定时器到期、调 goroutineReady——而此时 goroutine 根本还没 park 完,唤醒一个”还没睡着”的 goroutine 会出乱子。
所以 Go 把它拆成两步:先算好 when 存起来,gopark 成功之后再由回调去 reset 定时器。
这就是并发编程里最朴素的真理:你以为的”一个动作”,在并发世界里是两个动作,中间那条缝就是 bug 的产地。
顺带说一个复用技巧:timeSleep 用的是 gp.timer——每个 goroutine 结构体里自带一个 timer 字段,第一次 Sleep 时分配,之后一直复用。所以 time.Sleep 的热路径是零分配的。这个思路你已经在 sync.Pool、mcache、runq 上见过三遍了,这是第四遍。
最后,最关键的结论:定时器到期 ≠ goroutine 立刻运行。到期只是让 goroutine 变成 runnable,然后它还得排队等 P。所以:
实际唤醒时刻 = 到期时刻 + 等待被调度的时间
而”等待被调度的时间”取决于你的 CPU 有多忙、GOMAXPROCS 有多大、GC 有没有在跑。这就是下一节”精度与抖动”的伏笔。
子主题九:timer 与 netpoll——一个 delay 参数,把两个世界缝在了一起
现在问一个真正棘手的问题。
epoll_wait 是可以无限期阻塞的。如果某个线程正卡在 epoll_wait 里睡大觉,而此时一个定时器到期了——谁来把它叫醒?
这是个真问题。没有 I/O 事件,epoll_wait 不会返回;不返回,那个线程就永远回不到调度循环,timers.check 就永远不会被调用,定时器就永远”到期不了”。死锁了。
Go 的解法精巧得像一把锁的两半:
第一半:用”下一个定时器到期时间”作为 epoll 的超时。
在 findrunnable 里,那个从 timers.check 拿到的 pollUntil 被直接换算成了 netpoll 的等待时长:
delay := int64(-1) // -1 = 无限等待
if pollUntil != 0 {
now = nanotime()
delay = pollUntil - now // 睡到下一个定时器到期为止
if delay < 0 {
delay = 0 // 已经到期了,别睡了,立刻返回
}
}
...
list, delta := netpoll(delay) // block until new work is available于是线程最多睡到”下一个定时器到期”那一刻,一定会醒过来去 check 定时器。
第二半:睡眠期间来了更早的定时器,怎么办?
addtimer / modify 如果发现新加的定时器比当前 wakeTime 更早,会调 wakeNetPoller(when):
func wakeNetPoller(when int64) {
if sched.lastpoll.Load() == 0 {
// 有线程正阻塞在 netpoll 里
pollerPollUntil := sched.pollUntil.Load()
if pollerPollUntil == 0 || pollerPollUntil > when {
netpollBreak() // 打断它
}
} else {
// 没有线程在 netpoll 里,拉一个 P 去干活
if GOOS != "plan9" {
wakep()
}
}
}Linux 上的 netpollBreak() 是什么?往一个 eventfd 里写一个 8 字节的 1。这个 eventfd 早就被注册进 epoll 了,所以一写,epoll_wait 立刻返回。注意那个 CAS:netpollWakeSig.CompareAndSwap(0, 1)——如果已经有一个唤醒在路上,就不再写第二次。避免 eventfd 被写爆,也避免无谓的系统调用。
于是形成一个漂亮的双向契约:
定时器告诉 netpoll “你最多能睡多久”;netpoll 告诉定时器 “你要是提前有事就打断我”。
这个模式和 Redis 的 aeApiPoll 超时计算是一模一样的思想(见子主题十五)。好设计总是相通的。
现在说一个会改变你写码习惯的事实。
你以为 net.Conn 的 deadline 是内核管的?不是。看这条调用链:
conn.SetReadDeadline(t)
→ fd.SetReadDeadline → setDeadlineImpl
→ runtime_pollSetDeadline
→ poll_runtime_pollSetDeadline
→ pd.rt.modify(pd.rd, 0, rtf, pd.makeArg(), pd.rseq) // ← 一个 runtime timer
每一个带 deadline 的 fd,背后就是一个 runtime timer,就躺在某个 P 的四叉堆里。
推论,一个比一个实在:
- 服务端有 10 万个长连接、每个都设了 30 秒读超时 → 10 万个 timer 分布在 GOMAXPROCS 个四叉堆里。
- 很多框架在每次读循环前重设 deadline(
SetReadDeadline(time.Now().Add(30s)))→ 每次重设就是一次timer.modify,走的是timerModified惰性标记路径 + 可能的wakeNetPoller。 modify里那段代码很讲究:只有当when < minWhenModified时才updateMinWhenModified并wakeNetPoller。把 deadline 往后延(更晚)是不需要打断 netpoll 的,只有提前才需要。 这就是”只在必要时付代价”。- 那些
pd.rseq序列号是干嘛的?用来作废过期的 timer 回调。fd 被复用(连接关了又开)时pd.rseq++,旧 timer 到期后发现 seq 对不上,直接不干活。和 Go 1.23 timer channel 的 seq 机制是同一个套路——用单调递增的序号识别”陈旧消息”,这是分布式系统里最常见的手法,在单机运行时里照样好使。
所以,下次你写 ctx, cancel := context.WithTimeout(...) 的时候,心里要有数:这不是免费的。 每个带超时的 context 底下大概率是一个 runtime timer。在十万级并发的服务里,定时器的管理成本是真实存在的一笔开销。
再问一句反问题:那是不是说 Go 的定时器在海量连接场景下必然不行? 别急,子主题十四会告诉你,这不是”行不行”的问题,是”数量级和精度要求”的问题。
子主题十:time.After / time.Tick 泄漏的完整剖析(以及 Go 1.23 之后它还成立吗)
回到开篇那个循环,这次把它彻底讲透。
time.After 到底是什么?
func After(d Duration) <-chan Time {
return NewTimer(d).C
}就一行。*它返回的是 channel,不是 Timer。 你把 *Timer 弄丢了,于是你拿不到 Stop() 的句柄。
这就是全部问题的根源:你只能等它自己到期,没法主动取消。
那么泄漏是怎么发生的? 三段式:
- 每次调用
time.After(1s)→ 分配一个Timer+ 一个chan Time(容量 1)+ 一个 runtimetimer,然后塞进当前 P 的四叉堆; - 循环每转一轮就新建一个。如果这一轮走的是
ctx.Done()分支(或者doSomething很快又回到 select),刚刚那个 1 秒定时器就成了孤儿——没人会再去读它的 channel,但它还得在堆里等到 1 秒后才被清理; - 稳态下,同时存活的孤儿定时器数量 ≈ 循环频率 × 超时时长。循环频率是 1000/s、超时是 1s,那就常年挂着 1000 个定时器;QPS 涨到 10 万,就是 10 万个。
这不只是内存。四叉堆里塞了 10 万个元素,每次插入删除都是 log₄100000 ≈ 8 层下沉,而且 zombies 清理阈值 len/4 会越来越难触发——CPU 也一起泄漏了。
这段代码最阴险的地方在于:它在功能测试里 100% 正确,只有压测跑上几个小时,你才会看到内存曲线在缓慢爬升。 教科书级的”看起来很标准”的坑。
正确写法,三种,按场景选:
// ① 固定周期:用 Ticker,全程只有一个定时器
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doSomething()
case <-ctx.Done():
return
}
}
// ② 可变的单次超时:复用一个 Timer,Stop + Reset
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
timer.Reset(timeout)
select {
case <-timer.C:
doSomething()
case <-ctx.Done():
return
}
}
// ③ 超时需要跨函数传播:用 context
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()现在,必须诚实地说一件事:Go 1.23 改变了这个问题的答案。
如果你读 Go 1.23 的源码注释,会看到这样两段话:
As of Go 1.23, the garbage collector can recover unreferenced, unstopped timers. There is no reason to prefer NewTimer when After will do.
As of Go 1.23, the garbage collector can recover unreferenced tickers, even if they haven’t been stopped. The Stop method is no longer necessary to help the garbage collector.
也就是说,在 Go 1.23 之后,一个没人引用的、还没到期的 timer,是可以被 GC 回收的。开篇那个循环的”内存无上限增长”基本上被运行时消灭了。
而且 Go 1.23 做了一件更狠的事:channel timer 变成了”惰性入堆”。
看 needsAdd 这个函数:
func (t *timer) needsAdd() bool {
need := t.state&timerHeaped == 0 && t.when > 0 && (!t.isChan || t.blocked > 0)
...
}注意最后那个条件:!t.isChan || t.blocked > 0。翻译成人话——如果一个 timer 是 channel timer,但没有任何 goroutine 阻塞在它的 channel 上,它就根本不进堆。
配套的两个函数:
blockTimerChan(c):当某个 goroutine 决定阻塞在 timer channel 上时被调用,t.blocked++,然后把 timer 加进堆;unblockTimerChan(c):接收方走了,t.blocked--,降到 0 就标记成 zombie,从堆里摘掉;maybeRunChan():真正有人来 receive 时才检查”这 timer 是不是早该触发了”,是就现场触发。
这意味着什么? 意味着 time.After 创建出来的定时器,在没人等它的时候,压根儿不占四叉堆的位置。它只是一个被 GC 管理的普通对象。
那么问题来了——
Go 1.23 之后,“循环里用 time.After”还是坑吗?
我的回答是三层的:
- 内存泄漏:基本被运行时解决了。 这是事实,得认。
- 但分配开销还在。 每轮循环依然要分配一个 Timer、一个 channel、一个 runtime timer。在热路径上(比如每个请求都走一遍),这是实打实的 GC 压力。Ticker 是一劳永逸的一次分配。
- 语义上它本来就不是你要的东西。 你要的是”每秒一次”,
time.After表达的是”从现在起 1 秒后”。如果你的循环里doSomething()花了 200ms,那After写法的实际周期是 1.2 秒,而Ticker会自动对齐补偿。你确定你要的是前者吗?
而且最现实的一条:你的代码很可能跑在 Go 1.21/1.22 上,公司升级 Go 版本的节奏你控制不了。“在新版本上不泄漏”不是”现在就能这么写”的理由。
所以结论不变,但理由要升级:循环里用 Ticker,不是因为怕泄漏(虽然你确实该怕),而是因为它用一次分配表达了正确的语义。
顺便说一句,还有个更隐蔽的:
time.Tick和time.NewTicker的取舍在 1.23 之后也变了。以前time.Tick的文档明说”底层 Ticker 永远不会被 GC 回收”,所以官方推荐NewTicker。1.23 之后这条警告被删掉了,注释改成 “There is no longer any reason to prefer NewTicker when Tick will do.”。但我的建议依然是用
NewTicker+defer Stop()。为什么?因为Tick不给你Stop()的句柄,而”停止一个周期任务”是个非常常见的需求。GC 能回收 ≠ 你想让它继续跑。资源该由你显式管理,别指望 GC 替你做业务决策。
子主题十一:Reset 陷阱、Timer channel 的缓冲,以及 Go 1.23 的 seq 机制
这一节的每个坑都能让你 debug 一整天。
坑一:Reset 之前的”陈旧值”。
想象这个场景:一个 Timer 已经到期了,值被塞进了 channel,但你还没读。此时你调 Reset(1s),下一轮 select 立刻读到那个”上辈子”的时间值——于是你以为超时了,实际上才过了 1 毫秒。伪超时。
Go 1.23 之前的标准解法长这样:
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(d)Stop() 返回 false 表示”这个 timer 已经到期或已经被停了”,那就手动把 channel 排空。
但这段代码本身就有竞态。 你想想:如果 Stop() 返回的瞬间,timer 的发送逻辑刚好在并发往 channel 里塞值呢?你的 select...default 可能读到的是刚刚才发出去的新值,也可能什么都没读到但马上就要有一个值进来。这段”标准写法”在极端并发下是不严密的——这就是 Go 1.23 要彻底解决它的原因。
坑二:channel 的缓冲大小。
Go 1.23 之前,NewTimer 创建的 channel 是缓冲 1 的(现在源码里还是 make(chan Time, 1),但行为被运行时接管了)。缓冲意味着:值可以”寄存在”channel 里,不需要接收方在场。这正是”陈旧值”能存活的物理基础。
Go 1.23 之后,文档写得明明白白:
Before Go 1.23, the channel associated with a Timer was asynchronous (buffered, capacity 1), which meant that stale time values could be received even after Timer.Stop or Timer.Reset returned. As of Go 1.23, the channel is synchronous (unbuffered, capacity 0), eliminating the possibility of those stale values.
而且给了强保证:
any receive from t.C after Reset has returned is guaranteed not to receive a time value corresponding to the previous timer settings; if the program has not received from t.C already and the timer is running, Reset is guaranteed to return true. Before Go 1.23, the only safe way to use Reset was to Stop and explicitly drain the timer first.
所以 Go 1.23 之后,Reset 前面不需要那套 Stop+drain 的仪式了。 直接 t.Reset(d) 就完事。
那运行时是怎么做到”发送到一半能撤回”的? 这段源码值得细看,因为它是一套通用的并发模式:
- 每个 timer 有一个
seq序号; stop和modify在持锁时t.seq++(作废所有”基于旧配置”的发送);- 发送方在发送之前就复制了一份
seq,因为锁的顺序问题(不能在持 timer 锁时做 channel 发送),它必须先解锁; - 解锁后它去拿
sendLock,然后二次检查t.seq != seq——如果对不上,说明期间被 stop/reset 了,直接把 f 换成空函数,这次发送就不发生了。
lock(&t.sendLock)
...
if t.seq != seq {
f = func(any, uintptr, int64) {} // ← 撤销这次发送
}
f(arg, seq, delay)再加一个 isSending 计数器,让 stop/reset 能判断”是不是已经有个发送在路上了”,从而正确返回 pending。
这套”序号 + 二次检查”的模式你一定在别处见过:网络请求去重、缓存失效、分布式锁的 fencing token,骨子里都是它。单机运行时里的并发问题,和分布式系统里的并发问题,本质是同一类问题——你永远不能假设”我检查过的状态”在”我行动时”还成立。
坑三:如果你依赖了旧行为怎么办?
Go 1.23 提供了逃生舱:GODEBUG=asynctimerchan=1 恢复 Go 1.23 之前的行为(不回收未到期 timer + channel 带缓冲)。源码注释说这个开关可能在 Go 1.27 或之后被移除。所以它是给你临时迁移用的,不是长期方案。
(顺带一提,还有个 asynctimerchan=2:只启用”GC 可回收”但保留异步 channel。这是给 Go 团队自己 debug 用的——万一新行为有问题,能二分定位是哪一个改动引起的。这种”把两个正交的改动拆成两个可独立开关的 GODEBUG”的做法,是发布高风险变更的标准姿势。)
坑四:Ticker 的 channel 依然会丢 tick。
看 NewTicker 的文档:
The ticker will adjust the time interval or drop ticks to make up for slow receivers.
源码里 channel 依然是 make(chan Time, 1),注释写着:
Give the channel a 1-element time buffer. If the client falls behind while reading, we drop ticks on the floor until the client catches up.
所以 Ticker 不是”每秒精确触发一次”的节拍器,它是”尽力而为,跟不上就丢”。 如果你的处理逻辑偶尔超过一个周期,你会丢掉整个 tick,而不是积压。
这对不对?在绝大多数场景(心跳、定期刷新缓存)是对的——丢一次无所谓,积压才可怕。但如果你的业务要求”每个周期都必须执行一次,且要追上进度”,Ticker 就不合适了,你得自己算:
next := time.Now()
for {
next = next.Add(period)
d := time.Until(next)
if d < 0 {
// 落后太多,跳过,重新对齐(而不是疯狂补跑)
next = time.Now().Add(period)
d = period
}
select {
case <-time.After(d):
doSomething()
case <-ctx.Done():
return
}
}注意这段代码里的”落后就重新对齐”——如果你不这么做,一旦落后了,程序会进入疯狂补跑状态,越跑越落后。这是所有周期任务调度的通用法则:要么丢,要么对齐,绝不能无脑补。
子主题十二:单调时钟 vs 墙上时钟——一个能让你的耗时变成负数的坑
先给一个场景,你自己判断:
你写了一个耗时统计:
start := time.Now(); doSomething(); log.Println(time.Since(start))。某天线上突然打出一行-2147h的耗时。是 Go 的 bug 吗?
不是。是你的监控告警里那条被忽略的 “NTP 时钟跳变” 在作祟。
先说清楚两个时钟是什么。
墙上时钟(wall clock):就是”现在几点”。它必须和真实世界对齐,所以它必须允许被修改——NTP 同步、管理员手动 date -s、虚拟机迁移后的时钟补偿,都会改它。改的时候它可以往前跳,也可以往回跳。
单调时钟(monotonic clock):一个只增不减的计数器,通常从系统启动(或某个固定起点)开始数。它没有任何绝对意义——你不能问”单调时钟现在是几点”,它只回答”从某个时刻到现在过了多久”。正因为它不承诺”是几点”,所以它不需要也不允许被修改。
Linux 上,Go 的 nanotime 走的是 VDSO 里的 clock_gettime,传的 clock id 就是 CLOCK_MONOTONIC——这段在 runtime/sys_linux_amd64.s 里是汇编,你能看到那行注释:
MOVL $1, DI // CLOCK_MONOTONICWindows 上没有 CLOCK_MONOTONIC,Go 用的是 QueryPerformanceCounter(QPC),一个高精度性能计数器。macOS/BSD 上是 mach_absolute_time 一类的东西。不同的实现,同一个契约:只增、不跳、不可写。
墙上时钟的坑到底怎么触发的?
NTP 校时有两种方式:
- slew(缓慢调整):发现快了 100ms,就每秒多走一点点,慢慢追上。这个方式不破坏单调性(只是走得快一点或慢一点),对你的耗时测量影响微乎其微。
- step(一步跳变):发现差了 3 秒,直接
settimeofday跳过去。这个方式会让墙上时钟瞬间倒退或前进几秒。
一旦发生 step,你的 t2.Sub(t1) 就可能变成负数(时钟往回跳),或者变成一个巨大的值(时钟往前跳)。
Go 是怎么解决的?
Go 的做法极其优雅:一个 time.Time 里同时装两份读数。
type Time struct {
wall uint64 // 墙钟(含标志位)
ext int64 // 单调读数(纳秒,进程启动起算)or 秒数
loc *Location
}源码注释写得很直白:
The Time returned by time.Now contains a monotonic clock reading.
然后定了一条铁律:
tell the time 用墙钟(Format、String、日期计算);measure the time 用单调时钟(Before/After/Equal/Compare/Sub)。
所以为什么 time.Since(start) 自动就用单调时钟?因为它就是 time.Now().Sub(start),而 Sub 的快路径源码注释是:
// Common case optimization: if t has monotonic time, then Sub will use only it.只要两边都有单调读数,Sub 就完全忽略墙钟。 于是”系统校时导致耗时为负”这件事,在 Go 里默认就不会发生。
那么,哪些操作会把单调读数弄丢? 这是这一节最实用的部分:
| 操作 | 是否保留单调读数 |
|---|---|
time.Now() | ✅ 有 |
t.Add(d) | ✅ 两个读数同时加 |
t.Sub(u) / Before / After / Equal / Compare | ✅ 只用单调读数 |
t.Round(d) / t.Truncate(d) / t.AddDate(...) | ❌ 剥掉(它们是”墙钟语义”的计算) |
t.In(loc) / t.Local() / t.UTC() | ❌ 剥掉(改变的是墙钟的解释方式) |
time.Date(...) / time.Parse(...) / time.Unix(...) | ❌ 从一开始就没有 |
t.GobEncode / MarshalJSON / MarshalText / MarshalBinary | ❌ 序列化时明确省略 |
t.Format(...) | ❌ 没有对应的格式化动词 |
记住这句:t = t.Round(0) 是官方推荐的”手动剥离单调读数”的写法。
由此衍生出四个真实会咬人的坑:
坑 A:跨进程传时间后做减法。 单调读数只在当前进程内有意义(源码原话:“The monotonic clock reading has no meaning outside the current process”),所以 JSON 序列化时会被丢掉。你在 A 服务打上时间戳,传到 B 服务算延迟——如果两台机器的墙钟没完全同步(NTP 只能做到毫秒级,跨机房可能更差),算出负数不是 bug,是设计如此。 要精确测跨机延迟,得在同一个进程内做收发打点。
坑 B:== 比较 Time。 源码文档明说:
the Go == operator compares not just the time instant but also the Location and the monotonic clock reading.
两个表示同一瞬间、但一个带单调读数一个不带的 Time,用 == 比是不相等的。 永远用 t.Equal(u)。
坑 C:笔电合盖/容器暂停。 源码文档有一条:
On some systems the monotonic clock will stop if the computer goes to sleep.
系统休眠时单调时钟会停。 所以如果你想量的是”真实世界过去了多久”(比如”用户离开页面多久了”),单调时钟会少算休眠那段时间。这种场景反而要 t.Round(0) 剥掉单调读数,退化成墙钟计算。
坑 D:误以为 Duration 里也带时钟信息。 不会。Duration 就是一个 int64 的纳秒数,它不是”从哪到哪”,它只是”多久”。把它加到不同的 Time 上结果不同。
最后反问一句:既然单调时钟这么好,Go 为什么不干脆全用它?
因为它答不了”现在几点”。你的日志要打时间、你的订单要存创建时间、你的 TLS 证书要校验证书有效期——这些都需要墙钟。墙钟的价值在于”大家都认可同一个读数”,单调时钟的价值在于”它自己永远自洽”。两者解决的是完全不同的问题,所以 Go 选择同时带着,各取所需。
子主题十三:精度与抖动——为什么 Go 的定时器不是纳秒精确的
这一节要先破除一个幻想:time.Duration 用 int64 存纳秒,不代表定时器有纳秒精度。 那只是”表示精度”,不是”实现精度”。
先问一个问题:time.Sleep(1 * time.Millisecond) 实际会睡多久?
正确答案是:至少 1 毫秒,上不封顶。
这不是我瞎说,是 API 契约。time.Sleep 的文档第一句就是:
Sleep pauses the current goroutine for at least the duration d.
只保证下界。 记住这四个字,它能省掉你无数个”为什么我的定时器不准”的困惑。
那么超出的那部分(抖动)来自哪里?至少六个独立来源:
① 时钟源本身的分辨率。 单调时钟不是无限细的。虽然 Linux 上 clock_gettime 报的是纳秒,但底层硬件计数器的更新频率是有限的,实际的读数粒度可能是几十到几百纳秒。Go 源码里甚至专门处理过这个:time/time.go 的注释提到 “monotonic time resolution is fairly low (e.g. Windows 2008)“,并且刻意避免让单调读数取到 0(因为 0 被用作”没有单调读数”的哨兵值)。
② 定时器的检查时机。 这是最大的一块,也是子主题八的核心结论:定时器到期只在调度路径上被检查。你的 goroutine 到期了,只是变成 runnable,还得等 P 来跑它。如果此时所有 P 都在忙,它就等着。
③ CPU 饱和。 设想 GOMAXPROCS=4,有 8 个 CPU 密集型的 goroutine 在跑。你的 timer 到期了,但没有一个 P 会主动去看一眼定时器——因为大家都在跑用户代码,不进调度循环。只有等某个 goroutine 主动让出(channel 阻塞、系统调用、被抢占)时,findrunnable 才会被调用,定时器才有机会被检查。
这就是为什么 Go 1.14 引入的异步抢占(基于信号的抢占)对定时器精度也是一次改进:在此之前,一个纯计算密集的 goroutine 可以长时间不让出,把所有定时器的响应延迟拖到几十毫秒。现在最坏情况被限制在抢占周期的量级。
④ GC。 虽然 Go 的 GC 停顿极短,但 STW 期间一切暂停。如果你的服务有严重的 GC 压力(频繁分配导致 GC 周期密集),定时器的抖动会肉眼可见地变大。内存问题和定时器问题经常是同一个问题的两面。
⑤ netpoll 的超时换算。 子主题九里那个 delay = pollUntil - now 是纳秒,但传给 epoll_wait 的是毫秒(会被截断取整)。这个取整误差最大到 1 毫秒。所以即使一切顺利,netpoll 路径上的定时器也不会比毫秒更准。
⑥ 容器与虚拟化。 在 K8s 里给 Pod 设了 CPU limit,cgroup 的 CFS 配额会在你用满配额后直接 throttle 掉整个进程(这段时间你的代码一行都不执行)。宿主机超卖、CPU steal time、NUMA 跨节点访问,全都是抖动来源。在容器里做亚毫秒级的定时精度,基本是在跟调度器作对。
那怎么办?两条实战结论:
结论一:不要指望定时器给你”周期性精确节拍”,要用”绝对时间点 + 补偿”。
// 错:每次都"再睡 100ms",误差会累积漂移
for {
time.Sleep(100 * time.Millisecond)
tick()
}
// 对:算下一个绝对时间点,自动补偿前面 drift 掉的时间
next := time.Now()
for {
next = next.Add(100 * time.Millisecond)
d := time.Until(next)
if d < 0 {
next = time.Now().Add(100 * time.Millisecond) // 落后太多,重新对齐
d = 100 * time.Millisecond
}
time.Sleep(d)
tick()
}第一种写法每轮的误差会累积,跑一小时可能已经偏了好几秒;第二种写法每轮都会拉回到绝对时间轴上,误差不累积。
结论二:接受”抖动是常态”,把定时器当”软实时”用。
Go 的定时器适合:超时控制、重试退避、心跳、定期刷新、任务延迟调度——这些场景对”晚几十毫秒”完全不敏感。
它不适合:音视频同步、工业控制采样、高频交易的精确时序、硬件时序协议。这些需求要的是 OS 级别的实时调度(RT 优先级、绑核、忙等),不是语言运行时能给你的东西。
一个类比收尾:Go 的定时器像闹钟——它保证不会提前响,但可能晚几分钟。你不会用闹钟去做微波炉的 3 秒加热倒计时,对吧? 反过来,你也不会为了知道”该起床了”去买一台原子钟。选工具的第一件事是搞清楚你要的精度量级。
子主题十四:时间轮——当定时器多到一个堆扛不住的时候
先把问题摆出来:四叉堆的插入/删除是 O(log n)。n = 100 万时,log₄n ≈ 10,也就是约 10 层下沉、每层一次潜在的 cache miss。听起来还行?
问题是”每次都要”。 每秒创建 100 万个定时器(Kafka 的延迟操作、Netty 的海量连接超时、游戏服务器的技能 CD),就是每秒 100 万 × 10 层的堆操作。这时候你需要一个摊还 O(1) 的结构。
时间轮(timing wheel)就是这个答案。
结构极简:一个环形数组 + 一根指针。
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ← 8 个 slot
└─┬─┴───┴───┴───┴───┴───┴───┴───┘
▲
指针(每 tick 走一格)
- 每个 slot 挂一个链表(或小堆),存”在这个时刻到期”的任务;
- 到期时间
t的任务,放进slot = t / tickDuration % N; - 指针每 tick(比如 1ms)走一格,把当前 slot 里的任务全部取出执行。
复杂度:插入 O(1)(算个下标,链表头插),删除 O(1)(链表摘除),推进 O(1) 摊还。
为什么它在海量场景赢? 因为堆的代价是”每次操作都重新排序”,而时间轮的代价是”指针匀速空转”。前者和 n 相关,后者和 n 无关。n 越大,时间轮越赚。
生活类比,狠一点的版本:
堆像一个”按截止日期排好序的待办清单”——你每往里塞一张新纸条,都得花时间找到它该插在第几张。纸条越多,找得越久。
时间轮像一块钟面,上面钉着 12 个钩子——“三点要开会”你就把纸条挂在 3 那个钩子上,挂完就完事了。指针走到 3,把钩子上的纸条全抖下来。你挂一万张纸条,挂一张的成本也不会变高。
代价?你得让指针一直走。 就算一个任务都没有,指针也得一圈一圈地转。
时间轮的三个真实代价,必须说清楚:
- 精度受 tick 粒度限制。 tick = 1ms,你就做不到 100μs 的精度——所有落在同一个 tick 里的任务,到期时间是”这一格的开始”,而不是精确时刻。堆没有这个问题,堆能精确到纳秒。
- 跨度大就要分层。 要覆盖 1 小时、tick 是 1ms,就需要 360 万个 slot。解法是分层时间轮(hierarchical timing wheel),像时钟的秒针/分针/时针:第一层管毫秒、第二层管秒、第三层管分,高层的任务到期时降级到低层。Kafka 的
TimingWheel和 Netty 的HashedWheelTimer都是这个路子。复杂度是省下来了,但代码复杂度上去了。 - 删除要惰性化。 任务取消了,你不一定能 O(1) 从链表里摘(如果是双向链表加前置指针可以做到),常见的做法是打标记、指针走到时跳过。是不是很眼熟?Go 的 zombie 机制就是同一招。 好设计在不同层面反复出现,这不是巧合。
那么,关键问题:Go 为什么没选时间轮?
四条理由,都是真实的工程权衡:
- Go 的定时器要的是”任意精度 + 任意跨度”。
time.Sleep(37ns)和time.Sleep(30 * 24 * time.Hour)都得正确工作,而且Reset可以改成任意时刻。时间轮要么为纳秒精度准备海量 slot,要么牺牲精度。四叉堆在这两个维度上是”通吃”的,而且代码只有一百行。 - Go 的定时器数量没到那个量级。 四叉堆在 n 为几万、几十万时表现完全够用。Go 的定时器主要服务于:超时控制、netpoll deadline、
time.Sleep、GC 和 sysmon 的内部定时——这些场景数量中等,但精度和正确性要求高。堆是更合适的选择。 - 时间轮需要一个”匀速推进”的驱动者。 在 Go 的调度模型里,这意味着要么起一个专门的 goroutine 每个 tick 醒来(tick 越细,唤醒越频繁,越浪费),要么依赖调度循环的 check(那就等于把粒度绑在调度频率上,退化成一个粗糙的时间轮)。堆没有这个负担——它是”事件驱动”的,没活就不动。
- 可维护性。 这是 Russ Cox 那代 Go 核心开发者的明确偏好:在性能足够的前提下,永远选更简单、更可验证的实现。 源码里那些详尽的注释(子主题六里
adjust那段关于minWhenModified清零顺序长达 40 行的推演)就是这种文化的体现。
所以结论不是”谁更好”,而是”谁更适合这个场景”:
| 场景 | 定时器特征 | 最优结构 |
|---|---|---|
| Go 通用运行时 | 数量中等、精度纳秒、跨度任意、频繁 Reset | 四叉堆 |
| Kafka 延迟操作(Purgatory) | 数十万级、毫秒粒度、跨度有限 | 分层时间轮 |
| Netty 连接超时 | 海量连接、秒级粒度、可容忍粗精度 | 单层时间轮 |
| Redis serverCron | 个位数定时任务 | 无序链表(见下节) |
再强调一次这张表背后的思想:数据结构的选择不取决于理论复杂度,取决于”真实的 n”和”真实的操作比例”。 O(n) 击败 O(log n) 的例子遍地都是,下一节我们就看一个。
子主题十五:Redis 与 timerfd——两个”别人家的定时器”
Redis:用 O(n) 链表,而且是故意的
先问一个挑衅的问题:Redis 号称高性能,它的定时事件管理用了什么高级数据结构?
答案是:一个无序链表。
Redis 的 aeEventLoop 里有一个 timeEventHead,是个单链表,存所有定时事件。每次 processTimeEvents 就是从头到尾遍历一遍,看谁的 when 到了。O(n)。
Redis 疯了吗?没有。因为它只有几个定时事件。
Redis 的核心定时任务就一个:serverCron(默认每秒跑若干次,做过期键清理、统计更新、持久化触发、主从心跳等)。加上个别模块的辅助定时事件,总数是”个位数”这个量级。
n = 5 的时候,O(n) 遍历五次和 O(log n) 的堆操作比,链表反而更快——没有堆调整、没有指针跳跃、全是顺序访问、代码二十行。而且 Redis 做了个优化:如果链表里根本没有有效的定时事件,就把 aeApiPoll 的超时时间设成一个固定的长值(而不是每次都算),连遍历都省了。
这就是那条不变的设计原则,第三遍重复它:
脱离了”真实数据规模”谈复杂度,就是耍流氓。 一个 O(n) 的链表在 n=5 时比 O(log n) 的堆快;一个 O(log n) 的堆在 n=100 万时比 O(1) 但有巨大常数的时间轮更省心。你得先知道 n 是多少。
但 Redis 有一点和 Go 完全一样,而且这是整个定时系统设计的关键点:
// ae.c: aeProcessEvents
if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT)) {
shortest = aeSearchNearestTimer(eventLoop); // 找最近的定时事件
}
...
numevents = aeApiPoll(eventLoop, tvp); // tvp = 最近定时事件的剩余时间“用最近的定时事件计算 poll 的超时时间”——和 Go 的 delay = pollUntil - now 是同一行思想。
为什么两边都这么干?因为这是单线程/少线程事件循环的必然结构:
事件循环只能在一个地方等。它等待的时长,必须同时满足两个约束:不能错过 I/O,也不能错过定时器。所以等待时长 = min(I/O 等待意愿, 距最近定时器到期的时长)。
Go 的 findrunnable、Redis 的 aeProcessEvents、libuv 的 uv_run、Node.js 的 event loop,全都是这个结构。这不是谁抄谁,是”把多个等待源合并成一次阻塞等待”这个问题的唯一正解。
Linux timerfd:把定时器变成文件描述符
Linux 提供了一组系统调用:timerfd_create / timerfd_settime / timerfd_gettime。
它的思路堪称漂亮:把定时器变成文件描述符。定时器到期 = fd 变成”可读”。
于是你可以把这个 fd 丢进 epoll,和 socket 一起等:
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
// 设 5 秒后到期
struct itimerspec ts = {{0,0},{5,0}};
timerfd_settime(tfd, 0, &ts, NULL);
// 然后把 tfd 加进 epoll,和 socket 一起 epoll_wait“I/O 就绪”和”定时器到期”被统一成了同一个事件类型。 事件循环从此只需要一种等待原语。这是 Unix “一切皆文件” 哲学最优雅的应用之一。
(顺便一提,Go 的运行时里其实有 timer_create / timer_settime / timer_delete 的汇编封装——在 runtime/sys_linux_amd64.s 里能看到——那是给某些平台的抢占实现用的,不是给 time 包用的。别搞混了。)
那么问题来了:既然 timerfd 这么优雅,Go 为什么不用它来实现 time 包?
三个理由,每一个都是硬伤:
① 一个 fd 一个定时器,fd 是稀缺资源。 一个进程能开的 fd 数量受 ulimit -n 限制(默认常常是 1024,线上调过也就几万到几十万)。而 Go 服务里同时存在几万、几十万个定时器太常见了(想想每个带 deadline 的连接)。用 fd 做定时器,等于给定时器数量设了一个系统级上限。 四叉堆的定时器是纯用户态对象,上限就是内存。
② 每次修改都是系统调用。 timerfd_settime 是一次 write/syscall。而 Go 里改一个定时器有多便宜?大部分情况下只是打一个 timerModified 标记位(惰性调整,见子主题六),连锁都不用加(用原子变量 minWhenModified)。系统调用是微秒级,用户态标记位是纳秒级——差三个数量级。
③ 可移植性。 timerfd 是 Linux 独有的。Windows 上有 WaitableTimer,macOS 上有 kqueue 的 EVFILT_TIMER,语义各不相同。而 Go 的四叉堆是纯 Go 实现(加一点点汇编读时钟),在所有平台上行为完全一致。对一门以”跨平台一致性”为核心卖点的语言,这是决定性的。
所以 Go 的定时器到底是什么架构?一句话概括:
用户态数据结构(四叉堆)+ 内核只负责”睡多久”(一次带超时的 epoll/kqueue 等待)+ 一个 eventfd 作为紧急打断通道。
复杂性全部留在用户态,内核交互被压缩到极致:一个 delay 参数进去,一个 eventfd 写入出来。
这个设计的好处是:绝大多数定时器操作(创建、修改、Stop)完全不需要陷入内核。 只有当”没人可跑、必须睡觉”这一种情况发生时,才会和内核打交道,而且只打交道一次。
反问一句收尾:这让你想起了 Go 的哪个设计?
内存分配:mcache 本地无锁分配,只有缺页了才找内核要。 调度:runq 本地无锁调度,只有真没活了才休眠和偷窃。 定时器:四叉堆本地无锁管理,只有真要睡了才调 epoll。
同一个套路,第三次出现。现在你应该能自己把它说出来了:把高频操作留在用户态本地,把低频操作下沉到共享层或内核,用”惰性”把两次下沉之间的所有操作都消化掉。
业界对照
Java ScheduledThreadPoolExecutor / DelayQueue: 定时任务用延迟队列(二叉堆)管理,线程池执行。Java 的 Timer 是单线程、一个任务慢了会拖累其他任务,已被 ScheduledThreadPool 取代。
Linux timerfd / epoll 超时: 操作系统级定时。高性能网络框架(如 Netty、libevent)常用”时间轮(timing wheel)“——一种比堆更适合”大量、粗粒度、循环定时”的结构。Go 选了四叉堆,是平衡了”定时器数量""精度跨度”和”实现简单”的选择(详见子主题十四、十五)。
Redis 事件循环: Redis 用”最近定时器的时间”作为 epoll_wait 的超时时间,单线程事件循环统一处理定时和 I/O;它的定时事件本身是无序链表(O(n)),因为它的定时事件只有几个——这是”按真实数据规模选结构”的最佳注脚。
时间轮(timing wheel): 业界高并发定时任务的经典结构(Kafka、Netty 用它),插入/删除 O(1),适合海量定时器。Go 没选它,是因为 Go 的定时器语义要求”纳秒精度 + 任意跨度 + 任意 Reset”,而时间轮要么为精度付出海量内存、要么牺牲精度;四叉堆在这些维度上更均衡,且实现更简单可验证。
版本演进小结
| 版本 | 关键变化 |
|---|---|
| 早期 | 全局 timer 堆 + 全局锁 |
| 1.10 | timer 改用四叉堆(缓存友好) |
| 1.14 | timer 拆到每个 P 上,无锁本地化,消除全局锁竞争;异步抢占间接改善定时器响应延迟 |
| 1.23 | timer channel 改为同步(无缓冲),消除 Stop/Reset 后的陈旧值;未被引用的未到期 timer 可被 GC 回收;channel timer 惰性入堆(blocked 计数);GODEBUG=asynctimerchan=1 可退回旧行为(可能在 1.27 后移除) |
| 持续 | timer 与调度器、netpoll 的协同持续优化 |
主线:Go 定时器的演进,是”用缓存友好的四叉堆”+“用 P 做无锁本地化”+“用惰性标记摊薄删除”的三重优化——前者是数据结构层面的精妙,后两者是复用贯穿 Go 运行时的”P 本地化”和”惰性消化”原则。它再一次印证了那条核心设计思想:把全局竞争摊薄到局部无锁,把昂贵操作摊薄到批量惰性。
本章思考题
【思考题】
-
为什么定时器管理用四叉堆而不是二叉堆?“矮而宽”的树为什么在现代 CPU 上更快?
-
开篇的
time.After循环为什么会泄漏定时器?正确写法是什么?本质教训是什么? -
Go 的 timer 堆为什么要拆到每个 P 上?这体现了哪条贯穿 Go 运行时的核心原则?
-
为什么测量时间间隔不能用”两个 time.Now() 相减”(如果不注意单调时钟)?系统校时会怎样?
-
Go 的四叉堆里存的类型是
timerWhen{timer *timer; when int64},为什么不直接存*timer然后比较heap[i].timer.when?这和timerHeapN = 4有什么关系? -
Timer.Stop()之后,定时器并没有立刻从四叉堆里被摘掉,只是被打了个timerZombie标记。为什么 Go 不直接删掉它?什么时候才真的删除? -
定时器到期时,Go 是”内核发信号唤醒”还是”运行时主动检查”?具体有哪几条检查路径?如果所有 P 都在跑 CPU 密集任务、没人进调度循环,会发生什么?
-
epoll_wait可以无限期阻塞。如果线程卡在里面,定时器到期了谁来唤醒它?Go 用了哪两个机制保证既不睡过头、也不漏掉新来的定时器? -
有人说”Go 1.23 之后 time.After 已经不泄漏了,循环里随便用”。这个说法对吗?请分”内存""分配开销""语义”三层评价。
-
Go 1.23 把 Timer 的 channel 从缓冲 1 改成无缓冲(同步),并保证”Reset 返回后不会再收到旧配置的时间值”。运行时是如何做到”发送一半能撤回”的?这套机制在 Go 运行时里还有哪个地方用了同样的思路?
-
time.Since(t)为什么自动使用单调时钟?请说出Time结构体的字段布局,以及哪些操作会把单调读数”剥掉”(至少举 4 个)。 -
time.Sleep的文档只承诺”至少”睡这么久。请列出至少 5 个独立的抖动来源。如果要做”每 100ms 执行一次、误差不累积”的周期任务,应该怎么写? -
海量定时场景下为什么时间轮比堆更合适?它付出了什么代价?Go 为什么没有采用时间轮?Redis 用 O(n) 的链表管理定时事件,为什么它反而是合理的?
-
Linux 的
timerfd能把定时器变成 fd、直接塞进 epoll 统一等待,看起来非常优雅。Go 为什么不用它来实现time包?请从 fd 数量、修改成本、可移植性三个角度回答,并总结 Go 定时器架构与内存分配器、调度器的共同设计套路。
【参考答案】
-
四叉堆树更矮(高度约为二叉堆的一半),层数少则缓存未命中少;虽然每层要多比较几个子节点,但比较是 CPU 寄存器操作、极快,而缓存未命中是内存访问、极慢。所以”用多一点比较换少一点缓存未命中”总体更快。这体现了”缓存友好的数据结构设计”:现代 CPU 上内存访问代价远高于计算,“矮而宽”的树(四叉堆、B 树)往往比”高而窄”的树更快。
-
因为每轮循环
time.After都新建一个定时器,且无法取消,只能等到期才释放;循环若一秒内转多轮,就会同时堆积大量未到期定时器,内存和 CPU 都泄漏。正确写法是复用time.NewTicker(只建一个定时器)。本质教训:凡是”每轮循环新建资源”的写法,都要问”这个资源会被及时释放吗”。 -
因为全局 timer 堆要加全局锁,定时器操作频繁,锁竞争激烈成瓶颈;拆到每个 P 上,goroutine 优先塞当前 P 的堆,P 独占无锁。这体现了贯穿 Go 运行时的核心原则:用 P(稳定执行单元)作为”无锁本地化”的边界,把全局竞争摊薄到局部无锁——分配器(mcache)、调度器(runqueue)、定时器(timer 堆)三处同一条原则。
-
因为
time.Now()的”墙上时钟”会被系统校时(NTP step 跳变、手动改时间)往回拨或往前跳;两个墙钟时间相减,可能在校时瞬间变成负数或超大值。测量时间间隔必须用”单调时钟”(单调递增、不受校时影响),time.Since(t)会自动用单调时钟。这是隐蔽但致命的坑。 -
因为存
when的拷贝可以让整个 sift 过程不追指针:timerWhen在 64 位平台是 16 字节(8 字节指针 + 8 字节 int64),连续存放,4 个恰好 64 字节 = 一条缓存行。而timerHeapN = 4,所以siftDown里heap[leftChild : leftChild+4]取”4 个兄弟”正好是一次缓存行读取。若存*timer则每次比较都要解引用跳到随机地址(timer 是独立分配、毫无局部性的大结构体),每下沉一层就是一次 cache miss。这是典型的冷热分离:热比较键打包进连续数组,冷对象放外面用指针挂。 -
两个原因:① 跨 P——timer 属于创建它的那个 P 的堆,但调
Stop()的 goroutine 可能跑在另一个 P 上,真删就得锁别的 P 的timers.mu,per-P 无锁本地化就白做了;② 找不到位置——堆里只存when的拷贝,timer上没有”我在堆里的下标”字段,删除前得先 O(n) 扫描。所以 Go 用惰性删除 + 墓碑(zombie):Stop 只打timerZombie标记并zombies++,真正摘除发生在下次ts.adjust(),且只有本地 P 且zombies > len(heap)/4时才强制清理。副作用:Stop 之后堆内存不会立刻降下来。 -
是运行时主动检查,没有定时器中断。三条路径:①
findrunnable开头pp.timers.check(0)(每次调度循环都会走);②stealWork偷不到 goroutine 时去偷别的 P 的定时器(p2.timers.check(now),这是唯一持别的 P 的 timers 锁的地方,用于兜底”某个 P 上的 goroutine 全阻塞了就再也不会自己 check”);③ 所有 P 空闲时sysmon用timeSleepUntil()算全局最早到期时间,决定自己睡多久(20μs 起步、空闲超过 50 轮翻倍、上限 10ms)。如果所有 P 都在跑 CPU 密集任务且不让出,定时器就没人检查——这正是 Go 1.14 异步抢占间接改善定时器响应延迟的原因。到期也只是让 goroutine 变 runnable,还得排队等 P。 -
两个机制构成双向契约:① 用
pollUntil算netpoll的等待时长——findrunnable里delay = pollUntil - now,把”下一个定时器到期时间”作为epoll_wait的超时,保证最多睡到那一刻一定醒;②wakeNetPoller(when)——新加/修改了更早的定时器时,若sched.lastpoll == 0(有线程正阻塞在 netpoll)就netpollBreak()(Linux 上是往一个已注册进 epoll 的 eventfd 写一个 8 字节的 1,并用 CAS 去重避免重复写),否则wakep()拉起一个 P。前者防”睡过头”,后者防”漏掉新来的”。 -
分三层:① 内存泄漏基本被解决——Go 1.23 起 GC 可回收未被引用的未到期 timer,更关键的是 channel timer 惰性入堆(
needsAdd里!t.isChan || t.blocked > 0,没人阻塞在 channel 上就不进堆),所以不再无上限堆积;② 分配开销仍在——每轮循环仍要分配 Timer + channel + runtime timer,热路径上是真实的 GC 压力,Ticker 是一次分配;③ 语义本来就不对——你要的是”每秒一次”,After表达的是”从现在起 1 秒后”;若doSomething花 200ms,After写法的实际周期是 1.2 秒,而 Ticker 会自动对齐补偿。另外大量生产环境仍跑在 1.21/1.22。所以结论不变:循环里用 Ticker,但理由从”怕泄漏”升级为”用一次分配表达正确语义”。 -
靠序号 + 二次检查(seq fencing):每个 timer 有
seq;stop/modify持锁时t.seq++作废所有基于旧配置的发送;发送方在解锁前先复制一份 seq,但因锁顺序不能持锁发送,只能解锁后取sendLock,拿锁后二次检查t.seq != seq——对不上就把f换成空函数,这次发送直接作废;另用isSending计数器让 stop/reset 判断”是否已有发送在路上”以正确返回pending。同一套思路:internal/poll里poll_runtime_pollSetDeadline用pd.rseq序号让 fd 复用后旧 timer 回调自动失效(netpoll deadline)。本质上它就是分布式系统里的 fencing token——不能假设”检查过的状态”在”行动时”还成立。 -
time.Since(start)=time.Now().Sub(start),而Sub的快路径源码注释写着 “if t has monotonic time, then Sub will use only it”——两个 Time 都带单调读数时Sub完全忽略墙钟,因此天生免疫校时跳变。Time布局是wall uint64(墙钟+标志位)、ext int64(单调读数,纳秒,进程启动起算;或墙钟秒数)、loc *Location。会剥掉单调读数的操作:Round/Truncate/AddDate/In/Local/UTC(官方推荐的手动剥离写法是t.Round(0));以及所有序列化(MarshalJSON/MarshalText/MarshalBinary/GobEncode)和所有构造器(time.Date/time.Parse/time.Unix)。另注:==会比较Location和单调读数,判断同一瞬间必须用Equal;系统休眠时单调时钟会停,量”真实经过时间”反而要先Round(0)。 -
抖动来源(任举五个):① 时钟源自身分辨率有限(Go 源码甚至处理过某系统单调分辨率很低的情况,且刻意避免读数为 0 以免撞上哨兵值);② 定时器只在调度路径被 check,到期后还要排队等 P;③ CPU 饱和时没人进调度循环(GOMAXPROCS 全忙);④ GC 的 STW 与 GC 压力;⑤
epoll_wait只接受毫秒级超时,纳秒delay会被截断取整(最大 1ms 误差);⑥ 容器的 cgroup CPU 配额 throttle、宿主机 steal time、NUMA 跨节点访问。周期任务写法:维护next绝对时间点,每轮next = next.Add(period); d := time.Until(next),若d < 0说明落后太多,就重新对齐(next = time.Now().Add(period))而不是疯狂补跑。核心:要么丢,要么对齐,绝不能无脑补。 -
时间轮赢在摊还 O(1):插入是算个下标 + 链表头插,删除是链表摘除,推进是指针走一格,成本与定时器数量无关;而堆每次操作都要 O(log n) 重新排序、伴随多次 cache miss。代价有三:① 精度受 tick 粒度限制(tick=1ms 就做不到 100μs,且同 slot 内到期时间被取整);② 跨度大必须分层(Kafka 分层时间轮 / Netty HashedWheelTimer),代码复杂度上升;③ 删除往往也要惰性标记(和 Go 的 zombie 同招)。Go 不选它:Go 定时器要求”纳秒精度 + 任意跨度 + 任意 Reset”(
Sleep(37ns)和Sleep(30天)都正确),时间轮要么为精度付海量内存、要么牺牲精度;且时间轮需要”匀速推进的驱动者”,在 Go 里要么起专门 goroutine 高频唤醒、要么绑在调度频率上退化成粗糙时间轮,而堆是事件驱动的、没活就不动;再加上”性能足够时选更简单可验证的实现”的取舍。Redis 用 O(n) 链表合理,是因为它的定时事件只有个位数(serverCron等),n=5 时顺序遍历比堆调整更快、代码更简单——脱离真实数据规模谈复杂度就是耍流氓。 -
三个角度:① fd 是稀缺资源——一个 timerfd 一个定时器,而线上服务几十万个定时器很常见(每连接一个 deadline),fd 数量受
ulimit -n限制,等于给定时器数量设了系统级上限;四叉堆的定时器是纯用户态对象,上限是内存。② 修改成本——timerfd_settime是一次系统调用(微秒级),而 Go 修改定时器多数情况只是打个timerModified标记位、用原子变量minWhenModified惰性处理(纳秒级),差三个数量级。③ 可移植性——timerfd 是 Linux 独有,Windows 的 WaitableTimer、macOS 的EVFILT_TIMER语义各异,而四叉堆是纯 Go 实现、全平台行为一致。总结架构:用户态四叉堆 + 内核只负责”睡多久”(一次带超时的等待)+ 一个 eventfd 作为紧急打断通道,复杂性全在用户态、内核交互压缩到一个 delay 和一个写入。共同套路(第三次出现):把高频操作留在用户态本地无锁完成,把昂贵操作下沉到共享层/内核,用”惰性标记”把两次下沉之间的所有操作都消化掉——内存分配(mcache 本地分配,缺页才找内核)、调度(runq 本地调度,没活才休眠偷窃)、定时器(四叉堆本地管理,要睡才调 epoll)三处同源。