GO实验(7)Time计时器源码包阅读

前言

在1.14版本以前,go的time是会包装成一个协程执行,那么就有一个问题(极端情况),若已达协程最大数且所有协程进入死循环,之后的time就没法执行(本质上是没法产生新协程)。此外,time包实现计时器的数据结构也经历了很多变化

1.数据结构变化

1.1 全局四叉堆

在1.9之前所有的计时器由一个全局唯一的四叉堆(time heap)维护,所有的timer操作去抢这个四叉堆的同一把锁,即执行time.Sleep或After函数都会创建timer对象挂到runtime的四叉堆上。
最初的四叉堆runtime.timers结构体

type timer struct {
    i int // heap index
    when   int64
    period int64
    f      func(interface{}, uintptr)
    arg    interface{}
    seq    uintptr
}

var timers struct {
    lock         mutex
    gp           *g
    created      bool
    sleeping     bool
    rescheduling bool
    sleepUntil   int64
    waitnote     note
    t            []*timer
}
  • lock全局唯一锁
  • t是最小四叉堆,timer都会放在其中
    runtime.rimerproc创建一个Gorutinue来进行运行时间事件驱动来唤醒计时器(在for循环中不断检查堆元素是否到期,到期则触发并调整堆元素位置,直到所有timer执行完毕去休眠)

1.1.1四叉堆原理

四叉堆说白了就是四叉树,最多有4个子节点
image.png

四叉堆建立基准:以timer的触发时间(timer的when属性)为准进行排列,堆顶(根)一定是最快时间触发的,来一个新timer则进行按规则调整(四叉树的父节点的触发时间一定小于子节点,四个兄弟的节点间并不要求按其触发时间早晚排序)

1.1.2为什么设计要使用4叉堆

算法弱,直接看网上结论吧:四叉堆与二叉堆比较
1.插入操作执行性能更好优于二叉堆(插入耗时只有一半)
2.上推操作更快
也就是说timer更注重插入和上推操作

1.1.3弊端

共用一把互斥锁,严重影响计时器性能

1.2分片四叉堆

1.10将全局四叉堆分成了64个更小的四叉堆(理想情况应该是四叉堆数量和处理器数量相等,这里以内存换取性能直接写死64个)

const timersLen = 64

var timers [timersLen]struct {
    timersBucket
}

type timersBucket struct {
    lock         mutex
    gp           *g
    created      bool
    sleeping     bool
    rescheduling bool
    sleepUntil   int64
    waitnote     note
    t            []*timer
}

bucket这个单词在学习哈希桶的时候很熟悉了


image.png

每一个计时器桶上都由一个timerproc创建协程处理
G如何找桶?G都有一个id,找对应的桶编号即可

1.2.1弊端

将全局锁分成了粒度更小的锁,但是需要处理器频繁切换上下文,也影响了计时器性能

1.3最小四叉堆

1.14之后时间事件不再依赖timerproc(该函数被移除)创建G来执行,而是直接加入到GMP的调度循环中(交给网络轮询器和调度器触发)。
runtime.p是用来调度M处理G的p中有5个字段与time相关

type p struct {
    ...
    timersLock mutex
    timers []*timer

    numTimers     uint32
    adjustTimers  uint32
    deletedTimers uint32
    ...
}
  • timersLock互斥锁
  • timers为最小四叉堆
  • numTimers是处理器中的计时器数量
  • adjustTimer 处理器中处于 timerModifiedEarlier 状态的计时器数量;
  • deletedTimers是处理器中处于timerDeleted状态的计时器数量
    image.png

    schedule源码
    image.png

    同时它也有工作窃取,在饥饿状态下会去其它P那里偷取timer

1.3.1兜底

runtime.sysmon会给未被触发的timer兜底,启动新线程

2.timer结构体

上面的是1.14之前的,现在我的是1.18
利用函数NewTimer创建Timer

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
}

内部创建了一个通道和Timer结构体
Timer是对外暴露的,是对私有timer的包装
time.Timer

type Timer struct {
    C <-chan Time
    r runtimeTimer
}

time.timer
内置私有结构体

type timer struct {
    pp puintptr
    when   int64
    period int64
    f      func(interface{}, uintptr)
    arg    interface{}
    seq    uintptr
    nextwhen int64
    status uint32
}
  • when计时器被唤醒时间,根据这个排列四叉树
  • period两次被唤醒的间隔
  • f 计时器被唤醒调用的函数
  • arg 计时器处于 timerModifiedXX 状态时,用于设置 when 字段;
  • status是计时器状态
    Timer的channel作用:当计时器失效时,订阅计时器Channel的Goroutine会收到计时器失效信号/时间,最常见的例子就是time.After的使用
    Timer的管道也是有缓存的,在结束后会调用sendTime方法往管道发送数据
func sendTime(c interface{}, seq uintptr) {
    select {
    case c.(chan Time) <- Now():
    default:
    }
}

这里因为有default的原因,即使管道满了没有接收方,也不会造成阻塞

2.1计时器状态

一共有10种状态,不同的状态记录不同的信息(存在时间和是否在堆上),这里先只分析两种

2.2增加计时器addtimer

startTimer本质上是执行addtimer

func startTimer(t *timer) {
    if raceenabled {
        racerelease(unsafe.Pointer(t))
    }
    addtimer(t)
}

time.addtimer

func addtimer(t *timer) {
...前面一堆验证
    t.status = timerWaiting

    when := t.when

    pp := getg().m.p.ptr()
    lock(&pp.timersLock)
    cleantimers(pp)
    doaddtimer(pp, t)
    unlock(&pp.timersLock)

    wakeNetPoller(when)
}
  1. timer的状态改为timerWaiting等待触发
  2. cleantimers清除处理器中的计时器
  3. doaddtimer将其加入四叉堆中
  4. 调用wakeNetPoller唤醒网络轮询器中休眠的线程
    每次增加新的计时器都会中断正在阻塞的轮询,触发调度器检查是否有计时器到期

2.3清除计时器cleantimers

根据状态清除处理器队列头计时器

func cleantimers(pp *p) bool {
    for {
        if len(pp.timers) == 0 {
            return true
        }
        t := pp.timers[0]
        switch s := atomic.Load(&t.status); s {
        case timerDeleted:
            atomic.Cas(&t.status, s, timerRemoving)
            dodeltimer0(pp)
            atomic.Cas(&t.status, timerRemoving, timerRemoved)
        case timerModifiedEarlier, timerModifiedLater:
            atomic.Cas(&t.status, s, timerMoving)

            t.when = t.nextwhen

            dodeltimer0(pp)
            doaddtimer(pp, t)
            atomic.Cas(&t.status, timerMoving, timerWaiting)
        default:
            return true
        }
    }
}

根据计时器的状态进行不同的操作:

  • timerDeleted:修改为timerRemoving,再调用dodeltimer0删除四叉堆顶上的计时器,之后将计时器状态修改为timerRemoved
  • timerModifiedEarlier或timerModifiedLater:
    先改为Removing,再使用下次触发时间nextWhen覆盖when,删除堆顶计时器,使用doaddtimer将计时器加入队中,修改timerRemoved

2.3调度timer

即checkTimers函数,它在以下情况下被调用:

  • schedule执行调度
  • findrunable获取可执行G
  • findrunable从其他处理器窃取计时器
func checkTimers(pp *p, now int64) (rnow, pollUntil int64, ran bool) {
    if atomic.Load(&pp.adjustTimers) == 0 {
        next := int64(atomic.Load64(&pp.timer0When))
        if next == 0 {
            return now, 0, false
        }
        if now == 0 {
            now = nanotime()
        }
        if now < next {
            if pp != getg().m.p.ptr() || int(atomic.Load(&pp.deletedTimers)) <= int(atomic.Load(&pp.numTimers)/4) {
                return now, next, false
            }
        }
    }

    lock(&pp.timersLock)
    adjusttimers(pp)
  1. 如果处理器中不存在需要调整的计时器(主要指调整四叉树的节点位置,可能是遍历也可能是修改堆顶):
  • 没有需要执行的计时器则直接返回
  • 下一个计时器没到期且需要删除计时器较少则直接返回
  1. 否则调用adjusttimers调整计时器
    rnow = now
    if len(pp.timers) > 0 {
        if rnow == 0 {
            rnow = nanotime()
        }
        for len(pp.timers) > 0 {
            if tw := runtimer(pp, rnow); tw != 0 {
                if tw > 0 {
                    pollUntil = tw
                }
                break
            }
            ran = true
        }
    }

通过runtimer依次检查堆中是否存在需要执行的计时器,存在则直接运行,不存在则获取罪行计时器触发时间,runtime.runtimer是运行计时器

2.4计时器兜底

即runtime.sysmon一般是以10s为周期,检查并启动线程来处理计时器(会遍历运行时的全部处理器)

3.Timer使用的一些坑

主要为错误创建过多timer浪费资源和调用Stop并不会主动关闭C,阻塞程序

func main() {
    for {
        // xxx 一些操作
        timeout := time.After(30 * time.Second)
        select {
        case <- someDone:
            // do something
        case <-timeout:
            return
        }
    }
}

for中不断创建timer,我们知道checkTimers会轮询遍历timer堆的,这样创建过多的timer会增加遍历耗时,内存暴涨导致CPU异常,正确做法是利用time.Reset来重复利用timer

func main() {
    timer := time.NewTimer(time.Second * 5)    
    for {
        t.Reset(time.Second * 5)

        select {
        case <- someDone:
            // do something
        case <-timer.C:
            return
        }
    }
}

另一个Stop的例子

func main() {
    timer1 := time.NewTimer(2 * time.Second)
    go func() {
        timer1.Stop()
    }()
    <-timer1.C

    println("done")
}

此时尽管子协程调用stop停止了计时器,可是main仍堵塞,根本原因是通道没关闭
stop源码

func (t *Timer) Stop() bool {
    if t.r.f == nil {
        panic("time: Stop called on uninitialized Timer")
    }
    return stopTimer(&t.r)
}

只关闭了私有变量timer

func main() {
    timer1 := time.NewTimer(2 * time.Second)
    go func() {
        if !timer1.Stop() {
            <-timer1.C
        }
    }()

    select {
    case <-timer1.C:
        fmt.Println("expired")
    default:
    }
    println("done")
}

参考

1.一起学GO计时器
2.timer如何被调度

  1. 计时器的实现
    4.计时器底层实现深度刨析
    5.四叉堆与二叉堆比较

你可能感兴趣的:(GO实验(7)Time计时器源码包阅读)