golang 定時器(一) Time, Ticker 基本用法整理
在後端中常常會有需要定時的場景出現,像是如果處理 request 超過一定時間就觸發 timeout,或者利用定時的功能推送請求等等,這篇文章將會簡單介紹一些 golang 內建的定時器功能,以及可能會遇到的坑。
所有有關定時器的功能都在 time 內,使用前必須先
import "time"
單次定時事件 - Timer
// src/time/sleep.go
// The Timer type represents a single event.
// When the Timer expires, the current time will be sent on C,
// unless the Timer was created by AfterFunc.
// A Timer must be created with NewTimer or AfterFunc.
type Timer struct {
    C <- chan Time
    r runtimeTimer
}
在 golang 中 Timer 可以用來表示一個單一事件,可以用 NewTimer 或者 AfterFunc 兩個 function 去建立一個新的 Timer,簡單的使用範例如下
package main
import (
    "fmt"
    "time"
)
func main() {
    fmt.Println("now: ", time.Now().Format("2006-01-02 15:04:05"))
    // 定時三秒之後觸發
    timer := time.NewTimer(3 * time.Second)
    c := <-timer.C
    fmt.Println(c)
    // 在 <-timer.C 那行會等待三秒倒數完成事件發生,才會繼續往下
    fmt.Println("now: ", time.Now().Format("2006-01-02 15:04:05"))
}
執行結果如下

NewTimer
// NewTimer creates a new Timer that will send
// the current time on its channel after at least duration d.
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
}
NewTimer 的 d 參數代 Timer 需要等待多長時間,Timer.C 是一個帶有 buffer 的 channel,在上面的範例中,<-timer.C 那行會把程式阻塞直到 Timer 往 Timer.C 裡面傳送時間(當時倒數完的結果),阻塞解除後才可以繼續往下執行,達到延遲執行,或者定時觸發的目的。
循環執行的定時任務 - Ticker
對於有持續執行的定時任務,可以使用 time.Sleep 或者 time.Ticker 兩種方式來實現。
用 time.Sleep 方式實現
package main
import (
    "fmt"
    "time"
)
func main() {
    sleepTime := 4 * time.Second // 設定每四秒執行一次
    for {
        time.Sleep(sleepTime)
        fmt.Println("now: ", time.Now().Format("2006-01-02 15:04:05"))
    }
}
用 time.Ticker 方式實現
package main
import (
    "fmt"
    "time"
)
func main() {
    count := 0
    IntervalTime := 2 * time.Second      // 觸發間隔時間
    ticker := time.NewTicker(IntervalTime) // 設定 2 秒觸發一次
finish:
    for {
        select {
        case c := <-ticker.C:
            fmt.Println("now: ", c)
            count++
            // 設定 4 輪之後結束工作
            if count == 4 {
                ticker.Stop()
                break finish
            }
        }
    }
}
如果是定時任務的需求,還是建議使用 time.Ticker 去實現,雖然沒有仔細去比較過兩種方式的時間誤差,不過 time.Ticker 利用 channel 的方式可以帶來比較高的彈性,用 select 可以設置超時條件,或者設定 default 的執行方式。
請一定要記得在使用完畢之後呼叫 Stop,不然會造成 memory leak 的問題,假設定時任務跑在背景的 goroutine 上一定要記得在結束前呼叫 Stop(),可以養成下例的好習慣:
go func() {
    ticker := time.NewTicker(5*time.Second)
    defer ticker.Stop()
    for now := range ticker.C {
        // do something
        if (exception) {
            break
        }
    }
}()
利用 defer 的機制讓退出時自動呼叫 Stop
Ticker 的一些注意事項
以下是 NewTicker 與 Stop 的具體實現,位置在 src/time/tick.go
// NewTicker returns a new Ticker containing a channel that will send
// the current time on the channel after each tick. The period of the
// ticks is specified by the duration argument. The ticker will adjust
// the time interval or drop ticks to make up for slow receivers.
// The duration d must be greater than zero; if not, NewTicker will
// panic. Stop the ticker to release associated resources.
func NewTicker(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),
                f: sendTime,
                arg: c,
        },
    }	
    startTimer(&t.r)
    return t
}
// Stop turns off a ticker. After Stop, no more ticks will be sent.
// Stop does not close the channel, to prevent a concurrent goroutine
// reading from the channel from seeing an erroneous "tick".
func (t *Ticker) Stop() {
    stopTimer(&t.r)
}
有時候一些問題一定要看文檔才會注意到,在 Stop 的註解中有講到 Stop does not close the channel, to prevent a concurrent goroutine reading from the channel from seeing an erroneous "tick".
這句話代表什麼呢? 下面用一個簡單的範例來說明,如果今天的場景是
package main 
import (
    "fmt"
    "time"
)
func NewCronJob() *time.Ticker {
    ticker := time.NewTicker(1 * time.Second)
    go func(ticker *time.Ticker) {
        for range ticker.C {
            fmt.Println("Cron job...")
        }
        fmt.Println("Ticker Stop! Channel must be closed.")
    }(ticker)
    return ticker
}
func main() {
    ticker := NewCronJob()
    time.Sleep(5 * time.Second)
    ticker.Stop()
}
實際運作情況:

單從程式看 fmt.Println("Ticker Stop! Channel must be closed.") 這行會發生在 ticker.C 關閉後,實際執行結果並沒有執行到這行,意味著其實 Ticker 內部的 Channel 是沒有關閉的,如果今天程式不斷的使用這種寫法,最終會導致主機嚴重的 memory leak 發生。
如果沒有看過實際的 source code,就不會發現其實在註解的時候已經有明確說明了,Stop 並不會關閉 channel,預防讀取 channel 發生錯誤,所以在 Ticker 的使用上,比較推薦下方這種寫法,可以有效的執行超時之後下方的程式碼,實現起來也比較優雅。
package main
import (
    "fmt"
    "time"
)
func NewCronJob() chan bool {
    ticker := time.NewTicker(1 * time.Second)
    stopChan := make(chan bool)
    go func(ticker *time.Ticker) {
        defer ticker.Stop()
        for {
            select {
            case <-ticker.C:
            fmt.Println("Cron job...")
            case stop := <- stopChan:
                if stop {
                    fmt.Println("Ticker Stop! Channel must be closed")
                    return
                }
            }
        }
    }(ticker)
    return stopChan
}
func main() {
    stopController := NewCronJob()
    time.Sleep(5 * time.Second)
    stopController <- true
    close(stopController)
}
實際運行結果如下

這次我們利用 defer 的機制,在 goroutine 內部呼叫 Stop 的方式,可以看到這種寫法在相較用 for range 來說,彈性高出很多,而且在調用 Tocker.Stop 時並不會 close 該 channel,用 for range 的寫法會造成一個無限迴圈卡在那裡,golang 中只要沒有 goroutine 持有該 channel,到最後那個 channel 就會被 gc,這就是為什麼不推薦使用 for range 的緣故,一般來說使用 for range 的寫法都要配合 close,但是在 Timer 的情況,無法主動關閉內部的 channel,只好退出 goroutine 讓 gc 回收。
Timer vs Ticker
以用法來說,Timer 就是在 timeout 之後觸發一次,Ticker 則是週期性的觸發定時事件,因為 Ticker 是週期性的,所以如果沒有顯式的呼叫 Stop 就會發生上面提到的 Resource leak 問題,之後會再寫一兩篇文章從 source code 切入,還有 Timer reset 的問題也值得探討。