不要在nodejs中阻塞event loop

flydean發表於2021-02-02

為什麼我們不要在nodejs中阻塞event loop

簡介

我們知道event loop是nodejs中事件處理的基礎,event loop中主要執行的初始化和callback事件。除了event loop之外,nodejs中還有Worker Pool用來處理一些耗時的操作,比如I/O操作。

nodejs高效執行的祕訣就是使用非同步IO從而可以使用少量的執行緒來處理大量的客戶端請求。

而同時,因為使用了少量的執行緒,所以我們在編寫nodejs程式的時候,一定要特別小心。

event loop和worker pool

在nodejs中有兩種型別的執行緒。第一類執行緒就是Event Loop也可以被稱為主執行緒,第二類就是一個Worker Pool中的n個Workers執行緒。

如果這兩種執行緒執行callback花費了太多的時間,那麼我們就可以認為這兩個執行緒被阻塞了。

執行緒阻塞第一方面會影響程式的效能,因為某些執行緒被阻塞,就會導致系統資源的佔用。因為總的資源是有限的,這樣就會導致處理其他業務的資源變少,從而影響程式的總體效能。

第二方面,如果經常會有執行緒阻塞的情況,很有可能被惡意攻擊者發起DOS攻擊,導致正常業務無法進行。

nodejs使用的是事件驅動的框架,Event Loop主要用來處理為各種事件註冊的callback,同時也負責處理非阻塞的非同步請求,比如網路I/O。

而由libuv實現的Worker Pool主要對外暴露了提交task的API,從而用來處理一些比較昂貴的task任務。這些任務包括CPU密集性操作和一些阻塞型IO操作。

而nodejs本身就有很多模組使用的是Worker Pool。

比如IO密集型操作:

DNS模組中的dns.lookup(), dns.lookupService()。

和除了fs.FSWatcher()和 顯式同步的檔案系統的API之外,其他多有的File system模組都是使用的Worker Pool。

CPU密集型操作:

Crypto模組:crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair()。

Zlib模組:除了顯示同步的API之外,其他的API都是用的是worker pool。

一般來說使用Worker Pool的模組就是這些了,除此之外,你還可以使用nodejs的C++ add-on來自行提交任務到Worker Pool。

event loop和worker pool中的queue

在之前的檔案中,我們講到了event loop中使用queue來儲存event的callback,實際上這種描述是不準確的。

event loop實際上維護的是一個檔案描述符集合。這些檔案描述符使用的是作業系統核心的 epoll (Linux), kqueue (OSX), event ports (Solaris), 或者 IOCP (Windows)來對事件進行監聽。

當作業系統檢測到事件準備好之後,event loop就會呼叫event所繫結的callback事件,最終執行callback。

相反的,worker pool就真的是儲存了要執行的任務佇列,這些任務佇列中的任務由各個worker來執行。當執行完畢之後,Woker將會通知Event Loop該任務已經執行完畢。

阻塞event loop

因為nodejs中的執行緒有限,如果某個執行緒被阻塞,就可能會影響到整個應用程式的執行,所以我們在程式設計的過程中,一定要小心的考慮event loop和worker pool,避免阻塞他們。

event loop主要關注的是使用者的連線和響應使用者的請求,如果event loop被阻塞,那麼使用者的請求將會得不到及時響應。

因為event loop主要執行的是callback,所以,我們的callback執行時間一定要短。

event loop的時間複雜度

時間複雜度一般用在判斷一個演算法的執行速度上,這裡我們也可以藉助時間複雜度這個概念來分析一下event loop中的callback。

如果所有的callback中的時間複雜度都是一個常量的話,那麼我們可以保證所有的callback都可以很公平的被執行。

但是如果有些callback的時間複雜度是變化的,那麼就需要我們仔細考慮了。

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

先看一個常量時間複雜度的情況,上面的例子中我們直接設定了respose的status,是一個常量時間操作。

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);
});

上面的例子是一個O(n)的時間複雜度,根據request中傳入的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);
});

上面的例子是一個O(n^2)的時間複雜度。

這種情況應該怎麼處理呢?首先我們需要估算出系統能夠承受的響應極限值,並且設定使用者傳入的引數極限值,如果使用者傳入的資料太長,超出了我們的處理範圍,則可以直接從使用者輸入端進行限制,從而保證我們的程式的正常執行。

Event Loop中不推薦使用的Node.js核心模組

在nodejs中的核心模組中,有一些方法是同步的阻塞API,使用起來開銷比較大,比如壓縮,加密,同步IO,子程式等等。

這些API的目的是供我們在REPL環境中使用的,我們不應該直接在伺服器端程式中使用他們。

有哪些不推薦在server端使用的API呢?

  • Encryption:
    crypto.randomBytes (同步版本)
    crypto.randomFillSync
    crypto.pbkdf2Sync

  • Compression:
    zlib.inflateSync
    zlib.deflateSync

  • File system:
    不要使用fs的同步API

  • Child process:
    child_process.spawnSync
    child_process.execSync
    child_process.execFileSync

partitioning 或者 offloading

為了不阻塞event loop,同時給其他event一些執行機會,我們實際上有兩種解決辦法,那就是partitioning和offloading。

partitioning就是分而治之,把一個長的任務,分成幾塊,每次執行一塊,同時給其他的event一些執行時間,從而不再阻塞event loop。

舉個例子:

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

比如我們要計算n個數的平均數。上面的例子中我們的時間複雜度是O(n)。

function asyncAvg(n, avgCB) {
  // Save ongoing sum in JS closure.
  var 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){
      var avg = sum/n;
      avgCB(avg);
  });
}

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

這裡我們用到了setImmediate,將sum的任務分解成一步一步的。雖然asyncAvg需要執行很多次,但是每一次的event loop都可以保證不被阻塞。

partitioning雖然邏輯簡單,但是對於一些大型的計算任務來說,並不合適。並且partitioning本身還是執行在event loop中的,它並沒有享受到多核系統帶來的優勢。

這個時候我們就需要將任務offloading到worker Pool中。

使用Worker Pool有兩種方式,第一種就是使用nodejs自帶的Worker Pool,我們可以自行開發C++ addon或者node-webworker-threads。

第二種方式就是自行建立Worker Pool,我們可以使用Child Process 或者 Cluster來實現。

當然offloading也有缺點,它的最大缺點就是和Event Loop的互動損失。

V8引擎的限制

nodejs是執行在V8引擎上的,通常來說V8引擎已經足夠優秀足夠快了,但是還是存在兩個例外,那就是正規表示式和JSON操作。

REDOS正規表示式DOS攻擊

正規表示式有什麼問題呢?正規表示式有一個悲觀回溯的問題。

什麼是悲觀回溯呢?

我們舉個例子,假如大家對正規表示式已經很熟悉了。

假如我們使用/^(x*)y$/ 來和字串xxxxxxy來進行匹配。

匹配之後第一個分組(也就是括號裡面的匹配值)是xxxxxx。

如果我們把正規表示式改寫為 /^(x*)xy$/ 再來和字串xxxxxxy來進行匹配。 匹配的結果就是xxxxx。

這個過程是怎麼樣的呢?

首先(x)會盡可能的匹配更多的x,知道遇到字元y。 這時候(x)已經匹配了6個x。

接著正規表示式繼續執行(x)之後的xy,發現不能匹配,這時候(x)需要從已經匹配的6個x中,吐出一個x,然後重新執行正規表示式中的xy,發現能夠匹配,正規表示式結束。

這個過程就是一個回溯的過程。

如果正規表示式寫的不好,那麼就有可能會出現悲觀回溯。

還是上面的例子,但是這次我們用/^(x*)y$/ 來和字串xxxxxx來進行匹配。

按照上面的流程,我們知道正規表示式需要進行6次回溯,最後匹配失敗。

考慮一些極端的情況,可能會導致回溯一個非常大的次數,從而導致CPU佔用率飆升。

我們稱正規表示式的DOS攻擊為REDOS。

舉個nodejs中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);
});

上面的callback中,我們本意是想匹配 /a/b/c這樣的路徑。但是如果使用者輸入filePath=///.../\n,假如有100個/,最後跟著換行符。

那麼將會導致正規表示式的悲觀回溯。因為.表示的是匹配除換行符 \n 之外的任何單字元。但是我們只到最後才發現不能夠匹配,所以產生了REDOS攻擊。

如何避免REDOS攻擊呢?

一方面有一些現成的正規表示式模組,我們可以直接使用,比如safe-regex,rxxr2和node-re2等。

一方面可以到www.regexlib.com網站上查詢要使用的正規表示式規則,這些規則是經過驗證的,可以減少自己編寫正規表示式的失誤。

JSON DOS攻擊

通常我們會使用JSON.parse 和 JSON.stringify 這兩個JSON常用的操作,但是這兩個操作的時間是和輸入的JSON長度相關的。

舉個例子:

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

var before, str, pos, res, took;

for (var 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);

上面的例子中我們對obj進行解析操作,當然這個obj比較簡單,如果使用者傳入了一個超大的json檔案,那麼就會導致event loop的阻塞。

解決辦法就是限制使用者的輸入長度。或者使用非同步的JSON API:比如JSONStream和Big-Friendly JSON。

阻塞Worker Pool

nodejs的理念就是用最小的執行緒來處理最大的客戶連線。上面我們也講過了要把複雜的操作放到Worker Pool中來藉助執行緒池的優勢來執行。

但是執行緒池中的執行緒個數也是有限的。如果某一個執行緒執行了一個long run task,那麼就等於執行緒池中少了一個worker執行緒。

惡意攻擊者實際上是可以抓住系統的這個弱點,來實施DOS攻擊。

所以對Worker Pool中long run task的最優解決辦法就是partitioning。從而讓所有的任務都有平等的執行機會。

當然,如果你可以很清楚的區分short task和long run task,那麼我們實際上可以分別構造不同的worker Pool來分別為不同的task任務型別服務。

總結

event loop和worker pool是nodejs中兩種不同的事件處理機制,我們需要在程式中根據實際問題來選用。

本文作者:flydean程式那些事

本文連結:http://www.flydean.com/nodejs-block-eventloop/

本文來源:flydean的部落格

歡迎關注我的公眾號:「程式那些事」最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!

相關文章