支持首次触发的 Go Ticker

促使我写这篇文章主要是在写一个关于虚拟货币账户监控的项目时使用 Ticker 的问题。

Ticker 的问题

如果用过 Ticker 的朋友会知道,创建 Ticker 后并不会马上执行,而是会等待一个时间 d,这就是创建时的间隔时间。如果间隔时间很短这基本上不会有太大问题,但是如果对首次执行时间有要求,就会很麻烦。例如以下这个案例:


package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	ts := time.NewTicker(5 * time.Second)
	fmt.Println("start_time#", time.Now().Unix())
	chanClose := make(chan struct{})
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
			case <-chanClose:
				return
			case <-ts.C:
				fmt.Println("run_time#", time.Now().Unix())
			}
		}
	}()

	go func() {
		time.Sleep(10 * time.Second)
		chanClose <- struct{}{}
		ts.Stop()
	}()
	wg.Wait()
}

它将返回以下内容:

start_time# 1656860176
run_time# 1656860181
run_time# 1656860186

为了方便演示我们在事例中设了一个很短的时间,我们可以看到从代码启动到真正定时器触发,代码等待了5秒,就是time.NewTicker 创建时我们传的参数时间。但如果我们把这个时间改成1个小时,我们需要等待1个小时才会真正开始执行。

寻找解决方案

在我的项目中需要定时器马上执行,所以我通过搜索搜到了 Go 官方仓库 Issues 中提到过这个问题的解决方案 “time: create ticker with instant first tick”。我们可以看一下这个事例:

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	ts := time.NewTicker(5 * time.Second)
	fmt.Println("start_time#", time.Now().Unix())
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		for ; true; <-ts.C {
			fmt.Println("run_time#", time.Now().Unix())
		}
	}()

	go func() {
		time.Sleep(10 * time.Second)
		ts.Stop()
		wg.Done()
	}()
	wg.Wait()
}

上述的执行后返回内容:

start_time# 1656860889
run_time# 1656860889
run_time# 1656860894

我们可以看到首次定时器触发任务的时间变成了程序执行的开始时间!在我们的例子中,这种方式没有问题,但是我们需要关注退出条件,在这里是 main goroutine 直接退出。第一个 goroutine 其实直到 main 退出前一直是堵塞状态。如果你的项目中多次使用这种形式的定时器,每一个都会有一个堵塞的 goroutine,虽然不会对你程序造成 panic,但我还是感觉不是很好。

我的版本

先上代码:

package ticktock

import (
	"time"
)
// 这个结构体内容是为了兼容 Ticker 的使用方式
type tickerStart struct {
	C      chan time.Time
	ticker *time.Ticker
	close  chan struct{}
}

func NewTickerStart(d time.Duration) *tickerStart {
    // 这里我们创建的 channel 设了一个 buffer,原因是我们需要
    // 在下面 Start 方法中及时推送当前时间而不至于堵塞。
    // 
	c := make(chan time.Time, 1) 
	return &tickerStart{ticker: time.NewTicker(d), C: c, close: make(chan struct{})}
}
// 这是我们核心的方法
func (ts *tickerStart) Start() {
	ts.C <- time.Now() // 首次触发关键
	go func() {
		for {
			select {
			case _, ok := <-ts.close: // 用于关闭这个 goroutine
				if !ok {
					return
				}
			case t := <-ts.ticker.C: 
			// 把go原生定时器 push 的时间推送到我们定义的 time channel 中
				ts.C <- t
			}

		}
	}()
}
// 兼容 ticker
func (ts *tickerStart) Reset(d time.Duration) {
	ts.ticker.Reset(d)
}
// 兼容 ticker
func (ts *tickerStart) Stop() {
	ts.ticker.Stop()
	close(ts.close)
}

使用代码如下:

package main

import (
	"fmt"
	"sync"
	"time"
	"ticktock"
)

func main() {
	fmt.Println("start_time#", time.Now().Unix())
	chanClose := make(chan struct{})
	tts := ticktock.NewTickerStart(5 * time.Second)
	tts.Start()
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
			case <-chanClose:
				return
			case <-tts.C:
				fmt.Println("run_time#", time.Now().Unix())
			}
		}
	}()

	go func() {
		time.Sleep(10 * time.Second)
		chanClose <- struct{}{}
		tts.Stop()
	}()
	wg.Wait()
}

执行返回内容如下:

start_time# 1656861872
run_time# 1656861872
run_time# 1656861877

可以看到,和我们想要的一致。但和官方给出的不同我们不会堵塞 goroutine 。

最后

这是我在写虚拟货币账户监控项目中碰到的其中一个问题,我也会在后续的文章中写一写我碰到的其他问题。当然这个项目会开源,可以关注我的 github GanymedeNil’s github

最后的最后

顺便给自己宣传一下,如果你对我感兴趣或者想和我聊聊可以加我微信 ganymede-nil和下载我的简历,找工作中,注明加我的理由,防止被我当作营销人员😊。