golang - time包深入浅出

1,179 阅读5分钟

​ time 包在go中很轻巧,也很好用,因此为了好奇,我决定研究一下,哈哈哈哈。开发中也经常使用到这个包,主要是思考:

  • 大量使用Timer不影响程序性能吗?
  • 使用Timer需要注意的细节点?
  • Timer的实现原理?
  • time.Time的使用技巧和注意点。

一、Timer 和 Ticker

1、基本使用

​ 这俩都是定时器,那么区别在哪

func main() {
	wg := sync.WaitGroup{}
	wg.Add(1)
	go func() {
		//  ticket 滴答器,它像表盘的秒针一样,每隔1s滴答一次,所以循环里无限滴答
		ticket := time.NewTicker(time.Second)
		for {
			<-ticket.C
			fmt.Println("ticker trigger", time.Now().Format("2006-01-02 15:04:05"))
		}
	}()

	go func() {
		// timer 定时器,它就是个纯粹的定时器,可以理解为一段时间后发生什么,像下面这种写法第二次直接无限阻塞下去了,因为channel没有数据了。
		timer := time.NewTimer(time.Second)
		for {
			<-timer.C
			fmt.Println("timer trigger", time.Now().Format("2006-01-02 15:04:05"))
		}
	}()

	runtime.Gosched()
	wg.Wait()
}

输出:

timer trigger 2020-05-25 23:44:39
ticker trigger 2020-05-25 23:44:40
ticker trigger 2020-05-25 23:44:42
ticker trigger 2020-05-25 23:44:44

可以看到,timer只会触发一次,而ticket会一直触发

简单的差别就是上面讲的, 那么ticket触发,那么如果我加入任务时间呢

go func() {
  //  ticket 滴答
  ticket := time.NewTicker(time.Second)
  for {
    cur := <-ticket.C
    // 休息2s
    time.Sleep(2 * time.Second)
    fmt.Printf("ticker trigger %s receive:%s\n", time.Now().Format("2006-01-02 15:04:05"),cur.Format("2006-01-02 15:04:05"))
  }
}()

输出:

ticker trigger 2020-05-25 23:50:46 receive:2020-05-25 23:50:44
ticker trigger 2020-05-25 23:50:48 receive:2020-05-25 23:50:45
ticker trigger 2020-05-25 23:50:50 receive:2020-05-25 23:50:47
ticker trigger 2020-05-25 23:50:52 receive:2020-05-25 23:50:49
ticker trigger 2020-05-25 23:50:54 receive:2020-05-25 23:50:51

根据结果可以发现,recive 时间还是有出入的,因为Ticker本身是1s滴答一次,但是接受时间却是2s,这个是为啥呢?,后续解释

2、Reset Stop 方法

reset : 关闭/重置/启动

func (t *Timer) Reset(d Duration) bool {
	if t.r.f == nil {
		panic("time: Reset called on uninitialized Timer")
	}
	w := when(d)
	active := stopTimer(&t.r) // 关闭
	t.r.when = w // 重置
	startTimer(&t.r)// 启动
	return active
}

stop : 关闭Timer

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

那么使用中经常看到StopReset , 那么问题:

  • 1、Stop一定不会接受到通知了吗?,那么无期限的阻塞下去怎么办呢?
  • 2、Stop后还能Reset吗?

问题一

func main() {
	timer := time.NewTimer(time.Second)
	start := time.Now()
	time.Sleep(time.Second * 2)
	fmt.Printf("Stop: %v\n", timer.Stop()) // 在timer启动2s后关闭timer,由于buffer是1个缓冲区,1s的时候就塞进去数据了,所以造成Stop后,channel还有数据,也就是会造成触发。
	cur := <-timer.C
	fmt.Printf("start %s,timer trigger %s receive:%s\n", start.Format("15:04:05"), time.Now().Format("15:04:05"), cur.Format("15:04:05"))
}
//Stop: false
//start 00:03:20,timer trigger 00:03:22 receive:00:03:21

那么出现这么问题如何解决呢?是的timer.Stop()返回值判断哇?,因此假如程序如下,会发生什么呢?

func main() {
	timer := time.NewTimer(time.Second)
	start := time.Now()
	fmt.Printf("Stop: %v\n", timer.Stop()) // 在timer启动2s后关闭timer,由于buffer是1个缓冲区,1s的时候就塞进去数据了,所以造成Stop后,channel还有数据,也就是会造成触发。
	time.Sleep(time.Second * 2)
	/// 。。。。不变
}
// Stop: true
// fatal error: all goroutines are asleep - deadlock! // pannic,无法recover的panic,死锁。

此时我们可以发现只需要判断一下就行了,因为关闭成功返回true,也就是保证Chanel里没有数据。对于程序而言,因为如果出现上面这种代码,直接panic的,绝对接受不了的。因此需要做判读,也就是如果关闭成功了,就不去执行接受channel数据了,这个也是官方推荐的写法。

func main() {
	timer := time.NewTimer(time.Second)
	if !timer.Stop() { // 关闭成功,不去执行。
		cur := <-timer.C
		fmt.Printf("timer trigger %s receive:%s\n", time.Now().Format("15:04:05"), cur.Format("15:04:05"))
	}
}

问题二

func main() {
	timer := time.NewTimer(time.Second)
	timer.Stop() // stop
	timer.Reset(time.Second) // 重置
	time.Sleep(time.Second * 2)// 等待2s,此时timer的channel接收到数据
	if !timer.Stop() { // 关闭失败,因为触发过一次
		cur := <-timer.C // 接受数据,触发定时器。
		fmt.Printf("timer trigger %s receive:%s\n", time.Now().Format("15:04:05"), cur.Format("15:04:05"))
	}
}

显然是可以重置的,那么具体为啥可以重置等等,我们继续深入研究。

3、源码分析

1、基本结构

​ 可以get到,这俩对象本身来说并无结构差异,所以他们的实现决定与两个参数

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

runtimeTimer 源码位置:src/runtime/time.go:18 ,这里就不过多解释为啥go可以结构转换了。

type runtimeTimer struct {
	tb uintptr     // the bucket the timer lives in, 哪个桶
	i  int        // heap index ,堆内的索引
	when   int64   // 下一次触发的时间
	period int64  // 循环周期,这里大于0就会循环触发,后续讲解
	f      func(interface{}, uintptr) // 这个是定时器触发执行的方法,回掉
	arg    interface{} // 这个是f的第一个参数
	seq    uintptr // 这个是f的第二个参数
}

2、初始化

Timer初始化源码:

func NewTimer(d Duration) *Timer {
  c := make(chan Time, 1) // 一个缓冲区大小,原因至少第一次的时间是对的。比如我启动一个timer(1s),但是我使用channel去接受的时候已经过去2s,此时加入这个是blocking chan,此时就需要继续等待1s。
	t := &Timer{
		C: c,
		r: runtimeTimer{
			when: when(d),
			f:    sendTime,
			arg:  c,
		},
	}
	startTimer(&t.r)
	return t
}
func sendTime(c interface{}, seq uintptr) {
	select {
	case c.(chan Time) <- Now():
	default: // default,说明这个是个无阻塞的方法。
	}
}

Ticket初始化源码:

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),// 多了一个period,代表周期执行。
			f:      sendTime, // 和timer的一样.
			arg:    c,
		},
	}
	startTimer(&t.r)
	return t
}

所以核心是理解:startTimerstopTimer 方法。后续讲解。

3、startTimer 方法分析

源码位置:src/runtime/time.go:110

// go linkname不做解释,有兴趣的可以去了解一下。多为了系统实现,防止外部程序直接使用
//go:linkname startTimer time.startTimer
func startTimer(t *timer) {
	if raceenabled { // race,说明这个方法本身存在并发安全的问题。
		racerelease(unsafe.Pointer(t))
	}
	addtimer(t)
}

addtimer 方法

func addtimer(t *timer) {
  tb := t.assignBucket() // 获取桶,根据一个当前goroutine的值去mod`GOMAXPROCS(默认64)`获取对应的桶(一个goroutine的timer全部落在一起的好处是相互隔离)
	lock(&tb.lock) // lock 桶,所以这个锁是锁住整个桶
	ok := tb.addtimerLocked(t)
	unlock(&tb.lock)
	if !ok {
		badTimer()
	}
}

addtimerLocked 将timer添加到桶内

func (tb *timersBucket) addtimerLocked(t *timer) bool {
	if t.when < 0 { // 时间<0,直接无期限限制
		t.when = 1<<63 - 1
	}
	t.i = len(tb.t)              // timer在桶中的index
	tb.t = append(tb.t, t)       // timer添加到桶中
	if !siftupTimer(tb.t, t.i) { //添加,堆排序(heapify)
		return false
	}
	if t.i == 0 { // 如果它在首位,也就是min(when-now)的那个
		// siftup moved to top: new earliest deadline.
		if tb.sleeping && tb.sleepUntil > t.when { // g睡眠 && 最近一次发生的时间比添加的这个发生时间还要迟(说明需要唤醒)
			tb.sleeping = false      // 唤醒
			notewakeup(&tb.waitnote) // 唤醒(这里是唤醒用户程序等待timer的g)
		}
		if tb.rescheduling { // 如果主g被挂起,
			tb.rescheduling = false //标记未挂起
			goready(tb.gp, 0) // 唤醒主g
		}
		if !tb.created {// 第一次初始化这个bucket的主g
			tb.created = true//标记已创建
			go timerproc(tb)// 主g运行,死循环
		}
	}
	return true
}

siftupTimer Heap maintenance algorithms 方法

func siftupTimer(t []*timer, i int) bool {
	if i >= len(t) { // 原长度>=添加后的长度,false,正常调用这个是不可能的。
		return false
	}
	when := t[i].when//最后那个
	tmp := t[i]//
	for i > 0 { // 
		p := (i - 1) / 4 // parent
		if when >= t[p].when {
			break
		}
		t[i] = t[p]
		t[i].i = i
		i = p
	}
	if tmp != t[i] {
		t[i] = tmp
		t[i].i = i
	}
	return true
}

timerproc 核心方法,主函数,启动一个g去循环的执行任务

func timerproc(tb *timersBucket) {
	tb.gp = getg()
	for {
		lock(&tb.lock)      // 锁住整个桶,停止add,del
		tb.sleeping = false // 停止sleep
		now := nanotime()   // 获取当前系统时间
		delta := int64(-1)
		for { // 循环处理桶内全部的timer,直到桶清空/最近的一个发生时间>now
			if len(tb.t) == 0 { // 0,直接break
				delta = -1
				break
			}
			t := tb.t[0] // 第一个是时间最近的那个
			delta = t.when - now
			if delta > 0 { // 时间未到,break
				break
			}
			// delta<0
			ok := true
			if t.period > 0 { // 循环周期>0,运行继续循环,因此需要添加到堆中,重排
				// leave in heap but adjust next time to fire
				t.when += t.period * (1 + -delta/t.period)
				if !siftdownTimer(tb.t, 0) {
					ok = false
				}
			} else {
				// remove from heap // 从堆中移除
				last := len(tb.t) - 1
				if last > 0 {
					tb.t[0] = tb.t[last]
					tb.t[0].i = 0
				}
				tb.t[last] = nil
				tb.t = tb.t[:last]
				if last > 0 {
					if !siftdownTimer(tb.t, 0) {
						ok = false
					}
				}
				t.i = -1 // mark as removed

			}
			// 获取f
			f := t.f
			arg := t.arg
			seq := t.seq
			unlock(&tb.lock) // 解锁
			if !ok {
				badTimer()
			}
			if raceenabled {
				raceacquire(unsafe.Pointer(t))
			}
			f(arg, seq)    // 执行func,所以这个方法不能阻塞。因此timer使用select+default,afterFunc采用开启一个新的goroutine
			lock(&tb.lock) //
		}
		if delta < 0 || faketime > 0 { // 1、防止空转,挂起 g,与只相互对应的是goready
			// No timers left - put goroutine to sleep.
			tb.rescheduling = true // true表示g被挂起
			goparkunlock(&tb.lock, waitReasonTimerGoroutineIdle, traceEvGoBlock, 1)
			continue
		}
		// 如果timer时间未到,睡会觉,等待被唤醒
		// At least one timer pending. Sleep until then.
		tb.sleeping = true // true表示睡眠中
		tb.sleepUntil = now + delta
		noteclear(&tb.waitnote)
		unlock(&tb.lock)
		notetsleepg(&tb.waitnote, delta) //sleep delta
	}
}

到此,add timer 方法就结束了,可以get到,go在这里很细节,

1、使用堆排序,排序时间,效率高

2、使用桶,解决依靠一个堆排序整个程序的任务的难度

3、当没有timer时候挂起g,防止空转的发生

4、当最近的那个发生时间还未到,挂起g,防止空转

4、stopTimer 方法分析

func stopTimer(t *timer) bool {
	return deltimer(t)
}
func deltimer(t *timer) bool { // 删除timer
	if t.tb == nil {
		return false
	}
	tb := t.tb
	lock(&tb.lock) // lock
	removed, ok := tb.deltimerLocked(t)
	unlock(&tb.lock)
	if !ok {
		badTimer()
	}
	return removed
}

deltimerLocked ,就是从堆中移除timer,

func (tb *timersBucket) deltimerLocked(t *timer) (removed, ok bool) {
	// t may not be registered anymore and may have
	// a bogus i (typically 0, if generated by Go).
	// Verify it before proceeding.
	i := t.i// index
	last := len(tb.t) - 1//last 
	if i < 0 || i > last || tb.t[i] != t {
		return false, true
	}
	// 被移除的不是最后那个,和最后一个交换
	if i != last {
		tb.t[i] = tb.t[last]
		tb.t[i].i = i
	}
	tb.t[last] = nil
	tb.t = tb.t[:last]
	ok = true
	if i != last {
		if !siftupTimer(tb.t, i) {
			ok = false
		}
		if !siftdownTimer(tb.t, i) {
			ok = false
		}
	}
	return true, ok
}

5、time.Sleep的实现原理

//go:linkname timeSleep time.Sleep
func timeSleep(ns int64) {
	if ns <= 0 {
		return
	}

	gp := getg() // 每一个g都有一个timer,属于g作用域,所以go的设计者它自己可以使用g这个作用域的变量,不让用户使用,类似于Java的ThreadLocal
	t := gp.timer
	if t == nil { // ? 这里的代码确实奇葩,唯一作用防止空指针。。
		t = new(timer)
		gp.timer = t
	}
	*t = timer{}// 
	t.when = nanotime() + ns//发生时间
	t.f = goroutineReady // 到期限后唤醒g
	t.arg = gp
	tb := t.assignBucket()// 添加timer
	lock(&tb.lock)
	if !tb.addtimerLocked(t) {
		unlock(&tb.lock)
		badTimer()
	}
	goparkunlock(&tb.lock, waitReasonSleep, traceEvGoSleep, 2)// 挂起g
}
// 唤醒g
func goroutineReady(arg interface{}, seq uintptr) {
	goready(arg.(*g), 0)
}

4、总结

1、start timer ,大致就是添加到堆中,一个g去根据触发事件执行

2、stop timer,大致就是直接从堆中移除。

3、使用堆排序的原因是什么?其次这个堆排序的原理是啥?,这个堆排序的算法叫啥?

4、最开始说的:Reset : 关闭/重置/启动,其实关闭就是从堆中移除,启动就是添加。所以本质上我们NewTimer也就是执行了添加,而Reset是移除后再添加。 Stop:关闭,本质上就是从堆中移除。所以大家懂了吗。。。。

5、日常开发中,对好使用Timer后,复用一个Timer,减少Channel的实例话,减少GC,灵活使用ResetStop方法达到业务效果。

6、TimerTicker各有使用场景,看业务需求。

7、time.Sleep 也是个Timer,调用time.Sleep的时候将当前g挂起,到触发的时候,将g的唤醒。(可以看出go的设计者复用的思想,其次就是go的开发者对于g级别的变量的偏心,只让自己用,其他人你别用,也可能是为了设计的安全,因为g的设计者认为启动/创建一个g很简单,并不希望用户给一个g绑定太多的东西,影响gc等,比如这个g回收了,那么g级别的变量就需要回收,此时造成回收难度)

5、关于Time的堆排序(动态寻找最小子节点)

1、siftupTimer

siftupTimer 从下自上过滤排序,实这个算法不难,它本质上是一个四叉堆,因为对于四叉堆来说,每一个父节点都是(index-1)/4 ,因此根据4我们确定了是四叉堆。

大致流程就是:子节点和父节点(父节点小于子节点),如果子比父还小,就交换。交换完后,父为子,继续找父的父去比较。最终找到合适的位置。

所以最差的复杂度其实是,log4(n),效率很高的。


// 这个方法参数i一直是最后一个节点
func siftupTimer(t []int, i int) bool {
	if i >= len(t) {
		return false
	}
	when := t[i]
	tmp := t[i] // 最后一个节点
	for i > 0 {
		p := (i - 1) / 4 // 节点的父节点(四叉树)
		if when >= t[p] { // 如果最后一个节点比父节点大,break
			break
		}
		t[i] = t[p] // else交换,说明我插入的节点比较小,继续向上寻找,找到合适的位置
		i = p
	}
	if tmp != t[i] {// 交换
		t[i] = tmp
	}
	return true
}
2、siftdownTimer

siftdownTimer 自上而下,这种情况一般是 :根节点发生变更,需要将跟节点移除/或者值发生变化,此时一般做法是将最后一个和跟节点交换,执行siftdownTimer。算法流程很简单,就是找子节点小的交换,交换完后,子节点为目标作为父节点,继续找是否有子节点比自己小的。

所以最差的复杂度其实是,4log4(n),效率很高的。

// 这个方法参数i一直是0或者父节点
func siftdownTimer(t []int, i int) bool {
	n := len(t)
	if i >= n {
		return false
	}
	when := t[i] // 根节点(目标节点)
	for {
		c := i*4 + 1 // left child
		c3 := c + 2  // mid child
		if c >= n {  // 子节点 out of bound,直接break,说明遍历完毕
			break
		}
		w := t[c]
		if c+1 < n && t[c+1] < w { // 子节点1 和 子节点2比较,选最小的
			w = t[c+1]
			c++
		}
		if c3 < n {
			w3 := t[c3]
			if c3+1 < n && t[c3+1] < w3 { // 子节点3和4比较,选最小的。
				w3 = t[c3+1]
				c3++
			}
			if w3 < w { // 3 1比较选最小的,目的其实是为了寻找四个子节点谁最小。
				w = w3
				c = c3
			}
		}
		if w >= when { // 如果 最小值比我们需要插入的节点还要大,说明我们插入的节点就是最小值,直接break
			break
		}
		t[i] = t[c] // else,和子节点交换。继续遍历(以子节点为父节点继续找,找到子节点的子节点是否有比这个值小的)
		i = c
	}
	if when != t[i] { // 最后把我们需要交换的节点和它交换
		t[i] = when
	}
	return true
}

优点:我们只需要最小子节点,并不需要排序,所以利用小顶堆的特性,很好的解决了复杂度高的问题。因为对于排序算法复杂度是nlogn,但是此算法是。log4(n) 和4log4(n)。

二、Time

​ go的time.Time特别的好用,简直和做加减乘除一样。。。和Java1.8加入的Time差不多,Java感觉别用Date类了吧。

今天时间有限,后期再讲。。业务中经常用时间计算。