1. 程式人生 > 其它 >libco 的定時器實現: 時間輪

libco 的定時器實現: 時間輪

一、簡介

  定時器是網路框架中非常重要的組成部分,往往可以利用定時器做一些超時事件的判斷或者定時清理任務等。

  定時器有許多經典高效的實現。例如,libevent 採用了小根堆實現定時器,redis 則結合自己場景直接使用了簡單粗暴的雙向連結串列

  時間輪也是一個非常經典的定時器實現,Linux 2.6 核心之前就採用了多級時間輪作為其低精度定時器的實現。而在微信的協程庫 libco 中,也用了單級時間輪來處理其內部的超時事件。

  在 libco 的時間輪中,對超時事件的新增刪除查詢操作均可以達到O(1)的時間複雜度,是一個非常高效的資料結構。

二、時間輪的表示

  libco 的時間輪的資料結構定義如下:

 1 struct stTimeout_t
 2 {
 3     /**
 4      * pItems 是一個數組,陣列長度為 iItemSize。而陣列中的每個元素是 stTimeoutItemLink_t 型別,
 5      * 這是一個雙向連結串列實現。而同一個連結串列中的每個元素,它們的超時時間都是相同的。
 6      * libco 的時間輪是一個環形陣列的實現
 7      */
 8     stTimeoutItemLink_t *pItems;
 9     int iItemSize;
10 
11     //ullStart 代表當前最近超時時間的時間戳,單位是毫秒
12 unsigned long long ullStart; 13 //llStartIdx 代表當前最近超時時間對應的 index 14 long long llStartIdx; 15 };

  時間輪stTimeout_t負責 libco 中所有超時事件的管理, 其中各屬性的意義如下:

    1、pItems是一個數組,陣列長度為 iItemSize。而陣列中的每個元素是stTimeoutItemLink_t型別,這是一個雙向連結串列實現。而同一個連結串列中的每個元素,它們的超時時間都是相同的。

    2、llStartIdx代表當前最近超時時間對應的 index。

    3、ullStart代表當前最近超時時間的時間戳,單位是毫秒

  總體來說,libco 的時間輪是一個環形陣列的實現,如下圖所示:

  在這個環形陣列中,陣列中每個元素代表 1ms。而 libco 將環形陣列的總長度設為60*1000,即最多可以表達 1 分鐘以內的超時事件,且超時精度是毫秒

  而且,有可能會有多個超時事件在同一時刻發生,因此陣列中的元素是個連結串列,代表同在該時刻觸發的超時事件

  在 libco 初始化時,ullStart被初始化為當前時刻的時間戳 (單位為毫秒),llStartIdx初始化為 0。

三、新增一個超時事件

3.1相對時間轉化為時間戳

  我們看下 libco 是怎麼新增一個超時事件的,第一步將相對時間轉化為時間戳,這點不難理解,只有統一成標準的時間表示,才可以和其他超時事件統一的放在一起。程式碼如下:

1    // 獲取當前時間
2     unsigned long long now = GetTickMS();
3     // 超時時間
4     arg.ullExpireTime = now + timeout;

3.2 時間差

  計算該超時事件的觸發時間距離時間輪中最近的超時時間ullStart的時間差值,為了下一步確定其在陣列中的位置。

1    //計算該超時事件的觸發時間距離時間輪中最近的超時時間 ullStart 的時間差值
2     unsigned long long diff = apItem->ullExpireTime - apTimeout->ullStart;

  計算得到了這個時間差值,才可以進一步計算新的超時事件在時間輪中的位置。 當然,在把超時事件放入時間輪之前,需要先判斷下該超時事件是否越界了。如果比ullStart大於 1 分鐘,

則 libco 時間輪沒有辦法表示這個超時事件,將會報錯。相關程式碼如下:

 1    /**
 2      * 如果比 ullStart 大於 1 分鐘, 則 libco 時間輪沒有辦法表示這個超時事件,將會報錯
 3      */
 4     if( diff >= (unsigned long long)apTimeout->iItemSize )//超出最大時間刻度
 5     {
 6         diff = apTimeout->iItemSize - 1;
 7         co_log_err("CO_ERR: AddTimeout line %d diff %d",
 8                     __LINE__,diff);
 9 
10         //return __LINE__;
11     }

3.2 位置確定以及插入

  這裡其實有兩步:計算在時間輪中的位置。其實很簡單,就是一個簡單的取餘過程,這個也是環形陣列的普遍做法。得到的(apTimeout->llStartIdx+diff)%apTimeout->iItemSize,即是

該超時事件所在的位置。將該超時事件追加在該位置上的連結串列最後。前面講過,有可能多個超時事件可能會在同一時刻觸發。

四、超時事件的啟用

  libco 是如何判斷事件是否超時以及取出所有已超時的事件呢?過程如下:

    • 如果當前的時間小於ullStart,說明目前沒有事件超時
    • 如果大於等於ullStart,用當前時間減去ullStart,就可以得出一共過去了多少毫秒,一毫秒代表一個數組元素,從llStartIdx開始遍歷即可

五、總結

  時間輪是典型的空間換時間的做法,需要預先把環形陣列的記憶體空間都分配好,這也是 libco 的超時事件存取高效的原因

  講到這裡,其實 libco 的整個時間輪演算法已經全部分析完成了。

5.1 存在的問題

  但是對於 libco 的時間輪大家可能會有一些疑問:

    1. libco 的時間輪最多隻能支援 1 分鐘的超時時間。雖然這個時間對於後臺服務的場景已經完全足夠了,但是如果我們在其他場景需要更長的超時時間呢

    2. libco 中的一個數組元素代表 1ms。如果我們需要更長的時間那豈不是記憶體空間也隨之線性增長了

5.2 優化

  那接下來我們就簡單講下對於時間輪的進一步優化:

    1、單級時間輪的優化。我們可以對 libco 的單級時間輪做一些簡單的優化,例如給每個超時事件加一個 rotation 引數,代表該超時事件會在第幾輪觸發,這樣就可以在一個單級時間輪中存放無限長的超時事件了。但這樣代價是超時事件的判斷和取出將不會是O(1)了

    2、多級時間輪。Linux 核心中就採用了多級時間輪的機制,模擬了現實生活中水錶刻度。即第一級的時間輪與普通的單級時間輪相同,而第二級時間輪的每個元素的時長等於第一級時間輪的全部總時長,依次類推。Linux 核心中一共採用了五級時間輪。第一級的時間輪所有事件消耗完成後,會觸發第二級時間輪的事件遷移。

5.3 多級事件輪(分層時間輪)

  分層時間輪是這樣一種思想:

    • 針對時間複雜度的問題:不做遍歷計算round,凡是任務列表中的都應該是應該被執行的,直接全部取出來執行。
    • 針對空間複雜度的問題:分層,每個時間粒度對應一個時間輪,多個時間輪之間進行級聯協作。

  第一點很好理解,第二點有必要舉個例子來說明:

  比如我有三個任務:

    任務一每週二上午九點。

    任務二每週四上午九點。

    任務三每個月12號上午九點。

  三個任務涉及到四個時間單位:小時、天、星期、月份。

  拿任務三來說,任務三得到執行的前提是,時間刻度先得來到12號這一天,然後才需要關注其更細一級的時間單位:上午9點。

  基於這個思想,我們可以設定三個時間輪:月輪、周輪、天輪。

  月輪的時間刻度是天。

  周輪的時間刻度是天。

  天輪的時間刻度是小時。

  初始新增任務時,任務一新增到天輪上,任務二新增到周輪上,任務三新增到月輪上

  三個時間輪以各自的時間刻度不停流轉。

  當週輪移動到刻度2(星期二)時,取出這個刻度下的任務,丟到天輪上,天輪接管該任務,到9點執行

  同理,當月輪移動到刻度12(12號)時,取出這個刻度下的任務,丟到天輪上,天輪接管該任務,到9點執行。

  這樣就可以做到既不浪費空間,有不浪費時間。

  整體的示意圖如下所示:

5.3時間輪的應用

  時間輪的思想應用範圍非常廣泛,各種作業系統的定時任務排程,Crontab,還有基於java的通訊框架Netty中也有時間輪的實現,幾乎所有的時間任務排程系統採用的都是時間輪的思想。
至於採用round型的時間輪還是採用分層時間輪,看實際需要吧,時間複雜度和實現複雜度的取捨。

六、參考文章

https://blog.csdn.net/xinzhongtianxia/article/details/86221241

https://zhuanlan.zhihu.com/p/97464445

本文來自部落格園,作者:Mr-xxx,轉載請註明原文連結:https://www.cnblogs.com/MrLiuZF/p/15207035.html