Node.js 核心模組 Timers 詳解

Starkwang發表於2017-11-06

Timers 模組應該是 Node.js 最重要的模組之一了。為什麼這麼說呢?

在 Node.js 基礎庫中,任何一個 TCP I/O 都會產生一個 timer(計時器)物件,以便記錄請求/響應是否超時。例如,HTTP請求經常會附帶 Connection:keep-alive 這個請求頭,以讓伺服器維持 TCP 連線,但這個連線顯然不可能一直保持著,所以會為它設定一個超時時間,這在內部庫里正是通過 Timers 實現的。

所以可以肯定地說,任何使用 Node.js 編寫的 Web 服務,一定在底層涉及到了 Timers 模組。(之後我們可以看到,Timers 模組的效能對於 Web 服務而言極其重要

另外,你的 Node.js 程式碼中也許會呼叫到諸如 setTimeoutsetInternalsetImmediate ,這些方法在瀏覽器內一般是由瀏覽器內部實現,而 Node.js 中,這些方法都是由 Timers 模組提供的:

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

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

Timers 模組如此重要,所以瞭解它的執行機制和實現原理對我們深刻理解 Node.js 十分有幫助,那麼就開始吧。


一、定時器的實現

剛才已經提到,在 Node.js 裡,setTimeoutsetInternalsetImmediate 這些定時器相關的函式是由基礎庫實現的,那麼它們到底是怎麼實現的呢?

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 層面的定時器物件,例如 setTimeoutsetInterval 返回的物件:

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 是給內部模組(如 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.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 該觸發了,這個時候會發生一些系列事情:

  1. 承擔計時任務的 TimersList 物件中的 Timer (也就是 C++ 實現的那個東西)觸發回撥,執行 timer1 所繫結的回撥函式;
  2. timer1 從連結串列中移除;
  3. 重新計算多久後觸發下一次回撥(即 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 應該插入到哪兒呢?一個線性查詢便可以找到位置,即在 timer2timer3 之間:

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

熟悉演算法的同學看到線性查詢肯定立刻意識到了,完全可以用一個O(lgn)二叉樹代替上面這個需要線性查詢O(n)的連結串列。

這樣我們不但可以只用一個 Timer 物件,而且可以用最佳的效率找到 timer 的插入位置,為什麼 Node.js 都 v9.0 了還沒有使用這樣的演算法呢?

事實上,社群很早之前就已經嘗試過二叉樹或者時間片輪轉排程演算法,但這些演算法實際的效能資料卻不如上文提到的多連結串列實現,為什麼呢?

因為內部庫裡,如 nethttp 模組,為 timer 設定的超時時間基本是固定的,所以產生了一個經驗性的假設:越晚建立的 timer 很可能越晚執行

基於這個假設我們再來看二叉樹演算法,由於假設的存在,每次插入新的 timer,由於插入時間最晚,執行時間極可能也是最晚的,所以很容易進入樹的最右側,此時,我們的二叉樹便退化成了一個普通的連結串列。效能優勢也不復存在。(平衡二叉樹?那就更不可能了)


五、後談

其實,Timers 模組的設計並非一簇而就,它的歷史也是十分有趣。早在 Node.js v0.x 的史前時代,之前提到的 refedListsunrefedLists 這兩個物件是分開實現的:對於第三方程式碼,使用的是多連結串列的結構;對於內部庫,使用的是單一連結串列的結構。

你可以在這裡看到當年是如何實現連結串列的線性查詢插入的。

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

現在,社群裡又有人產生了新的關於優化 Timers 的想法:

Optimizing 'unreferenced' timers #16105

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

六、參考

1、Timers in Node.js

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

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

4、JavaScript 執行機制詳解:再談Event Loop - 阮一峰的網路日誌

相關文章