不要阻塞事件迴圈(或工作執行緒池)

你應該閱讀本指南嗎?

如果你正在編寫比簡短命令列指令碼更復雜的任何東西,閱讀本文將幫助你編寫更高效能、更安全的應用程式。

本文件是為 Node.js 伺服器編寫的,但這些概念也適用於複雜的 Node.js 應用程式。在作業系統特定的細節有所不同的地方,本文件以 Linux 為中心。

摘要

Node.js 在事件迴圈中執行 JavaScript 程式碼(初始化和回撥),並提供一個工作執行緒池來處理像檔案 I/O 這樣的昂貴任務。Node.js 的伸縮性很好,有時甚至比像 Apache 這樣的重量級方法更好。Node.js 可伸縮性的秘訣在於它使用少量執行緒來處理許多客戶端。如果 Node.js 可以用更少的執行緒來完成工作,那麼它就可以將更多的系統時間和記憶體用於處理客戶端,而不是為執行緒支付空間和時間開銷(記憶體、上下文切換)。但是因為 Node.js 只有少數幾個執行緒,你必須明智地構建你的應用程式來使用它們。

這裡有一條保持你的 Node.js 伺服器快速執行的好經驗法則:當與每個客戶端在任何給定時間相關的工作量都“很小”時,Node.js 就會很快

這適用於事件迴圈上的回撥和工作執行緒池中的任務。

為什麼我應該避免阻塞事件迴圈和工作執行緒池?

Node.js 使用少量執行緒來處理許多客戶端。在 Node.js 中有兩種型別的執行緒:一個事件迴圈(也稱主迴圈、主執行緒、事件執行緒等),以及一個工作執行緒池(也稱執行緒池)中的 k 個工作執行緒。

如果一個執行緒執行回撥(事件迴圈)或任務(工作執行緒)花費了很長時間,我們稱之為“阻塞”。當一個執行緒被阻塞為一個客戶端工作時,它無法處理來自任何其他客戶端的請求。這為我們既不阻塞事件迴圈也不阻塞工作執行緒池提供了兩個動機:

  1. 效能:如果你在任何一種執行緒上定期執行重量級活動,你的伺服器的吞吐量(請求數/秒)將會受到影響。
  2. 安全:如果對於某些輸入,你的某個執行緒可能會阻塞,那麼惡意客戶端可以提交這個“惡意輸入”,使你的執行緒阻塞,並阻止它們為其他客戶端工作。這將是一種拒絕服務攻擊。

快速回顧 Node

Node.js 使用事件驅動架構:它有一個用於編排的事件迴圈和一個用於昂貴任務的工作執行緒池。

什麼程式碼在事件迴圈上執行?

當 Node.js 應用程式啟動時,它們首先完成一個初始化階段,require 模組併為事件註冊回撥。然後 Node.js 應用程式進入事件迴圈,透過執行適當的回撥來響應傳入的客戶端請求。這個回撥是同步執行的,並且可能會註冊非同步請求以便在它完成後繼續處理。這些非同步請求的回撥也將在事件迴圈上執行。

事件迴圈還將完成其回調發出的非阻塞非同步請求,例如網路 I/O。

總而言之,事件迴圈執行為事件註冊的 JavaScript 回撥,並且還負責完成像網路 I/O 這樣的非阻塞非同步請求。

什麼程式碼在工作執行緒池上執行?

Node.js 的工作執行緒池在 libuv 中實現(文件),它公開了一個通用的任務提交 API。

Node.js 使用工作執行緒池來處理“昂貴”的任務。這包括作業系統沒有提供非阻塞版本的 I/O,以及特別耗費 CPU 的任務。

以下是使用此工作執行緒池的 Node.js 模組 API

  1. I/O 密集型
    1. DNSdns.lookup(), dns.lookupService()
    2. 檔案系統:除了 fs.FSWatcher() 和那些明確同步的 API 之外,所有檔案系統 API 都使用 libuv 的執行緒池。
  2. CPU 密集型
    1. Cryptocrypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair()
    2. Zlib:除了那些明確同步的 API 之外,所有 zlib API 都使用 libuv 的執行緒池。

在許多 Node.js 應用程式中,這些 API 是工作執行緒池任務的唯一來源。使用 C++ 外掛的應用程式和模組可以向工作執行緒池提交其他任務。

為了完整起見,我們注意到,當您從事件迴圈上的回撥中呼叫這些 API 之一時,事件迴圈會支付一些小的設定成本,因為它進入該 API 的 Node.js C++ 繫結並將任務提交給工作執行緒池。與任務的總體成本相比,這些成本可以忽略不計,這也是事件迴圈要解除安裝它的原因。當向工作執行緒池提交這些任務之一時,Node.js 會提供一個指向 Node.js C++ 繫結中相應 C++ 函式的指標。

Node.js 如何決定接下來執行什麼程式碼?

抽象地說,事件迴圈和工作執行緒池分別維護待處理事件和待處理任務的佇列。

實際上,事件迴圈並不真正維護一個佇列。相反,它有一組檔案描述符,它要求作業系統使用像 epoll (Linux)、kqueue (OSX)、事件埠 (Solaris) 或 IOCP (Windows) 這樣的機制來監視。這些檔案描述符對應於網路套接字、它正在監視的任何檔案等等。當作業系統說這些檔案描述符中的一個準備就緒時,事件迴圈會將其轉換為適當的事件並呼叫與該事件相關聯的回撥。你可以在這裡瞭解更多關於這個過程的資訊。

相比之下,工作執行緒池使用一個真正的佇列,其條目是待處理的任務。一個工作執行緒從此佇列中彈出一個任務並進行處理,當完成後,工作執行緒會為事件迴圈引發一個“至少一個任務已完成”的事件。

這對應用程式設計意味著什麼?

在像 Apache 這樣每個客戶端一個執行緒的系統中,每個待處理的客戶端都被分配了它自己的執行緒。如果一個處理一個客戶端的執行緒阻塞了,作業系統會中斷它並給另一個客戶端一個機會。因此,作業系統確保需要少量工作的客戶端不會因為需要更多工作的客戶端而受到懲罰。

因為 Node.js 用少量執行緒處理許多客戶端,如果一個執行緒在處理一個客戶端的請求時阻塞了,那麼待處理的客戶端請求可能要等到該執行緒完成其回撥或任務後才能得到處理。因此,公平對待客戶端是你的應用程式的責任。這意味著你不應該在任何單個回撥或任務中為任何客戶端做太多的工作。

這是 Node.js 能夠很好地擴充套件的部分原因,但它也意味著你有責任確保公平排程。接下來的部分將討論如何為事件迴圈和工作執行緒池確保公平排程。

不要阻塞事件迴圈

事件迴圈會注意到每個新的客戶端連線並協調生成響應。所有傳入的請求和傳出的響應都透過事件迴圈。這意味著如果事件迴圈在任何一點花費太長時間,所有當前和新的客戶端都將得不到處理機會。

你應該確保你永遠不會阻塞事件迴圈。換句話說,你的每個 JavaScript 回撥都應該快速完成。這當然也適用於你的 await、你的 Promise.then 等等。

確保這一點的一個好方法是分析你的回撥的“計算複雜性”。如果你的回撥無論其引數是什麼都花費恆定的步驟數,那麼你將總是給每個待處理的客戶端一個公平的機會。如果你的回撥根據其引數花費不同的步驟數,那麼你應該考慮引數可能有多長。

示例 1:一個常量時間的回撥。

app.get('/constant-time', (, ) => {
  .sendStatus(200);
});

示例 2:一個 O(n) 的回撥。這個回撥對於小的 n 會執行得很快,而對於大的 n 會執行得更慢。

app.get('/countToN', (, ) => {
  const  = .query.n;

  // n iterations before giving someone else a turn
  for (let  = 0;  < ; ++) {
    .(`Iter ${}`);
  }

  .sendStatus(200);
});

示例 3:一個 O(n^2) 的回撥。這個回撥對於小的 n 仍然會執行得很快,但對於大的 n,它會比之前的 O(n) 示例執行得慢得多。

app.get('/countToN2', (, ) => {
  const  = .query.n;

  // n^2 iterations before giving someone else a turn
  for (let  = 0;  < ; ++) {
    for (let  = 0;  < ; ++) {
      .(`Iter ${}.${}`);
    }
  }

  .sendStatus(200);
});

你應該多小心?

Node.js 使用 Google V8 引擎來處理 JavaScript,它對於許多常見操作來說都非常快。這個規則的例外是正則表示式和 JSON 操作,下面會討論。

然而,對於複雜的任務,你應該考慮限制輸入並拒絕過長的輸入。這樣,即使你的回撥具有很高的複雜性,透過限制輸入,你也能確保回撥在最長的可接受輸入上花費的時間不會超過最壞情況下的時間。然後,你可以評估此回撥的最壞情況成本,並確定其執行時間在你的上下文中是否可以接受。

阻塞事件迴圈:REDOS

一種常見的災難性地阻塞事件迴圈的方式是使用“易受攻擊的”正則表示式

避免易受攻擊的正則表示式

正則表示式(regexp)將輸入字串與模式進行匹配。我們通常認為正則表示式匹配需要對輸入字串進行單次遍歷——時間複雜度為 `O(n)`,其中 `n` 是輸入字串的長度。在許多情況下,確實只需要單次遍歷。不幸的是,在某些情況下,正則表示式匹配可能需要指數級次數的遍歷——時間複雜度為 `O(2^n)`。指數級次數的遍歷意味著,如果引擎需要 `x` 次遍歷來確定匹配,那麼如果我們只在輸入字串中增加一個字元,它將需要 `2*x` 次遍歷。由於遍歷次數與所需時間呈線性關係,這種評估的效果將是阻塞事件迴圈。

一個易受攻擊的正則表示式是指你的正則表示式引擎可能需要指數級時間來處理它,從而使你在面對“惡意輸入”時暴露於REDOS(正則表示式拒絕服務攻擊)的風險中。你的正則表示式模式是否易受攻擊(即正則表示式引擎可能需要指數級時間來處理它)實際上是一個很難回答的問題,並且會根據你使用的是 Perl、Python、Ruby、Java、JavaScript 等而有所不同,但這裡有一些適用於所有這些語言的經驗法則:

  1. 避免巢狀量詞,如 (a+)*。V8 的正則表示式引擎可以快速處理其中一些,但其他的則易受攻擊。
  2. 避免使用帶有重疊子句的 OR,如 `(a|a)*`。同樣,這些有時是快速的。
  3. 避免使用反向引用,如 (a.*) \1。沒有正則表示式引擎可以保證線上性時間內評估這些。
  4. 如果你正在進行簡單的字串匹配,請使用 indexOf 或本地等效方法。它會更便宜,並且永遠不會超過 `O(n)`。

如果你不確定你的正則表示式是否易受攻擊,請記住,即使對於易受攻擊的正則表示式和長輸入字串,Node.js 通常也能毫無問題地報告一個匹配。指數行為是在不匹配但 Node.js 在嘗試了輸入字串的許多路徑之前無法確定時觸發的。

一個 REDOS 示例

這是一個易受攻擊的正則表示式示例,它將其伺服器暴露於 REDOS 攻擊之下。

app.get('/redos-me', (, ) => {
  const  = .query.filePath;

  // REDOS
  if (.match(/(\/.+)+$/)) {
    .('valid path');
  } else {
    .('invalid path');
  }

  .sendStatus(200);
});

這個例子中易受攻擊的正則表示式是檢查 Linux 上有效路徑的一種(糟糕的!)方法。它匹配由“/”分隔的名稱序列組成的字串,例如“/a/b/c”。它很危險,因為它違反了規則 1:它有一個雙重巢狀的量詞。

如果一個客戶端用 filePath `///.../\n`(100 個 / 後面跟著一個正則表示式的 "." 無法匹配的換行符)進行查詢,那麼事件迴圈將花費幾乎無限長的時間,從而阻塞了事件迴圈。這個客戶端的 REDOS 攻擊導致所有其他客戶端在正則表示式匹配完成之前都得不到處理機會。

因此,你應該謹慎使用複雜的正則表示式來驗證使用者輸入。

反 REDOS 資源

有一些工具可以檢查你的正則表示式的安全性,比如:

但是,這些都不能捕獲所有易受攻擊的正則表示式。

另一種方法是使用不同的正則表示式引擎。你可以使用 node-re2 模組,它使用了谷歌的超快 RE2 正則表示式引擎。但請注意,RE2 與 V8 的正則表示式不是 100% 相容的,所以如果你換用 node-re2 模組來處理你的正則表示式,請檢查是否有迴歸。而且特別複雜的正則表示式 node-re2 也不支援。

如果你想匹配一些“顯而易見”的東西,比如 URL 或檔案路徑,可以在正則表示式庫中找一個例子,或者使用一個 npm 模組,例如 ip-regex

阻塞事件迴圈:Node.js 核心模組

幾個 Node.js 核心模組有同步的昂貴 API,包括

這些 API 很昂貴,因為它們涉及大量的計算(加密、壓縮)、需要 I/O(檔案 I/O),或者可能兩者都需要(子程序)。這些 API 是為了指令碼編寫的方便而設計的,但並不適用於伺服器環境。如果你在事件迴圈上執行它們,它們完成的時間將遠長於一個典型的 JavaScript 指令,從而阻塞事件迴圈。

在伺服器中,你不應該使用這些模組的以下同步 API

  • 加密
    • crypto.randomBytes (同步版本)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • 你也應該小心向加密和解密例程提供大量輸入。
  • 壓縮
    • zlib.inflateSync
    • zlib.deflateSync
  • 檔案系統
    • 不要使用同步檔案系統 API。例如,如果你訪問的檔案位於分散式檔案系統(如 NFS)中,訪問時間可能會有很大差異。
  • 子程序 (Child process)
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

截至 Node.js v9,此列表相當完整。

阻塞事件迴圈:JSON DOS

JSON.parseJSON.stringify 是其他潛在的昂貴操作。雖然它們在輸入長度上是 O(n),但對於大的 n,它們可能需要出乎意料的長的時間。

如果你的伺服器處理 JSON 物件,特別是來自客戶端的物件,你應該對你在事件迴圈上處理的物件或字串的大小保持謹慎。

示例:JSON 阻塞。我們建立一個大小為 2^21 的物件 obj,並對其進行 JSON.stringify,然後對該字串執行 indexOf,最後對其進行 JSON.parse。JSON.stringify 後的字串大小為 50MB。對物件進行字串化需要 0.7 秒,對 50MB 的字串進行 indexOf 操作需要 0.03 秒,解析該字串需要 1.3 秒。

let  = { : 1 };
const  = 20;

// Expand the object exponentially by nesting it
for (let  = 0;  < ; ++) {
   = { : , :  };
}

// Measure time to stringify the object
let  = .();
const  = .();
let  = .();
.('JSON.stringify took', );

// Measure time to search a string within the JSON
 = .();
const  = .('nomatch'); // Always -1
 = .();
.('String.indexOf took', );

// Measure time to parse the JSON back to an object
 = .();
const  = .();
 = .();
.('JSON.parse took', );

有一些 npm 模組提供了非同步 JSON API。例如:

  • JSONStream,它有流式 API。
  • Big-Friendly JSON,它既有流式 API,也有使用下面概述的在事件迴圈上分割槽的正規化的標準 JSON API 的非同步版本。

在不阻塞事件迴圈的情況下進行復雜計算

假設你想在不阻塞事件迴圈的情況下在 JavaScript 中進行復雜的計算。你有兩個選擇:分割槽或解除安裝。

分割槽

你可以分割槽你的計算,這樣每個計算都在事件迴圈上執行,但會定期讓步(給其他待處理的事件機會)。在 JavaScript 中,很容易將正在進行的任務的狀態儲存在一個閉包中,如下面的示例 2 所示。

舉個簡單的例子,假設你想計算從 1n 的數字的平均值。

示例 1:未分割槽的平均值,成本為 O(n)

for (let  = 0;  < n; ++) {
  sum += ;
}

const  = sum / n;
.('avg: ' + );

示例 2:分割槽平均值,n 個非同步步驟中的每一個成本都是 `O(1)`。

function (, ) {
  // Save ongoing sum in JS closure.
  let  = 0;
  function (, ) {
     += ;
    if ( == ) {
      ();
      return;
    }

    // "Asynchronous recursion".
    // Schedule next operation asynchronously.
    (.(null,  + 1, ));
  }

  // Start the helper, with CB to call avgCB.
  (1, function () {
    const  =  / ;
    ();
  });
}

(n, function () {
  .('avg of 1-n: ' + );
});

你可以將這個原則應用於陣列迭代等等。

解除安裝

如果你需要做更復雜的事情,分割槽不是一個好選擇。這是因為分割槽只使用事件迴圈,你將無法從你機器上幾乎肯定可用的多個核心中受益。請記住,事件迴圈應該協調客戶端請求,而不是自己完成它們。對於複雜的任務,將工作從事件迴圈轉移到工作執行緒池上。

如何解除安裝

你有兩個選擇來決定將工作解除安裝到哪個工作執行緒池。

  1. 你可以透過開發一個 C++ 外掛來使用內建的 Node.js 工作執行緒池。在舊版本的 Node 上,使用 NAN 構建你的 C++ 外掛,而在新版本上使用 N-APInode-webworker-threads 提供了一種僅使用 JavaScript 的方式來訪問 Node.js 工作執行緒池。
  2. 你可以建立和管理自己的專門用於計算的工作執行緒池,而不是 Node.js 以 I/O 為主題的工作執行緒池。最直接的方法是使用 子程序叢集

不應該簡單地為每個客戶端建立一個子程序。你接收客戶端請求的速度可能比你建立和管理子程序的速度快,你的伺服器可能會變成一個fork 炸彈

解除安裝的缺點

解除安裝方法的缺點是它會產生通訊成本的開銷。只有事件迴圈被允許看到你的應用程式的“名稱空間”(JavaScript 狀態)。從一個工作執行緒中,你無法操作事件迴圈名稱空間中的 JavaScript 物件。相反,你必須序列化和反序列化任何你希望共享的物件。然後工作執行緒可以在它自己的這些物件的副本上操作,並將修改後的物件(或一個“補丁”)返回給事件迴圈。

有關序列化問題,請參閱關於 JSON DOS 的部分。

一些解除安裝的建議

你可能希望區分 CPU 密集型和 I/O 密集型任務,因為它們具有顯著不同的特徵。

一個 CPU 密集型任務只有在它的工作執行緒被排程時才會取得進展,並且該工作執行緒必須被排程到你機器的邏輯核心之一上。如果你有 4 個邏輯核心和 5 個工作執行緒,其中一個工作執行緒將無法取得進展。結果是,你為這個工作執行緒支付了開銷(記憶體和排程成本),卻沒有得到任何回報。

I/O 密集型任務涉及查詢外部服務提供商(DNS、檔案系統等)並等待其響應。當一個帶有 I/O 密集型任務的工作執行緒正在等待其響應時,它沒有其他事情可做,可以被作業系統取消排程,從而給另一個工作執行緒提交請求的機會。因此,即使關聯的執行緒沒有在執行,I/O 密集型任務也將在進行中。像資料庫和檔案系統這樣的外部服務提供商已經被高度最佳化,可以併發處理許多待處理的請求。例如,檔案系統會檢查大量的待處理寫入和讀取請求,以合併衝突的更新並以最佳順序檢索檔案。

如果你只依賴一個工作執行緒池,例如 Node.js 工作執行緒池,那麼 CPU 密集型和 I/O 密集型工作的不同特性可能會損害你的應用程式效能。

因此,你可能希望維護一個獨立的計算工作執行緒池。

解除安裝:結論

對於簡單的任務,比如迭代任意長度陣列的元素,分割槽可能是一個不錯的選擇。如果你的計算更復雜,解除安裝是更好的方法:通訊成本,即在事件迴圈和工作執行緒池之間傳遞序列化物件的開銷,被使用多個核心的好處所抵消。

但是,如果你的伺服器嚴重依賴複雜的計算,你應該考慮 Node.js 是否真的是一個好的選擇。Node.js 在 I/O 密集型工作方面表現出色,但對於昂貴的計算,它可能不是最佳選擇。

如果你採用解除安裝方法,請參閱關於不阻塞工作執行緒池的部分。

不要阻塞工作執行緒池

Node.js 有一個由 k 個工作執行緒組成的工作執行緒池。如果你正在使用上面討論的解除安裝正規化,你可能會有一個單獨的計算工作執行緒池,同樣的原則也適用於它。在任何一種情況下,我們都假設 k 遠小於你可能同時處理的客戶端數量。這與 Node.js 的“一個執行緒服務多個客戶端”的理念相符,這也是其可擴充套件性的秘訣。

如上所述,每個工作執行緒在處理工作執行緒池佇列中的下一個任務之前,會先完成其當前任務。

現在,處理您的客戶端請求所需的任務成本會有所不同。有些任務可以很快完成(例如,讀取短檔案或快取檔案,或生成少量隨機位元組),而其他任務則需要更長的時間(例如,讀取較大或未快取的檔案,或生成更多隨機位元組)。您的目標應該是最小化任務時間的差異,您應該使用任務分割槽來實現這一目標。

最小化任務時間的差異

如果一個工作執行緒的當前任務比其他任務昂貴得多,那麼它將無法處理其他待處理的任務。換句話說,每個相對較長的任務在完成之前,實際上都會將工作執行緒池的大小減少一個。這是不可取的,因為在一定程度上,工作執行緒池中的工作執行緒越多,工作執行緒池的吞吐量(任務數/秒)就越大,從而伺服器的吞吐量(客戶端請求數/秒)也越大。一個具有相對昂貴任務的客戶端會降低工作執行緒池的吞吐量,進而降低伺服器的吞吐量。

為了避免這種情況,你應該儘量減少你提交給工作執行緒池的任務長度的差異。雖然將你的 I/O 請求所訪問的外部系統(資料庫、檔案系統等)視為黑盒是合適的,但你應該意識到這些 I/O 請求的相對成本,並應避擴音交那些你可以預料到會特別長的請求。

兩個例子應該能說明任務時間的可能差異。

差異示例:長時間執行的檔案系統讀取

假設您的伺服器必須讀取檔案以處理某些客戶端請求。在查閱了 Node.js 檔案系統 API 後,您為了簡單起見選擇了使用 fs.readFile()。然而,v10 之前的 fs.readFile() 並沒有進行分割槽:它提交了一個跨越整個檔案的單個 fs.read() 任務。如果您為某些使用者讀取較短的檔案,而為其他使用者讀取較長的檔案,fs.readFile() 可能會在任務長度上引入顯著的差異,這對工作執行緒池的吞吐量是不利的。

在最壞的情況下,假設攻擊者可以使您的伺服器讀取任意檔案(這是一個目錄遍歷漏洞)。如果您的伺服器執行在 Linux 上,攻擊者可以指定一個極其緩慢的檔案:/dev/random。實際上,/dev/random 是無限慢的,每個被要求從 /dev/random 讀取的工作執行緒都永遠無法完成該任務。然後,攻擊者提交 k 個請求,每個工作執行緒一個,這樣使用工作執行緒池的其他客戶端請求將無法取得進展。

差異示例:長時間執行的加密操作

假設您的伺服器使用 crypto.randomBytes() 生成加密安全的隨機位元組。crypto.randomBytes() 沒有進行分割槽:它建立一個單獨的 randomBytes() 任務來生成您請求的任意數量的位元組。如果您為某些使用者生成較少的位元組,而為其他使用者生成更多的位元組,crypto.randomBytes() 是任務長度差異的另一個來源。

任務分割槽

時間成本可變的任務會損害工作執行緒池的吞吐量。為了最小化任務時間的差異,你應該儘可能地將每個任務分割槽成成本相當的子任務。當每個子任務完成時,它應該提交下一個子任務,當最後一個子任務完成時,它應該通知提交者。

繼續 fs.readFile() 的例子,你應該改用 fs.read()(手動分割槽)或 ReadStream(自動分割槽)。

同樣的原則也適用於 CPU 密集型任務;`asyncAvg` 示例可能不適合事件迴圈,但它非常適合工作執行緒池。

當你將一個任務劃分為子任務時,較短的任務會擴充套件為少量子任務,而較長的任務會擴充套件為更多子任務。在較長任務的每個子任務之間,分配給它的工作執行緒可以處理來自另一個較短任務的子任務,從而提高了工作執行緒池的整體任務吞吐量。

請注意,完成的子任務數量並不是衡量工作執行緒池吞吐量的有用指標。相反,你應該關心完成的任務數量。

避免任務分割槽

回想一下,任務分割槽的目的是最小化任務時間的差異。如果你能區分較短的任務和較長的任務(例如,對陣列求和與對陣列排序),你可以為每類任務建立一個工作執行緒池。將較短的任務和較長的任務路由到不同的工作執行緒池是另一種最小化任務時間差異的方法。

支援這種方法的原因是,分割槽任務會產生開銷(建立工作執行緒池任務表示和操作工作執行緒池佇列的成本),而避免分割槽可以節省你額外往返工作執行緒池的成本。它還可以防止你在分割槽任務時犯錯。

這種方法的缺點是,所有這些工作執行緒池中的工作執行緒都會產生空間和時間開銷,並會相互競爭 CPU 時間。請記住,每個 CPU 密集型任務只有在被排程時才會取得進展。因此,只有在仔細分析後才應考慮這種方法。

工作執行緒池:結論

無論您是隻使用 Node.js 工作執行緒池還是維護單獨的工作執行緒池,您都應該最佳化您的池的吞吐量。

為此,透過使用任務分割槽來最小化任務時間的差異。

npm 模組的風險

雖然 Node.js 核心模組為各種應用程式提供了構建塊,但有時還需要更多。Node.js 開發者從 npm 生態系統中受益匪淺,數十萬個模組提供了加速您開發過程的功能。

然而,請記住,這些模組中的大多數是由第三方開發者編寫的,並且通常只提供盡力而為的保證。使用 npm 模組的開發者應該關心兩件事,儘管後者經常被遺忘。

  1. 它是否遵守其 API?
  2. 它的 API 是否可能會阻塞事件迴圈或工作執行緒?許多模組沒有努力說明其 API 的成本,這對社群是不利的。

對於簡單的 API,你可以估算其成本;字串操作的成本不難理解。但在許多情況下,一個 API 可能需要多少成本是不清楚的。

如果你正在呼叫一個可能做一些昂貴事情的 API,請仔細檢查成本。要求開發者將其文件化,或者自己檢查原始碼(並提交一個記錄成本的 PR)。

請記住,即使 API 是非同步的,你也不知道它在其每個分割槽中可能在工作執行緒或事件迴圈上花費多少時間。例如,假設在上面給出的 `asyncAvg` 示例中,每次呼叫輔助函式都對一半的數字求和,而不是其中一個。那麼這個函式仍然是非同步的,但每個分割槽的成本將是 `O(n)`,而不是 `O(1)`,這使得它對於任意 `n` 值的使用遠不安全。

結論

Node.js 有兩種型別的執行緒:一個事件迴圈和 k 個工作執行緒。事件迴圈負責 JavaScript 回撥和非阻塞 I/O,而工作執行緒執行與完成非同步請求的 C++ 程式碼相對應的任務,包括阻塞 I/O 和 CPU 密集型工作。兩種型別的執行緒一次只處理不超過一項活動。如果任何回撥或任務花費很長時間,執行它的執行緒就會被阻塞。如果你的應用程式發出阻塞的回撥或任務,這可能導致吞吐量(客戶端/秒)下降,最壞的情況下可能導致完全的拒絕服務。

為了編寫一個高吞吐量、更具 DoS 防護能力的網路伺服器,你必須確保無論是在良性還是惡意輸入下,你的事件迴圈和工作執行緒都不會阻塞。