前端學習 node 快速入門 系列 —— 事件迴圈

2023-05-23 06:00:54

事件迴圈

本篇將對以下問題進行討論:

  • 瀏覽器有事件迴圈,node 也有事件迴圈,兩者有什麼異同?
  • node 核心特性(事件驅動和非阻塞 I/O )和事件迴圈有什麼關係?
  • node 中的高並行和高效能和事件迴圈有關係嗎?
  • node 不適合什麼場景?
  • 有人說 Node 是單執行緒,有人又說 node 存在多執行緒,哪個正確?
  • 如果一個請求需要2秒,用 pm2 能將其優化嗎?

瀏覽器中的事件迴圈

有關事件迴圈前文已經略作介紹,這裡進一步補充。

請在瀏覽器中執行這段程式碼:

console.log('1');

setTimeout(() => {
    console.log('2');
    Promise.resolve().then(() => console.log('3'));
    Promise.resolve().then(() => console.log('4'));
}, 100);

setTimeout(() => {
    console.log('5');
    Promise.resolve().then(() => console.log('6'));
}, 150);

Promise.resolve().then(() => console.log('7'));

setTimeout(() => console.log('8'), 200);

console.log('9');

/*
結果:
1
9
7
2
3
4
5
6
8
*/

分析這段程式碼的事件迴圈的詳細過程之前,有幾點需要說一下:

  • 在一次事件迴圈中,只會執行一個宏任務所有的微任務,而且宏任務和微任務的處理順序是固定的:每次執行完一個宏任務後,首先會立即處理所有的微任務,然後才會執行下一個宏任務。如果在執行微任務時又產生了新的微任務,那麼這些新的微任務也會被新增到佇列中,直到全部微任務都執行完成,才會執行宏任務。
  • 宏任務執行期間產生的微任務都會在當前宏任務執行完畢之後立即執行,不會延遲到下一個宏任務或事件迴圈中執行
  • 當一個宏任務執行的過程中產生了微任務,那麼這些微任務會被推入微任務佇列中等待處理。而只有噹噹前宏任務執行結束之後,主執行緒才會去處理微任務佇列中的所有微任務。因此,所有的微任務都會在下一個宏任務執行之前被處理完畢。
  • 在瀏覽器中,主執行緒使用輪詢方式來實現事件迴圈機制。在執行完當前的任務之後,如果宏任務佇列為空,主執行緒會等待一段時間,這個時間間隔是由瀏覽器廠商自行決定的,然後再次查詢宏任務佇列是否有任務需要執行。
  • setTimeout 是宏任務,比如執行 setTimeout(() => console.log('8'), 200),瀏覽器會建立一個定時器(200ms),並將回撥函數和指定的時間儲存在一個任務中。當指定的時間到達時,定時器才會將這個任務推入宏任務佇列中等待處理

這段程式碼大概有四次事件迴圈,執行過程如下:

  • 第一次事件迴圈:
首先將 console.log('1') 加入執行棧中,輸出 1,然後將其從執行棧中彈出。

第一個 setTimeout 函數被呼叫時,瀏覽器會建立一個定時器(100ms),並將回撥函數和指定的時間儲存在一個任務中。當指定的時間到達時,定時器會將這個任務推入宏任務佇列中等待處理

第二個 setTimeout 與第一 setTimeout 類似,等待 150ms 後會被放入宏任務佇列中

Promise.resolve().then(() => console.log('7')) 放入微任務佇列

第三個 setTimeout 與第一 setTimeout 類似,等待 200ms 後會被放入宏任務佇列中

執行 console.log('9')

取出微任務佇列中的所有任務,輸出 7
  • 第二次事件迴圈:
執行棧為空,主執行緒輪詢檢視宏任務佇列(微任務佇列剛才已經清空了),此時宏任務佇列為空

100ms後,第一個setTimeout 宏任務推入宏任務佇列中,取出這個宏任務放入執行棧中

輸出 2

執行 `Promise.resolve().then(() => console.log('3'));`、`Promise.resolve().then(() => console.log('4'));`,放入微任務佇列

這個宏任務執行完畢之後,主執行緒會轉而執行當前微任務佇列中的所有任務,輸出 3 和 4
  • 第三次事件迴圈:
執行棧為空,主執行緒輪詢宏任務佇列發現其為空

150ms後,第二個setTimeout 宏任務推入宏任務佇列中,取出這個宏任務放入執行棧中

輸出 5

執行 `Promise.resolve().then(() => console.log('6'));` 放入微任務佇列

這個宏任務執行完畢之後,主執行緒會轉而執行當前微任務佇列中的所有任務,輸出 6
  • 第四次事件迴圈:
執行棧為空,主執行緒輪詢宏任務佇列發現其為空

200ms後,第三個setTimeout 宏任務推入宏任務佇列中,取出這個宏任務放入執行棧中

輸出 8

宏任務優先順序

宏任務之間其實存在優先順序。比如 click > requestAnimationFrame > setTimeout

  • 使用者互動相關的任務具有最高的優先順序。在使用者互動(例如點選)後,會將與該事件相關的任務新增到宏任務佇列中並標記為緊急,從而使它們具有比其他任務更高的優先順序。這確保了與使用者直接互動相關的操作具有更快的響應時間。
  • requestAnimationFrame函數,這個函數也有較高的優先順序,因為它需要在下一次螢幕重新整理之前進行處理以提供平滑的動畫效果
  • setTimeout 或 setInterval 新增的回撥函數。通常情況下,先新增到佇列中的回撥函數會優先得到處理。它們只能保證至少在指定的時間後才開始執行

請看範例:

function log(message) {
    const now = new Date();
    console.log(`[${now.getSeconds()}:${now.getMilliseconds()}] ${message}`);
}

setTimeout(() => {
    log('setTimeout callback');
}, 0);

requestAnimationFrame(() => {
    log('requestAnimationFrame callback');
});

document.addEventListener('click', () => {
    log('click event');
});

// 手動觸發 click 事件
const event = new Event('click');
document.dispatchEvent(event);

/*
[46:280] click event
[46:299] setTimeout callback
[5:646] requestAnimationFrame callback
*/

無論測試多少次,click 總是最先輸出。但是 requestAnimationFrame 就不一定先 setTimeout 輸出,因為 requestAnimationFrame 有自己的節奏,只要不影響平滑的動畫效果,即使在 setTimeout 後面也可能。

核心特性

Node.js 核心的特性是事件驅動(Event-driven)和非阻塞 I/O(Non-blocking I/O):

  • 事件驅動 - nodejs 中的非同步操作基於事件,也就是說,當某個操作完成時,Node.js 會發出一個事件來通知你,然後你就可以通過註冊事件的方式來執行回撥函數。
  • 非阻塞 I/O - nodejs 執行一個 I/O 操作時,它不會像傳統的同步阻塞 I/O 一樣等待操作完成,而是會在操作的同時繼續處理其他請求。這種方式可以避免 I/O 導致的阻塞,提高系統的吞吐量和響應能力。

Tip:兩個特性有關係,但不是一個概念。比如可以說:基於事件驅動的非阻塞 I/O

Node.js 中的事件驅動和非阻塞 I/O 是基於事件迴圈實現的。

在 node 中,事件迴圈是一個持續不斷的迴圈過程,不斷地從事件佇列中取出事件並處理,直到事件佇列為空。具體來說,當 Node.js 遇到一個需要非同步處理的 I/O 操作時,它不會等待操作完成後再執行下一步操作,而是將該操作放到事件佇列中,並繼續執行下一步。當操作完成後,Node.js 會將相應的回撥函數也放到事件佇列中,等待事件迴圈來處理。這樣一來,Node.js 就可以同時處理多個請求,而且不會因為某一個操作的阻塞而影響整個應用程式的效能。

除了 I/O 操作之外,事件迴圈還可以用於處理定時器HTTP 請求資料庫存取等各種型別的事件

Tip: 事件佇列不僅包含宏任務佇列微任務佇列,還有維護著幾個其他的佇列,這些佇列通過事件迴圈機制來實現非同步非阻塞。其他佇列有:

  • check 佇列。check 佇列用於存放 setImmediate() 的回撥函數
  • I/O 觀察器佇列(watcher queue)
  • 關閉事件佇列(close queue)

高並行和高效能

在 Node.js 中,高並行指的是系統能夠處理高並行請求的能力。不會因為一個請求的處理而阻塞其他請求的執行,系統能夠同時處理眾多請求。高效能通常指的是它在處理大量並行請求時表現出的優異效能。

事件迴圈是 Node.js 實現高並行和高效能的核心機制之一。通過將計算密集型任務和 I/O 任務分離並採用非同步執行,Node.js 能夠充分利用 CPU 和記憶體資源,從而實現高效能和高並行。

沒有事件迴圈,Node.js 就無法實現非同步 I/O 和非阻塞式程式設計模型。在傳統的阻塞式 I/O 模型中,一個 I/O 操作會一直等待資料返回,導致應用程式被阻塞,無法進行其他操作。而通過事件迴圈機制,Node.js 實現了非同步 I/O,當一個 I/O 操作被觸發後,Node.js 將其放入事件迴圈佇列中,然後立即執行下一個任務,不必等待當前的 I/O 操作結束。當 I/O 操作完成時,Node.js 會將相應的回撥函數新增到事件佇列中等待執行。

node 中的事件迴圈

vs 瀏覽器中的事件迴圈

相同點:單個主執行緒、單個執行棧、有宏任務佇列和微任務佇列

不同點:

  • 實現不同。Node.js 是一款伺服器端執行時,而瀏覽器則用於頁面和互動等,場景不同,所以實現方式不同。Node.js 中的事件迴圈機制是通過 libuv 庫來實現,因為它具有跨平臺性、高效性、多功能性(除了事件迴圈機制外,libuv 還提供了很多其他的系統功能和服務,能夠滿足 Node.js 在伺服器端程式設計上的需要)等。
  • 一次事件迴圈不同。瀏覽器中的一次事件迴圈包括一個宏任務和相關所有微任務。在 node 中,一次事件迴圈包含6個階段(下文會詳細介紹)

雖然兩者有不同,但它們有相同的設計目標:高效而可靠的方式處理非同步任務(或者說:解決 JavaScript 非同步程式設計問題)。

原理

一次事件迴圈包含以下 6 個階段:

+--------------------------+
|                          |
|   timers                 | 計時器階段:處理 setTimeout() 和 setInterval() 定時器的回撥函數。
|                          |
+--------------------------+
|                          |
|   pending callbacks      | 待定回撥階段:用於處理系統級別的錯誤資訊,例如 TCP 錯誤或者 DNS 解析異常。
|                          |
+--------------------------+
|                          |
|   idle, prepare          | 僅在內部使用,可以忽略不計。
|                          |
+--------------------------+
|                          |
|   poll                   | 輪詢階段:等待 I/O 事件(如網路請求或檔案 I/O 等)的發生,然後執行對應的回撥函數,並且會處理定時器相關的回撥函數。
|                          |          如果沒有任何 I/O 事件發生,此階段可能會使事件迴圈阻塞。
+--------------------------+
|                          |
|   check                  | 檢查階段:處理 setImmediate() 的回撥函數。check 的回撥優先順序比 setTimeout 高,比微任務要低
|                          |
+--------------------------+
|                          |
|   close callbacks        | 關閉回撥階段:處理一些關閉的回撥函數,比如 socket.on('close')。
|                          |
+--------------------------+

這 6 個階段執行順序:

  1. 事件迴圈首先會進入 timers 階段,執行所有超時時間到達的定時器相關的回撥函數。
  2. 當 Node.js 執行完 timers 階段後,就會進入到 pending callbacks 階段。在這個階段, Node.js 會執行一些系統級別的回撥函數,這些回撥函數一般都是由 Node.js 的內部模組觸發的,而不是由 JavaScript 程式碼直接觸發的。
  3. 然後進入 poll 階段,等待 I/O 事件的發生,處理相關的回撥函數。如果在此階段確定沒有任何 I/O 事件需要處理,那麼事件迴圈會等待一定的時間,以防止 CPU 空轉,這個時間會由系統自動設定或者手動在程式碼中指定。如果有定時器在此階段需要處理,那麼事件迴圈會回到 timers 階段繼續執行相應的回撥函數。
  4. 接著進入 check 階段,處理 setImmediate() 註冊的回撥函數。setImmediate() 的優先順序比 timers 階段要高。當事件迴圈進入 check 階段時,如果發現事件佇列中存在 setImmediate() 的回撥函數,則會立即執行該回撥函數而不是繼續等待 timers 階段的到來。
  5. 最後進入 close callbacks 階段,處理一些關閉的回撥函數。

事件迴圈的每個階段都有對應的宏任務佇列微任務佇列。當一個階段中的所有宏任務都執行完之後,事件迴圈會進入下一個階段。在該階段結束時,如果存在微任務,事件迴圈將會在開始下一個階段之前執行所有的微任務。這樣一來,無論在何時新增微任務,都能確保先執行所有的微任務,避免了某些任務的並行問題。如果我們在某個階段中新增了多個微任務,那麼它們會在該階段結束時依次執行,直到所有微任務都被處理完成,才會進入下一個階段的宏任務佇列。

一次事件迴圈週期以清空6個階段的宏任務佇列和微任務佇列來結束。

一次事件迴圈週期內,每個階段是否可以執行多次。例如此時在 poll 階段,這時 timers 階段任務佇列中有了回撥函數,由於 timers 的優先順序高於 poll,所以又回到 timers 階段,執行完該階段的宏任務和微任務後,在回到 poll 階段。

總之,這 6 個階段構成了 Node.js 的事件迴圈機制,確保了所有被註冊的回撥函數都能得到及時、準確的執行

Tip:當呼叫 setTimeout 方法時,如果超時時間還沒到,則生成的定時器宏任務也不會立刻放入宏任務佇列中,而是會被放入計時器佇列中。計時器佇列和延遲佇列類似,都是由定時器宏任務組成的小根堆結構,每個定時器宏任務也對應著其到期時間以及對應的回撥函數。當超時時間到達後,Node.js 會將該定時器宏任務從計時器佇列中取出並放入宏任務佇列中,等待事件迴圈去執行。

儘管事件迴圈的機制比較明確,但由於各種因素的影響,具體的執行順序仍然難以精確預測。其順序取決於當前事件佇列中各個回撥函數的執行情況、耗時以及系統各種資源的利用情況等多種因素。每次事件迴圈的順序都不一定相同:

  • 例如,在事件迴圈的 poll 階段中,如果存在大量耗時較長的 I/O 回撥函數,則事件迴圈可能會在 poll 階段中花費較長的時間。此時,即使定時器的超時時間到達了,事件迴圈也不會立即進入 timers 階段,而是要先處理 poll 階段中還未完成的任務。

Tip: setTimeout 在node 中最小是 1ms,在瀏覽器中是4ms

範例

console.log("start");

setTimeout(() => {
  console.log("first timeout callback");
}, 1);

setImmediate(() => {
  console.log("immediate callback");
});

process.nextTick(() => {
  console.log("next tick callback");
});

console.log("end");

執行10次node 輸出如下:

start
end
next tick callback
first timeout callback
immediate callback

執行分析:

  • 先執行同步程式碼,輸出 startend
  • setTimeout和setImmediate屬於宏任務
  • process.nextTick 是微任務,輸出 next tick callback

現在的難點是 setImmediate 和 setTimeout 的回撥哪個先執行!

:在某些特殊情況下,timers 階段和 check 階段的任務可能會交錯執行。這通常發生在以下兩種情況下:

  • 當 timers 階段中存在長時間執行的回撥函數時(如一個耗時很長的 for 迴圈),會導致該階段阻塞,影響事件迴圈的正常執行。在這種情況下,如果 check 階段中有一些較短的回撥函數需要執行,Node.js 可能會在 timers 階段中間中斷執行,並立即進入 check 階段處理已經準備好的回撥函數,然後再返回 timers 階段繼續執行剩餘的回撥函數。
  • 當註冊了 setImmediate() 和 setTimeout() 回撥函數並且它們被分別安排到不同的事件迴圈週期中執行時,這時候 setImmediate() 的回撥函數可能會在 timers 階段的回撥函數之前被執行。這是因為 check 階段的任務佇列優先順序比 timers 階段的任務佇列要高,所以在下一個迴圈週期的 check 階段中,setImmediate() 的回撥函數會被優先處理。

根據結果,我們推測:setImmediate 和 setTimeout 都進入了下一個迴圈週期,先執行 timers 階段,在執行 check 階段的回撥。

Tip: 儘管 setImmediate 被稱為 "immediate",但它並不保證會立刻執行。在 Node.js 的事件迴圈中,setImmediate() 的回撥函數會被加入到 check 階段的任務佇列中,等到輪到 check 階段時才會執行。

CPU 密集型場景

Node.js 不適合CPU 密集型場景。比如大量數學計算,可能會阻塞 Node.js 主執行緒。

比如一個 1 到 10億求和的請求:

const http = require('http');

http.createServer((req, res) => {
  console.log('start');
  let sum = 0;
  for (let i = 1; i <= 1000000000; i++) {
    sum += i;
  }
  console.log('end');

  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end(sum.toString());
}).listen(3000);

console.log('server running at http://localhost:3000/');

通過curl 檢測存取 http://localhost:3000/ 的時間,分別是 1.754s1.072s2.821s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    18    0    18    0     0     15      0 --:--:--  0:00:01 --:--:--    15500000000067109000

real    0m1.754s
user    0m0.000s
sys     0m0.078s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    18    0    18    0     0     20      0 --:--:-- --:--:-- --:--:--    21500000000067109000

real    0m1.072s
user    0m0.015s
sys     0m0.093s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    18    0    18    0     0      6      0 --:--:--  0:00:02 --:--:--     6500000000067109000

real    0m2.821s
user    0m0.031s
sys     0m0.077s

接著用node 內建的 cluster 模組將計算工作分配到4個子程序中,存取速度大幅度提升。

const http = require('http');
const cluster = require('cluster');

if (cluster.isMaster) {
  // 計算工作分配到4個子程序中
  const numCPUs = require('os').cpus().length;
  const range = 1000000000;
  const rangePerCore = Math.ceil(range / numCPUs);
  let endIndex = 0;
  let sum = 0;

  for (let i = 0; i < numCPUs; i++) {
    const worker = cluster.fork();
    worker.on('message', function({ endIndex, result }) {
      sum += result;
      if (endIndex === range) {
        console.log(sum);
        // 啟動 Web 伺服器,在主程序中處理請求
        http.createServer((req, res) => {
          res.statusCode = 200;
          res.setHeader('Content-Type', 'text/plain');
          res.end(`The sum is ${sum}\n`);
        }).listen(3000, () => {
          console.log(`Server running at http://localhost:3000/`);
        });
      }
    });
    worker.send({ startIndex: endIndex + 1, endIndex: endIndex + rangePerCore });
    endIndex += rangePerCore;
  }
} else {
  process.on('message', function({ startIndex, endIndex }) {
    let sum = 0;
    for (let i = startIndex; i <= endIndex; i++) {
      sum += i;
    }
    process.send({ endIndex, result: sum });
  });
}

存取時長分別是:0.230s0.216s0.205s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    30  100    30    0     0   2354      0 --:--:-- --:--:-- --:--:--  4285The sum is 500000000098792260


real    0m0.230s
user    0m0.000s
sys     0m0.109s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    30  100    30    0     0   2212      0 --:--:-- --:--:-- --:--:--  3750The sum is 500000000098792260


real    0m0.216s
user    0m0.000s
sys     0m0.078s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    30  100    30    0     0   2545      0 --:--:-- --:--:-- --:--:--  6000The sum is 500000000098792260


real    0m0.205s
user    0m0.000s
sys     0m0.078s

其他

pm2 的一個侷限性

假如一個請求得花費2秒(1 到 10億之和),使用 pm2 也不能減小請求時間。

pm2能做的是:比如一個 node 應用單核(1個cpu核心)可以支援一千個並行請求,現在並行四千個請求,由於超出能力,請求響應會變慢。現在通過 Pm2 在四核伺服器中啟動4個node應用,之前還存在負載均衡,這樣就可以支援四千個並行請求。

Tip:pm2的介紹請看這裡

單執行緒

Node.js 是單執行緒的,這意味著所有事件迴圈(Event Loop)和 I/O 操作都在一個主執行緒中執行。所以說,Node.js 中只存在一個事件迴圈和一個執行上下文棧。

不過,Node.js 的實現並不簡單粗暴。它通過使用非阻塞 I/O、非同步程式設計以及事件驅動機制,讓單執行緒可以支援高並行處理大量的 I/O 操作。Node.js 底層採用的是 libuv 庫來實現非同步 I/O 模型,該庫在底層會使用 libev 和 libeio 等多種事件驅動框架來實現對底層 I/O 系統呼叫的封裝,從而讓單執行緒可以同時處理多個 I/O 任務,避免了執行緒切換的開銷,提高了應用程式的效能。

此外,在 Node.js 版本 10.5.0 之後,Node.js 引入了 worker_threads 模組,支援通過建立子執行緒的方式來實現多執行緒。worker_threads 模組提供了一套 API,使得開發者可以方便地建立和管理多個子執行緒,並利用多執行緒來加速處理計算密集型任務等場景。

總之,Node.js 是單執行緒的,但同時也通過採用非同步 I/O 模型、事件驅動機制和多執行緒等技術手段,來支援高並行、高效能的應用程式開發。