c语言定时器作用,Go语言定时器实现原理及作用
對于任何一個正在運行的應用,如何獲取準確的絕對時間都非常重要,但是在一個分布式系統中我們很難保證各個節點上絕對時間的一致性,哪怕通過 NTP 這種標準的對時協議也只能把時間的誤差控制在毫秒級,所以相對時間在一個分布式系統中顯得更為重要,在接下來的講解中我們將會介紹一下Go語言中的定時器以及它在并發編程中起到什么樣的作用。
絕對時間一定不會是完全準確的,它對于一個運行中的分布式系統其實沒有太多指導意義,但是由于相對時間的計算不依賴于外部的系統,所以它的計算可以做的比較準確,首先介紹一下Go語言中用于計算相對時間的定時器的實現原理。
結構
timer 就是Go語言定時器的內部表示,每一個 timer 其實都存儲在堆中,tb 就是用于存儲當前定時器的桶,而 i 是當前定時器在堆中的索引,我們可以通過這兩個變量找到當前定時器在堆中的位置:
type timer struct {
tb *timersBucket
i? int
when?? int64
period int64
f????? func(interface{}, uintptr)
arg??? interface{}
seq??? uintptr
}
when 表示當前定時器(Timer)被喚醒的時間,而 period 表示兩次被喚醒的間隔,每當定時器被喚醒時都會調用 f(args, now) 函數并傳入 args 和當前時間作為參數。
然而這里的 timer 作為一個私有結構體其實只是定時器的運行時表示,time 包對外暴露的定時器使用了如下所示的結構體:
type Timer struct {
C
r runtimeTimer
}
Timer 定時器必須通過 NewTimer 或者 AfterFunc 函數進行創建,其中的 runtimeTimer 其實就是上面介紹的 timer 結構體,當定時器失效時,失效的時間就會被發送給當前定時器持有的 Channel C,訂閱管道中消息的 Goroutine 就會收到當前定時器失效的時間。
在 time 包中,除了 timer 和 Timer 兩個分別用于表示運行時定時器和對外暴露的 API 之外,timersBucket 這個用于存儲定時器的結構體也非常重要,它會存儲一個處理器上的全部定時器,不過如果當前機器的核數超過了 64 核,也就是機器上的處理器 P 的個數超過了 64 個,多個處理器上的定時器就可能存儲在同一個桶中:
type timersBucket struct {
lock???????? mutex
gp?????????? *g
created????? bool
sleeping???? bool
rescheduling bool
sleepUntil?? int64
waitnote???? note
t??????????? []*timer
}
每一個 timersBucket 中的 t 就是用于存儲定時器指針的切片,每一個運行的Go語言程序都會在內存中存儲著 64 個桶,這些桶中都存儲定時器的信息:
每一個桶持有的 timer 切片其實都是一個最小堆,這個最小堆會按照 timer 應該觸發的時間對它們進行排序,最小堆最上面的定時器就是最近需要被喚醒的 timer,下面來介紹下定時器的創建和觸發過程。
工作原理
既然我們已經介紹了定時器的數據結構,接下來我們就可以開始分析它的常見操作以及工作原理了,在這一節中我們將介紹定時器的創建、觸發、time.Sleep 與定時器的關系以及計時器 Ticker 的實現原理。
創建
time 包對外提供了兩種創建定時器的方法,第一種方法就是 NewTimer 接口,這個接口會創建一個用于通知觸發時間的 Channel、調用 startTimer 方法并返回一個創建指向 Timer 結構體的指針:
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
}
另一個用于創建 Timer 的方法 AfterFunc 其實也提供了非常相似的結構,與 NewTimer 方法不同的是該方法沒有創建一個用于通知觸發時間的 Channel,它只會在定時器到期時調用傳入的方法:
func AfterFunc(d Duration, f func()) *Timer {
t := &Timer{
r: runtimeTimer{
when: when(d),
f: goFunc,
arg: f,
},
}
startTimer(&t.r)
return t
}
startTimer 基本上就是創建定時器的入口了,所有定時器的創建和重啟基本上都需要調用該函數:
func startTimer(t *timer) {
addtimer(t)
}
func addtimer(t *timer) {
tb := t.assignBucket()
tb.addtimerLocked(t)
}
它會調用 addtimer 函數,這個函數總共做了兩件事情,首先通過 assignBucket 方法為當前定時器選擇一個 timersBucket 桶,我們會根據當前 Goroutine 所在處理器 P 的 id 選擇一個合適的桶,隨后調用 addtimerLocked 方法將當前定時器加入桶中:
func (tb *timersBucket) addtimerLocked(t *timer) bool {
t.i = len(tb.t)
tb.t = append(tb.t, t)
if !siftupTimer(tb.t, t.i) {
return false
}
if t.i == 0 {
if tb.sleeping && tb.sleepUntil > t.when {
tb.sleeping = false
notewakeup(&tb.waitnote)
}
if tb.rescheduling {
tb.rescheduling = false
goready(tb.gp, 0)
}
if !tb.created {
tb.created = true
go timerproc(tb)
}
}
return true
}
addtimerLocked 會先將最新加入的定時器加到隊列的末尾,隨后調用 siftupTimer 將當前定時器與四叉樹(或者四叉堆)中的父節點進行比較,保證父節點的到期時間一定小于子節點:
這個四叉樹只能保證父節點的到期時間大于子節點,這對于我們來說其實也足夠了,因為我們只關心即將被觸發的計數器,如果當前定時器是第一個被加入四叉樹的定時器,我們還會通過 go timerproc(tb) 啟動一個 Goroutine 用于處理當前樹中的定時器,這也是處理定時器的核心方法。
觸發
定時器的觸發都是由 timerproc 中的一個雙層 for 循環控制的,外層的 for 循環主要負責對當前 Goroutine 進行控制,它不僅會負責鎖的獲取和釋放,還會在合適的時機觸發當前 Goroutine 的休眠:
func timerproc(tb *timersBucket) {
tb.gp = getg()
for {
tb.sleeping = false
now := nanotime()
delta := int64(-1)
// inner loop
if delta < 0 {
tb.rescheduling = true
goparkunlock(&tb.lock, waitReasonTimerGoroutineIdle, traceEvGoBlock, 1)
continue
}
tb.sleeping = true
tb.sleepUntil = now + delta
noteclear(&tb.waitnote)
notetsleepg(&tb.waitnote, delta)
}
}
如果距離下一個定時器被喚醒的時間小于 0,當前的 timerproc 就會將 rescheduling 標記設置成 true 并立刻陷入休眠,這其實也意味著當前 timerproc 中不包含任何待處理的定時器,當我們再向該 timerBucket 加入定時器時就會重新喚醒 timerproc Goroutine。
在其他情況下,也就是下一次計數器的響應時間是 now + delta 時,timerproc 中的外層循環會通過 notesleepg 將當前 Goroutine 陷入休眠。
func notetsleepg(n *note, ns int64) bool {
gp := getg()
if gp == gp.m.g0 {
throw("notetsleepg on g0")
}
semacreate(gp.m)
entersyscallblock()
ok := notetsleep_internal(n, ns, nil, 0)
exitsyscall()
return ok
}
該函數會先獲取當前的 Goroutine 并在當前的 CPU 上創建一個信號量,隨后在 entersyscallblock 和 exitsyscall 之間執行系統調用讓當前的 Goroutine 陷入休眠并在 ns 納秒后返回。
內部循環的主要作用就是觸發已經到期的定時器,在這個內部循環中,我們會按照以下的流程對當前桶中的定時器進行處理:
如果桶中不包含任何定時器就會直接返回并陷入休眠等待定時器加入當前桶;
如果四叉樹最上面的定時器還沒有到期會通過 notetsleepg 方法陷入休眠等待最近定時器的到期;
如果四叉樹最上面的定時器已經到期;
當定時器的 period > 0 就會設置下一次會觸發定時器的時間并將當前定時器向下移動到對應的位置;
當定時器的 period <= 0 就會將當前定時器從四叉樹中移除;
在每次循環的最后都會從定時器中取出定時器中的函數、參數和序列號并調用函數觸發該計數器;
for {
if len(tb.t) == 0 {
delta = -1
break
}
t := tb.t[0]
delta = t.when - now
if delta > 0 {
break
}
ok := true
if t.period > 0 {
t.when += t.period * (1 + -delta/t.period)
if !siftdownTimer(tb.t, 0) {
ok = false
}
} else {
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 := t.f
arg := t.arg
seq := t.seq
f(arg, seq)
}
使用 NewTimer 創建的定時器,傳入的函數時 sendTime,它會將當前時間發送到定時器持有的 Channel 中,而使用 AfterFunc 創建的定時器,在內層循環中調用的函數就會是調用方傳入的函數了。
休眠
如果你使用過一段時間的Go語言,一定在項目中使用過 time 包中的 Sleep 方法讓當前的 Goroutine 陷入休眠以等待某些條件的完成或者觸發一些定時任務,time.Sleep 就是通過如下所示的 timeSleep 方法完成的:
func timeSleep(ns int64) {
if ns <= 0 {
return
}
gp := getg()
t := gp.timer
if t == nil {
t = new(timer)
gp.timer = t
}
*t = timer{}
t.when = nanotime() + ns
t.f = goroutineReady
t.arg = gp
tb := t.assignBucket()
lock(&tb.lock)
if !tb.addtimerLocked(t) {
unlock(&tb.lock)
badTimer()
}
goparkunlock(&tb.lock, waitReasonSleep, traceEvGoSleep, 2)
}
timeSleep 會創建一個新的 timer 結構體,在初始化的過程中我們會傳入當前 Goroutine 應該被喚醒的時間以及喚醒時需要調用的函數 goroutineReady,隨后會調用 goparkunlock 將當前 Goroutine 陷入休眠狀態,當定時器到期時也會調用 goroutineReady 方法喚醒當前的 Goroutine:
func goroutineReady(arg interface{}, seq uintptr) {
goready(arg.(*g), 0)
}
time.Sleep 方法其實只是創建了一個會在到期時喚醒當前 Goroutine 的定時器并通過 goparkunlock 將當前的協程陷入休眠狀態等待定時器觸發的喚醒。
Ticker
除了只用于一次的定時器(Timer)之外,Go語言的 time 包中還提供了用于多次通知的 Ticker 計時器,計時器中包含了一個用于接受通知的 Channel 和一個定時器,這兩個字段共同組成了用于連續多次觸發事件的計時器:
type Ticker struct {
C
r runtimeTimer
}
想要在Go語言中創建一個計時器只有兩種方法,一種是使用 NewTicker 方法顯示地創建 Ticker 計時器指針,另一種可以直接通過 Tick 方法獲取一個會定期發送消息的 Channel:
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),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}
func Tick(d Duration)
if d <= 0 {
return nil
}
return NewTicker(d).C
}
Tick 其實也只是對 NewTicker 的簡單封裝,從實現上我們就能看出來它其實就是調用了 NewTicker 獲取了計時器并返回了計時器中 Channel,兩個創建計時器的方法的實現都并不復雜而且費容易理解,所以在這里也就不詳細展開介紹了。
需要注意的是每一個 NewTicker 方法開啟的計時器都需要在不需要使用時調用 Stop 進行關閉,如果不顯示調用 Stop 方法,創建的計時器就沒有辦法被垃圾回收,而通過 Tick 創建的計時器由于只對外提供了 Channel,所以是一定沒有辦法關閉的,我們一定要謹慎使用這一接口創建計時器。
性能分析
定時器在內部使用四叉樹的方式進行實現和存儲,當我們在生產環境中使用定時器進行毫秒級別的計時時,在高并發的場景下會有比較明顯的性能問題,我們可以通過實驗測試一下定時器在高并發時的性能,假設我們有以下的代碼:
func runTimers(count int) {
durationCh := make(chan time.Duration, count)
wg := sync.WaitGroup{}
wg.Add(count)
for i := 0; i < count; i++ {
go func() {
startedAt := time.Now()
time.AfterFunc(10*time.Millisecond, func() {
defer wg.Done()
durationCh
})
}()
}
wg.Wait()
close(durationCh)
durations := []time.Duration{}
totalDuration := 0 * time.Millisecond
for duration := range durationCh {
durations = append(durations, duration)
totalDuration += duration
}
averageDuration := totalDuration / time.Duration(count)
sort.Slice(durations, func(i, j int) bool {
return durations[i] < durations[j]
})
fmt.Printf("run %v timers with average=%v, pct50=%v, pct99=%v\n", count, averageDuration, durations[count/2], durations[int(float64(count)*0.99)])
}
注意:由于機器和性能的不同,多次運行測試可能會有不一樣的結果。
這段代碼開了 N 個 Goroutine 并在每一個 Goroutine 中運行一個定時器,我們會在定時器到期時將開始計時到定時器到期所用的時間加入 Channel 并用于之后的統計,在函數的最后我們會計算出 N 個 Goroutine 中定時器到期時間的平均數、50 分位數和 99 分位數:
$ go test ./... -v
=== RUN?? TestTimers
run 1000 timers with average=10.367111ms, pct50=10.234219ms, pct99=10.913219ms
run 2000 timers with average=10.431598ms, pct50=10.37367ms, pct99=11.025823ms
run 5000 timers with average=11.873773ms, pct50=11.986249ms, pct99=12.673725ms
run 10000 timers with average=11.954716ms, pct50=12.313613ms, pct99=13.507858ms
run 20000 timers with average=11.456237ms, pct50=10.625529ms, pct99=25.246254ms
run 50000 timers with average=21.223818ms, pct50=14.792982ms, pct99=34.250143ms
run 100000 timers with average=36.010924ms, pct50=31.794761ms, pct99=128.089527ms
run 500000 timers with average=176.676498ms, pct50=138.238588ms, pct99=676.967558ms
--- PASS: TestTimers (1.21s)
我們將上述代碼輸出的結果繪制成如下圖所示的折線圖,其中橫軸是并行定時器的個數,縱軸表示定時器從開始到觸發時間的差值,三個不同的線分別表示時間的平均值、50 分位數和 99 分位數:
雖然測試的數據可能有一些誤差,但是從圖中我們也能得出一些跟定時器性能和現象有關的結論:
定時器觸發的時間一定會晚于創建時傳入的時間,假設定時器需要等待 10ms 觸發,那它觸發的時間一定是晚于 10ms 的;
當并發的定時器數量達到 5000 時,定時器的平均誤差達到了 ~18%,99 分位數上的誤差達到了 ~26%;
并發定時器的數量超過 5000 之后,定時器的誤差就變得非常明顯,不能有效、準確地完成計時任務;
這其實也是因為定時器從開始到觸發的時間間隔非常短,當我們將計時的時間改到 100ms 時就會發現性能問題有比較明顯的改善:
哪怕并行運行了 10w 個定時器,99 分位數的誤差也只有 ~12%,我們其實能夠發現Go語言標準庫中的定時器在計時時間較短并且并發較高時有著非常明顯的問題,所以在一些性能非常敏感的基礎服務中使用定時器一定要非常注意,它可能達不到我們預期的效果。
不過哪怕我們不主動使用定時器,而是使用 context.WithDeadline 這種方法,由于它底層也會使用定時器實現,所以仍然會受到影響。
總結
Go語言的定時器在并發編程起到了非常重要的作用,它能夠為我們提供比較準確的相對時間,基于它的功能,標準庫中還提供了計時器、休眠等接口能夠幫助我們在Go語言程序中更好地處理過期和超時等問題。
標準庫中的定時器在大多數情況下是能夠正常工作并且高效完成任務的,但是在遇到極端情況或者性能敏感場景時,它可能沒有辦法勝任,而在 10ms 的這個粒度下,目前也沒有找到能夠使用的定時器實現,一些使用時間輪算法的開源庫也不能很好地完成這個任務。
總結
以上是生活随笔為你收集整理的c语言定时器作用,Go语言定时器实现原理及作用的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: linux pap认证,配置PPP PA
- 下一篇: c语言程序设计实训教材,C语言程序设计实