深入理解 Node.js 中的 Worker 線程


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

 

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

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

  • 使用 child_process 模塊並在一個子進程中運行 CPU 密集型代碼

  • 使用 cluster 模塊,在多個進程中運行多個 CPU 密集型操作

  • 使用諸如 Microsoft 的 Napa.js 這樣的第三方模塊

但是受限於性能、額外引入的復雜性、占有率低、薄弱的文檔化等,這些解決方案無一被廣泛采用。

 

為 CPU 密集型操作使用 worker 線程

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

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

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

// 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:

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

 

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 (https://github.com/nodejs/node/blob/921493e228/src/node_worker.cc)。

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

  • 初始化腳本 worker.js— 負責初始化 worker 實例,並建立初次父子 worker 通信,以確保從父 worker 傳遞 worker 元數據至子 worker。(https://github.com/nodejs/node/blob/921493e228/lib/internal/worker.js)

  • 執行腳本 worker_thread.js— 根據用戶提供的  workerData 數據和其它父 worker 提供的元數據執行用戶的 worker JS 腳本。(https://github.com/nodejs/node/blob/921493e228/lib/internal/main/worker_thread.js)

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

基於上述,我們可以將 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 ,以及其它一些信息。

按我們的例子來說,初始化元數據如:

:phone: 嘿!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(); } } // ...

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

這就是為何 workerData 和  parentPort 屬性只在子 worker 線程內部可用,而在父 worker 的代碼中不可用了。

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

電腦刺綉綉花廠 http://www.szhdn.com 廣州品牌設計公司https://www.houdianzi.com

充分利用 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 個線程的線程池

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

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

希望你現在能深入理解了 worker 線程如何工作,並能開始體驗並利用 worker 線程編寫你的 CPU 密集型應用。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM