深入出不來nodejs原始碼-timer模組(JS篇)
鴿了好久,最近沉迷遊戲,繼續寫點什麼吧,也不知道有沒有人看。
其實這個node的原始碼也不知道該怎麼寫了,很多模組涉及的東西比較深,JS和C++兩頭看,中間被工作耽擱回來就一臉懵逼了,所以還是挑一些簡單的吧!
這一篇選的是定時器模組,簡單講就是初學者都非常熟悉的setTimeout與setInterval啦,原始碼的JS內容在目錄lib/timers.js中。
node的定時器模組是自己單獨實現的,與Chrome的window.setTimeout可能不太一樣,但是思想應該都是相通的,學一學總沒錯。
連結串列
定時器模組實現中有一個關鍵資料結構:連結串列。用JS實現的連結串列,大體上跟其他語言的連結串列的原理還是一樣,每一個節點內容可分為前指標、後指標、資料。
原始碼裡的連結串列建構函式有兩種,一個是List的容器,一個是容器裡的item。
這裡看看List:
function TimersList(msecs, unrefed) { // 前指標 this._idleNext = this; // 後指標 this._idlePrev = this; // 資料 this._unrefed = unrefed; this.msecs = msecs; // ...更多 }
這是一個很典型的連結串列例子,包含2個指標(屬性)以及資料塊。item的建構函式大同小異,也是包含了兩個指標,只是資料內容有些不同。
關於連結串列的操作,放在了一個單獨的JS檔案中,目錄在lib/internal/linkedlist.js,實現跟C++、Java內建的有些許不一樣。
看一下增刪就差不多了,首先看刪:
function remove(item) { // 處理前後節點的指標指向 if (item._idleNext) { item._idleNext._idlePrev = item._idlePrev; } if (item._idlePrev) { item._idlePrev._idleNext = item._idleNext; } // 重置節點自身指標指向 item._idleNext = null; item._idlePrev = null; }
關於資料結構的程式碼,都是雖然看起來少,但是理解起來都有點噁心,能畫出圖就差不多了,所以這裡給一個簡單的示意圖。
應該能看懂吧……反正中間那個假設就是item,首先讓前後兩個對接上,然後把自身的指標置null。
接下來是增。
function append(list, item) { // 先保證傳入節點是空白節點 if (item._idleNext || item._idlePrev) { remove(item); } // 處理新節點的頭尾連結 item._idleNext = list._idleNext; item._idlePrev = list; // 處理list的前指標指向 list._idleNext._idlePrev = item; list._idleNext = item; }
這裡需要注意,初始化的時候就有一個List節點,該節點只作為連結串列頭,與其餘item不一樣,一開始前後指標均指向自己。
以上是append節點的三步示例圖。
之前說過JS實現的連結串列與C++、Java有些許不一樣,就在這裡,每一次新增新節點時:
C++/Java:node-node => node-node-new
JS(node):list-node-node => list-new-node-node
總的來說,JS用了一個list來作為連結串列頭,每一次新增節點都是往前面塞,整體來講是一個雙向迴圈連結串列。
而在C++/Java中則是可以選擇,API豐富多彩,連結串列型別也分為單向、單向迴圈、雙向等。
setTimeout
連結串列有啥用,後面就知道了。
首先從setTimeout這個典型的API入手,node的呼叫方式跟window.setTimeout一致,所以就不介紹了,直接上程式碼:
/** * * @param {Function} callback 延遲觸發的函式 * @param {Number} after 延遲時間 * @param {*} arg1 額外引數1 * @param {*} arg2 額外引數2 * @param {*} arg3 額外引數3 */ function setTimeout(callback, after, arg1, arg2, arg3) { // 只有第一個函式引數是必須的 if (typeof callback !== 'function') { throw new ERR_INVALID_CALLBACK(); } var i, args; /** * 引數修正 * 簡單來說 就是將第三個以後的引數包裝成陣列 */ switch (arguments.length) { case 1: case 2: break; case 3: args = [arg1]; break; case 4: args = [arg1, arg2]; break; default: args = [arg1, arg2, arg3]; for (i = 5; i < arguments.length; i++) { args[i - 2] = arguments[i]; } break; } // 生成一個Timeout物件 const timeout = new Timeout(callback, after, args, false, false); active(timeout); // 返回該物件 return timeout; }
可以看到,呼叫方式基本一致,但是有一點很不一樣,該方法返回的不是一個代表定時器ID的數字,而是直接返回生成的Timeout物件。
稍微測試一下:
雖然說返回的是物件,但是clearTimeout需要的引數也正是一個timeout物件,總體來說也沒啥需要注意的。
Timeout
接下來看看這個物件的內容,原始碼來源於lib/internal/timers.js。
/** * * @param {Function} callback 回撥函式 * @param {Number} after 延遲時間 * @param {Array} args 引數陣列 * @param {Boolean} isRepeat 是否重複執行(setInterval/setTimeout) * @param {Boolean} isUnrefed 不知道是啥玩意 */ function Timeout(callback, after, args, isRepeat, isUnrefed) { /** * 對延遲時間引數進行數字型別轉換 * 數字型別字串 會變成數字 * 非數字非數字字串 會變成NaN */ after *= 1; if (!(after >= 1 && after <= TIMEOUT_MAX)) { // 最大為2147483647 官網有寫 if (after > TIMEOUT_MAX) { process.emitWarning(`${after} does not fit into` + ' a 32-bit signed integer.' + '\nTimeout duration was set to 1.', 'TimeoutOverflowWarning'); } // 小於1、大於最大限制、非法引數均會被重置為1 after = 1; } // 呼叫標記 this._called = false; // 延遲時間 this._idleTimeout = after; // 前後指標 this._idlePrev = this; this._idleNext = this; this._idleStart = null; // V8層面的優化我也不太懂 留下英文註釋自己研究吧 // this must be set to null first to avoid function tracking // on the hidden class, revisit in V8 versions after 6.2 this._onTimeout = null; // 回撥函式 this._onTimeout = callback; // 引數 this._timerArgs = args; // setInterval的引數 this._repeat = isRepeat ? after : null; // 摧毀標記 this._destroyed = false; this[unrefedSymbol] = isUnrefed; // 暫時不曉得幹啥的 initAsyncResource(this, 'Timeout'); }
之前講過,整個方法,只有第一個引數是必須的,如果不傳延遲時間,預設設定為1。
這裡有意思的是,如果傳一個字串的數字,也是合法的,會被轉換成數字。而其餘非法值會被轉換為NaN,且NaN與任何數字比較都返回false,所以始終會重置為1這個合法值。
後面的屬性基本上就可以分為兩個指標和資料塊了,最後的initAsyncResource目前還沒搞懂,其餘模組也見過這個東西,先留個坑。
active/insert
生成了Timeout物件,第三步就會利用前面的連結串列進行處理,這裡才是重頭戲。
const refedLists = Object.create(null); const unrefedLists = Object.create(null); const active = exports.active = function(item) { insert(item, false); }; /** * * @param {Timeout} item 定時器物件 * @param {Boolean} unrefed 區分內部/外部呼叫 * @param {Boolean} start 不曉得幹啥的 */ function insert(item, unrefed, start) { // 取出延遲時間 const msecs = item._idleTimeout; if (msecs < 0 || msecs === undefined) return; if (typeof start === 'number') { item._idleStart = start; } else { item._idleStart = TimerWrap.now(); } // 內部使用定時器使用不同物件 const lists = unrefed === true ? unrefedLists : refedLists; // 延遲時間作為鍵來生成一個連結串列型別值 var list = lists[msecs]; if (list === undefined) { debug('no %d list was found in insert, creating a new one', msecs); lists[msecs] = list = new TimersList(msecs, unrefed); } // 留個坑 暫時不懂這個 if (!item[async_id_symbol] || item._destroyed) { item._destroyed = false; initAsyncResource(item, 'Timeout'); } // 把當前timeout物件新增到對應的連結串列上 L.append(list, item); assert(!L.isEmpty(list)); }
從這可以看出node內部處理定時器回撥函式的方式。
首先有兩個空物件,分別儲存內部、外部的定時器物件。物件的鍵是延遲時間,值則是一個連結串列頭,即以前介紹的list。每一次生成一個timeout物件時,會連結到list後面,通過這個list可以引用到所有該延遲時間的物件。
畫個圖示意一下:
那麼問題來了,node是在哪裡開始觸發定時器的?實際上,在生成對應list連結串列頭的時候就已經開始觸發了。
完整的list建構函式原始碼如下:
function TimersList(msecs, unrefed) { this._idleNext = this; this._idlePrev = this; this._unrefed = unrefed; this.msecs = msecs; // 來源於C++內建模組 const timer = this._timer = new TimerWrap(); timer._list = this; if (unrefed === true) timer.unref(); // 觸發 timer.start(msecs); }
最終還是指向了內建模組,將list本身作為屬性新增到timer上,通過C++程式碼觸發定時器。
C++部分單獨寫吧。