不要阻塞事件循環(或工作池)

你應該閱讀這份指南嗎?

如果你在寫任何比簡單的命令行腳本更複雜的東西,閱讀這份文件應該會幫助你寫出更高效、更安全的應用程式。

本文件是針對 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 應用程序首先完成初始化階段,需要模塊並註冊事件回調。然後 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. 加密crypto.pbkdf2()crypto.scrypt()crypto.randomBytes()crypto.randomFill()crypto.generateKeyPair()
    2. Zlib:除了明確同步的API之外,所有 zlib API 均使用 libuv 的線程池。

在許多 Node.js 應用程式中,這些 API 是 Worker Pool 中任務的唯一來源。使用C++ add-on的應用程式和模組可以提交其他任務給 Worker Pool。

為了完整起見,我們注意到當您從事件迴圈的回調函數中調用這些 API 時,事件迴圈會支付一些較小的設置成本,因為它進入 Node.js C++ 綁定的該 API 並將任務提交給 Worker Pool。與任務的整體成本相比,這些成本是微不足道的,這就是為什麼事件迴圈正在卸載它。當將這些任務之一提交給 Worker Pool 時,Node.js 提供了指向 Node.js C++ 綁定中對應 C++ 函數的指標。

Node.js 如何決定下一個要運行的程式碼?

抽象地說,事件迴圈和 Worker Pool 分別維護待處理事件和待處理任務的佇列。

事實上,事件迴圈並沒有實際維護一個佇列。相反,它有一組文件描述符,它請求操作系統使用像epoll(Linux)、kqueue(OSX)、事件端口(Solaris)或IOCP(Windows)等機制來監視。這些文件描述符對應於網絡套接字、正在觀察的任何文件等。當操作系統表示這些文件描述符中的一個已準備就緒時,事件迴圈將其轉換為相應的事件並調用與該事件關聯的回調函數。您可以在這裡了解更多有關此過程的信息。

相較之下,Worker Pool 使用真正的佇列,其項目為待處理的任務。Worker 從這個佇列彈出一個任務並處理它,當完成時,Worker 就會對事件迴圈引發一個「至少有一個任務已完成」的事件。

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

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

因為 Node.js 使用少量線程處理許多客戶端,如果一個線程在處理一個客戶端的請求時被阻塞,則待處理的客戶端請求可能要等到該線程完成其回調或任務才能執行。因此,對客戶端的公平待遇取決於您的應用程式。這意味著您不應該在任何單一回調或任務中為任何客戶端做太多的工作。

這是為什麼 Node.js 可以很好地擴展的原因之一,但這也意味著您需要負責確保公平的排程。接下來的部分將討論如何確保事件迴圈和 Worker Pool 的公平排程。

不要阻塞事件迴圈

事件迴圈注意到每個新的客戶端連線並協調產生一個回應。所有傳入的請求和傳出的回應都會通過事件迴圈。這意味著如果事件迴圈在任何時間點花費太長的時間,所有當前和新的客戶端都將無法執行。

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

確保這一點的一個好方法是對您的回調函數進行計算複雜度的思考。如果您的回調函數無論其參數如何都需要恆定步數,那麼您將始終給予每個等待的客戶公平的機會。如果您的回調函數根據其參數需要不同的步數,那麼您應該考慮參數可能有多長。

示例1:恆定時間的回調。

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

示例2:一個O(n)的回調。對於小的n,此回調將運行迅速,對於大的n,則運行速度較慢。

app.get('/countToN', (req, res) => {
  let n = req.query.n;

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

  res.sendStatus(200);
});

示例3:一個O(n^2)的回調。對於小的n,此回調仍然運行迅速,但對於大的n,它將比前一個O(n)的示例運行得慢得多。

app.get('/countToN2', (req, res) => {
  let n = req.query.n;

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

  res.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', (req, res) => {
  let filePath = req.query.filePath;

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

  res.sendStatus(200);
});

在此示例中易受攻擊的正規表達式是一種(糟糕的!)檢查Linux上有效路徑的方式。它匹配字符串,這些字符串是由"/"分隔的名稱序列,如"/a/b/c"。它是危險的,因為它違反了規則1:它具有雙重嵌套量詞。

如果客戶端使用 filePath ///.../\n(由 100 個斜線後跟一個無法匹配正則表達式的點的換行字符組成),那麼事件循環將會耗費大量時間,導致事件循環被阻塞。這個客戶端的 REDOS 攻擊會導致其他所有客戶端都無法執行,直到正則表達式匹配完成。

因此,您應該謹慎使用複雜的正則表達式來驗證用戶輸入。

反 REDOS 資源

有一些工具可用於檢查您的正則表達式是否安全,例如

  • safe-regex
  • rxxr2。然而,這些工具都不能捕捉到所有有漏洞的正則表達式。

另一種方法是使用不同的正則表達式引擎。您可以使用 node-re2 模組,該模組使用 Google 的快速 RE2 正則表達式引擎。但請注意,RE2 與 V8 的正則表達式並不完全兼容,因此如果將 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.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。對物件進行 stringify 需要 0.7 秒,對 50MB 字符串進行 indexOf 需要 0.03 秒,對字符串進行解析需要 1.3 秒。

let obj = { a: 1 };
let niter = 20;

let before, str, pos, res, took;

for (let i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj }; // Doubles in size each iter
}

before = process.hrtime();
str = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);

before = process.hrtime();
pos = str.indexOf('nomatch');
took = process.hrtime(before);
console.log('Pure indexof took ' + took);

before = process.hrtime();
res = JSON.parse(str);
took = process.hrtime(before);
console.log('JSON.parse took ' + took);

有一些 npm 模組提供了異步 JSON API。請參見

  • JSONStream,它具有流 API。
  • Big-Friendly JSON,具有流API以及使用以下事件循環分割範例的標準JSON API的非同步版本。

複雜計算而不阻塞事件循環

假設您想在JavaScript中進行複雜計算而不阻塞事件循環。您有兩個選擇:分割或卸載。

分割

您可以將計算“分割”,使每個計算都在事件循環上運行,但定期讓出(給予)其他待處理事件。在JavaScript中,將正在進行的任務狀態保存在閉包中很容易,如下面的示例2所示。

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

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

for (let i = 0; i < n; i++) sum += i;
let avg = sum / n;
console.log('avg: ' + avg);

示例2:分割的平均值,每個n的非同步步驟成本為O(1)

function asyncAvg(n, avgCB) {
  // Save ongoing sum in JS closure.
  let sum = 0;
  function help(i, cb) {
    sum += i;
    if (i == n) {
      cb(sum);
      return;
    }

    // "Asynchronous recursion".
    // Schedule next operation asynchronously.
    setImmediate(help.bind(null, i + 1, cb));
  }

  // Start the helper, with CB to call avgCB.
  help(1, function (sum) {
    let avg = sum / n;
    avgCB(avg);
  });
}

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

您可以將此原則應用於數組迭代等等。

卸載

如果您需要做更複雜的事情,分割不是一個好選擇。這是因為分割僅使用事件循環,而您的機器上幾乎肯定有多核心,您不會從中受益。記住,事件循環應該將客戶端請求管控好,而不是自己完成它們。對於複雜的任務,將工作從事件循環移至工作池。

如何卸載

對於要卸載工作的目標工作池,您有兩個選擇。

  1. 您可以通過開發C++ 擴展來使用內置的 Node.js Worker Pool。在舊版本的 Node 上,使用NAN來構建您的 C++ 擴展,而在較新的版本上則使用N-APInode-webworker-threads提供了一種僅使用 JavaScript 的方式來訪問 Node.js Worker Pool。
  2. 您可以創建並管理自己的 Worker Pool,專門用於計算,而不是 Node.js I/O 主題的 Worker Pool。最直接的方法是使用Child ProcessCluster

您不應該簡單地為每個客戶端創建Child Process。您可以更快地接收客戶端請求,而不是創建和管理子進程,而且您的伺服器可能會變成fork bomb

卸載的缺點

卸載方法的缺點是它會產生通信成本的開銷。只有事件循環可以看到應用程式的“命名空間”(JavaScript 狀態)。從 Worker 中,您無法操縱事件循環命名空間中的 JavaScript 對象。相反,您必須對希望共享的任何對象進行序列化和反序列化。然後,Worker 可以對其自己的這些對象的副本進行操作,並將修改後的對象(或“補丁”)返回給事件循環。

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

一些卸載建議

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

一個 CPU 密集型任務只有在其工作線程被安排時才會進展,而該工作線程必須被安排到您機器的一個邏輯核心上。如果您有 4 個邏輯核心和 5 個工作線程,其中一個工作線程就無法進展。因此,您將為這個工作線程支付開銷(內存和調度成本),卻沒有得到任何回報。

IO 密集型任務涉及查詢外部服務提供商(DNS、文件系統等)並等待其響應。當具有 IO 密集型任務的工作線程正在等待其響應時,它沒有其他事情可做,可以被操作系統取消調度,讓另一個工作線程有機會提交其請求。因此,即使關聯的線程未運行,IO 密集型任務也會取得進展。外部服務提供商(如數據庫和文件系統)已經高度優化,以處理許多待處理的請求。例如,文件系統將檢查一組大量的待處理寫入和讀取請求,以合併衝突的更新並以最優的順序檢索文件。

如果您僅依賴於一個工作線程池,例如 Node.js 工作線程池,那麼 CPU 密集型和 IO 密集型工作的不同特性可能會損害您應用程序的性能。

基於這個原因,您可能希望保持一個單獨的計算工作線程池。

卸載:結論

對於簡單的任務,例如遍歷任意長度數組的元素,分割可能是一個不錯的選擇。如果您的計算更複雜,卸載是一種更好的方法:通信成本,即在事件循環和工作線程池之間傳遞序列化對象的開銷,會被使用多個核心的好處所抵消。

然而,如果您的服務器嚴重依賴於複雜的計算,您應該考慮 Node.js 是否真的是一個合適的選擇。Node.js 擅長於 IO 密集型工作,但對於昂貴的計算可能不是最佳選擇。

如果您採用卸載方法,請參閱有關不阻塞工作線程池的部分。

不要阻塞工作線程池

Node.js 擁有由 k 個工作人員組成的工作人員池。如果您使用上面討論的 Offloading 範式,您可能會擁有一個單獨的計算工作人員池,對其適用相同的原則。在任何情況下,讓我們假設 k 遠小於您可能同時處理的客戶數量。這符合 Node.js 的「一個線程處理多個客戶」的哲學,這是其可擴展性的秘訣。

如上所述,每個工作人員在繼續下一個工作人員池隊列中的下一個工作之前,都會完成其當前任務。

現在,處理客戶請求所需任務的成本將有所不同。一些任務可以很快完成(例如讀取短或緩存文件,或生成少量的隨機字節),而其他任務將需要更長的時間(例如讀取更大或未緩存的文件,或生成更多的隨機字節)。您的目標應該是 最小化任務時間的變化,並且您應該使用 任務劃分 來實現這一目標。

最小化任務時間的變化

如果一個工作人員當前的任務比其他任務更昂貴,那麼它將無法處理其他待定的任務。換句話說,每個相對較長的任務都會有效地將工作人員池的大小減少一個,直到完成。這是不可取的,因為在某種程度上,工作人員池中的工作人員越多,工作人員池的吞吐量(任務/秒)就越大,因此服務器的吞吐量(客戶請求/秒)也越大。一個具有相對昂貴任務的客戶將降低工作人員池的吞吐量,進而降低服務器的吞吐量。

為了避免這種情況,您應該試著將提交給工作池的任務長度變化減到最低。雖然將您的 I/O 請求訪問的外部系統(例如 DB、FS 等)視為黑箱是合適的,但您應該了解這些 I/O 請求的相對成本,並且應該避免提交您預期將特別長的請求。

兩個例子應該說明了任務時間可能的變化。

變化範例:長時間運行的文件系統讀取

假設您的伺服器必須讀取文件以處理某些客戶請求。在查閱了 Node.js 文件系統 API 後,您選擇使用 fs.readFile() 來簡化操作。然而,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的示例可能不適合於事件循環,但非常適合於工作池。

當您將一個任務分割為子任務時,較短的任務會擴展成較少的子任務,而較長的任務會擴展成較多的子任務。在較長任務的每個子任務之間,被分配的工作器可以處理另一個較短任務的子任務,從而提高了工作池的整體任務吞吐量。

請注意,完成的子任務數量對於工作池的吞吐量並不是一個有用的指標。相反,請關注完成的任務數量。

避免任務分割

請記住,任務分割的目的是將任務時間的變化最小化。如果您可以區分較短的任務和較長的任務(例如對數組求和與對數組排序),您可以為每個類型的任務創建一個工作池。將較短的任務和較長的任務路由到不同的工作池是另一種最小化任務時間變化的方法。

支持這種方法的原因是,將任務分割會產生開銷(創建工作池任務表示和操作工作池隊列的成本),而避免分割可以節省額外的向工作池發送請求的成本。它還可以防止您在分割任務時出錯。

這種方法的缺點是所有這些 Worker Pool 中的 Workers 都會產生空間和時間的開銷,並且彼此競爭 CPU 時間。請記住,每個 CPU-bound 任務只有在排程時才會有進展。因此,在深入分析之後,您應該才考慮使用此方法。

Worker Pool:結論

無論您只使用 Node.js Worker Pool 還是保持獨立的 Worker Pool(s),都應該優化 Pool(s) 的任務吞吐量。

為了做到這一點,通過使用任務劃分來減少任務時間的變化。

npm 模組的風險

雖然 Node.js 核心模組為各種應用程序提供了基本組件,但有時需要更多的功能。 Node.js 開發人員從 npm 生態系統 中受益良多,其中數十萬個模組提供功能,可以加速您的開發過程。

但請記住,這些模組大多由第三方開發人員編寫,通常僅提供盡力而為的保證。使用 npm 模組的開發人員應關注兩個問題,儘管後者經常被忽略。

  1. 它是否遵守其 API?
  2. 其 API 是否可能阻塞事件循環或 Worker?許多模組不會努力指出其 API 的成本,這對社區是不利的。

對於簡單的 API,您可以估算 API 的成本;字符串操作的成本不難理解。但在許多情況下,不清楚 API 可能的成本。

如果您調用可能會產生高昂成本的 API,請仔細檢查成本。請開發人員記錄它,或者自己檢查源代碼(並提交一個記錄成本的 PR)。

請記住,即使 API 是異步的,您也不知道它在每個分區的 Worker 或事件循環上可能花費多少時間。例如,假設在上面給出的 asyncAvg 的示例中,每次對助手函數的調用都是對數字的一半進行求和,而不是其中一個。那麼,這個函數仍然是異步的,但每個分區的成本將是 O(n),而不是 O(1),這使得對於 n 的任意值來說,使用起來要不安全得多。

結論

Node.js 擁有兩種類型的執行緒:一個事件循環(Event Loop)和 k 個 Workers。事件循環負責處理 JavaScript 回調和非阻塞 I/O,而 Worker 則執行對應於 C++ 代碼的任務,完成異步請求,包括阻塞 I/O 和 CPU 密集型工作。這兩種類型的執行緒同時只能處理一個活動。如果任何回調或任務花費了很長的時間,執行它的執行緒將會變為 阻塞。如果您的應用程序進行阻塞回調或任務,這可能導致吞吐量(客戶端/秒)降低至最低,甚至在最壞的情況下完全拒絕服務。

要編寫一個高吞吐量、更具 DoS 防護性的 Web 伺服器,您必須確保在正常和惡意輸入時,既不會阻塞您的事件循環,也不會阻塞您的 Workers。