圖解 Node.js 的核心 Event-loop
這次我們來聊聊 Node.js 里面涉及到的一個核心概念:event-loop 。只有理解了它,才能明白 node 的進程模型,也才能明白異步調用在實現層面是什么樣子的,更能明白當同步代碼和異步代碼混雜在一起的時候,CPU 到底跑到我們代碼的哪一行了。文章分為兩篇:event-loop 篇和 Promise/Generator/async 篇。今天我們關注 event-loop 部分。
1、代碼思考
我寫了兩個函數,函數內部直接用 while(true){} 寫了一段死循環代碼。我們先來思考下面這段 Node.js code 執行結果是什么?很多人說 Node.js 是單線程的。如果是這樣,那 CPU 會不會陷入到 whileLoop_1() 的 while 循環里面出不來?
'use strict';
async function sleep(intervalInMS)
{
return new Promise((resolve,reject)=>{
setTimeout(resolve,intervalInMS);
});
}
async function whileLoop_1(){
while(true){
try {
console.log('new round of whileLoop_1');
await sleep(1000); // LINE-A
} catch (error) {
// ...
}
}
}
async function whileLoop_2(){
while(true){
try {
console.log('new round of whileLoop_2');
await sleep(1000); // LINE-B
} catch (error) {
// ...
}
}
}
whileLoop_1(); // LINE-C
whileLoop_2(); // LINE-D
不賣關子了,我先把執行結果發出來。
new round of whileLoop_1
new round of whileLoop_2
new round of whileLoop_1
new round of whileLoop_2
new round of whileLoop_1
new round of whileLoop_2
new round of whileLoop_1
new round of whileLoop_2
new round of whileLoop_1
new round of whileLoop_2
是的,正如你所見。這兩個 while 循環分別在交替執行, CPU 也沒有陷入到死循環里面出不來。那么問題來了:
- CPU 執行到 LINE-A 的時候發生了什么使得它能成功脫身并有機會執行 whileLoop_2 ?
- CPU 執行到 LINE-B 后,為什么又能回到 whileLoop_1 中繼續執行呢?
2、event-loop
在回答上面的問題前,我們需要先來看一個至關重要的概念:event-loop 。其實我們平時說 Node.js 是單線程僅僅是指 node 執行我們的 JS 代碼,更準確地說是 V8 執行 JS code 是發生在單線程里面的。實際上如果你打開 node 進程,會發現它有不少 worker thread。這是一個典型的單進程多線程模型。這些 worker thread 被放置于線程池里面,而 V8 執行 JS code 的線程被稱為主線程。主線程和線程池的配合關系如下圖所示。主線程負責執行 JS code ,線程池里面的 worker thread 負責執行類似訪問 DB、訪問文件這樣的耗時費力的工作,它倆通過消息隊列協調工作。這和餐館工作流程類似。餐館由一個長得漂亮的小姐姐招呼客人落座并負責收集來自各個餐桌的點單。每當收到一個點好的菜單時,小姐姐會迅速地把它通過一個小窗口遞交給后廚。后廚那里有一個小看板,所有的點單都被陳列在看板上。廚師長根據訂單的時間和菜品安排不同的廚師燒菜。菜燒好后,再由小姐姐負責上菜。
圖 1:Node.js 單進程多線程模型
嗯上面這張圖還是太簡單了,用來騙新手可以,我知道滿足不了你們。我們把它放大一些。下圖中左邊是主線程,右邊是線程池和一個 worker thread,中間是消息隊列。
圖 2:Node.js 主線程和工作線程關系圖
(1)主線程
主線程只干一件事:拼命地執行 JS code,做 non-blocking I/O 操作。這些 JS code 既包含我們自己寫的,也包含我們所依賴的 npm package 。這里提到的 non-blocking I/O 操作意味著主線程干的事情基本上都是非阻塞型的工作,例如對 2+3 求和,迭代數組等。主線程以 tick 為粒度工作。是的,你一定聽說過 process.nextTick() ,所謂 next tick 就是下一次執行 tick 的時機。每個 tick 又包含若干個 phase ,按照 Node.js 官網介紹,目前為止一共有 6 個 Phase。
- timers: 這個 phase 執行通過 setTimeout()? 和 setInterval() 所設置的 callback 函數。
- pending callbacks: 這個 phase 執行一些與系統操作相關的 callback,比如建立 TCP 連接時收到的 ECONNREFUSED 相關的 callback 。
- idle, prepare: 僅供Node.js內部使用。
- poll: 從消息隊列里面獲取新的 I/O event,執行相應的 callback (不包括 setImmediate / close callback / 以及 timer 所設置的 callback)。
- check: 執行通過 setImmediate()? 所設置的callback.
- close callbacks: 執行一些 close callback ,比如通過這樣的代碼 socket.on('close', ...)? 所設置的 callback。
絕大部分情況下,這些 callback 是用 JS 寫的,Node 通過 Google V8 engine ,在主線程里面來執行這些 callback 。我們把上面的 6個 phase 和 tick 的關系放置到時間軸上,或許能更形象地說明主線程所做的工作。
圖 3:Node.js 主線程時序圖
(2)消息隊列
主線程不單單是在執行 JS code,也不僅僅只是在做 non-blocking I/O 操作。它在執行代碼的過程中,還會產生各種各樣的異步請求。直觀一點的如通過 setImmediate(callback[, ...args]) / fs.readFile(path[, options], callback) 產生,晦澀一點的如通過 Promise / async 產生。這些異步請求大部分情況下有一些共性:需要耗費一定的時間去處理。讓主線程放著其它事情不管,傻傻地干等這次操作的結果可不是聰明的做法。所以它們都會被封裝成 async Request,并被交給線程池去處理。還記得我們之前舉的餐館工作流程的例子嗎?燒菜是一個費時間的事情,如果小姐姐拿到我們的訂單,自己跑到后廚去燒菜會出現什么后果?等她把單子上的菜都燒好再去下一桌點菜的話,對客人而言就出現了一個 blocking I/O 操作:進餐館沒有人接待了。消息隊列就如同后廚那里的看板。小姐姐只負責往看板上添加新的訂單,而訂單的制作交由廚師團隊來完成。
(3)工作線程
工作線程來完成具體的 I/O 請求操作。通常這個過程藉由 OS 所提供的異步機制來完成。如 Windows 里面的 IO 完成端口(IOCP)、Linux 里面的異步 IO。如圖 2 所示,當工作線程完成了一個異步請求后,會把操作結果放置到一個消息隊列里面。從圖中可以看到,主線程運行所涉及到的每個 phase 都有各自專屬的消息隊列。消息隊列里面有了消息,意味著主線程又需要干活了,干活的過程中會繼續產生新的異步請求,工作線程繼續不知疲倦地搬磚。完美的閉環。有一種場景圖 2 并沒有畫出來,當 Node.js 收到來自系統外部的事件如網絡請求時,工作流程是什么樣子的?到目前為止我們談及的 event 都是由 JS code 主動觸發的,如果我們說這種 event 是由頂向下觸發的話,網絡請求這樣的 event 是由底向上觸發的。聰明的你一定可以在腦袋里大致畫出一條線出來:這條線的起點是位于內核的網卡驅動,終點是 Node.js 主線程,中間依次經過了內核協議棧,Node.js 的消息隊列。
3、小結
行文至此,可以看到 Node.js 是一個完完全全的消息驅動型模型。Node進程活著的最大意義是:有各種各樣的 event 以及綁定在 event 上面的 callback 和 data需要它(main thread 和 worker thread)處理。event 的 callback 中也可能會產生新的異步請求,進而產生新的 event 。正是這些源源不斷的 event 驅動著 Node 活下去。如果沒有event需要Node進程處理了,它也就沒有存在的必要了。Node.js 還是一個標準的單進行多線程模型。其中主線程用來執行我們所寫的 JS code ,而線程池里面的 worker thread 則用來執行各種耗時長的 I/O 操作。這些操作可能會導致 worker thread 被阻塞掉。worker thread 被阻塞沒有關系,但主線程被阻塞就不太美麗了。最后再強調一下:我們所寫的 JS code 是交由 V8 在單線程里面運行的,所以盡量不要在 JS code 里面執行耗時長的同步操作。