欢迎访问我的个人网站获得更佳阅读体验:golang源码阅读之定时器以及避坑指南 | yoko blog (https://pengrl.com/p/62835/)
本文分为三部分:
第一部分为阅读源码后的总结。
第二部分为高性能场景使用定时器需要注意的地方。
第三部分为系统库源码以及我写的注释。
本文基于go version 1.11.4
先放总结
桶协程timer对象归哪个桶管理取决于申请该timer对象时G所在的P(通过P的id取余64作为桶数组下标)。 (关于golang线程调度模型中G P M的概念超出了本文的讨论范围。这里只简单理解G为当前goroutine,P为当前goroutine所属的任务队列。)
由于hash算法和P的id相关,所以一个程序最多有min(64, GOMAXPROCS)个桶在使用。 另外,和桶一对一关联的桶协程是懒开启的,只在桶被初次使用时(即有timer对象hash到了这个桶)才开启,开启后桶协程内部的循环永远不会退出。
不将桶数量直接设置为GOMAXPROCS是因为那样的话数组需要动态申请。 桶数量设置为64是权衡在不同环境下(GOMAXPROCS不同)内存使用以及性能间的一种经验值。
每个桶都有一个最小堆,根据桶内所有timer的超时触发绝对时间点做调整。 关于数据结构最小堆的详细介绍读者可以自行查找资料,这里你只需要知道堆的底层使用数组实现,插入和删除的时间复杂度都是O(logn),并且插入和删除后,最小堆始终保持最小的元素在堆顶位置,所以获取最小元素是O(1)的。 事实上,golang定时器中的最小堆使用的是四叉树实现,相较于常见的二叉树实现,在节点数量比较多时,四叉树对底层数组的访问路径的局部性更好,CPU cache更友好些。
当桶内没timer时,桶协程被挂起。即rescheduling状态。 当桶内还有timer时,桶内协程睡眠直到最小超时触发时间点后再唤醒。即sleeping状态。 当往桶内加入新timer而该timer的超时触发时间点正好是当前桶内最小的,则唤醒桶协程。让桶协程重新判断,设置新的最小超时触发时间点后进入sleeping状态。
桶协程业务层调用Timer的接口使用timer时,以下几点开销要做到心里有数,桶内互斥锁的开销,最小堆容器管理的开销,协程调度的开销,创建timer对象时、超时触发返回当前时间时、桶协程内部都会有获取当前时间调用的开销。
高性能场景如何使用
阅读源码的目的,是学习别人写的好的地方,以及保证正确的使用姿势。
你能看出下面这段伪代码存在的问题吗?
消费者ch另外,假设你在其它场景使用了time.Ticker(不同于Timer只在超时后触发一次,Ticker将周期性触发超时)而没有调用Stop(即使业务层已不再持有Ticker对象了),情况将更糟糕,底层容器将一直持有Ticker对象,并周期性触发超时,然后修改下次超时时间点。资源将永远得不到释放,内存和CPU将永久性的泄漏。
正确的做法应该是:
Ticker对象不再使用后,显式调用Stop方法。
selectch我之后会再写一篇文章,关于在某些特定场景下如何自己实现一个简易timer,牺牲部分我们不需要的精确度来大幅提高超时业务逻辑的性能。
部分源码的说明
涉及到文件为:
- src/time/sleep.go
- src/time/tick.go
- src/runtime/time.go
- 其它一些runtime中的代码
time/sleep.goruntime/time.go