golang有timer和ticker两类定时器。timer是你想要在未来做一次某事,ticker则是在规律的间隔持续做某事。
下面让我们来看一个使用timer的例子
for {
select {
case <- time.After(3 * time.Second):
// do sth repeatly
}
}
然而这样使用却会造成资源浪费,让我们深入源码探探究竟吧!
演化历史
无论NewTimer、time.After,还是timer.AfterFun初始化timer,都会最终加入到全局timer堆中,由go runtime统一管理
- Go1.9之前,全局由唯一四叉堆维护,提供专门的协程timerproc管理这些计时器,协程之间竞争激烈
- Go1.10~1.13全局使用64个四叉堆维护全部的计时器。虽然降低了锁的粒度,一定程度上解决了唯一四叉堆锁争用问题,可是唤醒timerproc带来的上下文切换问题,仍然悬而未决
- Go1.14之后,每个P单独维护一个四叉堆,并通过网络轮询器触发
go1.14前
在go1.14前,使用64个最小堆。所有在运行中创建的timer都被添加到最小堆,每个P创建的timer由相应最小堆维护
也就是说若P数量超过64,多个P的timer就会储存到相同bucket。
每个bucket负责管理一组这些有序timer,每个bucket都有一个相应timerproc异步任务负责调度这些定时器。
timerproc一直从bukcet取顶元素,在时间到的时候执行。在没有任务的时候,就会挂起,直到新的timer添加bucket。
timerproc在休眠时调用notesleepg,这会引发entersyscallblock调用,后者主动调用handoffp解除M、P绑定。而在下一个timer主动来临时,M和P又再次绑定。处理器P和线程M之间频繁的上下文切换也是定时器性能的主要影响因素之一。
四叉堆原理
四叉堆为四叉树。go runtime调度timer时,触发时间更早的timer,要减少查询,所以父节点触发时间是小于子节点的,并且为了同时兼顾四叉树插入、删除、重排速度,所以兄弟节点之间不要求按触发时间排序
以下是四叉堆的插入、删除动画演示
N叉堆 vs 二叉堆
- N叉堆对缓存更友好,二叉堆则是会有更多的缓存未命中情况和虚拟内存丢失
- N叉堆上推节点操作更快,只是二叉堆logN2log_N 2logN2倍
- 不过,二叉堆对于入队、出队频繁的操作更合适
数据结构
状态
const (
// 尚未设置的状态
timerNoStatus = iota
// 等待timer去触发,已经在一些P的堆上
timerWaiting
// timer正在执行
timerRunning
// timer被标记删除,应该被从堆中移除
timerDeleted
// timer正在被移除
timerRemoving
// timer已经停止,不在P堆
timerRemoved
// timer正在修改
timerModifying
// timer已经修改为更早的时间。新when值在nextwhen上
// timer在某些P堆上,可能在错误的位置
timerModifiedEarlier
// timer已经修改为更晚或者相同的时间。新when值在nextwhen上
// timer在某些P堆上,可能在错误的位置
timerModifiedLater
// timer已经被修改,正在被移动
timerMoving
)
timer
type timer struct {
pp uintptr // 对应当前P的指针
when int64 // 需要执行的时间
period int64 // 周期。ticker使用
f func(interface{}, uintptr) // 定时任务
arg interface{} // 与f相关的第一个参数
seq uintptr // 与f相关的第二个参数
nextwhen int64 // 下次执行的时间
status uint32 // 当前状态
}
操作
加入堆过程
- 通过NewTimer、time.After、timer.AfterFun初始化timer后,相关timer就会放到对应P的timer堆上
- timer执行完毕后标记为timeRemoved
- 调用time.Reset(d),就会重新加入到p的timer堆上
- STW,runtime释放不再使用的p资源,此时将有效timer(timerWaiting、timeModifiedEarlier、timerModifiedLater)重新加入到新p的timer
runtime/timer.go#addtimer()具体放入堆过程如下
-
边界、状态判断
timer被唤醒的时间when必须为正数、间隔period不能为负数。一个负数when会导致runtimer计算增值期间溢出,并且永远不会使其他运行时timer过期,而零将导致checkTimers注意不到计数器
-
禁止抢占,以防止改变其他P的堆
-
清理timer队列头部。这加快了程序创建、删除timer的速度
-
添加timer到当前p堆,初始化网络轮询器
-
唤醒网络轮询器中正在休眠的线程,若是它不打算在when之前唤醒或者还没有网络轮询器,则唤醒空闲P来访问timer和网络轮询器
加入堆的过程也可以解释前文提到为什么会资源浪费。因为time.After()实际创建了很多不需要的timer
为了解决这个问题,我们可以使用time.Reset重置timer,重复利用timer
timer := time.NewTimer(3 * time.Second) for { select { case <- timer.C: // do sth repeatly } timer.Reset(3 * time.Second) }
从堆中删除
runtime/timer.go#deltimer()删除的timer,可能在其他P上,因此不能直接从timer堆删除,只是标记为已删除,然后在适当的时候被堆所在P删除
-
对于等待去触发、被修改为晚点时间的状态、被修改为早点时间的状态,在cas修改状态为正在修改的情况下,将状态修改为deleted
-
对于标记删除、正在一处、已经移除以及尚未设置的状态,返回timer已经执行
-
对于正在执行、正在修改或者正在移除的状态等待完成
触发过程是怎样
在以下两种场景下会触发timer,运行timer保存的函数
- 调度器调度时会检查P中的timer是否准备就绪
- 系统监控会检查是否有未执行的到期timer
调度器调度执行runtime/proc.go#checkTimers运行P准备好的timer。
- 当没有需要执行、调整timer或者下一个计时器没有到期且需要删除timer不多于1/4,都会直接返回
- 当没有需要调整timer,就会调用runtime.adjusttimers根据时间将timers slice重新排列
- 查找并执行堆中需要执行timer。若执行成功,返回;若没有需要执行的timer,返回最近timer触发时间
- 在当前P和传入P为同一个,且要删除timer占1/4以上,就会调用runtime.clearDeletedTimers进行清理
reset如何操作的
runtime/time.go#resettimer()是将timer重新加入到timer堆中,等待被触发
- 对于timerRemoved的timer,会重新设置被触发时间,加入到timer堆中
- 对于等待被触发的timer,会修改触发时间和状态(timerModifiedEarlier或timerModifiedLater),然后由GMP触发,由checkTimers调用adjustTimers或者runtimer执行,重新加入到timer堆
stop如何执行
stop就是为了让timer停止,不再被触发。runtime/time.go#stoptimer()标记修改状态为timerDeleted,然后等待GMP触发,由checkTimers调用adjustTimers或者runtimer执行
Ref
- https://gobyexample.com/tickers
- https://juejin.cn/post/6884914839308533774
- https://cloud.tencent.com/developer/article/1840257
- https://www.sobyte.net/post/2022-01/go-timer-analysis/
- https://github.com/golang/go/issues/27707
- https://en.wikipedia.org/wiki/D-ary_heap
- https://segmentfault.com/a/1190000041591720
- https://zhuanlan.zhihu.com/p/430429302
- golang 1.17 source code