go-cron定时的简单学习 | 青训营笔记

585 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记。

本篇笔记是关于go-cron定时任务的,定时任务是写项目必不可少的一项工作,该篇部分来源于对项目的思考以及参考了cron的官方文档(pkg.go.dev/github.com/… 如果阐述有错误还望指正,本人确实刚接触go不久~。

1.cron的简单使用

阅读了官方文档会发现cron的使用及其简洁,只需要调用cron.New得到cron.Cron对象,然后通过AddFunc加入实际的定时任务,如实例代码1.1,最后调用Start启动定时任务。下述代码的功能就是每隔4秒打印“定时任务2启动”。

//代码1.1
c := cron.New(cron.WithSeconds())
c.AddFunc("*/4 * * * * *", func() {
 log.Println("定时任务2启动")
})
c.Start()

到此为止,我认为以上就是cron的简单使用(童叟无欺!)。

2.cron的一些细节

2.1 cron表达式

第一次学习定时框架还是在学java的过程中接触到的,大部分定时框架都离不开cron表达式。cron表达式是一个字符串,该字符串由6个空格分为7个域,每一个域代表一个时间含义。

正如代码1.1中的*/4 * * * * *,从左到右每一个域依次是秒、分、时、日、月、周。实际使用的时候,我更推荐使用cron自动生成(见下图),并且在之前用java的定时器写cron时,这种方法简直不要太香(其实是我记不住cron表达式那些符号的意义偷了个懒)。这里贴出在线生成的网站(cron.ciding.cc/)。 图2-1.png

在cron/v3中,如果要支持上述格式的cron表达式,需要在创建Cron的时候指定cron.WithSeconds()(正如代码1.1所示)。这是因为底层默认有一套自定义的cron表达式,其是没有支持到秒的。

2.2 cmd函数

cmd函数是AddFunc函数的第二个参数,代表了待执行函数,而其第一个参数就是2.1节介绍的cron表达式。底层实现中,其实是把cmd函数转为了Job接口类型(这里涉及了适配器模式)。其底层的Job接口以及FuncJob的实现见代码2.1。根据源码也能发现,需要定时执行的函数是没有任何参数以及返回值的。

//代码2.1
// Job接口
type Job interface {
   Run()
}
// FuncJob类型
type FuncJob func()
func (f FuncJob) Run() { f() }

2.3 cron定时原理

通过AddFunc就把相应的定时时间spec解析为特定的Schedule类型,并将以于定时任务cmd组合成Entry结构体。再调用Start函数的时候,会另开一个groutine执行任务(见代码2.2)。

//代码2.2
// Start the cron scheduler in its own goroutine, or no-op if already started.
func (c *Cron) Start() {
   c.runningMu.Lock()
   defer c.runningMu.Unlock()
   if c.running {
      return
   }
   c.running = true
   go c.run()
}

而cron是如何执行定时任务的,其原理都在run函数中。run函数首先会把所有的任务下一次执行的时间进行排序(具体实现是通过将entry转为byTime并为自定义了排序规则。)需要注意的是,所有的任务都是单独在一个groutine,是相互独立的,也就是说如果其中一个任务因为某些原因而一直未执行完也不会影响到其余任务的正常执行。 同时,对于任务的执行规则,其底层也定义了jobWrapper为各类任务提供了特殊的功能。我认为这里最终要的就是每一次定时周期到了但是上一次任务还未执行完成,是继续执行还是再开一个新的任务。对此,我写了测试代码进行说明,见代码2.3与代码2.4。

//代码2.3
c := cron.New(cron.WithSeconds())
log.Println("开启定时任务")
c.AddFunc("*/2 * * * * *", func() {
   index := rand.Int()
   log.Printf("定时任务%d启动\n", index)
   time.Sleep(3 * time.Second)
   log.Printf("定时任务%d结束\n", index)
})

//代码2.4
c := cron.New(cron.WithSeconds(), cron.WithChain(cron.SkipIfStillRunning(cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)))))
log.Println("开启定时任务")
c.AddFunc("*/2 * * * * *", func() {
   index := rand.Int()
   log.Printf("定时任务%d启动\n", index)
   time.Sleep(3 * time.Second)
   log.Printf("定时任务%d结束\n", index)
})

代码2.3的效果如下,不难发现,在定时周期为2秒,任务执行时间3秒的情况下,一个任务是无法在一个定时周期内完成的,当其在新的周期轮次的时候,开启了新的一次任务,所以出现了图中黄色线条标注的现象。

1653400918(1).png

代码2.4的效果如下,很明显地可以看到,每一次任务的开始一定会紧随着其结束,中间不会再一次启动新的任务。另外,从这个小实验也可以看出,当跳过了一次任务后,其会在这个任务结束的下一秒立马开始新的任务。

1653401162(1).png

2.4 关于cron的chain

2.3大致介绍了所有的任务都是独立进行的。对于每一个Cron,通过AddFunc将任务加入到Cron中其实可以看成是把这些任务都放到了一条链上,这些链上的任务都会经过同一个jobWrapper进行包装。看一下下面的代码就会豁然开朗(代码2.4与代码2.5)。

//代码2.4
c := cron.New(cron.WithSeconds(), cron.WithChain(cron.SkipIfStillRunning(cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)))))
log.Println("开启定时任务")
c.AddFunc("*/3 * * * * *", func() {
   log.Println("定时任务1启动")
   time.Sleep(4 * time.Second)
   log.Println("定时任务1结束")
})
c.AddFunc("*/3 * * * * *", func() {
   log.Println("定时任务2启动")
   log.Println("定时任务2结束")
})
c.AddFunc("*/3 * * * * *", func() {
   log.Println("定时任务3启动")
   log.Println("定时任务3结束")
})

//代码2.5
c := cron.New(cron.WithSeconds())
//定义了一个chain
chain := cron.NewChain(cron.SkipIfStillRunning(cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))))
job := chain.Then(cron.FuncJob(func() {
   log.Println("定时任务1启动")
   time.Sleep(4 * time.Second)
   log.Println("定时任务1结束")
}))

log.Println("开启定时任务")
c.AddJob("*/3 * * * * *", job)
c.AddFunc("*/3 * * * * *", func() {
   log.Println("定时任务2启动")
   log.Println("定时任务2结束")
})
c.AddFunc("*/3 * * * * *", func() {
   log.Println("定时任务3启动")
   log.Println("定时任务3结束")
})

代码2.4的效果如下图,可以看到所有的即便2.4中我们三次分别添加了三个不同的cmd,但是在任务执行周期到来时候,只要3个中其中一个没有执行完成,都不会被开启。比如任务3启动,当要执行任务2的时候任务3没有执行完,此时任务2仍不会被执行。 原因正如上所说,因为在创建cron.Cron的时候,我们指定了一个chain,cron.WithChain(cron.SkipIfStillRunning(cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)))),这个chain会将所有的任务都做相同的处理,比如我们加入了规则没有执行完不会新开一个执行,那么这个规则就会在整个chain上生效,并且彼此之间也是符合这个规则的。如果要想彼此不同的任务在不同的chain上呢,这就需要我们单独定义一些chain,将这些chain作为jobWrapper去装饰不同的job就行了,相当于指定不同的job在不同的chain上。(如代码2.5)

d191fa6642b82c647b1d5e61c528f19.png

代码2.5的效果如下,可以看到任务1是在一条chain上,其每次开启新任务一定会等到上一个任务结束才开始。而任务2和3没有任何chain修饰(看作一个特殊的chain),因此不受此限制。

1653403363(1).png

这里具体解释一下代码2.5中的一些细节。AddJobAddFunc是类似的,只不过前者是自定义Job,后者是底层通过cron.FuncJob装饰器转换成了Job。cron.NewChain新建了一个chain,通过调用then就表明该任务是被加入到这条“链上的”,这条链上所有规则都会作用在此任务上。由于Then只接受Job类型的参数,因此需要适配器cron.FuncJob适配成Job类型。

3. 定时有什么用

对于后端程序,难免会同时操作缓存和数据库。而有的时候我们需要对缓存的数据进行频繁的写,为了缓存与数据库保持一致,又必须把这些写操作同步到数据库。但是频繁的写有一个特点就是只有最后一次写是有效的,我们可以接收对内存的频繁写,但是无法接收数据库的频繁写。因此让频繁写操作内存,延迟一定时间后在同步到数据库,定时就派上用场了!