9 计时器
Go 计时器详解
9.1 Timer 底层数据结构为什么用四叉堆而非二叉堆
堆的基本概念
堆是一个近似完全二叉树的结构,满足堆的性质:
- 大顶堆:每个节点的值都大于或等于其左右孩子节点的值
- 小顶堆:每个节点的值都小于或等于其左右孩子节点的值
注意:堆结构只规定了父子节点之间的大小关系,对兄弟节点的大小关系没有要求。
为什么使用四叉堆
Timer 都存在一个"到期时间",为了判断当前时刻有哪些 Timer 到期,Go 语言采用了四叉堆的排序结构(小顶堆)。
四叉堆的特点:
- 孩子节点个数变成 4 个
- 树的高度变低
优势:
- 堆顶元素最小,如果堆顶元素没到期,所有子节点都不可能到期
- 时间复杂度从 O(log₂N) 降到 O(log₄N)
四叉堆示例:
10
/ | \ \
20 15 12 18
/|\ /|\
...
(节点的值为定时器到期时间)
9.2 Timer 曾做过哪些重大的改进
Go 1.10 之前
- 所有 Timer 在一个全局的四叉小顶堆中维护
- 问题:并发性能不够
Go 1.10
- 将堆的数量扩充到 64 个
- 问题:唤醒 Timer 时,需要频繁将 M 和 P 解绑
- Timerproc 在堆上没有 Timer ready 时休眠,导致 M 和 P 解绑
- 下一个定时事件到来时,又会尝试进行 GPM 绑定
- Timerproc 本身是协程,也需要 runtime 调度
- 性能:依然不够出众
Go 1.14(重大改进)
取消了 timerproc 协程,把检查到期定时任务的工作交给了 runtime.schedule:
改进点:
- 不需要额外的调度
- 在每次调度循环中,执行
runtime.schedule及findrunnable时直接检查并运行到期的定时任务 - Timer 使用 netpoll 进行驱动
- 每个 Timer 堆附着在 P 上,形成局部 Timer 堆
效果:
- 消除了唤醒 Timer 时进行 M/P 切换的开销
- 大幅削减了锁的竞争
- 与 nginx 中 Timer 的实现方式非常相似
9.3 定时器的使用场景有哪些
触发形式
- 经过固定时间间隔后触发(一次性)
- 按照固定时间间隔重复触发(周期性)
- 在某个具体时刻触发(定点)
典型使用场景
| 场景 | 触发类型 | 示例 |
|---|---|---|
| 超时控制 | 固定时间间隔 | 获取下游数据,超时时间 100ms |
| 定时统计 | 固定时间间隔重复 | 每天早上 10 点统计接口访问量并群发邮件 |
| 权限开放 | 具体时刻 | 电商平台"双十一"零点商品下单接口开放 |
| 缓存更新 | 固定时间间隔重复 | 后台每隔 5s 更新缓存数据 |
| 心跳检测 | 固定时间间隔重复 | TCP 长连接定时发送心跳请求 |
| 协议栈 | 固定时间间隔 | TCP 网络协议栈需要大量 Timer |
9.4 Timer/Ticker 的计时功能有多准确
影响时间准确性的因素
1. 对系统时间的依赖程度
挂钟时间(wall time):基于人类社会的公历记法,绝对时间
- 例如:GMT+8 时区 2020 年 12 月 31 日 23:59:00 触发
- 准确性依赖操作系统或时间提供方
单调时间(Monotonic clock):基于相对时间概念
- 例如:几个小时之后触发一次的任务
- 准确性依赖运行时对这个相对时间进行管理的准确性
2. 对运行时的依赖程度
运行时组件可能产生的影响:
- 调度器的调度延迟
- 垃圾回收器的干扰
- 操作系统对应用程序进行中断产生的延迟
时间获取的准确性
Go 通过 time.Now 获取时间,最终转化为 runtime 中对 walltime 和 nanotime 的调用:
// src/time/time.go
func Now() Time {
sec, nsec, mono := now()
...
}
// src/runtime/timestub.go
//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64) {
sec, nsec = walltime()
return sec, nsec, nanotime()
}
VDSO 机制:
- 虚拟动态共享对象(Virtual Dynamic Shared Object)
- 加速系统调用的机制
- 将内核维护的时间信息从内核空间映射到用户空间
- 避免频繁的系统调用延迟
- 根据软硬件环境,时间精度通常在毫秒级
运行时调度的准确性
Go 1.14 之后的调度循环检查机制:
// src/runtime/proc.go
func schedule() {
_g_ := getg()
...
pp := _g_.m.p.ptr()
pp.preempt = false
...
checkTimers(pp, 0)
...
}
func checkTimers(pp *p, now int64) (rnow, pollUntil int64, ran bool) {
...
for len(pp.timers) > 0 {
if tw := runtimer(pp, rnow); tw != 0 {
...
}
}
...
}
func runtimer(pp *p, now int64) int64 {
for {
t := pp.timers[0]
...
switch s := atomic.Load(&t.status); s {
case timerWaiting:
...
runOneTimer(pp, t, now)
}
}
}
func runOneTimer(pp *p, t *timer, now int64) {
...
f := t.f
arg := t.arg
seq := t.seq
...
unlock(&pp.timersLock)
f(arg, seq) // 触发任务
lock(&pp.timersLock)
...
}
调度优先级(可能导致延迟的因素):
- GC Worker(垃圾回收任务)
- 全局队列中的任务
- 本地队列中的任务
潜在延迟来源:
- 调度器正在执行某个 goroutine 而无法来到
checkTimers - 抢占过程存在上下文切换延迟
- 大量 Timer/Ticker 同时运行
- 垃圾回收器压力大
结论
尽管可以相信 Go 运行时实现的高效性,但需要保持怀疑态度。当系统出现可感知的延迟时,应着重调试:
- 当前调度器调度的任务数量
- Timer/Ticker 的密度
- 垃圾回收器的压力
9.5 定时器的实现还有其他哪些方式
主流定时器实现方式
| 实现方式 | 数据结构 | 使用场景 | 时间复杂度 |
|---|---|---|---|
| Go Timer | 四叉堆 | Go 语言 | O(log₄N) |
| Nginx | 红黑树 | Nginx | O(log₂N) |
| Linux Kernel | 时间轮 | Linux 内核 | O(1) |
时间轮(Timing Wheel)
简单时间轮
原理:
- 每个刻度代表一个时间单位(如 1s)
- 整个时间轮能表示的时间段为刻度数 × 刻度单位
- 当前指针指向某个位置,任务放到
(当前指针 + 延迟时间)的位置
示例(8 个刻度,每个刻度 1s):
- 当前指针指向 0,3s 后执行的任务 → 放到第 3 个格子
- 指针转 3 次后执行
问题:
- 格子数量有限,能表示的时间范围有限
- 10s 后到期的任务会导致溢出
解决方案:保存轮次信息(round)
- 检查过期任务时只执行 round=0 的任务
- 链表中其他任务的 round 减 1
单层时间轮的局限:
- 任务时间跨度大、数量多时
- 单个格子的链表很长
- 每次检查量大,会做很多无效检查
多层时间轮
原理:
- 过期任务一定在底层轮中执行
- 其他时间轮中的任务在接近过期时不断降级进入低一层时间轮
- 最底层的时间轮转一圈时,高一层的时间轮转一个格子
优点:
- 大大增加可表示的时间范围
- 减少空间占用
- 避免精度降低
- 避免指针空转的次数
三层时间轮示例(精确到秒,最长定时时间一天):
层次:时(24格)、分(60格)、秒(60格)
运行过程(以 2 小时 45 分 36 秒的定时任务为例):
- 起始时间:11:20:32
- 到期时间:14:06:08
- 任务挂到 14 时格子
- 时钟转到 14 时 → 任务降级到 6 分格子
- 分钟转到 6 → 任务降级到 8 秒格子
- 秒钟转到 8 → 任务到期执行
三种实现对比
| 特性 | 四叉堆 | 红黑树 | 时间轮 |
|---|---|---|---|
| 结构 | 完全四叉树 | 平衡二叉树 | 循环数组+链表 |
| 插入复杂度 | O(log₄N) | O(log₂N) | O(1) |
| 删除复杂度 | O(log₄N) | O(log₂N) | O(1) |
| 查找过期复杂度 | O(1)(堆顶) | O(log₂N) | O(1) |
| 适用场景 | 通用定时器 | 通用定时器 | 大量定时器、精度要求高 |
分层时间轮的意义
- 提高精度:单层时间轮提高 tickDuration 会降低精度,多层时间轮可以避免
- 负载均衡:多层结构分散任务
- 大跨度任务:长时间跨度的定时任务交给多层时间轮调度
总结
| 维度 | 内容 |
|---|---|
| 数据结构演进 | 全局堆(1.10前) → 64个堆(1.10) → 每P独立堆(1.14) |
| 时间复杂度 | 四叉堆 O(log₄N),比二叉堆 O(log₂N) 更优 |
| Go 1.14 改进 | 取消 timerproc,集成到调度循环,使用 netpoll 驱动 |
| 准确性影响因素 | 系统时间准确性、运行时调度延迟、GC 压力 |
| 其他实现方式 | 红黑树(Nginx)、时间轮(Linux Kernel) |
| 时间轮优势 | 增删查均为 O(1),适合大量定时器场景 |

浙公网安备 33010602011771号