阻塞與非阻塞概述

本概述介紹了 Node.js 中阻塞非阻塞呼叫的區別。本概述將引用事件迴圈和 libuv,但不需要事先了解這些主題。讀者應具備 JavaScript 語言和 Node.js 回撥模式的基本知識。

“I/O”主要指與由 libuv 支援的系統磁碟和網路的互動。

阻塞

阻塞是指 Node.js 程序中額外 JavaScript 的執行必須等到一個非 JavaScript 操作完成。這是因為當阻塞操作發生時,事件迴圈無法繼續執行 JavaScript。

在 Node.js 中,由於 CPU 密集型而非等待非 JavaScript 操作(如 I/O)而表現不佳的 JavaScript,通常不被稱為阻塞。Node.js 標準庫中使用 libuv 的同步方法是最常用的阻塞操作。原生模組也可能包含阻塞方法。

Node.js 標準庫中所有的 I/O 方法都提供非同步版本,這些版本是非阻塞的,並接受回撥函式。一些方法也有阻塞的對應版本,其名稱以 Sync 結尾。

程式碼比較

阻塞方法同步執行,而非阻塞方法非同步執行。

以檔案系統模組為例,這是一個同步檔案讀取:

const  = ('node:fs');

const  = .('/file.md'); // blocks here until file is read

這是一個等效的非同步示例:

const  = ('node:fs');

.('/file.md', (, ) => {
  if () {
    throw ;
  }
});

第一個示例看起來比第二個簡單,但缺點是第二行會阻塞任何其他 JavaScript 的執行,直到整個檔案被讀取。請注意,在同步版本中,如果丟擲錯誤,需要進行捕獲,否則程序將崩潰。在非同步版本中,由作者決定是否應該丟擲錯誤,如示例所示。

讓我們稍微擴充套件一下我們的示例:

const  = ('node:fs');

const  = .('/file.md'); // blocks here until file is read
.();
moreWork(); // will run after console.log

這是一個相似但不等效的非同步示例:

const  = ('node:fs');

.('/file.md', (, ) => {
  if () {
    throw ;
  }

  .();
});
moreWork(); // will run before console.log

在上面的第一個示例中,console.log 會在 moreWork() 之前被呼叫。在第二個示例中,fs.readFile()非阻塞的,因此 JavaScript 的執行可以繼續,moreWork() 會先被呼叫。能夠在不等待檔案讀取完成的情況下執行 moreWork() 是一種關鍵的設計選擇,它允許更高的吞吐量。

併發和吞吐量

Node.js 中的 JavaScript 執行是單執行緒的,因此併發性指的是事件迴圈在完成其他工作後執行 JavaScript 回撥函式的能力。任何期望以併發方式執行的程式碼都必須允許事件迴圈在非 JavaScript 操作(如 I/O)發生時繼續執行。

舉個例子,假設一個 Web 伺服器的每個請求需要 50 毫秒完成,其中 45 毫秒是可以透過非同步方式完成的資料庫 I/O。選擇非阻塞的非同步操作,可以為每個請求釋放出那 45 毫秒來處理其他請求。這僅僅是透過選擇使用非阻塞方法而非阻塞方法,就在處理能力上產生了顯著的差異。

事件迴圈與許多其他語言中的模型不同,在那些模型中,可能會建立額外的執行緒來處理併發工作。

混合使用阻塞和非阻塞程式碼的危險

在處理 I/O 時,有一些模式應該避免。讓我們看一個例子:

const  = ('node:fs');

.('/file.md', (, ) => {
  if () {
    throw ;
  }

  .();
});
.('/file.md');

在上面的例子中,fs.unlinkSync() 很可能在 fs.readFile() 之前執行,這將在檔案實際被讀取之前刪除 file.md。一個更好的寫法是完全非阻塞且保證按正確順序執行的方式:

const  = ('node:fs');

.('/file.md', (, ) => {
  if () {
    throw ;
  }

  .();

  .('/file.md',  => {
    if () {
      throw ;
    }
  });
});

以上程式碼將一個非阻塞fs.unlink() 呼叫放在 fs.readFile() 的回撥函式中,這保證了操作的正確順序。

更多資源