go

Go 系列 定时器

Posted by lichao modified on August 14, 2022

Ticker

ticker的英文原意是钟表的”滴哒”声,钟表周期性的产生”滴哒”声,也即周期性的产生事件。

Ticker 是周期性定时器,即周期性的触发一个事件,通过Ticker本身提供的管道将事件传递出去。

Ticker的数据结构与Timer完全一致:

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

Ticker 对外仅暴露一个 channel,指定的时间到来时就往该 channel 中写入系统时间,也即一个事件。

在创建 Ticker 时会指定一个时间,作为事件触发的周期。这也是Ticker与Timer的最主要的区别。

使用场景

简单定时任务:

有时,我们希望定时执行一个任务,这时就可以使用 ticker 来完成。下面代码演示,每隔1s记录一次日志:

1
2
3
4
5
6
7
8
9
// TickerDemo 用于演示ticker基础用法 
func TickerDemo() { 
  ticker := time.NewTicker(1 * time.Second) 
  defer ticker.Stop() 
  
  for range ticker.C { 
    log.Println("Ticker tick.") 
  } 
}

上述代码中, for range ticker.C 会持续从管道中获取事件,收到事件后打印一行日志,如果管道中没有数据会阻塞 等待事件,由于ticker会周期性的向管道中写入事件,所以上述程序会周期性的打印日志。

定时聚合任务:

有时,我们希望把一些任务打包进行批量处理。比如,公交车发车场景:

  • 公交车每隔5分钟发一班,不管是否已坐满乘客;
  • 已坐满乘客情况下,不足5分钟也发车;

下面代码演示公交车发车场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// TickerLaunch用于演示ticker聚合任务用法 
func TickerLaunch() { 
  ticker := time.NewTicker(5 * time.Minute) 
  maxPassenger := 30 // 每车最大装载人数 
  passengers := make([]string, 0, maxPassenger) 
  
  for { 
    passenger := GetNewPassenger() // 获取一个新乘客 
    if passenger != "" { 
      passengers = append(passengers, passenger) 
    } else { 
      time.Sleep(1 * time.Second) 
    } 
    
    select { 
      case <- ticker.C: // 时间到,发车 
        Launch(passengers) 
        passengers = []string{} 
      default: 
        if len(passengers) >= maxPassenger { 
          // 时间没到,车已座满,发车 
          Launch(passengers) 
          passengers = []string{} 
        } 
    } 
  } 
}

上面代码中for循环负责接待乘客上车,并决定是否要发车。每当乘客上车,select语句会先判断ticker.C中是否 有数据,有数据则代表发车时间已到,如果没有数据,则判断车是否已坐满,坐满后仍然发车。

Ticker对外接口

创建定时器:

使用NewTicker方法就可以创建一个周期性定时器,函数原型如下:

1
func NewTicker(d Duration) *Ticker

其中参数 d 即为定时器事件触发的周期。

停止定时器:

使用定时器对外暴露的Stop方法就可以停掉一个周期性定时器,函数原型如下:

1
func (t *Ticker) Stop()

需要注意的是,该方法会停止计时,意味着不会向定时器的管道中写入事件,但管道并不会被关闭。管道在使用完成后,生命周期结束后会自动释放。

Ticker在使用完后务必要释放,否则会产生资源泄露,进而会持续消耗CPU资源,最后会把CPU耗尽。更详细的信 息,后面我们研究Ticker实现原理时再详细分析。

简单接口:

部分场景下,我们启动一个定时器并且永远不会停止,比如定时轮询任务,此时可以使用一个简单的Tick函数来获取 定时器的管道,函数原型如下:

1
 func Tick(d Duration) <-chan Time

这个函数内部实际还是创建一个Ticker,但并不会返回出来,所以没有手段来停止该Ticker。所以,一定要考虑具体的使用场景。

错误示例:

Ticker用于for循环时,很容易出现意想不到的资源泄露问题,下面代码演示了一个泄露问题:

1
2
3
4
5
6
7
8
func WrongTicker() {
  for {
    select {
      case <-time.Tick(1 * time.Second):
        log.Printf("Resource leak!")
      }
    }
}

上面代码,select每次检测case语句时都会创建一个定时器,for循环又会不断的执行select语句,所以系统里会 有越来越多的定时器不断的消耗CPU资源,最终CPU会被耗尽。

总结:

Ticker相关内容总结如下:

  • 使用time.NewTicker()来创建一个定时器;
  • 使用Stop()来停止一个定时器;
  • 定时器使用完毕要释放,否则会产生资源泄露;

源码解析

实际上,Ticker与之前讲的Timer几乎完全相同,无论数据结构和内部实现机制都相同,唯一不同的是创建方式。

Timer创建时,不指定事件触发周期,事件触发后Timer自动销毁。而Ticker创建时会指定一个事件触发周期,事件 会按照这个周期触发,如果不显式停止,定时器永不停止。

Ticker 数据结构与Timer除名字不同外完全一样。 源码包 src/time/tick.go:Ticker 定义了其数据结构:

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

Ticker 只有两个成员:

  • C: 管道,上层应用跟据此管道接收事件;
  • r: runtime定时器,该定时器即系统管理的定时器,对上层应用不可见;

这里应该按照层次来理解 Ticker 数据结构,Ticker.C 即面向 Ticker 用户的,Ticker.r 是面向底层的定时器实现。

runtimeTimer也与Timer一样,这里不再赘述。

创建Ticker:

我们来看创建Ticker的实现,非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (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
}

NewTicker() 只是构造了一个 Ticker,然后把 Ticker.r 通过 startTimer() 交给系统协程维护。

其中 period 为事件触发的周期。

其中 sendTime() 方法便是定时器触发时的动作:

1
2
3
4
5
6
func sendTime(c interface{}, seq uintptr) {
  select {
    case c.(chan Time) <- Now():
    default:
  }
}

sendTime接收一个管道作为参数,其主要任务是向管道中写入当前时间。

创建Ticker时生成的管道含有一个缓冲区( make(chan Time, 1) ),但是Ticker触发的事件确是周期性的,如果管 道中的数据没有被取走,那么sendTime()也不会阻塞,而是直接退出,带来的后果是本次事件会丢失。