前言

在最近的日常后台开发中经常遇到定时任务的需求,如定时通知、定时检查等重要的需求,绝对时间一定不会是完全准确的,它对于一个运行中的分布式系统其实没有太多指导意义,但是由于相对时间的计算不依赖于外部的系统,所以它的计算可以做的比较准确,这里简单总结一下定时任务在Go中的实现

需要了解的几个词

  • Channel:Channel 是Go中的一个核心类型,你可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯
  • Goroutine:并发是Go语言最大的特点之一,在Go中可以非常方便地实现并发;Go从语言层面支持并发,Goroutine是Go中最基本的执行单元;事实上每一个Go程序至少有一个Goroutine:主Goroutine;当程序启动时,它就会自动创建

Go定时器的数据结构

timer 是 Golang 定时器的内部表示,每一个 timer 其实都存储在堆中,tb 就是用于存储当前定时器的桶,而 i 是当前定时器在堆中的索引,我们可以通过这两个变量找到当前定时器在堆中的位置:

1
2
3
4
5
6
7
8
9
10
type timer struct {
tb *timersBucket
i int

when int64
period int64
f func(interface{}, uintptr)
arg interface{}
seq uintptr
}

when 表示当前定时器(Timer)被唤醒的时间,而 period 表示两次被唤醒的间隔,每当定时器被唤醒时都会调用 f(args, now) 函数并传入 args 和当前时间作为参数。然而这里的 timer 作为一个私有结构体其实只是定时器的运行时表示,time 包对外暴露的定时器使用了如下所示的结构体:

1
2
3
4
type Timer struct {
C <-chan Time
r runtimeTimer
}

Timer 定时器必须通过 NewTimer 或者 AfterFunc 函数进行创建,其中的 runtimeTimer 其实就是上面介绍的 timer 结构体,当定时器失效时,失效的时间就会被发送给当前定时器持有的 Channel C,订阅管道中消息的 Goroutine 就会收到当前定时器失效的时间。

time 包中,除了 timerTimer 两个分别用于表示运行时定时器和对外暴露的 API 之外,timersBucket 这个用于存储定时器的结构体也非常重要,它会存储一个处理器上的全部定时器,不过如果当前机器的核数超过了 64 核,也就是机器上的处理器 P 的个数超过了 64 个,多个处理器上的定时器就可能存储在同一个桶中:

1
2
3
4
5
6
7
8
9
10
type timersBucket struct {
lock mutex
gp *g
created bool
sleeping bool
rescheduling bool
sleepUntil int64
waitnote note
t []*timer
}

每一个 timersBucket 中的 t 就是用于存储定时器指针的切片,每一个运行的 Go 语言程序都会在内存中存储着 64 个桶,这些桶中都存储定时器的信息

每一个桶持有的 timer 切片其实都是一个最小堆,这个最小堆会按照 timer 应该触发的时间对它们进行排序,最小堆最上面的定时器就是最近需要被唤醒的 timer,下面展开介绍定时器的创建和触发过程

具体实现

一次定时器(Timer)

time 包对外提供了两种创建定时器的方法,第一种方法就是 NewTimer 接口,这个接口会创建一个用于通知触发时间的 Channel、调用 startTimer 方法并返回一个创建指向 Timer 结构体的指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 的方法 AfterFunc 其实也提供了非常相似的结构,NewTimer 方法不同的是该方法没有创建一个用于通知触发时间的 Channel,它只会在定时器到期时调用传入的方法:

1
2
3
4
5
6
7
8
9
10
11
func AfterFunc(d Duration, f func()) *Timer {
t := &Timer{
r: runtimeTimer{
when: when(d),
f: goFunc,
arg: f,
},
}
startTimer(&t.r)
return t
}

使用 NewTimer 创建的定时器,传入函数时 sendTime,它会将当前时间发送到定时器持有的 Channel 中,而使用 AfterFunc 创建的定时器,在内层循环中调用的函数就会是调用方传入的函数

使用NewTimer创建的定时器,走完一个定时周期后,定时器就会暂停工作(Channel C不再发送消息),即可实现单次定时任务

多次定时器(Ticker)

在实际需求中我们更常遇到多次的定时任务,这时就可以用 Go 语言的 time 包中提供的用于多次通知的 Ticker 计时器,计时器中包含了一个用于接受通知的 Channel 和一个定时器,这两个字段共同组成了用于连续多次触发事件的计时器:

1
2
3
4
type Ticker struct {
C <-chan Time // The channel on which the ticks are delivered.
r runtimeTimer
}

想要在 Go 语言中创建一个计时器有两种方法,一种是使用 NewTicker 方法显式地创建Ticker 计时器指针,另一种是直接通过 Tick 方法获取一个会定期发送消息的 Channel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func NewTicker(d Duration) *Ticker {
if d <= 0 {
panic(errors.New("non-positive interval for NewTicker"))
}
c := make(chan Time, 1)
t := &Ticker{
C: c,
r: runtimeTimer{
when: when(d),
period: int64(d),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}

func Tick(d Duration) <-chan Time {
if d <= 0 {
return nil
}
return NewTicker(d).C
}

Tick 其实也只是对 NewTicker 的简单封装,从实现上我们就能看出来它其实就是调用了 NewTicker 获取了计时器并返回了计时器中 Channel

需要注意的是每一个 NewTicker 方法开启的计时器都需要在不需要使用时调用 Stop 进行关闭,如果不显示调用 Stop 方法,创建的计时器就没有办法被垃圾回收,而通过 Tick 创建的计时器由于只对外提供了 Channel,所以没有办法关闭的,我们一定要谨慎使用这一接口创建计时器

总结

Go 语言的定时器在并发编程起到了非常重要的作用,它能够为我们提供比较准确的相对时间,基于它的功能,标准库中还提供了计时器、休眠等接口能够帮助我们在 Go 语言程序中更好地处理过期和超时等问题

标准库中的定时器在大多数情况下是能够正常工作并且高效完成任务的,但是在遇到极端情况或者性能敏感场景时,它可能没有办法胜任,如在10ms的粒度下误差就会变得无法接受