一文深入瞭解 Node 中的事件迴圈

2021-12-31 22:00:16
Node.js是單執行緒的語言,是通過事件迴圈處理非阻塞I/O操作的。下面本篇文章帶大家詳細瞭解 中的事件迴圈,希望對大家有所幫助!

做為 JavaScript 的伺服器端執行時,主要與網路、檔案打交道,沒有了瀏覽器中事件迴圈的渲染階段。

在瀏覽器中有 HTML 規範來定義事件迴圈的處理模型,之後由各瀏覽器廠商實現。Node.js 中事件迴圈的定義與實現均來自於 Libuv。

Libuv 圍繞事件驅動的非同步 I/O 模型而設計,最初是為 Node.js 編寫的,提供了一個跨平臺的支援庫。下圖展示了它的組成部分,Network I/O 是網路處理相關的部分,右側還有檔案操作、DNS,底部 epoll、kqueue、event ports、IOCP 這些是底層不同作業系統的實現。

1.png

事件迴圈的六個階段

當 Node.js 啟動時,它會初始化事件迴圈,處理提供的指令碼,同步程式碼入棧直接執行,非同步任務(網路請求、檔案操作、定時器等)在呼叫 API 傳遞迴撥函數後會把操作轉移到後臺由系統核心處理。目前大多數核心都是多執行緒的,當其中一個操作完成時,核心通知 Node.js 將回撥函數新增到輪詢佇列中等待時機執行。

下圖左側是 Node.js 官網對事件迴圈過程的描述,右側是 Libuv 官網對 Node.js 的描述,都是對事件迴圈的介紹,不是所有人上來都能去看原始碼的,這兩個檔案通常也是對事件迴圈更直接的學習參考檔案,在 Node.js 官網介紹的也還是挺詳細的,可以做為一個參考資料學習。

2.png

左側 Node.js 官網展示的事件迴圈分為 6 個階段,每個階段都有一個 FIFO(先進先出)佇列執行回撥函數,這幾個階段之間執行的優先順序順序還是明確的。

右側更詳細的描述了,在事件迴圈迭代前,先去判斷迴圈是否處於活動狀態(有等待的非同步 I/O、定時器等),如果是活動狀態開始迭代,否則迴圈將立即退出。

下面對每個階段分別討論。

timers(定時器階段)

首先事件迴圈進入定時器階段,該階段包含兩個 API setTimeout(cb, ms)、setInterval(cb, ms) 前一個是僅執行一次,後一個是重複執行。

這個階段檢查是否有到期的定時器函數,如果有則執行到期的定時器回撥函數,和瀏覽器中的一樣,定時器函數傳入的延遲時間總比我們預期的要晚,它會受到作業系統或其它正在執行的回撥函數的影響。

例如,下例我們設定了一個定時器函數,並預期在 1000 毫秒後執行。

const now = Date.now();
setTimeout(function timer1(){
  log(`delay ${Date.now() - now} ms`);
}, 1000);
setTimeout(function timer2(){
 log(`delay ${Date.now() - now} ms`);
}, 5000);
someOperation();

function someOperation() {
  // sync operation...
  while (Date.now() - now < 3000) {}
}

當呼叫 setTimeout 非同步函數後,程式緊接著執行了 someOperation() 函數,中間有些耗時操作大約消耗 3000ms,當完成這些同步操作後,進入一次事件迴圈,首先檢查定時器階段是否有到期的任務,定時器的指令碼是按照 delay 時間升序儲存在堆記憶體中,首先取出超時時間最小的定時器函數做檢查,如果 nowTime - timerTaskRegisterTime > delay 取出回撥函數執行,否則繼續檢查,當檢查到一個沒有到期的定時器函數或達到系統依賴的最大數量限制後,轉移到下一階段。

在我們這個範例中,假設執行完 someOperation() 函數的當前時間為 T + 3000:

檢查 timer1 函數,當前時間為 T + 3000 - T > 1000,已超過預期的延遲時間,取出回撥函數執行,繼續檢查。

檢查 timer2 函數,當前時間為 T + 3000 - T < 5000,還沒達到預期的延遲時間,此時退出定時器階段。

pending callbacks

定時器階段完成後,事件迴圈進入到 pending callbacks 階段,在這個階段執行上一輪事件迴圈遺留的 I/O 回撥。根據 Libuv 檔案的描述:大多數情況下,在輪詢 I/O 後立即呼叫所有 I/O 回撥,但是,某些情況下,呼叫此類回撥會推遲到下一次迴圈迭代。聽完更像是上一個階段的遺留。

idle, prepare

idle, prepare 階段是給系統內部使用,idle 這個名字很迷惑,儘管叫空閒,但是在每次的事件迴圈中都會被呼叫,當它們處於活動狀態時。這一塊的資料介紹也不是很多。略...

poll

poll 是一個重要的階段,這裡有一個概念觀察者,有檔案 I/O 觀察者,網路 I/O 觀察者等,它會觀察是否有新的請求進入,包含讀取檔案等待響應,等待新的 socket 請求,這個階段在某些情況下是會阻塞的。

阻塞 I/O 超時時間

在阻塞 I/O 之前,要計算它應該阻塞多長時間,參考 Libuv 檔案上的一些描述,以下這些是它計算超時時間的規則:

如果迴圈使用 UV_RUN_NOWAIT 標誌執行、超時為 0。

如果迴圈將要停止(uv_stop() 被呼叫),超時為 0。

如果沒有活動的 handlers 或 request,超時為 0。

如果有任何 idle handlers 處於活動狀態,超時為 0。

如果有任何待關閉的 handlers,超時為 0。

如果以上情況都沒有,則採用最近定時器的超時時間,或者如果沒有活動的定時器,則超時時間為無窮大,poll 階段會一直阻塞下去。

範例一

很簡單的一段程式碼,我們啟動一個 Server,現在事件迴圈的其它階段沒有要處理的任務,它會在這裡等待下去,直到有新的請求進來。

const http = require('http');
const server = http.createServer();
server.on('request', req => {
  console.log(req.url);
})
server.listen(3000);

範例二

結合階段一的定時器,在看個範例,首先啟動 app.js 做為伺服器端,模擬延遲 3000ms 響應,這個只是為了配合測試。再執行 client.js 看下事件迴圈的執行過程:

首先程式呼叫了一個在 1000ms 後超時的定時器。

之後呼叫非同步函數 someAsyncOperation() 從網路讀取資料,我們假設這個非同步網路讀取需要 3000ms。

當事件迴圈開始時先進入 timer 階段,發現沒有超時的定時器函數,繼續向下執行。

期間經過 pending callbacks -> idle,prepare 當進入 poll 階段,此時的 http.get() 尚未完成,它的佇列為空,參考上面 poll 阻塞超時時間規則,事件迴圈機制會檢查最快到達閥值的計時器,而不是一直在這裡等待下去。

當大約過了 1000ms 後,進入下一次事件迴圈進入定時器,執行到期的定時器回撥函數,我們會看到紀錄檔 setTimeout run after 1003 ms。

在定時器階段結束之後,會再次進入 poll 階段,繼續等待。

// client.js
const now = Date.now();
setTimeout(() => log(`setTimeout run after ${Date.now() - now} ms`), 1000);
someAsyncOperation();
function someAsyncOperation() {
  http.get('http://localhost:3000/api/news', () => {
    log(`fetch data success after ${Date.now() - now} ms`);
  });
}

// app.js
const http = require('http');
http.createServer((req, res) => {
  setTimeout(() => { res.end('OK!') }, 3000);
}).listen(3000);

當 poll 階段佇列為空時,並且指令碼被 setImmediate() 排程過,此時,事件迴圈也會結束 poll 階段,進入下一個階段 check。

check

check 階段在 poll 階段之後執行,這個階段包含一個 API setImmediate(cb) 如果有被 setImmediate 觸發的回撥函數,就取出執行,直到佇列為空或達到系統的最大限制。

setTimeout VS setImmediate

拿 setTimeout 和 setImmediate 對比,這是一個常見的例子,基於被呼叫的時機和定時器可能會受到計算機上其它正在執行的應用程式影響,它們的輸出順序,不總是固定的。

setTimeout(() => log('setTimeout'));
setImmediate(() => log('setImmediate'));

// 第一次執行
setTimeout
setImmediate

// 第二次執行
setImmediate
setTimeout

setTimeout VS setImmediate VS fs.readFile

但是一旦把這兩個函數放入一個 I/O 迴圈內呼叫,setImmediate 將總是會被優先呼叫。因為 setImmediate 屬於 check 階段,在事件迴圈中總是在 poll 階段結束後執行,這個順序是確定的。

fs.readFile(__filename, () => {
  setTimeout(() => log('setTimeout'));
  setImmediate(() => log('setImmediate'));
})

close callbacks

在 Libuv 中,如果呼叫關閉控制程式碼 uv_close(),它將呼叫關閉回撥,也就是事件迴圈的最後一個階段 close callbacks。

這個階段的工作更像是做一些清理工作,例如,當呼叫 socket.destroy(),'close' 事件將在這個階段發出,事件迴圈在執行完這個階段佇列裡的回撥函數後,檢查迴圈是否還 alive,如果為 no 退出,否則繼續下一次新的事件迴圈。

包含 Microtask 的事件迴圈流程圖

在瀏覽器的事件迴圈中,把任務劃分為 Task、Microtask,在 Node.js 中是按照階段劃分的,上面我們介紹了 Node.js 事件迴圈的 6 個階段,給使用者使用的主要是 timer、poll、check、close callback 四個階段,剩下兩個由系統內部排程。這些階段所產生的任務,我們可以看做 Task 任務源,也就是常說的 「Macrotask 宏任務」。

通常我們在談論一個事件迴圈時還會包含 Microtask,Node.js 裡的微任務有 Promise、還有一個也許很少關注的函數 queueMicrotask,它是在 Node.js v11.0.0 之後被實現的,參見 PR/22951。

Node.js 中的事件迴圈在每一個階段執行後,都會檢查微任務佇列中是否有待執行的任務。

3.png

Node.js 11.x 前後差異

Node.js 在 v11.x 前後,每個階段如果即存在可執行的 Task 又存在 Microtask 時,會有一些差異,先看一段程式碼:

setImmediate(() => {
  log('setImmediate1');
  Promise.resolve('Promise microtask 1')
    .then(log);
});
setImmediate(() => {
  log('setImmediate2');
  Promise.resolve('Promise microtask 2')
    .then(log);
});

在 Node.js v11.x 之前,當前階段如果存在多個可執行的 Task,先執行完畢,再開始執行微任務。基於 v10.22.1 版本執行結果如下:

setImmediate1
setImmediate2
Promise microtask 1
Promise microtask 2

在 Node.js v11.x 之後,當前階段如果存在多個可執行的 Task,先取出一個 Task 執行,並清空對應的微任務佇列,再次取出下一個可執行的任務,繼續執行。基於 v14.15.0 版本執行結果如下:

setImmediate1
Promise microtask 1
setImmediate2
Promise microtask 2

在 Node.js v11.x 之前的這個執行順序問題,被認為是一個應該要修復的 Bug 在 v11.x 之後並修改了它的執行時機,和瀏覽器保持了一致,詳細參見 issues/22257 討論。

特別的 process.nextTick()

Node.js 中還有一個非同步函數 process.nextTick(),從技術上講它不是事件迴圈的一部分,它在當前操作完成後處理。如果出現遞迴的 process.nextTick() 呼叫,這將會很糟糕,它會阻斷事件迴圈。

如下例所示,展示了一個 process.nextTick() 遞迴呼叫範例,目前事件迴圈位於 I/O 迴圈內,當同步程式碼執行完成後 process.nextTick() 會被立即執行,它會陷入無限迴圈中,與同步的遞迴不同的是,它不會觸碰 v8 最大呼叫堆疊限制。但是會破壞事件迴圈排程,setTimeout 將永遠得不到執行。

fs.readFile(__filename, () => {
  process.nextTick(() => {
    log('nextTick');
    run();
    function run() {
      process.nextTick(() => run());
    }
  });
  log('sync run');
  setTimeout(() => log('setTimeout'));
});

// 輸出
sync run
nextTick

將 process.nextTick 改為 setImmediate 雖然是遞迴的,但它不會影響事件迴圈排程,setTimeout 在下一次事件迴圈中被執行。

fs.readFile(__filename, () => {
  process.nextTick(() => {
    log('nextTick');
    run();
    function run() {
      setImmediate(() => run());
    }
  });
  log('sync run');
  setTimeout(() => log('setTimeout'));
});

// 輸出
sync run
nextTick
setTimeout

process.nextTick 是立即執行,setImmediate 是在下一次事件迴圈的 check 階段執行。但是,它們的名字著實讓人費解,也許會想這兩個名字交換下比較好,但它屬於遺留問題,也不太可能會改變,因為這會破壞 NPM 上大部分的軟體包。

在 Node.js 的檔案中也建議開發者儘可能的使用 setImmediate(),也更容易理解。

更多node相關知識,請存取:!!

以上就是一文深入瞭解 Node 中的事件迴圈的詳細內容,更多請關注TW511.COM其它相關文章!