Node.js 核心模塊 Timers 詳解

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 代碼中也許會調用到諸如 setTimeoutsetInternalsetImmediate ,這些方法在瀏覽器內通常是由瀏覽器內部實現,而 Node.js 中,這些方法都是由 Timers 模塊提供的:github

// 瀏覽器中:
> setTimeout
//=> ƒ setTimeout() { [native code] }

// Node.js:
> setTimeout
//=> { [Function: setTimeout] [Symbol(util.promisify.custom)]: [Function] }複製代碼

Timers 模塊如此重要,因此瞭解它的運行機制和實現原理對咱們深入理解 Node.js 十分有幫助,那麼就開始吧。算法


1、定時器的實現

剛纔已經提到,在 Node.js 裏,setTimeoutsetInternalsetImmediate 這些定時器相關的函數是由基礎庫實現的,那麼它們究竟是怎麼實現的呢?api

Node.js 底層經過 C++ 在 libuv 的基礎上包裹了一個 timer_wrap 模塊,這個模塊提供了 Timer 對象,實現了在 runtime 層面的定時功能。瀏覽器

簡單的說,你能夠經過這個 Timer 對象實現一個你本身的 setTimeoutbash

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 內部很是巧妙地使用了雙向鏈表來解決這個問題。


2、名詞聲明

下面的文章中,我會使用 timer 表示 Node.js 層面的定時器對象,例如 setTimeoutsetInterval 返回的對象:

var timer = setTimeout(() => {}, 1000);複製代碼

大寫字母開頭的 Timer 表示由底層 C++ time_wrap 模塊提供的 runtime 層面的定時器對象

const Timer = process.binding('timer_wrap').Timer;複製代碼

3、Node.js 的定時器雙向鏈表

Node.js 會使用一個雙向鏈表來保存全部定時時間相同timer,對於同一個鏈表中的全部 timer,只會建立一個 Timer 對象。當鏈表中前面的 timer 超時的時候,會觸發回調,在回調中從新計算下一次超時的時間,而後重置 Timer 對象,以減小重複 Timer 對象的建立開銷。

上面這兩句話信息量比較大,第一次看不懂不要緊,接着往下看。

舉個例子,全部 setTimeout(func, 1000) 的定時器都會放置在同一個鏈表中,共用同一個 Timer 對象。下面咱們來看 Node.js 是怎麼作到的。

3.一、鏈表的建立

在程序執行最初,Timers 模塊會初始化兩個對象,用於保存鏈表的頭部(看源碼請點這裏):

// - key = time in milliseconds
// - value = linked list
const refedLists = Object.create(null);
const unrefedLists = Object.create(null);複製代碼

咱們在後文中稱這兩個對象爲 lists 對象,它們的區別在於, refedLists 是給 Node.js 外部的定時器(即第三方代碼)使用的,而 unrefedLists 是給內部模塊(如 nethttphttp2)使用。

相同之處在於,它們的 key 表明這一組定時器的超時時間,key 對應的 value,都是一個定時器鏈表。例如 lists[1000] 對應的就是由一個或多個超時時間爲 1000mstimer 組成的鏈表。

當你的代碼中第一次調用定時器方法時,例如:

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.二、向鏈表中添加節點

假設咱們一段時間後又添加了一個新的、相同時間的 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.三、使用雙向鏈表複用 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 該觸發了,這個時候會發生一些系列事情:

  1. 承擔計時任務的 TimersList 對象中的 Timer (也就是 C++ 實現的那個東西)觸發回調,執行 timer1 所綁定的回調函數;
  2. timer1 從鏈表中移除;
  3. 從新計算多久後觸發下一次回調(即 timer2 對應的回調),重置 Timer,重複 1 過程。

下面用圖來描繪一下整個過程:

首先,在兩個定時器添加以後:

100秒到了,此時 Timer 對象觸發,執行 timer1 對應的回調函數:

而後 timer1 被移除出鏈表,從新計算下次觸發時間,重設 Timer,此時狀態變爲:

整個過程的源碼請參考這裏

這樣,咱們便經過一個雙向鏈表實現了 Timer 對象的複用,一個鏈表只須要一個 Timer,大大提升了 Timers 模塊的性能。


4、爲何要這樣設計?

看到這裏你可能會以爲,上面提到的這些設計,都很理所固然,天經地義。

然而並不是如此,對於處理多個 timer 對象,熟悉算法的同窗確定能想到,咱們徹底能夠只用一個 Timer

例如,咱們能夠把全部 timer 都放在惟一一個鏈表中,每次建立 timer 時,都經過計算 timer 具體執行的時間,從而找到合適的位置,把新的 timer 插入到鏈表中:

好比,最初的鏈表是這樣:

TimersList <-----> timer1 <-----> timer2 <-----> timer3 <-----> ......
                1000ms後執行     1050ms後執行      1200ms後執行複製代碼

此時咱們調用

var timer4 = setTimeout(() => {}, 1100);複製代碼

timer4 應該插入到哪兒呢?一個線性查找即可以找到位置,即在 timer2timer3 之間:

插入!
TimersList <-----> timer1 <-----> timer2 <-----> timer4 <-----> timer3 <-----> ......
                1000ms後執行     1050ms後執行    1100ms後執行    1200ms後執行複製代碼

熟悉算法的同窗看到線性查找確定馬上意識到了,徹底能夠用一個O(lgn)二叉樹代替上面這個須要線性查找O(n)的鏈表。

這樣咱們不但能夠只用一個 Timer 對象,並且能夠用最佳的效率找到 timer 的插入位置,爲何 Node.js 都 v9.0 了尚未使用這樣的算法呢?

事實上,社區很早以前就已經嘗試過二叉樹或者時間片輪轉調度算法,但這些算法實際的性能數據卻不如上文提到的多鏈表實現,爲何呢?

由於內部庫裏,如 nethttp 模塊,爲 timer 設定的超時時間基本是固定的,因此產生了一個經驗性的假設:越晚建立的 timer 極可能越晚執行

基於這個假設咱們再來看二叉樹算法,因爲假設的存在,每次插入新的 timer,因爲插入時間最晚,執行時間很可能也是最晚的,因此很容易進入樹的最右側,此時,咱們的二叉樹便退化成了一個普通的鏈表。性能優點也不復存在。(平衡二叉樹?那就更不可能了)


5、後談

其實,Timers 模塊的設計並不是一簇而就,它的歷史也是十分有趣。早在 Node.js v0.x 的史前時代,以前提到的 refedListsunrefedLists 這兩個對象是分開實現的:對於第三方代碼,使用的是多鏈表的結構;對於內部庫,使用的是單一鏈表的結構。

你能夠在這裏看到當年是如何實現鏈表的線性查找插入的。

後來直到 2015 年的這個 PR 的提交才正式告別了以前的線性搜索:use unsorted array in Timers._unrefActive() #2540

如今,社區裏又有人產生了新的關於優化 Timers 的想法:

Optimizing 'unreferenced' timers #16105

讀懂了文章的你,有興趣嗎?

6、參考

一、Timers in Node.js

二、The Node.js Event Loop, Timers, and process.nextTick()

三、Node.js timer的優化故事 - 淘小杰

四、JavaScript 運行機制詳解:再談Event Loop - 阮一峯的網絡日誌

相關文章
相關標籤/搜索