原文:blog.insiderattack.net/deep-dive-i…javascript
多年以來,Node.js 都不是實現高 CPU 密集型應用的最佳選擇,這主要就是由於 JavaScript 的單線程。做爲對此問題的解決方案,Node.js v10.5.0 經過 worker_threads
模塊引入了實驗性的 「worker 線程」 概念,並從 Node.js v12 LTS 起成爲一個穩定功能。本文將解釋其如何工做,以及如何使用 Worker 線程得到最佳性能。前端
在 worker 線程以前,Node.js 中有多種方式執行 CPU 密集型應用。其中的一些爲:java
child_process
模塊並在一個子進程中運行 CPU 密集型代碼cluster
模塊,在多個進程中運行多個 CPU 密集型操做Napa.js
這樣的第三方模塊可是受限於性能、額外引入的複雜性、佔有率低、薄弱的文檔化等,這些解決方案無一被普遍採用。node
儘管對於 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 新手可能仍是會有點困惑。瀏覽器
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
如今關鍵的問題來了,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 事件循環的拷貝。
實例化一個新 worker、提供和父級/同級 JS 腳本的通訊,都是由 C++ 實現版本的 worker 完成的。在成文時,該實現爲worker.cc
(github.com/nodejs/node…)。
Worker 的實現經過 worker_threads
模塊被暴露爲用戶級的 JavaScript 腳本。該 JS 實現被分割爲兩個腳本,我將之稱爲:
workerData
數據和其它父 worker 提供的元數據執行用戶的 worker JS 腳本。(github.com/nodejs/node…)圖 2 以更清晰的方式解釋了這個過程:
基於上述,咱們能夠將 worker 設置過程劃分爲兩個階段:
來看看每一個階段都發生了什麼吧:
worker_threads
建立一個 worker 實例IMC
)被父 worker 建立。圖 2 中灰色的 「Initialisation Message Channel」 部分展現了這點PMC
)被 worker 初始化腳本建立。 該通道被用戶級 JS 使用以在父子 worker 之間傳遞消息。圖 1 中主要描述了這部分,也在圖 2 中被標爲了紅色。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 線程。
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
。
如今咱們理解 Node.js 的 worker 線程是如何工做的了,這的確能幫助咱們在使用 Worker 線程時得到最佳性能。當編寫比 worker-simple.js
更復雜的應用時,須要記住如下兩個主要的關注點:
爲了克服第 1 點的問題,咱們須要實現「worker 線程池」。
Node.js 的 worker 線程池是一組正在運行且可以被後續任務利用的 worker 線程。當一個新任務到來時,它能夠經過父子消息通道被傳遞給一個可用的 worker。一旦完成了這個任務,子 worker 能將結果經過一樣的消息通道回傳給父 worker。
一旦實現得當,因爲減小了建立新線程帶來的額外開銷,線程池能夠顯著改善性能。一樣值得一提的是,由於可被有效運行的並行線程數老是受限於硬件,建立一堆數目巨大的線程一樣難以奏效。
下圖是對三臺 Node.js 服務器的一個性能比較,它們都接收一個字符串並返回作了 12 輪加鹽處理的一個 Bcrypt 哈希值。三臺服務器分別是:
一眼就能看出,隨着負載增加,使用一個線程池擁有顯著小的開銷。
可是,截止成文之時,線程池仍不是 Node.js 開箱即用的原生功能。所以,你還得依賴第三方實現或編寫本身的 worker 池。
但願你如今能深刻理解了 worker 線程如何工做,並能開始體驗並利用 worker 線程編寫你的 CPU 密集型應用。
查看更多前端好文
請搜索 fewelife 關注公衆號
轉載請註明出處