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

改进点

  1. 不需要额外的调度
  2. 在每次调度循环中,执行 runtime.schedulefindrunnable 时直接检查并运行到期的定时任务
  3. Timer 使用 netpoll 进行驱动
  4. 每个 Timer 堆附着在 P 上,形成局部 Timer 堆

效果

  • 消除了唤醒 Timer 时进行 M/P 切换的开销
  • 大幅削减了锁的竞争
  • 与 nginx 中 Timer 的实现方式非常相似

9.3 定时器的使用场景有哪些

触发形式

  1. 经过固定时间间隔后触发(一次性)
  2. 按照固定时间间隔重复触发(周期性)
  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 中对 walltimenanotime 的调用:

// 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)
    ...
}

调度优先级(可能导致延迟的因素):

  1. GC Worker(垃圾回收任务)
  2. 全局队列中的任务
  3. 本地队列中的任务

潜在延迟来源

  • 调度器正在执行某个 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 秒的定时任务为例):

  1. 起始时间:11:20:32
  2. 到期时间:14:06:08
  3. 任务挂到 14 时格子
  4. 时钟转到 14 时 → 任务降级到 6 分格子
  5. 分钟转到 6 → 任务降级到 8 秒格子
  6. 秒钟转到 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),适合大量定时器场景
posted @ 2026-03-30 15:26  cyusouyiku  阅读(2)  评论(0)    收藏  举报