Linux定時器分為低精度定時器和高精度定時器兩種類型,內核對其均有實現。本文討論的是我們在應用程序開發中比較常見的低精度定時器。作為常用的基礎組件,定時器常用的幾種實現方法包括:基於排序鏈表實現、基於小根堆實現、基於紅黑樹實現、基於時間輪實現。本文講解的是時間復雜度最優,也是linux內核采用的基於時間輪的實現方式。
- 有序隊列
- 添加/刪除任務: 遍歷每一個節點, 找到相應的位置插入, 因此時間復雜度為O(n)
- 處理到期任務: 取出最小定時任務為首節點, 因此時間復雜度為O(1)
- 紅黑樹
有序隊列的性能瓶頸在於插入任務和刪除任務(查找排序), 而樹形結構能對其進行優化。
- 添加/刪除/查找任務: 紅黑樹能將排序的的時間復雜度降到O(log2N)
- 處理到期任務: 紅黑樹查找到最后過期的任務節點為最左側節點, 因此時間復雜度為O(log2N)
- 最小堆
- 添加/查找任務: 時間復雜度為O(log2N)
- 刪除任務: 時間復雜度為O(n), 可通過輔助數據結構(map)來加快刪除操作
- 處理到期任務: 最小節點為根節點, 時間復雜度為O(1)
- 跳表
- 添加/刪除/查找任務: 時間復雜度為O(log2N)
- 處理到期任務: 最小節點為最左側節點, 時間復雜度為O(1), 但空間復雜度比較高, 為O(1.5n)
之所以沒法做到O(1)的復雜度,究其原因是所有定時器節點掛在一條鏈表(或一棵樹)上。時間輪算法的核心思路是將定時器散列到多條鏈上(定時器就是指定時任務,鏈上的節點),是典型的空間換時間的策略。下文從單個時間輪出發講解,逐步擴展至linux實現定時器所采用的多級時間輪算法。
簡單的單時間輪
單時間輪只有一個由bucket串起來的輪子,下圖所示的時間輪有8個bucket,每個bucket下鏈接着未來對應時刻到期的節點。假設圖中相鄰bucket到期時間的間隔為slot=1s,從當前時刻0s開始計時,1s時到期的定時器節點掛在bucket[1]下,2s時到期的定時器節點掛在bucket[2]下……當tick檢查到時間過去了1s時,bucket[1]下所有節點執行超時動作,當時間到了2s時,bucket[2]下所有節點執行超時動作…….
由於bucket是一個數組,能直接根據下標定位到具體定時器節點鏈,因此添加刪除節點、定時器到期執行的時間復雜度均為O(1)。(有Hash內味了)
但使用這個定時器所受的限制也顯而易見:待添加的timer到期時間必須在8s以內。這顯然不能滿足實際需求。當然要擴展也很容易,直接增加bucket的個數就可以了。在 Linux 系統中,我們可以設置slot為1個jiffy(1/HZ)的定時器,假設最大的到期時間范圍要達到 2^32個 jiffies,如果采用上面這樣的單時間輪,我們就需要2^32個 bucket,這會帶來巨大的內存消耗,顯然是需要優化改進的。
改進的單時間輪
改進的單時間輪其實是一個對時間和空間折中的思路,即不會像單時間輪那樣有O(1)的時間復雜度,但也不會像單時間輪那樣對bucket個數有巨大的需求。其原理也很簡單,就是每個bucket不單可以掛接到期時間expire=slot的定時器,還可掛接expire%N=slot的定時器(N為bucket個數)。這也正好順應時間輪的輪回作用。如圖2所示,定時器中expire表示到期時間,rotation表示節點在時間輪轉了幾圈后才到期。當當前時間指針指向某個bucket時,不能像簡單時間輪那樣直接對bucket下的所有節點執行超時動作,而是需要對鏈表中節點遍歷一遍,判斷輪子轉動的次數是否等於節點中的rotation值,當兩者相等時,方可執行超時操作。
我們的uthread項目就是這種改進型的單時間輪,效果也很好,不過我們使用的單鏈表,但在一些開源實現里使用的雙向鏈表,是因為有其他場景(reset,惰性刪除等)
多時間輪
上面所述的時間輪都是單槍匹馬戰斗的,因此很難在時間和空間上都達到理想效果。Linux所實現的多時間輪算法,借鑒了日常生活中水表的度量方法,通過低刻度走得快的輪子帶動高一級刻度輪子走動的方法,達到了僅使用較少刻度即可表示很大范圍度量值的效果。
Linux定時器時間輪分為5個級別的輪子(tv1 ~ tv5),如圖3所示。每個級別的輪子的刻度值(slot)不同,規律是次級輪子的slot等於上級輪子的slot之和。Linux定時器slot單位為1jiffy,tv1輪子分256個刻度,每個刻度大小為1jiffy。tv2輪子分64個刻度,每個刻度大小為256個jiffy,即tv1整個輪子所能表達的范圍。相鄰輪子也只有滿足這個規律,才能達到“低刻度輪子轉一圈,高刻度輪子走一格”的效果。tv3,tv4,tv5也都是分為64個刻度,因此容易算出,最高一級輪子tv5所能表達的slot范圍達到了25664646464 = 2^32 jiffies。
Linux時間輪定時器算法的關鍵在於添加定時器操作和時間輪進位遷移鏈表操作。先來說添加定時器。添加定時器的關鍵又在於知道每個時間輪每一個刻度所能表示的到期時間的范圍。圖4列出了每一級時間輪能度量的jiffies的大小。假設有一個定時器在1000個jiffies后到期,根據圖4容易看出其應該掛在tv2輪上。tv2輪每個刻度表示的大小為256個jiffies,則其應該掛在(1000/256)=3即第三個bucket上。因此可以O(1)的添加定時器。
Linux在定時器到期檢查上的操作也實現得很巧妙。假設curr_time=0x12345678,那么下一個檢查的時刻為0x12345679。如果tv1.bucket[0x79]上鏈表非空,則下一個檢查時刻tv1.bucket[0x79]上的定時器節點超時。如果curr_time到了0x12345700,低8位為空,說明有進位產生,這時移出8~13位對應的定時器鏈表(即正好對應着tv2輪),重新加入定時器系統(放到tv1輪),這就完成了一次進位遷移操作。同樣地,當curr_time的第8-13位為0時,這表明tv2輪對tv3輪有進位發生,將curr_time第14-19位的值作為下標,移出tv3中對應的定時器鏈表,然后將它們重新加入到定時器系統中來(放到tv2或tv1)。tv4,tv5依次類推。遍歷 tv1中該 tick 的雙向循環鏈表,執行這些到期定時器的回調函數。之所以能夠根據curr_time來檢查超時鏈,是因為tv1~tv5輪的度量范圍正好依次覆蓋了整型的32位:tv1(1-8位),tv2(9-14位),tv3(15-20位),tv4(21-26位),tv5(27-32位);而curr_time計數的遞增中,低位向高位的進位正是低級時間輪轉圈帶動高級時間輪走動的過程。
這里有一個實現細節,我們用一個int就能表示這5級時間輪:
| 6bit | 6bit | 6bit | 6bit | 8bit |
111111 111111 111111 111111 11111111
這樣的話有一個優點,每次只需要將這個int整數+1,每一級時間輪都會自動進位。
對比
最后比較一下多級時間輪和單個簡單時間輪的時間復雜度及空間復雜度:linux使用了總計256+64+64+64+64=512個bucket,即可實現[0,2^32) jiffies的超時范圍。相比簡單的單時間輪,時間上僅僅多了1/256次(為約等於值,忽略了tv2以上產生的進位操作)的鏈表遷移操作耗時。可以認為其添加、刪除定時器節點及到期check的操作時間復雜度均為O(1)。
參考鏈接:
1.CSDN-時間輪定時器實現
2. 浪的不輕-多級時間輪定時器
3. changan's blog-linux定時器時間輪算法