node原理,事件迴圈,setTimeout/setImmediate/process.nextTick的差別
事件迴圈
Node.js 在主執行緒裡維護了一個事件佇列,當接到請求後,就將該請求作為一個事件放入這個佇列中,然後繼續接收其他請求。當主執行緒空閒時(沒有請求接入時),就開始迴圈事件佇列,檢查佇列中是否有要處理的事件,這時要分兩種情況:如果是非 I/O 任務,就親自處理,並通過回撥函式返回到上層呼叫;如果是 I/O 任務,就從 執行緒池 中拿出一個執行緒來處理這個事件,並指定回撥函式,然後繼續迴圈佇列中的其他事件。
當執行緒中的 I/O 任務完成以後,就執行指定的回撥函式,並把這個完成的事件放到事件佇列的尾部,等待事件迴圈,當主執行緒再次迴圈到該事件時,就直接處理並返回給上層呼叫。 這個過程就叫 事件迴圈
這個圖是整個 Node.js 的執行原理,從左到右,從上到下,Node.js 被分為了四層,分別是 應用層、V8引擎層、Node API層 和 LIBUV層。
應用層: 即 JavaScript 互動層,常見的就是 Node.js 的模組,比如 http,fs
V8引擎層: 即利用 V8 引擎來解析JavaScript 語法,進而和下層 API 互動
NodeAPI層: 為上層模組提供系統呼叫,一般是由 C 語言來實現,和作業系統進行互動 。
LIBUV層: 是跨平臺的底層封裝,實現了 事件迴圈、檔案操作等,是 Node.js 實現非同步的核心 。
無論是 Linux 平臺還是 Windows 平臺,Node.js 內部都是通過 執行緒池 來完成非同步 I/O 操作的,而 LIBUV 針對不同平臺的差異性實現了統一呼叫。因此,Node.js 的單執行緒僅僅是指 JavaScript 執行在單執行緒中(應用層是單執行緒的),而並非 Node.js 是單執行緒。
node對回撥事件的處理完全是基於事件迴圈的tick的,因此具有幾大特徵:
1、在應用層面,JS是單執行緒的,業務程式碼中不能存在耗時過長的程式碼,否則可能會嚴重拖後續程式碼(包括回撥)的處理。如果遇到需要複雜的業務計算時,應當想辦法啟用獨立程序或交給其他服務進行處理。
2、回撥是不精確,因為前面的原因,setTimeout並不能得到準確的超時回撥。
3、不同型別的觀察者,處理的優先順序不同,idle觀察者最先,I/O觀察者其次,check觀察者最後。
下面的這個圖是一個完整的node執行流程:
關於觀察者,從上圖中可以很明顯的看到,在整個事件迴圈過程中承擔了最基本的資料結構的角色,所有的io請求或者網路請求都被封裝成了觀察者物件,事件迴圈通過觀察者物件來呼叫回撥函式。這裡可以很明確的看到,觀察者就是檔案描述符表和callbach的和。這個圖太直觀,太明瞭了,事件迴圈的一切細節都在上面了。
說到這個觀察者物件,有人會覺得難道這就是傳說設計模式中的觀察者模式嗎?nonono,觀察者模式是定義物件間的一種一對多的依賴關係,當一個物件的狀態發生改變時,所有依賴於它的物件都得到通知並被自動更新。而反觀上面的事件迴圈機制,我們封裝了一個又一個的觀察者物件,然後事件迴圈通過觀察者物件來獲取非同步操作結束之後返回的資料,並交給主執行緒來處理。
仔細想想這是一種典型的生產者消費者模式。生產者是libuv中的執行緒池,執行緒池通對io處理返回資料給觀察者,實現迴圈檢查觀察者來獲取返回資料來操作,那麼事件迴圈中檢查觀察者的執行緒就是一個消費者。所以這是一個典型的生產者消費者模式
當Node.js啟動時會初始化event loop, 每一個event loop都會包含按如下順序六個迴圈階段,
timers 階段: 這個階段執行setTimeout(callback) and setInterval(callback)預定的callback;
I/O callbacks 階段: This phase executes callbacks for some system operations such as types of TCP errors. For example if a TCP socket receives ECONNREFUSED when attempting to connect, some *nix systems want to wait to report the error. This will be queued to execute in the I/O callbacks phase;
idle, prepare 階段: 僅node內部使用;
poll 階段: 獲取新的I/O事件, 適當的條件下node將阻塞在這裡;
check 階段: 執行setImmediate() 設定的callbacks;
close callbacks 階段: 比如socket.on(‘close’, callback)的callback會在這個階段執行.
event loop按順序執行上面的六個階段,每一個階段都有一個裝有callbacks的fifo queue(佇列),當event loop執行到一個指定階段時,node將執行該階段的fifo queue(佇列),當佇列callback執行完或者執行callbacks數量超過該階段的上限時,event loop會轉入下一下階段.
那麼我們平常的非同步io是在哪個階段執行的呢,答案是poll階段。
poll階段
在node.js裡,除了上面幾個特定階段的callback之外,任何非同步方法完成時,都會將其callback加到poll queue裡。分以下的兩種情況:
1.當event loop到poll階段時,且不存在timer,將會發生下面的情況
如果poll queue不為空,event loop將同步的執行queue裡的callback,直至queue為空,或執行的callback到達系統上限;
如果poll queue為空,將會發生下面情況:
如果程式碼已經被setImmediate()設定了callback 或者有滿足close callbacks階段的callback, event loop將結束poll階段進入check階段,並執行check階段的queue (check階段的queue是 setImmediate設定的)
如果程式碼沒有設定setImmediate(callback)或者沒有滿足close callbacks階段的callback,event loop將阻塞在該階段等待callbacks加入poll queue;
2.當event loop到poll階段時,如果存在timer並且timer未到超時時間,將會發生下面情況:
則會把最近的一個timer剩餘超時時間作為引數傳入io_poll()中,這樣event loop 阻塞在poll階段等待時,如果沒有任何I/O事件觸發,也會由timerout觸發跳出等待的操作,結束本階段,然後在close callbacks階段結束之後會在進行一次timer超時判斷
所以實際上,timer檢查會發生在兩個地方:timers階段和close callbacks階段結束之後。
應該說,事件迴圈、觀察者、請求物件、I/O執行緒池,這四者共同組成了Node非同步I/O模型的基本要素。
setTimeout/setInterval
setTimeout和setInterval的表現和實現其實基本相同,不同的只是setInterval會不斷重複。在底層實現上他們是建立了一個Timeout的中間物件,並且放到了實現定時器的紅黑樹中,每一次tick開始時,都會到這個紅黑樹中檢查是否存在超時的回撥,如果存在,則一一按照超時順序取出來進行回撥。因此,我們可以得出這樣一個結論:
js的定時器是不可靠的。因此單執行緒的原因,它是基於tick的,每次tick開始時才開始檢查是否有超時,如果一個tick耗時過長,在它之後出發的定時回撥都將被延遲
timer的效率不是很高,因為是從紅黑樹上取下所有超時的Timer物件,然後依次呼叫他們的回撥方法進行回撥。
process.nextTick()方法的操作相對較為輕量,每次呼叫Process.nextTick()方法,只會將回調函式放入佇列中,在下一輪Tick時取出執行。定時器採用紅黑樹的操作時間複雜度為o(lg(n)),而nextTick()的時間複雜度為o(1)。相較之下,process.nextTick()更高效。
nextTick函式,會將callback封裝為一個obj物件,並且插入到nextTickQueue佇列(陣列)中。
每次nextTick回撥,都會nextTickQueue陣列中的回撥全部跑完!
setImmediate函式,首先把callback封裝成了一個immediate物件,然後把它插入到了immediateQueue佇列(陣列)中
兩者之間其實是有差別的。區別表現為兩點:
1、process.nextTick中回撥函式的優先順序高於setImmediate,根據我前面寫的那篇文章可知,原因在於事件迴圈對觀察者的檢查是有先後順序的,process.nextTick屬於idle觀察者,setImmediate屬於check觀察者。在每一輪迴圈檢查中,idle觀察者先於I/O觀察者,I/O觀察者先於check觀察者。
2、在實現上,process.nextTick的回調函式儲存在一個數組中,setImmediate則儲存在一個連結串列中。順便這裡丟擲一個樸靈老師在《深入淺出Node.js》中對process.nextTick和setImmediate的不夠準確的描述:“在行為上,process.nextTick在每輪迴圈中將陣列中的回撥函式全部執行完,而setImmediate在每輪迴圈中執行連結串列中的一個回撥函式。
3、setImmediate可以使用clearImmediate清除(沒搞懂這個到底能幹嗎,誰明白請告訴我一下),process.nextTick不能被清除
觀察者優先順序
在每次輪訓檢查中,各觀察者的優先順序分別是:
idle觀察者 > I/O觀察者 > check觀察者。
idle觀察者:process.nextTick
I/O觀察者:一般性的I/O回撥,如網路,檔案,資料庫I/O等
check觀察者:setImmediate,setTimeout
知乎上曾有人貼過一段關於setImmediate和setTimeout(xxx,0)的代碼,得出了一個這樣的結論:“而在執行setImmedia時,setTimeout是隨機的插入在setImmediate的順序中的”。我對這個結論是持懷疑態度的
根本原因是node底層的設計所致,也就是說setTimeout(xxx,0)其實在底層強制設定成等同於setTimeout(xxx,1)。小於1秒都要強制設定成1秒
那就很容易理解知乎這位作者的給出的程式碼為什麼是這樣的結果了。因此:setTimeout的優先順序高於setImmediate,但是因為setTimeout的after被強制修正為1,這就可能存在下一個tick觸發時,耗時尚不足1ms,setTimeout的回撥依然未超時,因此setImmediate就先執行了!
優先順序順序:process.nextTick > setTimeout/setInterval > setImmediate
setTimeout需要使用紅黑樹,且after設定為0,其實會被node強制轉換為1,存在效能上的問題,建議替換為setImmediate
process.nextTick有一些比較難懂的問題和隱患,從0.8版本開始加入setImmediate,使用時,建議使用setImmediate