通常來講,就是利用 select 的空餘時間,來進行時鐘檢查,不管是 select / poll / epoll/ kevent,以下統稱 select,它有一個等待時間作為引數,即沒有事件時,最多 wait 多少時間,我們把這個作為網路庫的基準頻率,比如 10MS,或者 20MS, 25MS, 50MS,都是常用的幾個值。
就是說網路庫呼叫 select 等待事件時如果沒有事件,那麼最長等待 10MS 就返回了,這時再處理完所有網路事件後,就可以來處理時鐘資料了。事件處理函式就是這樣:
1 2 3 4 5 6 7 8 9 10 |
def update_events(milisec = 10): result = selector.select(milisec) for fd, event in result: do something with socket event current = time.time() update_timer(current) while 1: WAIT_MILLISEC = 10 update_events(WAIT_MILLISEC) |
關鍵就是這個兩次 select 中間 update_timer 的任務:集合中檢查需要喚醒的時鐘,並且呼叫它們的回撥函式,來驅動整個伺服器的時鐘執行,以最簡單的掃描法為例:
1 2 3 4 5 |
def update_timer (current): for timer in available_timers: while current >= timer.expires: timer.callback(current) timer.expires += timer.period |
available_timers 記錄著當前可用的所有 timer 的集合,expires 是他們需要被觸發的時間,如果當前時間大於等於這個 expires,認為該 timer 需要被觸發到。注意 timer.expires 更新的時候是 += 週期,而不是 = current + 週期,後者會導致誤差積累,長時間執行後偏差越來越大。同時這裡需要 while,因為可能跨越兩個以上週期,當然只執行一次的 timer 就不需要了,這裡只是簡化下。
比如 libevent 裡面的主迴圈 event_base_loop 每次 select 完畢後就呼叫一次 timeout_process。
這就是 Timer 排程的基本原理。
可能你會發現每次 select 結束都要掃描整個 available_timers 集合,是一個非常費時間的事情,那麼首先想到的就是優先佇列了:將 Timer 節點按照 expires 的先後順序,將最快要發生的超時節點放在前面,每次檢測佇列頭就可以判斷是否超時了。
比如 libevent 裡面的 timerout_process 函式,就是用最小堆來儲存超時事件,每次檢測堆的第一個節點如果超時則刪除並繼續檢測下一個,否則跳出迴圈(此時沒有任何節點到期)。
還有一種固定超時佇列,就是裡面的節點的超時週期都是相同的,那麼每次增加都在最後,每次檢測都只檢測頭部。比如所有連結都要檢測60秒無事件超時這個事情,就可以用它,因為60秒是固定的,新增時放到佇列最後,檢測時只檢測頭部是否超時,如果有事件來到,就刪除並重新加入佇列末尾,這是固定超時佇列。
還有上面專門說的系統提供的 timerfd,建立後加入 select, 但是受限於 linux 系統,跨平臺就用不了了,不能太依賴。
然而這些都不算最完美的解決方案,一旦超時節點多達上萬個,每個時間都不同,又考慮通用實現(非特定平臺實現)的話,這幾種排程方式是要吃虧的,目前最好的演算法是 Linux Kernel 的時間輪演算法,幾乎保證不管有多少個時鐘物件要處理,每次 update_timer 的時間都幾乎是常數。
具體可以看程式碼 kernel/timer.c ,時間輪的應用層實現見我寫的:
AsyncNet/itimer.h at master · skywind3000/AsyncNet · GitHub
AsyncNet/itimer.c at master · skywind3000/AsyncNet · GitHub
兩個檔案,拷貝走就得了,沒有任何第三個檔案的依賴,更沒有全域性唯一的時鐘管理器。
一般來講 “侵入式” 的網路庫,都會把這個事情給管理了,因為他們接管你的主迴圈,比如 libevent, skynet 之類,你進入一個 event_loop 就只有程式結束才能出來了,而非侵入式的網路庫不會接管你的主迴圈,可以讓你自己主動去觸發。
比如某同事想在 libevent 上跑一套自己的時鐘系統,而 event_base_dispatch 屬於進去就出不來的 “侵入式” 設計。為了能在 select 前後加入自己的時鐘排程,不得不把 libevent 改出一個 branch 來,所以 侵入式 是比較惡劣的設計。libevent 應該學習一下 win32,把 GetMessage, DispatchMessage 放出來,比如叫做:
1 |
int event_base_update(struct event_base *base, int max_wait_time); |
讓你主動去觸發它,然後靈活的在前後做點事情,還可以用 event_base_wakeup 從另外一個現成喚醒它,這樣就會靈活很多了,完全取代 event_base_dispatch 這個進去出不來的死迴圈。
每次 select wait 的時間一般用一個固定值,稱為一個 TICK,固定值選大了,時鐘基準週期就會很長,短時誤差就會增大,選小了,又會佔用額外 cpu,可以模擬 Linux 使用 100Hz的值,即 10ms來做,這也是通行做法。
不嫌麻煩還可以每次從 timer 集合裡面選擇最先要超時的事件,計算還有多長時間就會超時,作為 select wait 的值,每次都不一樣,每次都基本精確,同時不會佔用多餘 cpu,這叫 tickless,Linux 的 3.x以上版本也支援 tickless 的模式來驅動各種系統級時鐘,號稱更省電更精確,不過需要你手動開啟,FreeBSD 9 以後也引入了 tickless。
TICKLESS 模式可以說是一個新的方向,但是目前處於預設關閉的測試狀態,那麼你的網路庫到底是用 TICK 還是 TICKLESS,看你根據具體情況來評估了。