Timers 模塊應該是 Node.js 最重要的模塊之一了。爲何這麼說呢?html
在 Node.js 基礎庫中,任何一個 TCP I/O 都會產生一個 timer(計時器)對象,以便記錄請求/響應是否超時。例如,HTTP請求常常會附帶 Connection:keep-alive
這個請求頭,以讓服務器維持 TCP 鏈接,但這個鏈接顯然不可能一直保持着,因此會爲它設置一個超時時間,這在內部庫里正是經過 Timers 實現的。node
因此能夠確定地說,任何使用 Node.js 編寫的 Web 服務,必定在底層涉及到了 Timers 模塊。(以後咱們能夠看到,Timers 模塊的性能對於 Web 服務而言極其重要)git
另外,你的 Node.js 代碼中也許會調用到諸如 setTimeout
、setInternal
、setImmediate
,這些方法在瀏覽器內通常是由瀏覽器內部實現,而 Node.js 中,這些方法都是由 Timers 模塊提供的:github
// 瀏覽器中:
> setTimeout
//=> ƒ setTimeout() { [native code] }
// Node.js:
> setTimeout
//=> { [Function: setTimeout] [Symbol(util.promisify.custom)]: [Function] }複製代碼
Timers 模塊如此重要,因此瞭解它的運行機制和實現原理對咱們深入理解 Node.js 十分有幫助,那麼就開始吧。算法
剛纔已經提到,在 Node.js 裏,setTimeout
、setInternal
、setImmediate
這些定時器相關的函數是由基礎庫實現的,那麼它們究竟是怎麼實現的呢?api
Node.js 底層經過 C++ 在 libuv 的基礎上包裹了一個 timer_wrap
模塊,這個模塊提供了 Timer
對象,實現了在 runtime 層面的定時功能。瀏覽器
簡單的說,你能夠經過這個 Timer
對象實現一個你本身的 setTimeout
:bash
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 會使用一個雙向鏈表來保存全部定時時間相同的 timer
,對於同一個鏈表中的全部 timer
,只會建立一個 Timer
對象。當鏈表中前面的 timer
超時的時候,會觸發回調,在回調中從新計算下一次超時的時間,而後重置 Timer
對象,以減小重複 Timer
對象的建立開銷。
上面這兩句話信息量比較大,第一次看不懂不要緊,接着往下看。
舉個例子,全部 setTimeout(func, 1000)
的定時器都會放置在同一個鏈表中,共用同一個 Timer
對象。下面咱們來看 Node.js 是怎麼作到的。
在程序執行最初,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]
這個鏈表中。
這個時候,咱們鏈表的狀態是這樣的:
假設咱們一段時間後又添加了一個新的、相同時間的 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
對象複用的吧。
雖然咱們把定時時間相同的 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
讀懂了文章的你,有興趣嗎?