定时器使得咱们能够提早若干工夫执行某项工作,或者以某一时间周期性执行某项工作,Linux零碎自身也具备定时器能力,Go语言是定时器是基于零碎调用实现的吗?另外,Go语言不是多协程吗,定时器触发时,是在哪个协程执行工作的呢?创立工作的协程吗?

基本操作

  Go语言time包提供了工夫/定时器相干的API,如获取以后零碎工夫(能够达到纳秒级别),协程休眠指定工夫,提早指定工夫执行某项工作,以某一时间周期性执行某项工作等等,操作如下:

package mainimport (    "fmt"    "time")func main() {    //纳秒工夫戳    t := time.Now().UnixNano()    fmt.Println(t)  //1652510180050345000    //秒工夫戳    t = time.Now().Unix()    fmt.Println(t)  //1652510180    //格式化显示工夫    fmt.Println(time.Now().Format("2006-01-02 15:04:05")) //2022-05-14 14:36:20    // 一秒后执行函数    time.AfterFunc(time.Second, func() {        fmt.Println(time.Now().Format("2006-01-02 15:04:05"))    })    // 以一秒为周期,定时触发    ticker := time.NewTicker(time.Second)    go func() {        for {            <- ticker.C  //工夫触发时,管道可读            fmt.Println(time.Now().Format("2006-01-02 15:04:05"))        }    }()    //协程休眠3秒    time.Sleep(10 * time.Second)}

  留神格式化工夫显示工夫年月日时分秒,用的是"2006-01-02 15:04:05",Format办法应用其余格局如"2022-05-14 14:36:20"能够吗?按理说这两个工夫字符串格局是一样的,只是值不一样罢了,后果应该没区别。写个小程序测试下,你会发现,后果有点奇怪:

package mainimport (    "fmt"    "time")func main() {    //格式化显示工夫    fmt.Println(time.Now().Format("2022-05-14 14:36:20"))    //输入:141414-02-540 540:26:140}

  这是什么?为什么不是失常的年月日时分秒显示呢?这就须要钻研下Go语言Format办法,反对的格局标识,比方其余一些语言应用Y示意年,M示意月等。Go语言定义的年月日等标识如下:

const (    stdZeroMonth                                   // "01"    stdZeroDay                                     // "02"    stdHour                  = iota + stdNeedClock // "15"    stdZeroMinute                                  // "04"    stdZeroSecond                                  // "05"    stdLongYear              = iota + stdNeedDate  // "2006"    //省略了其余标识定义)

  看到了吧,"2006"、"01"等才是Go语言定义的工夫格局标识,所以"2006-01-02 15:04:05"能力失常显示年月日时分秒,而其余如"2022-05-14 14:36:20"的后果就有些不合乎预期了。当然,这里还省略了很多其余工夫格局标识,就定义在time/format.go文件,有趣味的读者能够本人钻研。

  再回顾下面的程序,time.AfterFunc能够在指定工夫执行函数,time.NewTicker能够以肯定工夫周期触发工夫,time.Sleep能够使协程休眠肯定工夫(过期后再复原协程的调度),这如何实现呢?要晓得我的项目中可能大量应用定时器,Go语言如何能在准确的工夫触发定时事件呢?另外time.AfterFunc,执行函数时,是在哪个协程执行呢?增加定时器的协程吗?

实现原理-堆

  Go语言如何能在准确的工夫触发定时事件呢?这意味着定时器的增删改查效率必须要高,不然难道每次都遍历所有定时器,判断哪些该触发吗?如果保护所有定时器有序呢?只须要查找第一个定时器,如果到期了则触发,并且持续查找;如果未到期也就不必持续查找了,因为前面的到期工夫必定大于第一个定时器的到期工夫。然而为了保护有序,增加定时器、批改定时器以及删除定时器的效率又太低。

  须要具备肯定的有序性,增删改查性能还要高,什么数据结构适合呢?有一种数据结构叫堆(最大堆,最小堆),以最小堆为例,它实质上是一棵齐全二叉树,只是要求任何一个节点的值,都小于左右两个子节点的值,所以根节点的值肯定是最小的。这样如果定时器应用了最小堆,只须要判断根节点是否到期,如果到期则触发并删除根节点,而删除的过程中还须要保障剩下的节点仍然满足最小堆的条件;这样一来,获取下一个最近到期的定时器,工夫复杂度仍然是O(1)。那么,最小堆,节点的删除以及增加的工夫复杂度是多少呢?也是比拟低的,O(logn)。

  一般二叉树节点通常须要左右left、right指针,而堆是齐全二叉树,能够基于数组实现的,为什么呢?因为父子节点的索引是有关系的,如下图:

  堆在插入节点时,通常先增加到数组的最初,再与父节点比拟,如果满足堆的定义,则完结;否则,与父节点替换。继续比拟替换到根节点为止,这一过程称之为上浮。一棵节点数为N的齐全二叉树,高度为logn,也就是说比拟替换最多须要执行logn次,即堆的插入工夫复杂度为O(logn)。

  堆在删除根节点时,通常先替换根节点与最初一个节点,再间接删除最初一个节点(也就是根节点);此时根节点可能不满足堆的定义,所以,还须要与左右两个子节点比拟。继续比拟替换到最初一个节点为止,这一过程称之为下沉。删除的工夫复杂度同样为O(logn)。删除过程逻辑如下:

  一般堆是齐全二叉树,而Go语言应用的是四叉树,为什么呢?树更低,工夫复杂度更低呗。联合咱们介绍的堆的插入与删除逻辑,咱们看看Go语言四叉堆的插入与上浮逻辑:

func siftupTimer(t []*timer, i int) int {        tmp := t[i]    for i > 0 {        //四叉树,父节点索引是(i - 1) / 4        p := (i - 1) / 4 // parent        if when >= t[p].when {  //父节点小于以后节点,break            break        }        t[i] = t[p]              //替换        i = p    }    if tmp != t[i] {              //替换         t[i] = tmp    }    return i}//增加定时器func doaddtimer(pp *p, t *timer) {    i := len(pp.timers)    pp.timers = append(pp.timers, t)    siftupTimer(pp.timers, i)      //如果是根节点,记录过期工夫(最小)    if t == pp.timers[0] {        atomic.Store64(&pp.timer0When, uint64(t.when))    }}

  Go语言四叉堆的删除与下沉逻辑十分相似,这里就不再赘述,能够参考runtime/time.go文件,dodeltimer0与siftdownTimer这两个函数。

  另外能够看到,构造timer就代表了一个定时器,蕴含了定时器的过期工夫,执行周期,执行办法等等,定义如下:

type timer struct {    // Timer wakes up at when, and then at when+period, ... (period > 0 only)    when   int64    period int64    //执行办法+参数    f      func(any, uintptr)    arg    any    seq    uintptr}

  思考一下,Go语言是多线程+多协程程序,如果多个协程并发增加定时器呢?如果全局只有一个定时器堆,是不是每次操作都须要加锁呢?Go语言低版本就是这么实现的。当初是每一个P都保护了一个定时器堆,从doaddtimer函数就能看进去,传入了以后线程M绑定的P,而定时器也是增加到该P的定时器堆。看看P构造定义的定时器相干字段:

type p struct {    //定时器堆    timers []*timer    // The when field of the first entry on the timer heap.    // This is updated using atomic functions.    // This is 0 if the timer heap is empty.    //定时器堆根节点的过期工夫(最小)    timer0When uint64}

  最初读者要留神的是,time包定义了很多定时器相干的函数,然而这些函数的具体实现往往是在runtime包,比方

func Sleep(d Duration)   //实现函数为 timeSleepfunc startTimer(*runtimeTimer)  //time.NewTicker、time.After等都是基于startTimer函数增加的定时器func stopTimer(*runtimeTimer) boolfunc resetTimer(*runtimeTimer, int64) bool  //timeSleep基于resetTimer实现

  能够简略看看time.AfterFunc函数的实现:

func AfterFunc(d Duration, f func()) *Timer {    t := &Timer{        r: runtimeTimer{            when: when(d),            f:    goFunc,  //启动一个协程执行函数f            arg:  f,        },    }    startTimer(&t.r)    return t}

定时器与调度器schedule

  每一个P都保护一个定时器四叉堆,那么Go语言是在什么时候检测是否要定时器过期呢?这必定与调度器schedule无关了:

func schedule() {    //检测定时器并执行    checkTimers(pp, 0)    //查找可执行协程}func checkTimers(pp *p, now int64) (rnow, pollUntil int64, ran bool) {    //最近过期的定时器    next := int64(atomic.Load64(&pp.timer0When))    if next == 0 {        // No timers to run or adjust.        return now, 0, false    }    if now == 0 {        now = nanotime()    }    //第一个定时器没有过期,返回    if now < next {        return now, next, false    }    if len(pp.timers) > 0 {        adjusttimers(pp, now)        for len(pp.timers) > 0 {                        //运行定时器(获取第一个定时器,如果过期则执行,否则返回时间差)            if tw := runtimer(pp, now); tw != 0 {                //第一个定时器没有过期,完结循环                break            }            ran = true        }    }}

  这下都明确了,调度器schedule在查找可运行协程时,先通过checkTimers检测定时器是否过期,并执行。也就是说,定时器函数f是在调度调度栈执行的;而且,如果某一个协程长时间运行(假如也没有被抢占),也有可能导致定时器的延后触发。

零碎工夫

  最初再思考一个问题,Go语言获取工夫戳甚至能够准确到纳秒级别,按理来说,获取工夫通常须要通过零碎调用实现,而零碎调用又会导致程序的低性能。那么Go语言是如何高性能的实现零碎工夫的获取呢?

  这里须要理解一个概念,Linux VDSO(virtual dynamic shared object),他是为了优化用户程序频繁的零碎调用,把零碎调用改成用户态的函数调用,gettimeofday就是其中一个。

  VDSO的介绍能够参考文章:https://zhuanlan.zhihu.com/p/...

总结

  本篇文章次要介绍了Go语言定时器的根本应用以及实现原理,堆(最大堆/最小堆)有很多利用场景,如定时器,优先级队列等等。用户程序可能增加很多定时工作,而调度器schedule在调度协程之前,会检测是否有定时工作到期,如果有则执行该定时工作。