go-zero 如何應對海量定時/延遲任務?
一個系統中存在著大量的排程任務,同時排程任務存在時間的滯後性,而大量的排程任務如果每一個都使用自己的排程器來管理任務的生命週期的話,浪費 cpu 的資源而且很低效。
本文來介紹 go-zero
中 延遲操作,它可能讓開發者排程多個任務時,只需關注具體的業務執行函式和執行時間「立即或者延遲」。而 延遲操作,通常可以採用兩個方案:
-
Timer
:定時器維護一個優先佇列,到時間點執行,然後把需要執行的 task 儲存在 map 中 -
collection
中的timingWheel
,維護一個存放任務組的陣列,每一個槽都維護一個儲存 task 的雙向連結串列。開始執行時,計時器每隔指定時間執行一個槽裡面的 tasks。
方案 2 把維護 task 從 優先佇列 O(nlog(n))
降到 雙向連結串列 O(1)
,而執行 task 也只要輪詢一個時間點的 tasks O(N)
,不需要像優先佇列,放入和刪除元素 O(nlog(n))
。
我們先看看 go-zero
中自己對 timingWheel
的使用 :
cache 中的 timingWheel
首先我們先來在 collection
的 cache
中關於 timingWheel
的使用:
timingWheel, err := NewTimingWheel(time.Second, slots, func(k, v interface{}) {
key, ok := k.(string)
if !ok {
return
}
cache.Del(key)
})
if err != nil {
return nil, err
}
cache.timingWheel = timingWheel
這是 cache
初始化中也同時初始化 timingWheel
做 key 的過期處理,引數依次代表:
-
interval
:時間劃分刻度 -
numSlots
:時間槽 -
execute
:時間點執行函式
在 cache
中執行函式則是 刪除過期 key,而這個過期則由 timingWheel
來控制推進時間。
接下來,就通過 cache
對 timingWheel
的使用來認識。
初始化
// 真正做初始化
func newTimingWheelWithClock(interval time.Duration, numSlots int, execute Execute, ticker timex.Ticker) (
*TimingWheel, error) {
tw := &TimingWheel{
interval: interval, // 單個時間格時間間隔
ticker: ticker, // 定時器,做時間推動,以interval為單位推進
slots: make([]*list.List, numSlots), // 時間輪
timers: NewSafeMap(), // 儲存task{key, value}的map [執行execute所需要的引數]
tickedPos: numSlots - 1, // at previous virtual circle
execute: execute, // 執行函式
numSlots: numSlots, // 初始化 slots num
setChannel: make(chan timingEntry), // 以下幾個channel是做task傳遞的
moveChannel: make(chan baseEntry),
removeChannel: make(chan interface{}),
drainChannel: make(chan func(key, value interface{})),
stopChannel: make(chan lang.PlaceholderType),
}
// 把 slot 中儲存的 list 全部準備好
tw.initSlots()
// 開啟非同步協程,使用 channel 來做task通訊和傳遞
go tw.run()
return tw, nil
}
以上比較直觀展示 timingWheel
的 “時間輪”,後面會圍繞這張圖解釋其中推進的細節。
go tw.run()
開一個協程做時間推動:
func (tw *TimingWheel) run() {
for {
select {
// 定時器做時間推動 -> scanAndRunTasks()
case <-tw.ticker.Chan():
tw.onTick()
// add task 會往 setChannel 輸入task
case task := <-tw.setChannel:
tw.setTask(&task)
...
}
}
}
可以看出,在初始化的時候就開始了 timer
執行,並以internal
時間段轉動,然後底層不停的獲取來自 slot
中的 list
的 task,交給 execute
執行。
Task Operation
緊接著就是設定 cache key
:
func (c *Cache) Set(key string, value interface{}) {
c.lock.Lock()
_, ok := c.data[key]
c.data[key] = value
c.lruCache.add(key)
c.lock.Unlock()
expiry := c.unstableExpiry.AroundDuration(c.expire)
if ok {
c.timingWheel.MoveTimer(key, expiry)
} else {
c.timingWheel.SetTimer(key, value, expiry)
}
}
- 先看在
data map
中有沒有存在這個 key - 存在,則更新
expire
->MoveTimer()
- 第一次設定 key ->
SetTimer()
所以對於 timingWheel
的使用上就清晰了,開發者根據需求可以 add
或是 update
。
同時我們跟原始碼進去會發現:SetTimer() MoveTimer()
都是將 task 輸送到 channel,由 run()
中開啟的協程不斷取出 channel
的 task 操作。
SetTimer() -> setTask()
:
- not exist task:
getPostion -> pushBack to list -> setPosition
- exist task:
get from timers -> moveTask()
MoveTimer() -> moveTask()
由上面的呼叫鏈,有一個都會呼叫的函式:moveTask()
func (tw *TimingWheel) moveTask(task baseEntry) {
// timers: Map => 通過key獲取 [positionEntry「pos, task」]
val, ok := tw.timers.Get(task.key)
if !ok {
return
}
timer := val.(*positionEntry)
// {delay < interval} => 延遲時間比一個時間格間隔還小,沒有更小的刻度,說明任務應該立即執行
if task.delay < tw.interval {
threading.GoSafe(func() {
tw.execute(timer.item.key, timer.item.value)
})
return
}
// 如果 > interval,則通過 延遲時間delay 計算其出時間輪中的 new pos, circle
pos, circle := tw.getPositionAndCircle(task.delay)
if pos >= timer.pos {
timer.item.circle = circle
// 記錄前後的移動offset。為了後面過程重新入隊
timer.item.diff = pos - timer.pos
} else if circle > 0 {
// 轉移到下一層,將 circle 轉換為 diff 一部分
circle--
timer.item.circle = circle
// 因為是一個陣列,要加上 numSlots [也就是相當於要走到下一層]
timer.item.diff = tw.numSlots + pos - timer.pos
} else {
// 如果 offset 提前了,此時 task 也還在第一層
// 標記刪除老的 task,並重新入隊,等待被執行
timer.item.removed = true
newItem := &timingEntry{
baseEntry: task,
value: timer.item.value,
}
tw.slots[pos].PushBack(newItem)
tw.setTimerPosition(pos, newItem)
}
}
以上過程有以下幾種情況:
-
delay < internal
:因為 < 單個時間精度,表示這個任務已經過期,需要馬上執行 - 針對改變的
delay
:-
new >= old
:<newPos, newCircle, diff>
-
newCircle > 0
:計算 diff,並將 circle 轉換為 下一層,故 diff + numslots - 如果只是單純延遲時間縮短,則將老的 task 標記刪除,重新加入 list,等待下一輪 loop 被 execute
-
Execute
之前在初始化中,run()
中定時器的不斷推進,推進的過程主要就是把 list 中的 task 傳給執行的 execute func
。我們從定時器的執行開始看:
// 定時器 「每隔 internal 會執行一次」
func (tw *TimingWheel) onTick() {
// 每次執行更新一下當前執行 tick 位置
tw.tickedPos = (tw.tickedPos + 1) % tw.numSlots
// 獲取此時 tick位置 中的儲存task的雙向連結串列
l := tw.slots[tw.tickedPos]
tw.scanAndRunTasks(l)
}
緊接著是如何去執行 execute
:
func (tw *TimingWheel) scanAndRunTasks(l *list.List) {
// 儲存目前需要執行的task{key, value} [execute所需要的引數,依次傳遞給execute執行]
var tasks []timingTask
for e := l.Front(); e != nil; {
task := e.Value.(*timingEntry)
// 標記刪除,在 scan 中做真正的刪除 「刪除map的data」
if task.removed {
next := e.Next()
l.Remove(e)
tw.timers.Del(task.key)
e = next
continue
} else if task.circle > 0 {
// 當前執行點已經過期,但是同時不在第一層,所以當前層即然已經完成了,就會降到下一層
// 但是並沒有修改 pos
task.circle--
e = e.Next()
continue
} else if task.diff > 0 {
// 因為之前已經標註了diff,需要再進入佇列
next := e.Next()
l.Remove(e)
pos := (tw.tickedPos + task.diff) % tw.numSlots
tw.slots[pos].PushBack(task)
tw.setTimerPosition(pos, task)
task.diff = 0
e = next
continue
}
// 以上的情況都是不能執行的情況,能夠執行的會被加入tasks中
tasks = append(tasks, timingTask{
key: task.key,
value: task.value,
})
next := e.Next()
l.Remove(e)
tw.timers.Del(task.key)
e = next
}
// for range tasks,然後把每個 task->execute 執行即可
tw.runTasks(tasks)
}
具體的分支情況在註釋中說明了,在看的時候可以和前面的 moveTask()
結合起來,其中 circle
下降,diff
的計算是關聯兩個函式的重點。
至於 diff
計算就涉及到 pos, circle
的計算:
// interval: 4min, d: 60min, numSlots: 16, tickedPos = 15
// step = 15, pos = 14, circle = 0
func (tw *TimingWheel) getPositionAndCircle(d time.Duration) (pos int, circle int) {
steps := int(d / tw.interval)
pos = (tw.tickedPos + steps) % tw.numSlots
circle = (steps - 1) / tw.numSlots
return
}
上面的過程可以簡化成下面:
steps = d / interval pos = step % numSlots - 1 circle = (step - 1) / numSlots
總結
timingWheel
靠定時器推動,時間前進的同時會取出當前時間格中list
「雙向連結串列」的 task,傳遞到execute
中執行。因為是是靠internal
固定時間刻度推進,可能就會出現:一個 60s 的 task,internal = 1s
,這樣就會空跑 59 次 loop。而在擴充套件時間上,採取
circle
分層,這樣就可以不斷複用原有的numSlots
,因為定時器在不斷loop
,而執行可以把上層的slot
下降到下層,在不斷loop
中就可以執行到上層的 task。這樣的設計可以在不創造額外的資料結構,突破長時間的限制。
同時在
go-zero
中還有很多實用的元件工具,用好工具對於提升服務效能和開發效率都有很大的幫助,希望本篇文章能給大家帶來一些收穫。
同時歡迎大家使用 go-zero
並加入我們,專案地址
參考資料
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- Android非同步、延遲和定時任務的簡易用法Android非同步
- Linux系統中延時任務及定時任務Linux
- Node.js結合RabbitMQ延遲佇列實現定時任務Node.jsMQ佇列
- 基於rabbitmq延遲外掛實現分散式延遲任務MQ分散式
- JMeter定時器設定延遲與同步JMeter定時器
- 如何建立systemd定時任務
- 延遲退休來了,如何應對“老齡化”的自己?
- 延遲退休最新訊息!如何做好應對計劃?
- PHP DIY 系列------應用篇:2. 延時任務PHP
- C#通過rabbitmq實現定時任務(延時佇列)C#MQ佇列
- SpringBoot如何實現定時任務Spring Boot
- Java如何實現定時任務?Java
- 定時任務
- 定時任務應該這麼玩
- 網路延遲對事務的影響
- 直播短影片原始碼,延遲任務的解決方法原始碼
- 基於訊息佇列(RabbitMQ)實現延遲任務佇列MQ
- 如何利用網路延遲穿越時空
- 定時器(setTimeout/setInterval)最小延遲的問題定時器
- Django 如何使用 Celery 完成非同步任務或定時任務Django非同步
- Linux中如何實現定時任務Linux
- 如何用 Java 實現 Web 應用中的定時任務?JavaWeb
- 定時任務操作
- SpringTask定時任務Spring
- Java 定時任務Java
- At 、Crontabl定時任務
- @Scheduled 定時任務
- Oracle定時任務Oracle
- Navicat定時任務
- schedule 定時任務
- SpringBoot定時任務Spring Boot
- 定時任務管理
- ubuntu定時任務Ubuntu
- Linux | 定時任務Linux
- springboot:定時任務Spring Boot
- laravel定時任務Laravel
- crontab定時任務
- 定時任務scheduler