go 进阶 九. 定时器

一. Timer

  1. Timer是一种单一事件的定时器指定的时间后触发一个事件,这个事件通过其本身提供的channel进行通知。之所以叫单一事件,是因为Timer只执行一次就结束,这也是Timer与Ticker的最重要的区别之一
  2. 通过timer.NewTimer(d Duration)可以创建一个timer,参数即等待的时间,时间到来后立即触发一个事件
  3. 底层结构: 对外仅暴露一个channel,指定的时间到来时就往该channel中写入系统时间,也即一个事件
//src/time/sleep.go:Timer
//Timer代表一次定时,时间到来后仅发生一个事件。
type Timer struct { 
    C <-chan Time
    r runtimeTimer
}

内部包含的方法解释

1. 创建定时器

  1. 指定一个时间即可创建一个Timer,Timer一经创建便开始计时,不需要额外的启动命令, 创建Timer意味着把一个计时任务交给系统守护协程,该协程管理着所有的Timer,当Timer的时间到达后向Timer的管道中发送当前的时间作为事件
func NewTimer(d Duration) *Timer

2. 停止定时器

  1. 通过该方法可以随时停止计时器
func (t *Timer) Stop() bool
  1. 回值代表定时器有没有超时:
  1. true: 定时器超时前停止,后续不会再有事件发送;
  2. false: 定时器超时后停止;
  1. 停止计时器意味着通知系统守护协程移除该定时器

3. 重置定时器

  1. 已过期的定时器或者已停止的定时器,可以通过重置动作重新激活
func (t *Timer) Reset(d Duration) bool
  1. 重置的动作实质上是先停掉定时器,再启动。其返回值也即停掉计时器的返回值
  2. 注意: 重置定时器虽然可以用于修改还未超时的定时器,但正确的使用方式还是针对已过期的定时器或已被停止的定时器,同时其返回值也不可靠,返回值存在的价值仅仅是与前面版本兼容
  3. 重置定时器意味着通知系统守护协程移除该定时器,重新设定时间后,再把定时器交给守护协程

4. After() 匿名定时器

  1. 没有需求提前停止定时器,也没有需求复用该定时器,那么可以使用匿名的定时器
func After(d Duration) <-chan Time
  1. 创建一个定时器,并返回定时器的管道示例:
func AfterDemo() {
    log.Println(time.Now())
    <- time.After(1 * time.Second)
    log.Println(time.Now())
}

5. AfterFunc() 延迟执行

  1. 下面的使用场景举例中延迟一个方法调用的示例,实际上通过AfterFunc可以更简洁
func AfterFunc(d Duration, f func()) *Timer
  1. 使用示例
func AfterFuncDemo() {
    log.Println("AfterFuncDemo start: ", time.Now())
    time.AfterFunc(1 * time.Second, func() {
        log.Println("AfterFuncDemo end: ", time.Now())
    })
    time.Sleep(2 * time.Second) // 等待协程退出
}
  1. AfterFuncDemo()中先打印一个时间,然后使用AfterFunc启动一个定器,并指定定时器结束时执行一个方法打印结束时间。
  2. 与上面的例子所不同的是,time.AfterFunc()是异步执行的,所以需要在函数最后sleep等待指定的协程退出,否则可能函数结束时协程还未执行

使用场景举例

  1. 例如设定超时时间,从一个管道中读取数据,没有数据时,设定一个超时时间,阻塞指定时间后管道中还是没有数据到来,则判定为超时
func WaitChannel(conn <-chan string) bool {
    timer := time.NewTimer(1 * time.Second)
    select {
    case <- conn:
        timer.Stop()
        return true
    case <- timer.C: // 超时
        println("WaitChannel timeout!")
        return false
    }
}
  1. 延迟执行某个方法,希望某个方法在今后的某个时刻执行,DelayFunction()会一直等待timer的事件到来才会执行后面的方法(打印)
func DelayFunction() {
    timer := time.NewTimer(5 * time.Second)
    select {
    case <- timer.C:
        log.Println("Delayed 5s, start to do something.")
    }
}

原理

1. 底层结构

  1. 先看一下底层结构,内部包含两个属性
  1. C: 管道,上层应用跟据此管道接收事件;
  2. r: runtime定时器,该定时器即系统管理的定时器,对上层应用不可见;
type Timer struct {
    C <-chan Time
    r runtimeTimer
}
  1. Timer实质上是把一个定时任务交给专门的协程进行监控,这个任务的载体便是runtimeTimer, 每创建一个Timer意味着创建一个runtimeTimer变量, 然后把它交给系统进行监控
  2. 查看runtimeTimer源码其成员如下:
  1. tb: 系统底层存储runtimeTimer的数组地址;
  2. i: 当前runtimeTimer在tb数组中的下标;
  3. when: 定时器触发事件的时间;
  4. period: 定时器周期性触发间隔(对于Timer来说,此值恒为0);
  5. f: 定时器触发时执行的回调函数,回调函数接收两个参数;
  6. arg: 定时器触发时执行回调函数的参数一;
  7. seq: 定时器触发时执行回调函数的参数二(Timer并不使用该参数)
type runtimeTimer struct {
    tb uintptr                          // 存储当前定时器的数组地址
    i  int                              // 存储当前定时器的数组下标
    when   int64                        // 当前定时器触发时间
    period int64                        // 当前定时器周期触发间隔
    f      func(interface{}, uintptr)   // 定时器触发时执行的函数
    arg    interface{}                  // 定时器触发时执行函数传递的参数一
    seq    uintptr                      // 定时器触发时执行函数传递的参数二(该参数只在网络收发场景下使用)
}
  1. 先总结一下:
  1. 一个进程中的多个Timer都由底层的一个协程来管理
  2. 协程把runtimeTimer存放在数组中,并按照when字段对所有的runtimeTimer进行堆排序,定时器触发时执行runtimeTimer中的预定义函数f,即完成了一次定时任务

2. 创建Timer

  1. 查看创建源码
func NewTimer(d Duration) *Timer {
    c := make(chan Time, 1)  // 创建一个管道
    t := &Timer{ // 构造Timer数据结构
        C: c,               // 新创建的管道
        r: runtimeTimer{
            when: when(d),  // 触发时间
            f:    sendTime, // 触发后执行函数sendTime
            arg:  c,        // 触发后执行函数sendTime时附带的参数
        },
    }
    startTimer(&t.r) // 此处启动定时器,只是把runtimeTimer放到系统协程的堆中,由系统协程维护
    return t
}
  1. 内部执行逻辑:
  1. NewTimer()构造了一个Timer,然后把Timer.r通过startTimer()交给系统协程维护。
  2. 封装了when()方法,计算下一次定时器触发的绝对时间,即当前时间+NewTimer()参数d
  3. endTime()方法便是定时器触发时的动作
  1. 其中sendTime()方法便是定时器触发时的动作
func sendTime(c interface{}, seq uintptr) {
    select {
    case c.(chan Time) <- Now():
    default:
    }
}
  1. sendTime接收一个管道作为参数,其主要任务是向管道中写入当前时间, 创建Timer时生成的管道含有一个缓冲区(make(chan Time, 1)),所以Timer触发时向管道写入时间永远不会阻塞,sendTime写完即退出
  2. 之所以sendTime()使用select并搭配一个空的default分支,是因为后面所要讲的Ticker也复用sendTime(),Ticker触发时也会向管道中写入时间,但无法保证之前的数据已被取走,所以使用select并搭配一个空的default分支,确保sendTime()不会阻塞,Ticker触发时,如果管道中还有值,则本次不再向管道中写入时间,本次触发的事件直接丢弃。
  3. startTimer(&t.r)的具体实现在runtime包,其主要作用是把runtimeTimer写入到系统协程的数组中,并启动系统协程(如果系统协程还未开始运行的话)。更详细的内容,待后面讲解系统协程时再介绍

3. 停止Timer

  1. 只是简单的把Timer从系统协程中移除, 底层代码
func (t *Timer) Stop() bool {
    return stopTimer(&t.r)
}
  1. stopTimer()即通知系统协程把该Timer移除,即不再监控。系统协程只是移除Timer并不会关闭管道,以避免用户协程读取错误
  2. 协程监控Timer是否需要触发,Timer触发后,系统协程会删除该Timer。所以在Stop()执行时有两种情况:
  1. Timer还未触发,系统协程已经删除该Timer,Stop()返回false;
  2. Timer已经触发,系统协程还未删除该Timer,Stop()返回true;

4. 重置Timer

  1. 重置Timer时会先timer先从系统协程中删除,修改新的时间后重新添加到系统协程中
func (t *Timer) Reset(d Duration) bool {
    w := when(d)
    active := stopTimer(&t.r)
    t.r.when = w
    startTimer(&t.r)
    return active
}
  1. 返回值与Stop()保持一致,即如果Timer成功停止,则返回true,如果Timer已经触发,则返回false,由于新加的Timer时间很可能变化,所以其在系统协程的位置也会发生变化。
  2. 注意,Reset()应该作用于已经停掉的Timer或者已经触发的Timer,按照这个约定其返回值将总是返回false,之所以仍然保留是为了保持向前兼容,使用老版本Go编写的应用不需要因为Go升级而修改代码。
  3. 如果不按照此约定使用Reset(),有可能遇到Reset()和Timer触发同时执行的情况,此时有可能会收到两个事件,从而对应用程序造成一些负面影响,使用时一定要注意

二. Ticker

  1. Ticker是周期性定时器,即周期性的触发一个事件,通过Ticker本身提供的管道将事件传递出去,Ticker的数据结构与Timer完全一致
type Ticker struct {
    C <-chan Time
    r runtimeTimer
}
  1. Ticker对外仅暴露一个channel,指定的时间到来时就往该channel中写入系统时间,也即一个事件, 在创建Ticker时会指定一个时间,作为事件触发的周期。这也是Ticker与Timer的最主要的区别

方法解释

1. 创建定时器

  1. 使用NewTicker方法就可以创建一个周期性定时器,d即为定时器事件触发的周期
func NewTicker(d Duration) *Ticker

2. 停止定时器

  1. 使用定时器对外暴露的Stop方法就可以停掉一个周期性定时器
func (t *Ticker) Stop()
  1. 注意,该方法会停止计时,意味着不会向定时器的管道中写入事件,但管道并不会被关闭。管道在使用完成后,生命周期结束后会自动释放
  2. Ticker在使用完后务必要释放,否则会产生资源泄露,进而会持续消耗CPU资源,最后会把CPU耗尽

3. 获取定时器的管道 Tick(d Duration) <-chan Time

  1. 启动一个定时器并且永远不会停止,比如定时轮询任务,此时可以使用一个简单的Tick函数来获取定时器的管道
func Tick(d Duration) <-chan Time
  1. 实际还是创建一个Ticker,但并不会返回出来,所以没有手段来停止该Ticker。所以,一定要考虑具体的使用场景
  2. 错误示例
//select每次检测case语句时都会创建一个定时器,
//for循环又会不断的执行select语句,
//所以系统里会有越来越多的定时器不断的消耗CPU资源,最终CPU会被耗尽
func WrongTicker() {
    for {
        select {
        case <-time.Tick(1 * time.Second):
            log.Printf("Resource leak!")
        }
    }
}

原理

1. 底层结构

  1. Ticker与Timer内部相同
  1. C: 管道,上层应用跟据此管道接收事件;
  2. r: runtime定时器,该定时器即系统管理的定时器,对上层应用不可见;
type Ticker struct {
    C <-chan Time // The channel on which the ticks are delivered.
    r runtimeTimer
}
  1. Ticker.C即面向Ticker用户的,Ticker.r是面向底层的定时器实现
  2. 并且runtimeTimer与Timer中的相同

2. 创建Ticker

  1. NewTicker()只是构造了一个Ticker,然后把Ticker.r通过startTimer()交给系统协程维护
  1. 其中period为事件触发的周期
  2. sendTime()方法便是定时器触发时的动作
func NewTicker(d Duration) *Ticker {
    if d <= 0 {
        panic(errors.New("non-positive interval for NewTicker"))
    }
    // Give the channel a 1-element time buffer.
    // If the client falls behind while reading, we drop ticks
    // on the floor until the client catches up.
    c := make(chan Time, 1)
    t := &Ticker{
        C: c,
        r: runtimeTimer{
            when:   when(d),
            period: int64(d), // Ticker跟Timer的重要区就是提供了period这个参数,据此决定timer是一次性的,还是周期性的
            f:      sendTime,
            arg:    c,
        },
    }
    startTimer(&t.r)
    return t
}
  1. sendTime接收一个管道作为参数,其主要任务是向管道中写入当前时间
func sendTime(c interface{}, seq uintptr) {
    select {
    case c.(chan Time) <- Now():
    default:
    }
}
  1. 创建Ticker时生成的管道含有一个缓冲区(make(chan Time, 1)),但是Ticker触发的事件确是周期性的,如果管道中的数据没有被取走,那么sendTime()也不会阻塞,而是直接退出,带来的后果是本次事件会丢失

3. 停止Ticker

  1. 只是简单的把Ticker从系统协程中移除
func (t *Ticker) Stop() {
    stopTimer(&t.r)
}
  1. stopTicker()即通知系统协程把该Ticker移除,即不再监控。系统协程只是移除Ticker并不会关闭管道,以避免用户协程读取错误。
  2. 与Timer不同的是,Ticker停止时没有返回值,即不需要关注返回值,实际上返回值也没啥用途
  3. Ticker没有重置接口,也即Ticker创建后不能通过重置修改周期
  4. 注意的是Ticker用完后必须主动停止,否则会产生资源泄露,会持续消耗CPU资源

三. 定时器存储 timer

  1. 前面我们介绍了一次性定时器Timer和周期性定时器Ticker,这两种定时器内部实现机制完全相同。创建定时器的协程并不负责计时,而是把任务交给系统协程,系统协程统一处理所有的定时器
  2. Timer和Ticker数据结构除名字外完全一样,二者都含有一个runtimeTimer类型的成员,这个就是系统协程所维护的对象。runtimeTimer类型是time包的名称,在runtime包中,这个类型叫做timer
  3. timer底层数据结构
type timer struct {
    tb *timersBucket // the bucket the timer lives in   // 当前定时器寄存于系统timer堆的地址
    i  int           // heap index                      // 当前定时器寄存于系统timer堆的下标
    when   int64                                        // 当前定时器下次触发时间
    period int64                                        // 当前定时器周期触发间隔(如果是Timer,间隔为0,表示不重复触发)
    f      func(interface{}, uintptr)                 // 定时器触发时执行的函数
    arg    interface{}                                // 定时器触发时执行函数传递的参数一
    seq    uintptr                                     // 定时器触发时执行函数传递的参数二(该参数只在网络收发场景下使用)
}
  1. 其中timersBucket便是系统协程存储timer的容器,里面有个切片来存储timer,而i便是timer所在切片的下标
  2. 内部包含一个timersBucket类型的属性,查看该数据结构
type timersBucket struct {
    lock         mutex
    gp           *g          // 处理堆中事件的协程
    created      bool        // 事件处理协程是否已创建,默认为false,添加首个定时器时置为true
    sleeping     bool        // 事件处理协程(gp)是否在睡眠(如果t中有定时器,还未到触发的时间,那么gp会投入睡眠)
    rescheduling bool        // 事件处理协程(gp)是否已暂停(如果t中定时器均已删除,那么gp会暂停)
    sleepUntil   int64       // 事件处理协程睡眠时间
    waitnote     note        // 事件处理协程睡眠事件(据此唤醒协程)
    t            []*timer    // 定时器切片
}
  1. timersBucket意为存储timer的容器,内部包含
  1. lock: 互斥锁,在timer增加和删除时需要使用;
  2. gp: 事件处理协程,就是我们所说的系统协程,这个协程在首次创建Timer或Ticker时生成;
  3. create: 状态值,表示系统协程是否创建;
  4. sleeping: 系统协程是否在睡眠;
  5. rescheduling: 系统协程是否已暂停;
  6. sleepUntil: 系统协程睡眠到指定的时间(如果有新的定时任务可能会提前唤醒);
  7. waitnote: 提前唤醒时使用的通知;
  8. t: 保存timer的切片,当调用NewTimer()或NewTicker()时便会有新的timer存到此切片中;
  1. 系统协程在首次创建定时器时创建,定时器存储在切片中,系统协程负责计时并维护这个切片
  2. 用户创建Ticker时会生成一个timer,这个timer指向timersBucket,timersBucket记录timer的指针,假设我们已经创建了3个Ticker,那么它们之间的关系如下
    在这里插入图片描述

timersBucket数组

  1. 通过timersBucket数据结构可以看到,系统协程负责计时并维护其中的多个timer,一个timersBucket包含一个系统协程
  2. 当系统中定时器非常多时,一个系统协程可能处理能力跟不上,所以Go在实现时实际上提供了多个timersBucket,也就有多个系统协程来处理定时器
  3. 最理想的情况,应该预留GOMAXPROCS个timersBucket,以便充分使用CPU资源,但需要跟据实际环境动态分配。为了实现简单,Go在实现时预留了64个timersBucket,绝大部分场景下这些足够了
  4. 每当协程创建定时器时,使用协程所属的ProcessID%64来计算定时器存入的timersBucket
    在这里插入图片描述
  5. 一般情况下,同一个Process的协程创建的定时器分布于同一个timersBucket中,只有当GOMAXPROCS大于64时才会出现多个Process分布于同一个timersBucket中

定时器运行机制

  1. 回顾一下定时器创建过程,创建Timer或Ticker实际上分为两步:
  1. 创建一个管道
  2. 创建一个timer并启动(注意此timer不是Timer,而是系统协程所管理的timer。
  1. 首先,每个timer都必须要归属于某个timersBucket的,所以第一步是先选择一个timersBucket,选择的算法很简单,将当前协程所属的Processor ID 与timersBucket数组长度求模,结果就是timersBucket数组的下标
const timersLen = 64
var timers [timersLen]struct { // timersBucket数组,长度为64
    timersBucket
}
func (t *timer) assignBucket() *timersBucket {
    id := uint8(getg().m.p.ptr().id) % timersLen // Processor ID 与数组长度求模,得到下标
    t.tb = &timers[id].timersBucket
    return t.tb
}
  1. 至此,第一步,给当前的timer选择一个timersBucket已经完成
  2. 其次,每个timer都必须要加入到timersBucket中。前面我们知道,timersBucket中切片中保存着timer的指针,新加入的timer并不是按加入时间顺序存储的,而是把timer按照触发的时间排序的一个小头堆。那么timer加入timersBucket的过程实际上也是堆排序的过程,只不过这个排序是指的是新加元素后的堆调整过程
  3. 函数负责添加timer源码:
func (tb *timersBucket) addtimerLocked(t *timer) bool {
    if t.when < 0 {
        t.when = 1<<63 - 1
    }
    t.i = len(tb.t)                 // 先把定时器插入到堆尾
    tb.t = append(tb.t, t)          // 保存定时器
    if !siftupTimer(tb.t, t.i) {    // 堆中插入数据,触发堆重新排序
        return false
    }
    if t.i == 0 { // 堆排序后,发现新插入的定时器跑到了栈顶,需要唤醒协程来处理
        // siftup moved to top: new earliest deadline.
        if tb.sleeping {                 // 协程在睡眠,唤醒协程来处理新加入的定时器
            tb.sleeping = false
            notewakeup(&tb.waitnote)
        }
        if tb.rescheduling {             // 协程已暂停,唤醒协程来处理新加入的定时器
            tb.rescheduling = false
            goready(tb.gp, 0)
        }
    }
    if !tb.created {       // 如果是系统首个定时器,则启动协程处理堆中的定时器
        tb.created = true
        go timerproc(tb)
    }
    return true
}
  1. 注意:
  1. 如果timer的时间是负值,那么会被修改为很大的值,来保证后续定时算法的正确性;
  2. 系统协程是在首次添加timer时创建的,并不是一直存在;
  3. 新加入timer后,如果新的timer跑到了栈顶,意味着新的timer需要立即处理,那么会唤醒系统协程。
  1. 实际上Go实现时使用的是四叉堆,使用四叉堆的好处是堆的高度降低,堆调整时更快

删除定时器

当Timer执行结束或Ticker调用Stop()时会触发定时器的删除。从timersBucket中删除定时器是添加定时器的逆过程,即堆中元素删除后,触发堆调整

timerproc

  1. timerproc为系统协程的具体实现。它是在首次创建定时器创建并启动的,一旦启动永不销毁。如果timersBucket中有定时器,取出堆顶定时器,计算睡眠时间,然后进入睡眠,醒来后触发事件
  2. 某个timer的事件触发后,跟据其是否是周期性定时器来决定将其删除还是修改时间后重新加入堆
  3. 如果堆中已没有事件需要触发,则系统协程将进入暂停态,也可认为是无限时睡眠,直到有新的timer加入才会被唤醒
  4. 流程图
    在这里插入图片描述

资源泄露问题

  1. 不使用的Ticker需要显式的Stop(),否则会产生资源泄露, 原因:
  1. 创建Ticker的协程并不负责计时,只负责从Ticker的管道中获取事件;其次,系统协程只负责定时器计时,向管道中发送事件,并不关心上层协程如何处理事件
  2. 如果创建了Ticker,则系统协程将持续监控该Ticker的timer,定期触发事件。如果Ticker不再使用且没有Stop(),那么系统协程负担会越来越重,最终将消耗大量的CPU资源

四. 总结

  1. 参考博客
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
实验报告:定时器实验 1. 实验要求 本次实验的目的是完成一个简单的秒表,通过定时器实现精准计时,并通过数码管进行显示。 2. 定时器原理 定时器是一种常见的计时电路,通过定时器芯片的内部计数器和时钟信号来实现计时功能。本实验中使用的是STC89C52单片机内部的定时器模块,包含一个16位的计数器和一个可编程的预分频器,可以通过软件设置计数器的初始值和计数模式,并通过外部时钟信号控制计数器的计数速度。 电路图连线说明: 本次实验需要连接以下几个模块: 1)STC89C52单片机; 2)数码管; 3)按键; 4)晶振电路。 具体连线如下: 1)将晶振电路连接到单片机的XTAL1和XTAL2引脚; 2)将数码管连接到单片机的P0口; 3)将按键连接到单片机的P3.2口。 3. 定时器初始化程序分析 定时器的初始化程序如下: ``` void Timer0Init() { TMOD |= 0x01; //选择为定时器0模式,工作方式1,仅用TR0打开启动计数 TH0 = 0xFC; //给定时器赋初值,定时1ms TL0 = 0x18; ET0 = 1; //打开定时器0中断允许 TR0 = 1; //打开定时器0开关,开始计时 } ``` 定时时间的确定通过给定时器的初值来实现,本实验中初值为0xFC18,表示定时1ms。 4. 数码管显示,走时程序分析 数码管的显示和走时程序如下: ``` void Display(unsigned char num[]) //数码管显示函数 { unsigned char i; for (i = 0; i < 8; i++) { P0 = num[i]; Delay(5); P0 = 0x00; //消隐 } } void Time0() interrupt 1 //定时器0中断服务函数 { TH0 = 0xFC; //给定时器赋初值,定时1ms TL0 = 0x18; ms++; //ms加1 if (ms == 1000) //1s { ms = 0; s++; if (s == 60) //1min { s = 0; m++; if (m == 60) //1hour { m = 0; } } } Display(num); //数码管显示当前时间 } ``` 在定时器0的中断服务函数中,每隔1ms就会将ms加1,当ms累计到1000时,即1s时,将s加1,并判断是否达到1min,1hour的计时要求,最后通过数码管显示当前时间。 5. 实验现象 经过实验,定时器的计时精度较高,可以实现秒表的计时功能,并通过数码管进行清晰的显示。 6. 心得体会 通过本次实验,我了解了定时器的原理和使用方法,学习了如何通过单片机实现计时功能,同时也体验到了调试程序的过程中需要耐心和细心,对于代码的每一个细节都要进行仔细的分析和测试,才能保证程序的正确性和稳定性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值