本文将基于 Golang 源码对 Timer 的底层实现进行深度剖析。主要包含以下内容:

  1. 1. Timer 和 Ticker 在 Golang 中的底层实现细节,包括数据结构等选型。
  2. 2. 分析 time.Sleep 的实现细节,Golang 如何实现 Goroutine 的休眠。

注:本文基于 go-1.13 源码进行分析,而在 go 的 1.14 版本中,关于定时器的实现略有一些改变,以后会再专门写一篇文章进行分析。

概述

我们在日常开发中会经常用到 time.NewTicker 或者 time.NewTimer 进行定时或者延时的处理逻辑。

Timer 和 Ticker 在底层的实现基本一致,本文将主要基于 Timer 进行探讨研究。Timer 的使用方法如下:

import (    "fmt"    "time")

func main() { timer := time.NewTimer(2 * time.Seconds)  fmt.Println("Timer fired")}

在上面的例子中,我们首先利用 time.NewTimer 构造了一个 2 秒的定时器,同时使用 阻塞等待定时器的触发。

Timer 的底层实现

对于 time.NewTimer 函数,我们可以轻易地在 go 源码中找到它的实现,其代码位置在 time/sleep.go#L82。如下:

func NewTimer(d Duration) *Timer { c := make(chan Time, 1) t := &Timer{  C: c,  r: runtimeTimer{   when: when(d),   f:    sendTime,   arg:  c,  }, } startTimer(&t.r) return t}

NewTimer 主要包含两步:

  1. 创建一个 Timer 对象,主要包括其中的 C 属性和 r 属性。r 属性是 runtimeTimer 类型。
  2. 调用 startTimer 函数,启动 timer。

在 Timer 结构体中的属性 C 不难理解,从最开始的例子就可以看到,它是一个用来接收 Timer 触发消息的 channel。注意,这个 channel 是一个有缓冲 channel,缓冲区大小为 1。

我们主要看的是 runtimeTimer 这个结构体:

  1. when: when 代表 timer 触发的绝对时间。计算方式就是当前时间加上延时时间。
  2. f: f 则是 timer 触发时,调用的 callback。而 arg 就是传给 f 的参数。在 Ticker 和 Timer 中,f 都是 sendTime。

timer 对象构造好后,接下来就调用了 startTimer 函数,从名字来看,就是启动 timer。具体里面做了哪些事情呢?

startTimer 具体的函数定义在 runtime/time.go 中,里面实际上直接调用了另外一个函数 addTimer。我们可以看下 addTimer 的代码 /runtime/time.go#L131:

func addtimer(t *timer) { // 得到要被插入的 bucket tb := t.assignBucket()

 // 加锁,将 timer 插入到 bucket 中 lock(&tb.lock) ok := tb.addtimerLocked(t) unlock(&tb.lock)

 if !ok {  badTimer() }}

可以看到 addTimer 至少做了两件事:

  1. 调用 assignBucket,得到获取可以被插入的 bucket
  2. 调用 addtimerLocked 将 timer 插入到 bucket 中。从函数名可以看出,这同时也是个加锁操作。

那么问题来了,bucket 是什么?timer 插入到 bucket 中后,会以何种方式触发?

Timerbucket

在 go 1.13 的 runtime 中,共有 64 个全局的 timer bucket。每个 bucket 负责管理一些 timer。timer 的整个生命周期包括创建、销毁、唤醒和睡眠等都由 timer bucket 管理和调度。

timersBucket 的结构: 最小四叉堆

每个 timersBucket 实际上内部是使用最小四叉堆来管理和存储各个 timer。最小堆是非常常见的用来管理 timer 的数据结构。在最小堆中,作为排序依据的 key 是 timer 的 when 属性,也就是何时触发。即最近一次触发的 timer 将会处于堆顶。如下图:

四叉堆

timerproc 的调度

每个 timerbucket 负责管理一堆这样有序的 timer,同时每个 timerbucket 都有一个对应的名为 timerproc 的 goroutine 来负责不断调度这些 timer。代码在 /runtime/time.go#L247

对于每个 timerbucket 对应的 timeproc,该 goroutine 也不是时时刻刻都在监听。timerproc 的主要流程概括起来如下:

  1. 创建。timeproc 是懒加载的,虽然 64 个 timerBucket 一直是存在的,但是这些 timerproc 对应的 goroutine 并不是一开始就存在。第一个 timer 被加到 timerbucket 中时,才会调用 go timerproc(tb), 创建该 goroutine。
  2. 调度。从 timerbucket 不断取堆顶元素,如果堆顶的 timer 已触发,则将其从最小堆中移除,并调用对应的 callback。这里的 callback 也就是 runtimeTimer 结构体中的 f 属性。
  3. 如果 timer 是个 ticker(周期性 timer),则生成新的 timer 塞进 timerbucket 中。
  4. 挂起。如果 timerbucket 为空,意味着所有的 timer 都被消费完了。则调用 gopark 挂起该 goroutine。
  5. 唤醒。当有新的 timer 被添加到该 timerbucket 中时,如果 goroutine 处于挂起状态,会调用 goready 重新唤醒 timerproc。

当 timer 触发时,timerproc 会调用对应的 callback。对于 timer 和 ticker 来说,其 callback 都是 sendTime 函数,如下:

func sendTime(c interface{}, seq uintptr) { select { case c.(chan Time)  default: }}

这里的 c interface{},也就是我们上文中提到的,在定义 timer 或 ticker 时,timer 对象中的 C 属性, 在 timer 和 ticker 中,它都被初始化为长度为 1 的有缓冲 channel。

调用 sendTime 时,会向 channel 中传递一个值。由于是缓冲为 1 的 buffer,因此当缓冲为空时,sendTime 可以无阻塞地把数据放到 channel 中。

如果定时时间过短,也不用担心用户调用 接收不到触发事件,因为事件已经放到了 channel 中。

而对于 ticker 来说,sendTime 会被调用多次,而 channel 的缓冲长度只有 1。如果 ticker 没有来得及消费 channel,会不会导致 timerproc 调用 callback 阻塞呢?答案是不会的。因为我们可以看到,在这个 select 语句中,有一个 default 选项,如果 channel 不可写,会触发 default。对于 ticker 来说,如果之前的触发事件没有来得及消费,那新的触发事件到来,就会被立即丢弃。

因此对于 timerproc 来说,调用 sendTime 的时候,永远不会阻塞。这样整个 timerproc 的过程也不会因为用户侧的行为,导致某个 timer 没有来得及消费而造成阻塞。

为什么是 64 个 timer bucket?

64 个 timerbucket 的定义代码如下,在 /runtime/time.go#L39 可以看到。

const timersLen = 64

var timers [timersLen]struct { timersBucket

 // The padding should eliminate false sharing // between timersBucket values. pad [cpu.CacheLinePadSize - unsafe.Sizeof(timersBucket{})%cpu.CacheLinePadSize]byte}

不过 64 个 timerbucket,而不是一个,或者说为什么不至于与 GOMAXPROCS 保持一致呢?

首先,在 go 1.10 之前,go runtime 中的确只有一个 timers 对象,负责管理 timer。这个时候也就没有分桶了,整个定时器调度模型非常简单。但问题也非常明显,

  1. 创建和停止 timer 都需要对 bucket 进行加锁操作。
  2. 当 timer 过多时,单个 bucket 的调度负担太重,可能会造成 timer 的延迟。

因此,在 go 1.10 中,引入了全局 64 个 timer 分桶的策略。将 timer 打散到分桶内,每个桶负责自己分配到的 timer 即可。好处也非常明显,可以有效降低了锁粒度和 timer 调度的负担。

那为什么是 64 个 timerbucket,而不是 32 个或者更多,或者不干脆与 GOMAXPROCS 保持一致?这点在源码注释中也有详细的说明:

Ideally, this would be set to GOMAXPROCS, but that would require dynamic reallocation.

The current value is a compromise between memory usage and performance that should cover the majority of GOMAXPROCS values used in the wild.

理想情况下,分桶的个数和保持 GOMAXPROCS 一致是最优解。但是这就会涉及到 go 启动时的动态内存分配。作为运行时应该尽量减少程序负担。而 64 个 bucket 则是内存占用和性能之间的权衡了。

每个 bucket 具体负责管理的 timer 和 go 调度模型 GMP 中 P 有关,代码如下:

func (t *timer) assignBucket() *timersBucket { id := uint8(getg().m.p.ptr().id) % timersLen t.tb = &timers[id].timersBucket return t.tb}

可以看到,timer 获取其对应的 bucket 时,是根据 golang 的 GMP 调度模型中的 P 的 id 进行取模。而当 GOMAXPROCS > 64, 一个 bucket 将会同时负责管理多个 P 上的 timer。

为什么是四叉堆

TimersBucket 里面使用最小堆管理 Timer,但是与我们常见的,使用二叉树来实现最小堆不同,Golang 这里采用了四叉堆 (4-heap) 来实现。这里 Golang 并没有直接给出解释。这里直接贴一段 知乎网友对二叉堆和 N 叉堆的分析。

  1. 上推节点的操作更快。假如最下层某个节点的值被修改为最小,同样上推到堆顶的操作,N 叉堆需要的比较次数只有二叉堆的  logn 2倍。
  2. 对缓存更友好。二叉堆对数组的访问范围更大,更加随机,而 N 叉堆则更集中于数组的前部,这就对缓存更加友好,有利于提高性能。

C 语言知名开源网络库 libev,内部既采用了二叉树也采用了四叉堆来实现。它在注释里提到 benchmark,四叉树相比来说缓存更加友好,在 50000 + 个 timer 的场景下,四叉树会有 5% 的性能提升。具体可见 libev/ev.c#L2227

sleep 的实现

我们通常使用 time.Sleep(1 * time.Second) 来将 goroutine 暂时休眠一段时间。sleep 操作在底层实现也是基于 timer 实现的。代码在 runtime/time.go#L84。有一些比较有意思的地方,单独拿出来讲下。

我们固然也可以这么做来实现 goroutine 的休眠:

timer := time.NewTimer(2 * time.Seconds)

这么做当然可以。但 golang 底层显然不是这么做的,因为这样有两个明显的额外性能损耗。

  1. 每次调用 sleep 的时候,都要创建一个 timer 对象。
  2. 需要一个 channel 来传递事件。

既然都可以放在 runtime 里面做。golang 里面做的更加干净:

  1. 每个 goroutine 底层的 G 对象上,都有一个 timer 属性,这是个 runtimeTimer 对象,专门给 sleep 使用。当第一次调用 sleep 的时候,会创建这个 runtimeTimer,之后 sleep 的时候会一直复用这个 timer 对象。
  2. 调用 sleep 时候,触发 timer 后,直接调用 gopark,将当前 goroutine 挂起。
  3. timerproc 调用 callback 的时候,不是像 timer 和 ticker 那样使用 sendTime 函数,而是直接调 goready 唤醒被挂起的 goroutine。

这个做法和libco的poll实现几乎一样:sleep时切走协程,时间到了就唤醒协程。

总结

分析 timer 的实现,可以明显的看到整个设计的演进,从最开始的全局 timers 对象,到分桶 bucket,以及到 go1.14 最新的 timer 调度。整个过程也可以学习到整个决策的走向和取舍。

参考

  • https://en.wikipedia.org/wiki/D-ary_heap
  • https://www.zhihu.com/question/358807741/answer/922148368
  • https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-timer/

推荐阅读

  • 大神是如何学习 Go 之并发编程与定时器


喜欢本文的朋友,欢迎关注“Go语言中文网”:

Go语言中文网启用微信学习交流群,欢迎加微信:274768166,投稿亦欢迎

golang延时_Golang 定时器底层实现深度剖析相关推荐

  1. golang延时_golang 实现延迟消息原理与方法

    实现延迟消息具体思路我是看的下面这篇文章 https://mp.weixin.qq.com/s/eDMV25YqCPYjxQG-dvqSqQ 实现延迟消息最主要的两个结构: 环形队列:通过golang ...

  2. golang延时_Go 定时器/延时触发器

    Go 可以借助 time.After/time.Ticker 来实现延迟/定时触发器,主要原理是借助无缓冲channel无数据时读取操作会阻塞当前协程,Go会在给定的时间后向channel中写入一些数 ...

  3. C++ STL deque 容器底层实现原理(深度剖析)

    事实上,STL 中每个容器的特性,和它底层的实现机制密切相关,deque 自然也不例外.<C++ STL deque容器>一节中提到,deque 容器擅长在序列的头部和尾部添加或删除元素. ...

  4. libevent源码深度剖析

    原文地址:http://blog.csdn.net/sparkliang/article/details/4957667 libevent源码深度剖析一 --序幕 张亮 1 前言 Libevent是一 ...

  5. libevent源码深度剖析一

    libevent源码深度剖析一 --序幕 张亮 1 前言 Libevent是一个轻量级的开源高性能网络库,使用者众多,研究者更甚,相关文章也不少.写这一系列文章的用意在于,一则分享心得:二则对libe ...

  6. Android应用程序开发以及背后的设计思想深度剖析(4)

    特别声明:本系列文章LiAnLab.org著作权所有,转载请注明出处.作者系LiAnLab.org资深Android技术顾问吴赫老师.本系列文章交流与讨论:@宋宝华Barry 紧接连载三,我们接下从性 ...

  7. libevent源码深度剖析十一

    libevent源码深度剖析十一 --时间管理 张亮 为了支持定时器,Libevent必须和系统时间打交道,这一部分的内容也比较简单,主要涉及到时间的加减辅助函数.时间缓存.时间校正和定时器堆的时间值 ...

  8. [Android] Toast问题深度剖析(二)

    欢迎大家前往云+社区,获取更多腾讯海量技术实践干货哦~ 作者: QQ音乐技术团队 题记 Toast 作为 Android 系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用 ...

  9. SQL Server性能调优之执行计划深度剖析 第二节 执行计划第一次实践

    SQL Server性能调优之执行计划深度剖析 第二节 执行计划第一次实践 前言:自从上一篇文章发出之后,收到了很朋友的关注.很多朋友要求多多实践,而不是纯粹的理论.确实,从打算出这个系列开始,我就本 ...

最新文章

  1. Kali Linux重设root密码
  2. 【深度学习入门到精通系列】nnU-Net论文解析
  3. OpenCV源码中Haar训练及特征提取的代码说明
  4. MIT 研究员开发不会泄露数据的安全平台
  5. python 解码gbk_Python3的url编码和解码,自定义gbk、utf-8的例子
  6. 3分钟了解数据库事务
  7. mysql的代码需要保存吗_php向Mysql数据库保存数据的代码
  8. uploadify没反应
  9. spark mlib行矩阵(RowMatrix)入门
  10. 变位齿轮重合度计算公式_齿轮变位系数的优化选择
  11. 2022年安全员-B证题库及答案
  12. Android Wi-Fi subsystem_ramdump简介(以QCOM为Base)
  13. 项目管理手记(八) SaaS模式的DRP系统是否适用?
  14. 161009_qt显示阿拉伯输入法9x7点阵
  15. CIM+规划:自带CIM平台的数字规划咨询服务,提升城市空间价值和产业活力
  16. 异或运算——小卡和质数
  17. STM32串口中断接收一帧数据
  18. Counting Stars 全中国最准确的翻译!
  19. 纯干货!轻量化软件设计五大法则
  20. [二分查找] [luoguP3500] [POI2010] TES-Intelligence Test

热门文章

  1. 特斯拉进入Autopilot 2.0时代,高举“普通雷达”旗帜!
  2. PHP:打造一个无限极评论模块
  3. 基于HFC实现10Gbps对称数据传输
  4. SEO技巧--代码优化
  5. 淘宝Tprofiler工具实现分析
  6. DDoS功击的判定方法和防护措施
  7. spring boot 学习(十一)使用@Async实现异步调用
  8. 【Foreign】朗格拉日计数 [暴力]
  9. Linux基础(13)文本查找利器find的使用方法
  10. OGG_GoldenGate日常维护(案例)