c语言定时器作用,Go语言定时器实现原理及作用
對(duì)于任何一個(gè)正在運(yùn)行的應(yīng)用,如何獲取準(zhǔn)確的絕對(duì)時(shí)間都非常重要,但是在一個(gè)分布式系統(tǒng)中我們很難保證各個(gè)節(jié)點(diǎn)上絕對(duì)時(shí)間的一致性,哪怕通過 NTP 這種標(biāo)準(zhǔn)的對(duì)時(shí)協(xié)議也只能把時(shí)間的誤差控制在毫秒級(jí),所以相對(duì)時(shí)間在一個(gè)分布式系統(tǒng)中顯得更為重要,在接下來的講解中我們將會(huì)介紹一下Go語言中的定時(shí)器以及它在并發(fā)編程中起到什么樣的作用。
絕對(duì)時(shí)間一定不會(huì)是完全準(zhǔn)確的,它對(duì)于一個(gè)運(yùn)行中的分布式系統(tǒng)其實(shí)沒有太多指導(dǎo)意義,但是由于相對(duì)時(shí)間的計(jì)算不依賴于外部的系統(tǒng),所以它的計(jì)算可以做的比較準(zhǔn)確,首先介紹一下Go語言中用于計(jì)算相對(duì)時(shí)間的定時(shí)器的實(shí)現(xiàn)原理。
結(jié)構(gòu)
timer 就是Go語言定時(shí)器的內(nèi)部表示,每一個(gè) timer 其實(shí)都存儲(chǔ)在堆中,tb 就是用于存儲(chǔ)當(dāng)前定時(shí)器的桶,而 i 是當(dāng)前定時(shí)器在堆中的索引,我們可以通過這兩個(gè)變量找到當(dāng)前定時(shí)器在堆中的位置:
type timer struct {
tb *timersBucket
i? int
when?? int64
period int64
f????? func(interface{}, uintptr)
arg??? interface{}
seq??? uintptr
}
when 表示當(dāng)前定時(shí)器(Timer)被喚醒的時(shí)間,而 period 表示兩次被喚醒的間隔,每當(dāng)定時(shí)器被喚醒時(shí)都會(huì)調(diào)用 f(args, now) 函數(shù)并傳入 args 和當(dāng)前時(shí)間作為參數(shù)。
然而這里的 timer 作為一個(gè)私有結(jié)構(gòu)體其實(shí)只是定時(shí)器的運(yùn)行時(shí)表示,time 包對(duì)外暴露的定時(shí)器使用了如下所示的結(jié)構(gòu)體:
type Timer struct {
C
r runtimeTimer
}
Timer 定時(shí)器必須通過 NewTimer 或者 AfterFunc 函數(shù)進(jìn)行創(chuàng)建,其中的 runtimeTimer 其實(shí)就是上面介紹的 timer 結(jié)構(gòu)體,當(dāng)定時(shí)器失效時(shí),失效的時(shí)間就會(huì)被發(fā)送給當(dāng)前定時(shí)器持有的 Channel C,訂閱管道中消息的 Goroutine 就會(huì)收到當(dāng)前定時(shí)器失效的時(shí)間。
在 time 包中,除了 timer 和 Timer 兩個(gè)分別用于表示運(yùn)行時(shí)定時(shí)器和對(duì)外暴露的 API 之外,timersBucket 這個(gè)用于存儲(chǔ)定時(shí)器的結(jié)構(gòu)體也非常重要,它會(huì)存儲(chǔ)一個(gè)處理器上的全部定時(shí)器,不過如果當(dāng)前機(jī)器的核數(shù)超過了 64 核,也就是機(jī)器上的處理器 P 的個(gè)數(shù)超過了 64 個(gè),多個(gè)處理器上的定時(shí)器就可能存儲(chǔ)在同一個(gè)桶中:
type timersBucket struct {
lock???????? mutex
gp?????????? *g
created????? bool
sleeping???? bool
rescheduling bool
sleepUntil?? int64
waitnote???? note
t??????????? []*timer
}
每一個(gè) timersBucket 中的 t 就是用于存儲(chǔ)定時(shí)器指針的切片,每一個(gè)運(yùn)行的Go語言程序都會(huì)在內(nèi)存中存儲(chǔ)著 64 個(gè)桶,這些桶中都存儲(chǔ)定時(shí)器的信息:
每一個(gè)桶持有的 timer 切片其實(shí)都是一個(gè)最小堆,這個(gè)最小堆會(huì)按照 timer 應(yīng)該觸發(fā)的時(shí)間對(duì)它們進(jìn)行排序,最小堆最上面的定時(shí)器就是最近需要被喚醒的 timer,下面來介紹下定時(shí)器的創(chuàng)建和觸發(fā)過程。
工作原理
既然我們已經(jīng)介紹了定時(shí)器的數(shù)據(jù)結(jié)構(gòu),接下來我們就可以開始分析它的常見操作以及工作原理了,在這一節(jié)中我們將介紹定時(shí)器的創(chuàng)建、觸發(fā)、time.Sleep 與定時(shí)器的關(guān)系以及計(jì)時(shí)器 Ticker 的實(shí)現(xiàn)原理。
創(chuàng)建
time 包對(duì)外提供了兩種創(chuàng)建定時(shí)器的方法,第一種方法就是 NewTimer 接口,這個(gè)接口會(huì)創(chuàng)建一個(gè)用于通知觸發(fā)時(shí)間的 Channel、調(diào)用 startTimer 方法并返回一個(gè)創(chuàng)建指向 Timer 結(jié)構(gòu)體的指針:
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
}
另一個(gè)用于創(chuàng)建 Timer 的方法 AfterFunc 其實(shí)也提供了非常相似的結(jié)構(gòu),與 NewTimer 方法不同的是該方法沒有創(chuàng)建一個(gè)用于通知觸發(fā)時(shí)間的 Channel,它只會(huì)在定時(shí)器到期時(shí)調(diào)用傳入的方法:
func AfterFunc(d Duration, f func()) *Timer {
t := &Timer{
r: runtimeTimer{
when: when(d),
f: goFunc,
arg: f,
},
}
startTimer(&t.r)
return t
}
startTimer 基本上就是創(chuàng)建定時(shí)器的入口了,所有定時(shí)器的創(chuàng)建和重啟基本上都需要調(diào)用該函數(shù):
func startTimer(t *timer) {
addtimer(t)
}
func addtimer(t *timer) {
tb := t.assignBucket()
tb.addtimerLocked(t)
}
它會(huì)調(diào)用 addtimer 函數(shù),這個(gè)函數(shù)總共做了兩件事情,首先通過 assignBucket 方法為當(dāng)前定時(shí)器選擇一個(gè) timersBucket 桶,我們會(huì)根據(jù)當(dāng)前 Goroutine 所在處理器 P 的 id 選擇一個(gè)合適的桶,隨后調(diào)用 addtimerLocked 方法將當(dāng)前定時(shí)器加入桶中:
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 會(huì)先將最新加入的定時(shí)器加到隊(duì)列的末尾,隨后調(diào)用 siftupTimer 將當(dāng)前定時(shí)器與四叉樹(或者四叉堆)中的父節(jié)點(diǎn)進(jìn)行比較,保證父節(jié)點(diǎn)的到期時(shí)間一定小于子節(jié)點(diǎn):
這個(gè)四叉樹只能保證父節(jié)點(diǎn)的到期時(shí)間大于子節(jié)點(diǎn),這對(duì)于我們來說其實(shí)也足夠了,因?yàn)槲覀冎魂P(guān)心即將被觸發(fā)的計(jì)數(shù)器,如果當(dāng)前定時(shí)器是第一個(gè)被加入四叉樹的定時(shí)器,我們還會(huì)通過 go timerproc(tb) 啟動(dòng)一個(gè) Goroutine 用于處理當(dāng)前樹中的定時(shí)器,這也是處理定時(shí)器的核心方法。
觸發(fā)
定時(shí)器的觸發(fā)都是由 timerproc 中的一個(gè)雙層 for 循環(huán)控制的,外層的 for 循環(huán)主要負(fù)責(zé)對(duì)當(dāng)前 Goroutine 進(jìn)行控制,它不僅會(huì)負(fù)責(zé)鎖的獲取和釋放,還會(huì)在合適的時(shí)機(jī)觸發(fā)當(dāng)前 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)
}
}
如果距離下一個(gè)定時(shí)器被喚醒的時(shí)間小于 0,當(dāng)前的 timerproc 就會(huì)將 rescheduling 標(biāo)記設(shè)置成 true 并立刻陷入休眠,這其實(shí)也意味著當(dāng)前 timerproc 中不包含任何待處理的定時(shí)器,當(dāng)我們?cè)傧蛟?timerBucket 加入定時(shí)器時(shí)就會(huì)重新喚醒 timerproc Goroutine。
在其他情況下,也就是下一次計(jì)數(shù)器的響應(yīng)時(shí)間是 now + delta 時(shí),timerproc 中的外層循環(huán)會(huì)通過 notesleepg 將當(dāng)前 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
}
該函數(shù)會(huì)先獲取當(dāng)前的 Goroutine 并在當(dāng)前的 CPU 上創(chuàng)建一個(gè)信號(hào)量,隨后在 entersyscallblock 和 exitsyscall 之間執(zhí)行系統(tǒng)調(diào)用讓當(dāng)前的 Goroutine 陷入休眠并在 ns 納秒后返回。
內(nèi)部循環(huán)的主要作用就是觸發(fā)已經(jīng)到期的定時(shí)器,在這個(gè)內(nèi)部循環(huán)中,我們會(huì)按照以下的流程對(duì)當(dāng)前桶中的定時(shí)器進(jìn)行處理:
如果桶中不包含任何定時(shí)器就會(huì)直接返回并陷入休眠等待定時(shí)器加入當(dāng)前桶;
如果四叉樹最上面的定時(shí)器還沒有到期會(huì)通過 notetsleepg 方法陷入休眠等待最近定時(shí)器的到期;
如果四叉樹最上面的定時(shí)器已經(jīng)到期;
當(dāng)定時(shí)器的 period > 0 就會(huì)設(shè)置下一次會(huì)觸發(fā)定時(shí)器的時(shí)間并將當(dāng)前定時(shí)器向下移動(dòng)到對(duì)應(yīng)的位置;
當(dāng)定時(shí)器的 period <= 0 就會(huì)將當(dāng)前定時(shí)器從四叉樹中移除;
在每次循環(huán)的最后都會(huì)從定時(shí)器中取出定時(shí)器中的函數(shù)、參數(shù)和序列號(hào)并調(diào)用函數(shù)觸發(fā)該計(jì)數(shù)器;
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 創(chuàng)建的定時(shí)器,傳入的函數(shù)時(shí) sendTime,它會(huì)將當(dāng)前時(shí)間發(fā)送到定時(shí)器持有的 Channel 中,而使用 AfterFunc 創(chuàng)建的定時(shí)器,在內(nèi)層循環(huán)中調(diào)用的函數(shù)就會(huì)是調(diào)用方傳入的函數(shù)了。
休眠
如果你使用過一段時(shí)間的Go語言,一定在項(xiàng)目中使用過 time 包中的 Sleep 方法讓當(dāng)前的 Goroutine 陷入休眠以等待某些條件的完成或者觸發(fā)一些定時(shí)任務(wù),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 會(huì)創(chuàng)建一個(gè)新的 timer 結(jié)構(gòu)體,在初始化的過程中我們會(huì)傳入當(dāng)前 Goroutine 應(yīng)該被喚醒的時(shí)間以及喚醒時(shí)需要調(diào)用的函數(shù) goroutineReady,隨后會(huì)調(diào)用 goparkunlock 將當(dāng)前 Goroutine 陷入休眠狀態(tài),當(dāng)定時(shí)器到期時(shí)也會(huì)調(diào)用 goroutineReady 方法喚醒當(dāng)前的 Goroutine:
func goroutineReady(arg interface{}, seq uintptr) {
goready(arg.(*g), 0)
}
time.Sleep 方法其實(shí)只是創(chuàng)建了一個(gè)會(huì)在到期時(shí)喚醒當(dāng)前 Goroutine 的定時(shí)器并通過 goparkunlock 將當(dāng)前的協(xié)程陷入休眠狀態(tài)等待定時(shí)器觸發(fā)的喚醒。
Ticker
除了只用于一次的定時(shí)器(Timer)之外,Go語言的 time 包中還提供了用于多次通知的 Ticker 計(jì)時(shí)器,計(jì)時(shí)器中包含了一個(gè)用于接受通知的 Channel 和一個(gè)定時(shí)器,這兩個(gè)字段共同組成了用于連續(xù)多次觸發(fā)事件的計(jì)時(shí)器:
type Ticker struct {
C
r runtimeTimer
}
想要在Go語言中創(chuàng)建一個(gè)計(jì)時(shí)器只有兩種方法,一種是使用 NewTicker 方法顯示地創(chuàng)建 Ticker 計(jì)時(shí)器指針,另一種可以直接通過 Tick 方法獲取一個(gè)會(huì)定期發(fā)送消息的 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 其實(shí)也只是對(duì) NewTicker 的簡(jiǎn)單封裝,從實(shí)現(xiàn)上我們就能看出來它其實(shí)就是調(diào)用了 NewTicker 獲取了計(jì)時(shí)器并返回了計(jì)時(shí)器中 Channel,兩個(gè)創(chuàng)建計(jì)時(shí)器的方法的實(shí)現(xiàn)都并不復(fù)雜而且費(fèi)容易理解,所以在這里也就不詳細(xì)展開介紹了。
需要注意的是每一個(gè) NewTicker 方法開啟的計(jì)時(shí)器都需要在不需要使用時(shí)調(diào)用 Stop 進(jìn)行關(guān)閉,如果不顯示調(diào)用 Stop 方法,創(chuàng)建的計(jì)時(shí)器就沒有辦法被垃圾回收,而通過 Tick 創(chuàng)建的計(jì)時(shí)器由于只對(duì)外提供了 Channel,所以是一定沒有辦法關(guān)閉的,我們一定要謹(jǐn)慎使用這一接口創(chuàng)建計(jì)時(shí)器。
性能分析
定時(shí)器在內(nèi)部使用四叉樹的方式進(jìn)行實(shí)現(xiàn)和存儲(chǔ),當(dāng)我們?cè)谏a(chǎn)環(huán)境中使用定時(shí)器進(jìn)行毫秒級(jí)別的計(jì)時(shí)時(shí),在高并發(fā)的場(chǎng)景下會(huì)有比較明顯的性能問題,我們可以通過實(shí)驗(yàn)測(cè)試一下定時(shí)器在高并發(fā)時(shí)的性能,假設(shè)我們有以下的代碼:
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)])
}
注意:由于機(jī)器和性能的不同,多次運(yùn)行測(cè)試可能會(huì)有不一樣的結(jié)果。
這段代碼開了 N 個(gè) Goroutine 并在每一個(gè) Goroutine 中運(yùn)行一個(gè)定時(shí)器,我們會(huì)在定時(shí)器到期時(shí)將開始計(jì)時(shí)到定時(shí)器到期所用的時(shí)間加入 Channel 并用于之后的統(tǒng)計(jì),在函數(shù)的最后我們會(huì)計(jì)算出 N 個(gè) Goroutine 中定時(shí)器到期時(shí)間的平均數(shù)、50 分位數(shù)和 99 分位數(shù):
$ 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)
我們將上述代碼輸出的結(jié)果繪制成如下圖所示的折線圖,其中橫軸是并行定時(shí)器的個(gè)數(shù),縱軸表示定時(shí)器從開始到觸發(fā)時(shí)間的差值,三個(gè)不同的線分別表示時(shí)間的平均值、50 分位數(shù)和 99 分位數(shù):
雖然測(cè)試的數(shù)據(jù)可能有一些誤差,但是從圖中我們也能得出一些跟定時(shí)器性能和現(xiàn)象有關(guān)的結(jié)論:
定時(shí)器觸發(fā)的時(shí)間一定會(huì)晚于創(chuàng)建時(shí)傳入的時(shí)間,假設(shè)定時(shí)器需要等待 10ms 觸發(fā),那它觸發(fā)的時(shí)間一定是晚于 10ms 的;
當(dāng)并發(fā)的定時(shí)器數(shù)量達(dá)到 5000 時(shí),定時(shí)器的平均誤差達(dá)到了 ~18%,99 分位數(shù)上的誤差達(dá)到了 ~26%;
并發(fā)定時(shí)器的數(shù)量超過 5000 之后,定時(shí)器的誤差就變得非常明顯,不能有效、準(zhǔn)確地完成計(jì)時(shí)任務(wù);
這其實(shí)也是因?yàn)槎〞r(shí)器從開始到觸發(fā)的時(shí)間間隔非常短,當(dāng)我們將計(jì)時(shí)的時(shí)間改到 100ms 時(shí)就會(huì)發(fā)現(xiàn)性能問題有比較明顯的改善:
哪怕并行運(yùn)行了 10w 個(gè)定時(shí)器,99 分位數(shù)的誤差也只有 ~12%,我們其實(shí)能夠發(fā)現(xiàn)Go語言標(biāo)準(zhǔn)庫中的定時(shí)器在計(jì)時(shí)時(shí)間較短并且并發(fā)較高時(shí)有著非常明顯的問題,所以在一些性能非常敏感的基礎(chǔ)服務(wù)中使用定時(shí)器一定要非常注意,它可能達(dá)不到我們預(yù)期的效果。
不過哪怕我們不主動(dòng)使用定時(shí)器,而是使用 context.WithDeadline 這種方法,由于它底層也會(huì)使用定時(shí)器實(shí)現(xiàn),所以仍然會(huì)受到影響。
總結(jié)
Go語言的定時(shí)器在并發(fā)編程起到了非常重要的作用,它能夠?yàn)槲覀兲峁┍容^準(zhǔn)確的相對(duì)時(shí)間,基于它的功能,標(biāo)準(zhǔn)庫中還提供了計(jì)時(shí)器、休眠等接口能夠幫助我們?cè)贕o語言程序中更好地處理過期和超時(shí)等問題。
標(biāo)準(zhǔn)庫中的定時(shí)器在大多數(shù)情況下是能夠正常工作并且高效完成任務(wù)的,但是在遇到極端情況或者性能敏感場(chǎng)景時(shí),它可能沒有辦法勝任,而在 10ms 的這個(gè)粒度下,目前也沒有找到能夠使用的定時(shí)器實(shí)現(xiàn),一些使用時(shí)間輪算法的開源庫也不能很好地完成這個(gè)任務(wù)。
總結(jié)
以上是生活随笔為你收集整理的c语言定时器作用,Go语言定时器实现原理及作用的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: linux pap认证,配置PPP PA
- 下一篇: c语言程序设计实训教材,C语言程序设计实