Timers 模組應該是 Node.js 最重要的模組之一了。為什麼這麼說呢?
在 Node.js 基礎庫中,任何一個 TCP I/O 都會產生一個 timer(計時器)物件,以便記錄請求/響應是否超時。例如,HTTP請求經常會附帶 Connection:keep-alive
這個請求頭,以讓伺服器維持 TCP 連線,但這個連線顯然不可能一直保持著,所以會為它設定一個超時時間,這在內部庫里正是通過 Timers 實現的。
所以可以肯定地說,任何使用 Node.js 編寫的 Web 服務,一定在底層涉及到了 Timers 模組。(之後我們可以看到,Timers 模組的效能對於 Web 服務而言極其重要)
另外,你的 Node.js 程式碼中也許會呼叫到諸如 setTimeout
、setInternal
、setImmediate
,這些方法在瀏覽器內一般是由瀏覽器內部實現,而 Node.js 中,這些方法都是由 Timers 模組提供的:
// 瀏覽器中:
> setTimeout
//=> ƒ setTimeout() { [native code] }
// Node.js:
> setTimeout
//=> { [Function: setTimeout] [Symbol(util.promisify.custom)]: [Function] }複製程式碼
Timers 模組如此重要,所以瞭解它的執行機制和實現原理對我們深刻理解 Node.js 十分有幫助,那麼就開始吧。
一、定時器的實現
剛才已經提到,在 Node.js 裡,setTimeout
、setInternal
、setImmediate
這些定時器相關的函式是由基礎庫實現的,那麼它們到底是怎麼實現的呢?
Node.js 底層通過 C++ 在 libuv 的基礎上包裹了一個 timer_wrap
模組,這個模組提供了 Timer
物件,實現了在 runtime 層面的定時功能。
簡單的說,你可以通過這個 Timer
物件實現一個你自己的 setTimeout
:
const Timer = process.binding('timer_wrap').Timer;
const kOnTimeout = Timer.kOnTimeout | 0;
function setTimeout(fn, ms) {
var timer = new Timer(); // 建立一個 Timer 物件
timer.start(ms, 0); // 設定觸發時間
timer[kOnTimeout] = fn; // 設定回撥函式
return timer; // 返回定時器
}
// 試一試
setTimeout(() => console.log('timeout!'), 1000);複製程式碼
當然,這個 setTimeout
方法不能真正地使用在生產環境中,因為它存在一個很嚴重的效能問題:
每次呼叫該方法,都會建立一個全新的 Timer
物件。
設想一下,伺服器每一秒都會面對成千上萬的 TCP 或 HTTP 請求,為每個請求都獨立地建立一個 Timer
物件,這裡的效能開銷對於追求高併發的伺服器端程式來說,是不可接受的。
所以我們需要一種更合理的資料結構來處理大量的 Timer 物件,Node.js 內部非常巧妙地使用了雙向連結串列來解決這個問題。
二、名詞宣告
下面的文章中,我會使用 timer
表示 Node.js 層面的定時器物件,例如 setTimeout
、setInterval
返回的物件:
var timer = setTimeout(() => {}, 1000);複製程式碼
大寫字母開頭的 Timer
表示由底層 C++ time_wrap
模組提供的 runtime 層面的定時器物件:
const Timer = process.binding('timer_wrap').Timer;複製程式碼
三、Node.js 的定時器雙向連結串列
Node.js 會使用一個雙向連結串列來儲存所有定時時間相同的 timer
,對於同一個連結串列中的所有 timer
,只會建立一個 Timer
物件。當連結串列中前面的 timer
超時的時候,會觸發回撥,在回撥中重新計算下一次超時的時間,然後重置 Timer
物件,以減少重複 Timer
物件的建立開銷。
上面這兩句話資訊量比較大,第一次看不懂沒關係,接著往下看。
舉個例子,所有 setTimeout(func, 1000)
的定時器都會放置在同一個連結串列中,共用同一個 Timer
物件。下面我們來看 Node.js 是怎麼做到的。
3.1、連結串列的建立
在程式執行最初,Timers 模組會初始化兩個物件,用於儲存連結串列的頭部(看原始碼請點這裡):
// - key = time in milliseconds
// - value = linked list
const refedLists = Object.create(null);
const unrefedLists = Object.create(null);複製程式碼
我們在後文中稱這兩個物件為 lists
物件,它們的區別在於, refedLists
是給 Node.js 外部的定時器(即第三方程式碼)使用的,而 unrefedLists
是給內部模組(如 net
、http
、http2
)使用。
相同之處在於,它們的 key 代表這一組定時器的超時時間,key 對應的 value,都是一個定時器連結串列。例如 lists[1000]
對應的就是由一個或多個超時時間為 1000ms
的 timer
組成的連結串列。
當你的程式碼中第一次呼叫定時器方法時,例如:
var timer1 = setTimeout(() => {}, 1000);複製程式碼
這個時候,lists
物件中當然是空的,沒有任何連結串列,Timers 便會在對應的位置上(這個例子中是lists[1000]
)建立一個 TimersList
作為連結串列的頭部,並且把剛才建立的新的 timer
放入連結串列中(原始碼請點這裡):
// Timers 模組部分原始碼:
const L = require('internal/linkedlist');
const lists = unrefed === true ? unrefedLists : refedLists;
// 如果已經有連結串列了,那麼直接複用,沒有的話就建立一個
var list = lists[msecs];
if (!list) {
lists[msecs] = list = createTimersList(msecs, unrefed);
}
// 略過一些初始化步驟......
// 把 timer 加入連結串列
L.append(list, timer);複製程式碼
你可以親自試一試:
var timer1 = setTimeout(() => {}, 1000)
timer1._idlePrev //=> TimersList { ...... }
timer1._idlePrev._idleNext //=> timer1複製程式碼
正如我們之前說到的,上面 setTimeout(() => {}, 1000)
這個定時器,會儲存在 lists[1000]
這個連結串列中。
這個時候,我們連結串列的狀態是這樣的:
3.2、向連結串列中新增節點
假設我們一段時間後又新增了一個新的、相同時間的 timer:
var timer2 = setTimeout(() => {}, 1000);複製程式碼
這個新的 timer2
會被加入到連結串列中, 和之前的 timer1
在同一個連結串列中:
timer1._idlePrev === timer2 // true
timer2._idleNext === timer1 // true複製程式碼
如果畫成圖的話就是類似這樣:
(PS:我們一直提到的這個雙向連結串列,其實是是獨立於 Timers 模組實現的,你可以在這裡看到它的具體實現程式碼:linkedlist.js)
用一張圖來總結一下就是,Timers 模組是這樣儲存 timer的:
現在你已經知道了如何用雙向連結串列儲存 timer,接下來我們看看 Node.js 是如何利用雙向連結串列實現 Timer
物件複用的吧。
3.3、使用雙向連結串列複用 Timer 物件
雖然我們把定時時間相同的 timer
放到了一起,但由於它們的新增時間不一樣,所以它們的執行時間也不一樣。
例如,連結串列中的第 1 個 timer
是在程式最初設定的,而第 2 個 timer
是在稍後時間設定的,它們定時時間相同,所以位於同一連結串列中,但由於時間先後順序,當然不可能同時觸發。
在連結串列中,Node.js 使用 _idleStart
來記錄 timer
設定定時的時間,或者理解為 timer
開始計時的時間。
var timer1 = setTimeout(() => {}, 100 * 1000) // 這裡我們把時間設長一些,防止定時器超時後被從連結串列中刪除
timer1._idleStart //=> 10829
// 一段時間後(100秒之內),設定第二個定時器 = ̄ω ̄=
var timer2 = setTimeout(() => {}, 100 * 1000)
timer2._idleStart //=> 23333
// 此時它們依然在同一個連結串列中:
timer1._idlePrev === timer2 // true
timer2._idleNext === timer1 // true複製程式碼
畫成圖的話就是這樣:
你可能會很奇怪,我們的連結串列中最左側這個奇怪的 TimersList
到底是個啥?難道只是一個頭部裝飾品嗎?
事實上,TimersList
就包含著我們所要複用的 Timer
物件,也就是底層 C++ 實現的那個定時器,它承擔了整個連結串列的計時工作。
等時間到 100 秒時,timer1
該觸發了,這個時候會發生一些系列事情:
- 承擔計時任務的
TimersList
物件中的Timer
(也就是 C++ 實現的那個東西)觸發回撥,執行timer1
所繫結的回撥函式; - 把
timer1
從連結串列中移除; - 重新計算多久後觸發下一次回撥(即
timer2
對應的回撥),重置Timer
,重複 1 過程。
下面用圖來描繪一下整個過程:
首先,在兩個定時器新增之後:
100秒到了,此時 Timer
物件觸發,執行 timer1
對應的回撥函式:
然後 timer1
被移除出連結串列,重新計算下次觸發時間,重設 Timer
,此時狀態變為:
整個過程的原始碼請參考這裡
這樣,我們便通過一個雙向連結串列實現了 Timer
物件的複用,一個連結串列只需要一個 Timer
,大大提高了 Timers 模組的效能。
四、為什麼要這樣設計?
看到這裡你可能會覺得,上面提到的這些設計,都很理所當然,天經地義。
然而並非如此,對於處理多個 timer
物件,熟悉演算法的同學肯定能想到,我們完全可以只用一個 Timer
!
例如,我們可以把所有 timer
都放在唯一一個連結串列中,每次建立 timer
時,都通過計算 timer
具體執行的時間,從而找到合適的位置,把新的 timer
插入到連結串列中:
比如,最初的連結串列是這樣:
TimersList <-----> timer1 <-----> timer2 <-----> timer3 <-----> ......
1000ms後執行 1050ms後執行 1200ms後執行複製程式碼
此時我們呼叫
var timer4 = setTimeout(() => {}, 1100);複製程式碼
timer4
應該插入到哪兒呢?一個線性查詢便可以找到位置,即在 timer2
和 timer3
之間:
插入!
TimersList <-----> timer1 <-----> timer2 <-----> timer4 <-----> timer3 <-----> ......
1000ms後執行 1050ms後執行 1100ms後執行 1200ms後執行複製程式碼
熟悉演算法的同學看到線性查詢肯定立刻意識到了,完全可以用一個O(lgn)
的二叉樹代替上面這個需要線性查詢O(n)
的連結串列。
這樣我們不但可以只用一個 Timer
物件,而且可以用最佳的效率找到 timer
的插入位置,為什麼 Node.js 都 v9.0 了還沒有使用這樣的演算法呢?
事實上,社群很早之前就已經嘗試過二叉樹或者時間片輪轉排程演算法,但這些演算法實際的效能資料卻不如上文提到的多連結串列實現,為什麼呢?
因為內部庫裡,如 net
、http
模組,為 timer
設定的超時時間基本是固定的,所以產生了一個經驗性的假設:越晚建立的 timer
很可能越晚執行。
基於這個假設我們再來看二叉樹演算法,由於假設的存在,每次插入新的 timer
,由於插入時間最晚,執行時間極可能也是最晚的,所以很容易進入樹的最右側,此時,我們的二叉樹便退化成了一個普通的連結串列。效能優勢也不復存在。(平衡二叉樹?那就更不可能了)
五、後談
其實,Timers 模組的設計並非一簇而就,它的歷史也是十分有趣。早在 Node.js v0.x 的史前時代,之前提到的 refedLists
和 unrefedLists
這兩個物件是分開實現的:對於第三方程式碼,使用的是多連結串列的結構;對於內部庫,使用的是單一連結串列的結構。
你可以在這裡看到當年是如何實現連結串列的線性查詢插入的。
後來直到 2015 年的這個 PR 的提交才正式告別了之前的線性搜尋:use unsorted array in Timers._unrefActive() #2540
現在,社群裡又有人產生了新的關於優化 Timers 的想法:
Optimizing 'unreferenced' timers #16105
讀懂了文章的你,有興趣嗎?