[轉]手把手教你如何用golang實現一個timewheel時間輪

 

轉,原文:https://lk668.github.io/2021/04/05/2021-04-05-%E6%89%8B%E6%8A%8A%E6%89%8B%E6%95%99%E4%BD%A0%E5%A6%82%E4%BD%95%E7%94%A8golang%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AAtimewheel/

--------------------------

 

手把手教你如何用golang實現一個timewheel時間輪

最近工作中有一個定時執行巡檢任務的需求,爲了讓代碼實現的更加優雅,選擇了TimeWheel時間輪算法,該算法的運用其實是非常的廣泛的,在 Netty、Akka、Quartz、ZooKeeper、Kafka 等組件中都存在時間輪的蹤影。本文手把手教你如何利用Golang實現一個簡單的timewheel時間輪算法。本文涉及到的代碼可以在https://github.com/lk668/timewheel查看

1. timewheel簡介

timewheel時間輪盤,簡單理解就是一個時鐘錶盤,指針每隔一段時間前進一格,走玩一圈是一個週期。而需要執行的任務就放置在錶盤的刻度處,當指針走到該位置時,就執行相應的任務。具體圖片如下所示

時間輪是一個環形隊列,底層實現就是一個固定長度的數組,數組中的每個元素存儲一個雙向列表,選擇雙向列表的原因是在O(1)時間複雜度實現插入和刪除操作。而這個雙向鏈表存儲的就是要在該位置執行的任務。

舉例分析,假設時間輪盤每隔1s前進一格,那麼上圖中的時間輪盤的週期就是12s,如果此時時間在刻度爲0的位置,此時需要添加一個定時任務,需要10s後執行,那麼該任務就需要放到刻度10處。當指針到達刻度10時,執行在該位置上,雙向鏈表存儲的所有任務。

此時你會有個以爲,如果此時我想添加一個20s之後執行的任務,應該怎麼添加呢?由於這個任務的延時超過了時間輪盤的週期(12s),所以單個輪盤已經沒法滿足需求了,此時有如下兩個解決方案。

1.1 方案一 多級時間輪

多級時間輪的理念,更貼近了鐘錶,鐘錶分爲時針、分針、秒針。每過60s,分針前進一格,每過60min,時針前進一格。多級時間輪的理念就是分兩級甚至更多級輪盤,當一級輪盤走一圈後,二級輪盤前進一格,二級輪盤走一圈後,三級輪盤前進一格,依次類推~

1.2 方案二 爲任務添加一個circle的參數

爲每個任務添加circle的參數,例如某個任務需要20s之後執行,那麼該任務的circle=1,計算得到的錶盤位置是4,也就是該任務需要在錶盤走一圈以後,在位置4處執行。相應的,錶盤指針每前進一格,該處的任務列表中,所有的任務的circle都-1。如果該處的任務circle==0,那麼執行該任務。

本文選取該種方案來做實現。

2. 代碼實現

接下來從代碼層面來介紹整體的實現過程。

2.1 結構體

2.1.1 首先需要一個TimeWheel的結構體來存儲時間輪盤的相關信息

  1. interval: 時間輪盤的精度,也就是時間輪盤每前進一步,所需要的時間
  2. slotNums: 時間輪盤總的齒輪數,interval*slotNums就是時間輪盤走一週所花的時間
  3. currentPos: 時間輪盤指針當前的位置
  4. ticker: 時鐘計時器,定時觸發。
  5. slots: 利用數組來實現時間輪盤,數組中每個元素是個雙向鏈表,來存儲要執行的任務
  6. taskRecords: 針對任務的一個map表,key是任務的key,value是任務對象
  7. isRunning: 時間輪盤是否是running狀態,避免重複啓動
1
2
3
4
5
6
7
8
9
10
11
12
13
type TimeWheel struct {
// 時間輪盤的精度
interval time.Duration
// 時間輪盤的齒輪數 interval*slotNums就是時間輪盤轉一圈走過的時間
slotNums int
// 時間輪盤當前的位置
currentPos int
ticker *time.Ticker
// 時間輪盤每個位置存儲的Task列表
slots []*list.List
taskRecords *sync.Map
isRunning bool
}

2.1.2 需要一個到時間以後執行的Job

1
type Job func(interface{})

2.1.3 需要一個執行任務的結構體

  1. key: 任務的唯一標識,必須是唯一的
  2. interval: 任務間隔多長時間執行
  3. createdTime: 任務的創建時間
  4. pos: 任務在時間輪盤的存儲位置,也就是TimeWheel.slots中的存儲位置
  5. circle: 任務需要
  6. job: 到時間時,任務需要執行的Job
  7. times: 該任務需要執行的次數,如果需要一直執行,設置成<0的數
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 時間輪盤上需要執行的任務
type Task struct {
// 用來標識task對象,是唯一的
key interface{}
// 任務週期
interval time.Duration
// 任務的創建時間
createdTime time.Time
// 任務在輪盤的位置
pos int
// 任務需要在輪盤走多少圈才能執行
circle int
// 任務需要執行的Job,優先級高於TimeWheel中的Job
job Job
// 任務需要執行的次數,如果需要一直循環執行,設置成<0的數
times int
}

2.2 啓動時間輪

需要一個函數來啓動時間輪盤,該函數需要啓動一個線程來執行。啓動以後,利用時間定時器,每隔固定時間(TimeWheel.Inteval),時間輪盤前進一格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Start 啓動時間輪盤
func (tw *TimeWheel) Start() {
tw.ticker = time.NewTicker(tw.interval)
go tw.start()
tw.isRunning = true
}

// 啓動時間輪盤的內部函數
func (tw *TimeWheel) start() {
for {
select {
case <-tw.ticker.C:
// 通過runTask函數來檢查當前需要執行的任務
tw.checkAndRunTask()
}
}
}

2.3 向時間輪添加任務

向時間輪盤添加任務的時候,需要計算任務的pos和circle,也就是在時間輪盤的位置和需要走的圈數。本文提供了兩種方式了,一種是基於createdTime來計算,主要用在服務剛剛啓動或者首次添加任務的時候使用。一種是基於任務的週期來計算下次需要執行的時間,這種是在任務執行以後,需要重建計算和添加任務的時候使用。第一種是爲了避免在服務重啓的時候,相同週期的任務被分配在同一個位置執行。從而加大任務執行時候的壓力。

在計算完pos和circle以後,添加任務主要包括兩部分,一個是在taskRecords這個map中存儲一下task對象(主要用處是,在刪除的時候,獲取任務在timewheel錶盤的位置),一個是根據計算的pos將task存儲在timewheel對應位置的雙向隊列中(主要用處是在執行任務的時候,獲取task需要執行的job)。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 添加任務的內部函數
// @param task Task Task對象
// @param byInterval bool 生成Task在時間輪盤位置和圈數的方式,true表示利用Task.interval來生成,false表示利用Task.createTime生成
func (tw *TimeWheel) addTask(task *Task, byInterval bool) {
var pos, circle int
if byInterval {
pos, circle = tw.getPosAndCircleByInterval(task.interval)
} else {
pos, circle = tw.getPosAndCircleByCreatedTime(task.createdTime, task.interval, task.key)
}

task.circle = circle
task.pos = pos

element := tw.slots[pos].PushBack(task)
tw.taskRecords.Store(task.key, element)
}

// 該函數通過任務的週期來計算下次執行的位置和圈數
func (tw *TimeWheel) getPosAndCircleByInterval(d time.Duration) (int, int) {
delaySeconds := int(d.Seconds())
intervalSeconds := int(tw.interval.Seconds())
circle := delaySeconds / intervalSeconds / tw.slotNums
pos := (tw.currentPos + delaySeconds/intervalSeconds) % tw.slotNums

// 特殊case,當計算的位置和當前位置重疊時,因爲當前位置已經走過了,所以circle需要減一
if pos == tw.currentPos && circle != 0 {
circle--
}
return pos, circle
}

// 該函數用任務的創建時間來計算下次執行的位置和圈數
func (tw *TimeWheel) getPosAndCircleByCreatedTime(createdTime time.Time, d time.Duration, key interface{}) (int, int) {

passedTime := time.Since(createdTime)
passedSeconds := int(passedTime.Seconds())
delaySeconds := int(d.Seconds())
intervalSeconds := int(tw.interval.Seconds())

circle := delaySeconds / intervalSeconds / tw.slotNums
pos := (tw.currentPos + (delaySeconds-(passedSeconds%delaySeconds))/intervalSeconds) % tw.slotNums

// 特殊case,當計算的位置和當前位置重疊時,因爲當前位置已經走過了,所以circle需要減一
if pos == tw.currentPos && circle != 0 {
circle--
}

return pos, circle
}

2.4 從時間輪刪除任務

刪除任務需要兩部分,一部分是從taskRecords中刪除任務記錄,一部分是從時間輪盤的雙向鏈表中刪除任務記錄。

1
2
3
4
5
6
7
8
9
10
// 刪除任務的內部函數
func (tw *TimeWheel) removeTask(task *Task) {
// 從map結構中刪除
val, _ := tw.taskRecords.Load(task.key)
tw.taskRecords.Delete(task.key)

// 通過TimeWheel.slots獲取任務的
currentList := tw.slots[task.pos]
currentList.Remove(val.(*list.Element))
}

2.5 到期執行任務

時間輪盤的currentPos記錄當前的位置,當時間輪盤前進一格的時候,就會檢查當前位置的雙向鏈表,檢查他們的circle是否爲0,如果爲0就執行任務,如果不爲零,circle–。執行完成後,將任務從雙向鏈表中刪除。
之後更新Task的times參數,如果times<0說明需要一直循環執行,計算下一次的執行位置,重新將任務添加到對應新位置的雙向隊列。如果times==0,表示不需要再執行了,就不需要再執行任務,將任務從taskRecords中刪除即可。如果times>0,更新times–,然後計算下一次的執行位置,重新將任務添加到新位置的雙端隊列。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 檢查該輪盤點位上的Task,看哪個需要執行
func (tw *TimeWheel) checkAndRunTask() {

// 獲取該輪盤位置的雙向鏈表
currentList := tw.slots[tw.currentPos]

if currentList != nil {
for item := currentList.Front(); item != nil; {
task := item.Value.(*Task)
// 如果圈數>0,表示還沒到執行時間,更新圈數
if task.circle > 0 {
task.circle--
item = item.Next()
continue
}

// 執行任務時,Task.job是第一優先級,然後是TimeWheel.job
if task.job != nil {
go task.job(task.key)
} else {
fmt.Println(fmt.Sprintf("The task %d don't have job to run", task.key))
}

// 執行完成以後,將該任務從時間輪盤刪除
next := item.Next()
tw.taskRecords.Delete(task.key)
currentList.Remove(item)

item = next

// 重新添加任務到時間輪盤,用Task.interval來獲取下一次執行的輪盤位置
if task.times != 0 {
if task.times < 0 {
tw.addTask(task, true)
} else {
task.times--
tw.addTask(task, true)
}

} else {
// 將任務從taskRecords中刪除
tw.taskRecords.Delete(task.key)
}
}
}

// 輪盤前進一步
if tw.currentPos == tw.slotNums-1 {
tw.currentPos = 0
} else {
tw.currentPos++
}
}

3. 總結

以上是實現一個時間輪盤所需要的必要操作,更多的優化請參考我的github源代碼https://github.com/lk668/timewheel。源代碼豐富了以下操作:

  1. 添加函數來關閉timewheel
  2. 添加函數來獲取timewheel的單例模式,從而保證timewheel的唯一性
  3. 添加檢查timewheel是否running的函數
  4. 爲TimeWheel結構體也添加了job參數,但是其優先級低於task,主要用於大部分task需要執行相同任務的情況,此時就沒必要爲每個task設置job。只需要爲TimeWheel結構體設置一個job即可

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章