[譯] 深刻理解 Node.js 中的 Worker 線程

原文:blog.insiderattack.net/deep-dive-i…javascript

多年以來,Node.js 都不是實現高 CPU 密集型應用的最佳選擇,這主要就是由於 JavaScript 的單線程。做爲對此問題的解決方案,Node.js v10.5.0 經過 worker_threads 模塊引入了實驗性的 「worker 線程」 概念,並從 Node.js v12 LTS 起成爲一個穩定功能。本文將解釋其如何工做,以及如何使用 Worker 線程得到最佳性能。前端

Node.js 中 CPU 密集型應用的歷史

在 worker 線程以前,Node.js 中有多種方式執行 CPU 密集型應用。其中的一些爲:java

  • 使用 child_process 模塊並在一個子進程中運行 CPU 密集型代碼
  • 使用 cluster 模塊,在多個進程中運行多個 CPU 密集型操做
  • 使用諸如 Microsoft 的 Napa.js 這樣的第三方模塊

可是受限於性能、額外引入的複雜性、佔有率低、薄弱的文檔化等,這些解決方案無一被普遍採用。node

爲 CPU 密集型操做使用 worker 線程

儘管對於 JavaScript 的併發性問題來講,worker_threads 是一個優雅的解決方案,但其並未給 JavaScript 自己帶來多線程特性。相反,worker_threads 經過運行應用使用多個相互隔離的 JavaScript workers 來實現併發,而 workers 和父 worker 之間的通訊由 Node 提供。聽懵了嗎? 🤷‍♂️git

在 Node.js 中,每個 worker 將擁有其本身的 V8 實例及事件循環(Event Loop)。但和 child_process 不一樣的是,workers 不共享內存。github

以上概念會在後面解釋。咱們首先來大體看一眼如何使用 Worker 線程。一個原生的用例看起來是這樣的:chrome

// worker-simple.js

const {Worker, isMainThread, parentPort, workerData} = require('worker_threads');
if (isMainThread) {
 const worker = new Worker(__filename, {workerData: {num: 5}});
 worker.once('message', (result) => {
 console.log('square of 5 is :', result);
 })
} else {
 parentPort.postMessage(workerData.num * workerData.num)
}
複製代碼

在上例中,咱們向每一個單獨的 workder 中傳入了一個數字以計算其平方值。在計算以後,子 worker 將結果發送回主 worker 線程。儘管看上去簡單,但 Node.js 新手可能仍是會有點困惑。瀏覽器

Worker 線程是如何工做的?

JavaScript 語言沒有多線程特性。所以,Node.js 的 Worker 線程以一種異於許多其它高級語言傳統多線程的方式行事。服務器

在 Node.js 中,一個 worker 的職責就是去執行一段父 worker 提供的代碼(worker 腳本)。這段 worker 腳本將會在隔絕於其它 workers 的環境中運行,並可以在其自身和父 worker 間傳遞消息。worker 腳本既能夠是一個獨立的文件,也能夠是一段可被 eval 解析的文本格式的腳本。在咱們的例子中,咱們將 __filename 做爲 worker 腳本,由於父 worker 和子 worker 代碼都在同一個腳本文件中,由 isMainThread 屬性決定其角色。多線程

每一個 worker 經過 message channel 鏈接到其父 worker。子 worker 可使用 parentPort.postMessage() 函數向消息通道中寫入信息,父 worker 則經過調用 worker 實例上的 worker.postMessage() 函數向消息通道中寫入信息。看一下圖 1:

圖 1:父子 workers 之間的消息通道

一個 Message Channel 就是一個簡單的通訊渠道,其兩端被稱做 ‘ports’。在 JavaScript/NodeJS 術語中,一個 Message Channel 的兩端就被叫作 port1port2

Node.js 的 workers 是如何並行的?

如今關鍵的問題來了,JavaScript 並不直接提供併發,那麼兩個 Node.js workers 要如何並行呢?答案就是 V8 isolate

一個 V8 isolate 就是 chrome V8 runtime 的一個單獨實例,包含自有的 JS 堆和一個微任務隊列。這容許了每一個 Node.js worker 徹底隔離於其它 workers 地運行其 JavaScript 代碼。其缺點在於 worker 沒法直接訪問其它 workers 的堆數據了。

擴展閱讀:JS在瀏覽器和Node下是如何工做的?

由此,每一個 worker 將擁有其本身的一份獨立於父 worker 和其它 workers 的 libuv 事件循環的拷貝。

跨越 JS/C++ 的邊界

實例化一個新 worker、提供和父級/同級 JS 腳本的通訊,都是由 C++ 實現版本的 worker 完成的。在成文時,該實現爲worker.cc (github.com/nodejs/node…)。

Worker 的實現經過 worker_threads 模塊被暴露爲用戶級的 JavaScript 腳本。該 JS 實現被分割爲兩個腳本,我將之稱爲:

  • 初始化腳本 worker.js — 負責初始化 worker 實例,並創建初次父子 worker 通訊,以確保從父 worker 傳遞 worker 元數據至子 worker。 (github.com/nodejs/node…)
  • 執行腳本 worker_thread.js — 根據用戶提供的 workerData 數據和其它父 worker 提供的元數據執行用戶的 worker JS 腳本。(github.com/nodejs/node…)

圖 2 以更清晰的方式解釋了這個過程:

圖 2:Worder 內部實現

基於上述,咱們能夠將 worker 設置過程劃分爲兩個階段:

  • worker 初始化
  • 運行 worker

來看看每一個階段都發生了什麼吧:

初始化步驟

  1. 用戶級腳本經過使用 worker_threads 建立一個 worker 實例
  2. Node 的父 worker 初始化腳本調用 C++ 並建立一個空的 worker 對象。此時,被建立的 worker 還只是個未被啓動的簡單的 C++ 對象
  3. 當 C++ worker 對象被建立後,其生成一個線程 ID 並賦值給自身
  4. 同時,一個空的初始化消息通道(讓咱們稱之爲 IMC)被父 worker 建立。圖 2 中灰色的 「Initialisation Message Channel」 部分展現了這點
  5. 一個公開的 JS 消息通道(稱其爲 PMC)被 worker 初始化腳本建立。 該通道被用戶級 JS 使用以在父子 worker 之間傳遞消息。圖 1 中主要描述了這部分,也在圖 2 中被標爲了紅色。
  6. Node 父 worker 初始化腳本調用 C++ 並將須要被髮送到 worker 執行腳本中的 初始元數據 寫入 IMC

什麼是初始元數據? 即執行腳本須要瞭解以啓動 worker 的數據,包括腳本名稱、worker 數據、PMC 的 port2,以及其它一些信息。

按咱們的例子來講,初始化元數據如:

☎️ 嘿!worker 執行腳本,請你用 {num: 5} 這樣的 worker 數據運行一下 worker-simple.js 好嗎?也請你把 PMC 的 port2 傳遞給它,這樣 worker 就能從 PMC 讀取數據啦。

下面的小片斷展現了初始化數據如何被寫入 IMC:

const kPublicPort = Symbol('kPublicPort');
// ...

const { port1, port2 } = new MessageChannel();
this[kPublicPort] = port1;
this[kPublicPort].on('message', (message) => this.emit('message', message));
// ...

this[kPort].postMessage({
  type: 'loadScript',
  filename,
  doEval: !!options.eval,
  cwdCounter: cwdCounter || workerIo.sharedCwdCounter,
  workerData: options.workerData,
  publicPort: port2,
  // ...
  hasStdin: !!options.stdin
}, [port2]);
複製代碼

代碼中的 this[kPort] 是初始化腳本中 IMC 的端點。儘管 worker 初始化腳本向 IMC 寫入了數據,但 worker 執行腳本仍沒法訪問該數據。

運行步驟

此時,初始化已告一段落;接下來 worker 初始化腳本調用 C++ 並啓動 worker 線程。

  1. 一個新的 V8 isolate 被建立並被分配給 worker。前面講過,一個 「v8 isolate」 就是 chrome V8 runtime 的一個單獨實例。這使得 worker 線程的執行上下文隔離於應用代碼中的其它部分。
  2. libuv 被初始化。這確保了 worker 線程保有其本身獨立於應用中的其它部分事件循環。
  3. worker 執行腳本被執行,而且 worker 的事件循環被啓動。
  4. worker 執行腳本調用 C++ 並從 IMC 中讀取初始化元數據。
  5. worker 執行腳本執行對應文件或代碼(在咱們的例子中就是 worker-simple.js),以做爲一個 worker 開始運行。

看看下面的代碼片斷,worker 執行腳本是如何從 IMC 讀取數據的:

const publicWorker = require('worker_threads');

// ...

port.on('message', (message) => {
  if (message.type === 'loadScript') {
    const {
      cwdCounter,
      filename,
      doEval,
      workerData,
      publicPort,
      manifestSrc,
      manifestURL,
      hasStdin
    } = message;

    // ...
    initializeCJSLoader();
    initializeESMLoader();
    
    publicWorker.parentPort = publicPort;
    publicWorker.workerData = workerData;

    // ...
    
    port.unref();
    port.postMessage({ type: UP_AND_RUNNING });
    if (doEval) {
      const { evalScript } = require('internal/process/execution');
      evalScript('[worker eval]', filename);
    } else {
      process.argv[1] = filename; // script filename
      require('module').runMain();
    }
  }
  // ...
複製代碼

是否注意到以上片斷中的 workerDataparentPort 屬性被指定給了 publicWorker 對象呢?後者是在 worker 執行腳本中由 require('worker_threads') 引入的。

這就是爲什麼 workerDataparentPort 屬性只在子 worker 線程內部可用,而在父 worker 的代碼中不可用了。

若是嘗試在父 worker 代碼中訪問這兩個屬性,都會返回 null

充分利用 worker 線程

如今咱們理解 Node.js 的 worker 線程是如何工做的了,這的確能幫助咱們在使用 Worker 線程時得到最佳性能。當編寫比 worker-simple.js 更復雜的應用時,須要記住如下兩個主要的關注點:

  1. 儘管 worker 線程比真正的進程更輕量,但若是頻繁讓 workers 陷入某些繁重的工做仍會開銷巨大。
  2. 使用 worker 線程承擔並行 I/O 操做還是不划算的,由於 Node.js 原生的 I/O 機制是比從頭啓動一個 worker 線程去作一樣的事更快的方式。

爲了克服第 1 點的問題,咱們須要實現「worker 線程池」。

worker 線程池

Node.js 的 worker 線程池是一組正在運行且可以被後續任務利用的 worker 線程。當一個新任務到來時,它能夠經過父子消息通道被傳遞給一個可用的 worker。一旦完成了這個任務,子 worker 能將結果經過一樣的消息通道回傳給父 worker。

一旦實現得當,因爲減小了建立新線程帶來的額外開銷,線程池能夠顯著改善性能。一樣值得一提的是,由於可被有效運行的並行線程數老是受限於硬件,建立一堆數目巨大的線程一樣難以奏效。

下圖是對三臺 Node.js 服務器的一個性能比較,它們都接收一個字符串並返回作了 12 輪加鹽處理的一個 Bcrypt 哈希值。三臺服務器分別是:

  • 不用多線程
  • 多線程,沒有線程池
  • 有 4 個線程的線程池

一眼就能看出,隨着負載增加,使用一個線程池擁有顯著小的開銷。

使用 worker_threads 的效率如何?

可是,截止成文之時,線程池仍不是 Node.js 開箱即用的原生功能。所以,你還得依賴第三方實現或編寫本身的 worker 池。

但願你如今能深刻理解了 worker 線程如何工做,並能開始體驗並利用 worker 線程編寫你的 CPU 密集型應用。



--End--

查看更多前端好文
請搜索 fewelife 關注公衆號

轉載請註明出處

相關文章
相關標籤/搜索