1. 說明
nodejs是單線程執行的,同時它又是基於事件驅動的非阻塞IO編程模型。這就使得我們不用等待異步操作結果返回,就可以繼續往下執行代碼。當異步事件觸發之後,就會通知主線程,主線程執行相應事件的回調。
本篇文章講解node中JavaScript的代碼的執行流程,下面是測試代碼,如果你知道輸出的結果,那麼就不需要再看本篇文章,如果不知道輸出結果,那麼本片文章可幫助你瞭解:
console.log(1)
setTimeout(function () {
new Promise(function (resolve) {
console.log(2)
resolve()
})
.then(() => { console.log(3) })
})
setTimeout(function () {
console.log(4)
})
複雜的:
setTimeout(() => {
console.log('1')
new Promise((resolve) => { console.log('2'); resolve(); })
.then(() => { console.log('3') })
new Promise((resolve)=> { console.log('4'); resolve()})
.then(() => { console.log('5') })
setTimeout(() => {
console.log('6')
setTimeout(() => {
console.log('7')
new Promise((resolve) => { console.log('8'); resolve() })
.then( () => { console.log('9') })
new Promise((resolve) => { console.log('10'); resolve() })
.then(() => { console.log('11') })
})
setTimeout(() => { console.log('12') }, 0)
})
setTimeout(() => { console.log('13') }, 0)
})
setTimeout(() => { console.log('14') }, 0)
new Promise((resolve) => { console.log('15'); resolve() })
.then( ()=> { console.log('16') })
new Promise((resolve) => { console.log('17'); resolve() })
.then(() => { console.log('18') })
2. nodejs的啓動過程
node.js啓動過程可以分爲以下步驟:
- 調用platformInit方法 ,初始化 nodejs 的運行環境。
- 調用 performance_node_start 方法,對 nodejs 進行性能統計。
- openssl設置的判斷。
- 調用v8_platform.Initialize,初始化 libuv 線程池。
- 調用 V8::Initialize,初始化 V8 環境。
- 創建一個nodejs運行實例。
- 啓動上一步創建好的實例。
- 開始執行js文件,同步代碼執行完畢後,進入事件循環。
- 在沒有任何可監聽的事件時,銷燬 nodejs 實例,程序執行完畢。
3. nodejs的事件循環詳解
Nodejs 將消息循環又細分爲 6 個階段(官方叫做 Phase), 每個階段都會有一個類似於隊列的結構, 存儲着該階段需要處理的回調函數.
Nodejs 爲了防止某個 階段 任務太多, 導致後續的 階段 發生飢餓的現象, 所以消息循環的每一個迭代(iterate) 中, 每個 階段 執行回調都有個最大數量. 如果超過數量的話也會強行結束當前 階段而進入下一個 階段. 這一條規則適用於消息循環中的每一個 階段.
3.1 Timer 階段
這是消息循環的第一個階段, 用一個 for
循環處理所有 setTimeout
和 setInterval
的回調.
這些回調被保存在一個最小堆(min heap) 中. 這樣引擎只需要每次判斷頭元素, 如果符合條件就拿出來執行, 直到遇到一個不符合條件或者隊列空了, 才結束 Timer Phase.
Timer 階段中判斷某個回調是否符合條件的方法也很簡單. 消息循環每次進入 Timer 的時候都會保存一下當時的系統時間,然後只要看上述最小堆中的回調函數設置的啓動時間是否超過進入 Timer 時保存的時間, 如果超過就拿出來執行.
3.2 Pending I/O Callback 階段
執行除了close callbacks
、setTimeout()
、setInterval()
、setImmediate()
回調之外幾乎所有回調,比如說TCP連接發生錯誤
、 fs.read
, socket
等 IO 操作的回調函數, 同時也包括各種 error 的回調.
3.3 Idle, Prepare 階段
系統內部的一些調用。
3.4 Poll 階段,重要階段
這是整個消息循環中最重要的一個 階段, 作用是等待異步請求和數據,因爲它支撐了整個消息循環機制.
poll階段有兩個主要的功能:一是執行下限時間已經達到的timers的回調,一是處理poll隊列裏的事件。
注:Node的很多API都是基於事件訂閱完成的,比如fs.readFile,這些回調應該都在poll
階段完成。
當事件循環進入poll階段:
-
poll
隊列不爲空的時候,事件循環肯定是先遍歷隊列並同步執行回調,直到隊列清空或執行回調數達到系統上限。 -
poll
隊列爲空的時候,這裏有兩種情況。- 如果代碼已經被
setImmediate()
設定了回調,那麼事件循環直接結束poll
階段進入check
階段來執行check
隊列裏的回調。 -
如果代碼沒有被設定
setImmediate()
設定回調:- 如果有被設定的timers,那麼此時事件循環會檢查timers,如果有一個或多個timers下限時間已經到達,那麼事件循環將繞回timers階段,並執行timers的有效回調隊列。
- 如果沒有被設定timers,這個時候事件循環是阻塞在poll階段等待事件回調被加入poll隊列。
- 如果代碼已經被
Poll階段,當js層代碼註冊的事件回調都沒有返回的時候,事件循環會暫時阻塞在poll階段,解除阻塞的條件:
- 在poll階段執行的時候,會傳入一個timeout超時時間,該超時時間就是poll階段的最大阻塞時間。
- timeout時間未到的時候,如果有事件返回,就執行該事件註冊的回調函數。timeout超時時間到了,則退出poll階段,執行下一個階段。
這個 timeout 設置爲多少合適呢? 答案就是 Timer Phase 中最近要執行的回調啓動時間到現在的差值, 假設這個差值是 detal. 因爲 Poll Phase 後面沒有等待執行的回調了. 所以這裏最多等待 delta 時長, 如果期間有事件喚醒了消息循環, 那麼就繼續下一個 Phase 的工作; 如果期間什麼都沒發生, 那麼到了 timeout 後, 消息循環依然要進入後面的 Phase, 讓下一個迭代的 Timer Phase 也能夠得到執行.
Nodejs 就是通過 Poll Phase, 對 IO 事件的等待和內核異步事件的到達來驅動整個消息循環的.
3.5 Check 階段
這個階段只處理 setImmediate 的回調函數.
那麼爲什麼這裏要有專門一個處理 setImmediate 的 階段 呢? 簡單來說, 是因爲 Poll 階段可能設置一些回調, 希望在 Poll 階段 後運行. 所以在 Poll 階段 後面增加了這個 Check 階段.
3.6 Close Callbacks 階段
專門處理一些 close 類型的回調. 比如 socket.on('close', ...)
. 用於資源清理.
4. nodejs執行JS代碼過程及事件循環過程
-
1、node初始化
- 初始化node環境
- 執行輸入的代碼
- 執行
process.nextTick
回調 - 執行微任務(microtasks)
-
2、進入事件循環
-
2.1、進入
Timer
階段- 檢查
Timer
隊列是否有到期的Timer
的回調,如果有,將到期的所有Timer
回調按照TimerId
升序執行 - 檢查是否有
process.nextTick
任務,如果有,全部執行 - 檢查是否有微任務(promise),如果有,全部執行
- 退出該階段
- 檢查
-
2.2、進入
Pending I/O Callback
階段- 檢查是否有
Pending I/O Callback
的回調,如果有,執行回調。如果沒有退出該階段 - 檢查是否有
process.nextTick
任務,如果有,全部執行 - 檢查是否有微任務(promise),如果有,全部執行
- 退出該階段
- 檢查是否有
- 2.3、進入
idle,prepare
階段這個階段與JavaScript關係不大,略過
-
2.4、進入
Poll
階段-
首先檢查是否存在尚未完成的回調,如果存在,分如下兩種情況:
-
第一種情況:有可執行的回調
- 執行所有可用回調(包含到期的定時器還有一些IO事件等)
- 檢查是否有
process.nextTick
任務,如果有,全部執行 - 檢查是否有微任務(promise),如果有,全部執行
- 退出該階段
-
第二種情況:沒有可執行的回調
- 檢查是否有
immediate
回調,如果有,退出Poll階段。如果沒有,阻塞在此階段,等待新的事件通知
- 檢查是否有
-
- 如果不存在尚未完成的回調,退出Poll階段
-
-
2.5、進入
check
階段- 如果有immediate回調,則執行所有immediate回調
- 檢查是否有
process.nextTick
任務,如果有,全部執行 - 檢查是否有微任務(promise),如果有,全部執行
- 退出該階段
-
2.6、進入
closing
階段- 如果有immediate回調,則執行所有immediate回調
- 檢查是否有
process.nextTick
任務,如果有,全部執行 - 檢查是否有微任務(promise),如果有,全部執行
- 退出該階段
-
-
3、檢查是否有活躍的
handles(定時器、IO等事件句柄)
- 如果有,繼續下一輪事件循環
- 如果沒有,結束事件循環,退出程序
注意:
事件循環的每一個子階段退出之前都會按順序執行如下過程:
- 檢查是否有 process.nextTick 回調,如果有,全部執行。
- 檢查是否有 微任務(promise),如果有,全部執行。
4.1 關於Promise和process.nextTick
事件循環隊列先保證所有的process.nextTick
回調,然後將所有的Promise
回調追加在後面,最終在每個階段結束的時候一次性拿出來執行。
此外,process.nextTick
和Promise
回調的數量是受限制的,也就是說,如果一直往這個隊列中加入回調,那麼整個事件循環就會被卡住
。
4.2 關於setTimeout(…, 0) 和 setImmediate
這兩個方法的回調到底誰快?
如下面的例子:
setImmediate(() => console.log(2))
setTimeout(() => console.log(1))
使用nodejs多次執行後,發現輸出結果有時是1 2
,有時是2 1
。
對於多次執行輸出結果不同,需要了解事件循環的基礎問題。
首先,Nodejs啓動,初始化環境後加載我們的JS代碼(index.js).發生了兩件事(此時尚未進入消息循環環節):
setImmediate 向 Check 階段 中添加了回調 console.log(2);setTimeout 向 Timer 階段 中添加了回調 console.log(1)
這時候, 要初始化階段完畢, 要進入 Nodejs 消息循環了。
爲什麼會有兩種輸出呢? 接下來一步很關鍵:
當執行到 Timer 階段 時, 會發生兩種可能. 因爲每一輪迭代剛剛進入 Timer 階段 時會取系統時間保存起來, 以 ms(毫秒) 爲最小單位.
如果 Timer 階段 中回調預設的時間 > 消息循環所保存的時間, 則執行 Timer 階段 中的該回調. 這種情況下先輸出 1, 直到 Check 階段 執行後,輸出2.總的來說, 結果是 1 2.
如果運行比較快, Timer 階段 中回調預設的時間可能剛好等於消息循環所保存的時間, 這種情況下, Timer 階段 中的回調得不到執行, 則繼續下一個 階段. 直到 Check 階段, 輸出 2. 然後等下一輪迭代的 Timer 階段, 這時的時間一定是滿足 Timer 階段 中回調預設的時間 > 消息循環所保存的時間 , 所以 console.log(1) 得到執行, 輸出 1. 總的來說, 結果就是 2 1.
所以, 輸出不穩定的原因就取決於進入 Timer 階段 的時間是否和執行 setTimeout 的時間在 1ms 內. 如果把代碼改成如下, 則一定會得到穩定的輸出:
require('fs').readFile('my-file-path.txt', () => {
setImmediate(() => console.log(2))
setTimeout(() => console.log(1))
});
這是因爲消息循環在 Pneding I/O Phase
才向 Timer 和 Check 隊列插入回調. 這時按照消息循環的執行順序, Check 一定在 Timer 之前執行。
從性能角度講, setTimeout 的處理是在 Timer Phase, 其中 min heap 保存了 timer 的回調, 因此每執行一個回調的同時都會涉及到堆調整. 而 setImmediate 僅僅是清空一個隊列. 效率自然會高很多.
再從執行時機上講. setTimeout(..., 0) 和 setImmediate 完全屬於兩個階段.
5. 一個實際例子演示
下面以一段代碼來說明nodejs運行JavaScript的機制。
如下面一段代碼:
setTimeout(() => { // settimeout1
console.log('1')
new Promise((resolve) => { console.log('2'); resolve(); }) // Promise3
.then(() => { console.log('3') })
new Promise((resolve)=> { console.log('4'); resolve()}) // Promise4
.then(() => { console.log('5') })
setTimeout(() => { // settimeout3
console.log('6')
setTimeout(() => { // settimeout5
console.log('7')
new Promise((resolve) => { console.log('8'); resolve() }) // Promise5
.then( () => { console.log('9') })
new Promise((resolve) => { console.log('10'); resolve() }) // Promise6
.then(() => { console.log('11') })
})
setTimeout(() => { console.log('12') }, 0) // settimeout6
})
setTimeout(() => { console.log('13') }, 0) // settimeout4
})
setTimeout(() => { console.log('14') }, 0) // settimeout2
new Promise((resolve) => { console.log('15'); resolve() }) // Promise1
.then( ()=> { console.log('16') })
new Promise((resolve) => { console.log('17'); resolve() }) // Promise2
.then(() => { console.log('18') })
上面代碼執行過程:
-
node初始化
-
執行JavaScript代碼
- 遇到
setTimeout
, 把回調函數放到Timer
隊列中,記爲settimeout1 - 遇到
setTimeout
, 把回調函數放到Timer
隊列中,記爲settimeout2 - 遇到
Promise
,執行,輸出15,把回調函數放到微任務
隊列,記爲Promise1 - 遇到
Promise
,執行,輸出17,把回調函數放到微任務
隊列,記爲Promise2 - 代碼執行結束,此階段輸出結果:
15 17
- 遇到
- 沒有
process.nextTick
回調,略過 -
執行微任務
- 檢查微任務隊列是否有可執行回調,此時隊列有2個回調:Promise1、Promise2
- 執行Promise1回調,輸出16
- 執行Promise2回調,輸出18
- 此階段輸出結果:
16 18
-
-
進入第一次事件循環
-
進入Timer階段
- 檢查Timer隊列是否有可執行的回調,此時隊列有2個回調:settimeout1、settimeout2
-
執行settimeout1回調:
- 輸出1、2、4
- 添加了2個微任務,記爲Promise3、Promise4
- 添加了2個Timer任務,記爲settimeout3、settimeout4
- 執行settimeout2回調,輸出14
- Timer隊列任務執行完畢
- 沒有
process.nextTick
回調,略過 - 檢查微任務隊列是否有可執行回調,此時隊列有2個回調:Promise3、Promise4
- 按順序執行2個微任務,輸出3、5
- 此階段輸出結果:
1 2 4 14 3 5
- Pending I/O Callback階段沒有任務,略過
-
進入 Poll 階段
- 檢查是否存在尚未完成的回調,此時有2個回調:settimeout3、settimeout4
-
執行settimeout3回調
- 輸出6
- 添加了2個Timer任務,記爲settimeout5、settimeout6
- 執行settimeout4回調,輸出13
- 沒有
process.nextTick
回調,略過 - 沒有微任務,略過
- 此階段輸出結果:
6 13
- check、closing階段沒有任務,略過
- 檢查是否還有活躍的
handles(定時器、IO等事件句柄)
,有,繼續下一輪事件循環
-
-
進入第二次事件循環
-
進入Timer階段
- 檢查Timer隊列是否有可執行的回調,此時隊列有2個回調:settimeout5、settimeout6
-
執行settimeout5回調:
- 輸出7、 8、10
- 添加了2個微任務,記爲Promise5、Promise6
- 執行settimeout6回調,輸出12
- 沒有
process.nextTick
回調,略過 - 檢查微任務隊列是否有可執行回調,此時隊列有2個回調:Promise5、Promise6
- 按順序執行2個微任務,輸出9、11
- 此階段輸出結果:
7 8 10 12 9 11
- Pending I/O Callback、Poll、check、closing階段沒有任務,略過
- 檢查是否還有活躍的
handles(定時器、IO等事件句柄)
,沒有了,結束事件循環,退出程序
-
- 程序執行結束,輸出結果:
15 17 16 18 1 2 4 14 3 5 6 13 7 8 10 12 9 11