深入理解Node.js中的Worker線程
概述
多年以來,Node.js都不是實(shí)現(xiàn)高 CPU 密集型應(yīng)用的最佳選擇,這主要就是因?yàn)镴avaScript的單線程。作為對(duì)此問題的解決方案,Node.jsv10.5.0 通過worker_threads模塊引入了實(shí)驗(yàn)性的 “worker 線程” 概念,并從 Node.js v12 LTS 起成為一個(gè)穩(wěn)定功能。本文將解釋其如何工作,以及如何使用 Worker 線程獲得最佳性能。
Node.js 中 CPU 密集型應(yīng)用的歷史
在 worker 線程之前,Node.js 中有多種方式執(zhí)行 CPU 密集型應(yīng)用。其中的一些為:
- 使用child_process模塊并在一個(gè)子進(jìn)程中運(yùn)行 CPU 密集型代碼
- 使用cluster模塊,在多個(gè)進(jìn)程中運(yùn)行多個(gè) CPU 密集型操作
- 使用諸如 Microsoft 的Napa.js這樣的第三方模塊
但是受限于性能、額外引入的復(fù)雜性、占有率低、薄弱的文檔化等,這些解決方案無一被廣泛采用。
為 CPU 密集型操作使用 worker 線程
盡管對(duì)于JavaScript的并發(fā)性問題來說,worker_threads是一個(gè)優(yōu)雅的解決方案,但其并未給 JavaScript 本身帶來多線程特性。相反,worker_threads通過運(yùn)行應(yīng)用使用多個(gè)相互隔離的 JavaScript workers 來實(shí)現(xiàn)并發(fā),而 workers 和父 worker 之間的通信由 Node 提供。聽懵了嗎? ♂️
在 Node.js 中,每一個(gè) worker 將擁有其自己的 V8 實(shí)例及事件循環(huán)(Event Loop)。但和child_process不同的是,workers 不共享內(nèi)存。
以上概念會(huì)在后面解釋。我們首先來大致看一眼如何使用 Worker 線程。一個(gè)原生的用例看起來是這樣的:
// 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) }
在上例中,我們向每個(gè)單獨(dú)的 workder 中傳入了一個(gè)數(shù)字以計(jì)算其平方值。在計(jì)算之后,子 worker 將結(jié)果發(fā)送回主 worker 線程。盡管看上去簡(jiǎn)單,但 Node.js 新手可能還是會(huì)有點(diǎn)困惑。
Worker 線程是如何工作的?
JavaScript 語言沒有多線程特性。因此,Node.js 的 Worker 線程以一種異于許多其它高級(jí)語言傳統(tǒng)多線程的方式行事。
在 Node.js 中,一個(gè) worker 的職責(zé)就是去執(zhí)行一段父 worker 提供的代碼(worker 腳本)。這段 worker 腳本將會(huì)在隔絕于其它 workers 的環(huán)境中運(yùn)行,并能夠在其自身和父 worker 間傳遞消息。worker 腳本既可以是一個(gè)獨(dú)立的文件,也可以是一段可被eval解析的文本格式的腳本。在我們的例子中,我們將__filename作為 worker 腳本,因?yàn)楦?worker 和子 worker 代碼都在同一個(gè)腳本文件中,由isMainThread屬性決定其角色。
每個(gè) worker 通過message channel連接到其父 worker。子 worker 可以使用parentPort.postMessage()函數(shù)向消息通道中寫入信息,父 worker 則通過調(diào)用 worker 實(shí)例上的worker.postMessage()函數(shù)向消息通道中寫入信息??匆幌聢D 1:
一個(gè) Message Channel 就是一個(gè)簡(jiǎn)單的通信渠道,其兩端被稱作 ‘ports'。在 JavaScript/NodeJS 術(shù)語中,一個(gè) Message Channel 的兩端就被叫做port1和port2
Node.js 的 workers 是如何并行的?
現(xiàn)在關(guān)鍵的問題來了,JavaScript 并不直接提供并發(fā),那么兩個(gè) Node.js workers 要如何并行呢?答案就是V8 isolate。
一個(gè)V8 isolate就是 chrome V8 runtime 的一個(gè)單獨(dú)實(shí)例,包含自有的 JS 堆和一個(gè)微任務(wù)隊(duì)列。這允許了每個(gè) Node.js worker 完全隔離于其它 workers 地運(yùn)行其 JavaScript 代碼。其缺點(diǎn)在于 worker 無法直接訪問其它 workers 的堆數(shù)據(jù)了。
擴(kuò)展閱讀:JS在瀏覽器和Node下是如何工作的?
由此,每個(gè) worker 將擁有其自己的一份獨(dú)立于父 worker 和其它 workers 的 libuv 事件循環(huán)的拷貝。
跨越 JS/C++ 的邊界
實(shí)例化一個(gè)新 worker、提供和父級(jí)/同級(jí) JS 腳本的通信,都是由 C++ 實(shí)現(xiàn)版本的 worker 完成的。在成文時(shí),該實(shí)現(xiàn)為worker.cc(https://github.com/nodejs/node/blob/921493e228/src/node_worker.cc)。
Worker 的實(shí)現(xiàn)通過worker_threads模塊被暴露為用戶級(jí)的 JavaScript 腳本。該 JS 實(shí)現(xiàn)被分割為兩個(gè)腳本,我將之稱為:
- 初始化腳本 worker.js— 負(fù)責(zé)初始化 worker 實(shí)例,并建立初次父子 worker 通信,以確保從父 worker 傳遞 worker 元數(shù)據(jù)至子 worker。(https://github.com/nodejs/node/blob/921493e228/lib/internal/worker.js)
- 執(zhí)行腳本 worker_thread.js— 根據(jù)用戶提供的workerData數(shù)據(jù)和其它父 worker 提供的元數(shù)據(jù)執(zhí)行用戶的 worker JS 腳本。(https://github.com/nodejs/node/blob/921493e228/lib/internal/main/worker_thread.js)
圖 2 以更清晰的方式解釋了這個(gè)過程:
基于上述,我們可以將 worker 設(shè)置過程劃分為兩個(gè)階段:
- worker 初始化
- 運(yùn)行 worker
來看看每個(gè)階段都發(fā)生了什么吧:
初始化步驟
1.用戶級(jí)腳本通過使用worker_threads創(chuàng)建一個(gè) worker 實(shí)例
2.Node 的父 worker 初始化腳本調(diào)用 C++ 并創(chuàng)建一個(gè)空的 worker 對(duì)象。此時(shí),被創(chuàng)建的 worker 還只是個(gè)未被啟動(dòng)的簡(jiǎn)單的 C++ 對(duì)象
3.當(dāng) C++ worker 對(duì)象被創(chuàng)建后,其生成一個(gè)線程 ID 并賦值給自身
4.同時(shí),一個(gè)空的初始化消息通道(讓我們稱之為IMC)被父 worker 創(chuàng)建。圖 2 中灰色的 “Initialisation Message Channel” 部分展示了這點(diǎn)
5.一個(gè)公開的 JS 消息通道(稱其為PMC)被 worker 初始化腳本創(chuàng)建。該通道被用戶級(jí) JS 使用以在父子 worker 之間傳遞消息。圖 1 中主要描述了這部分,也在圖 2 中被標(biāo)為了紅色。
6.Node 父 worker 初始化腳本調(diào)用 C++ 并將需要被發(fā)送到 worker 執(zhí)行腳本中的初始元數(shù)據(jù)寫入IMC。
什么是初始元數(shù)據(jù)?即執(zhí)行腳本需要了解以啟動(dòng) worker 的數(shù)據(jù),包括腳本名稱、worker 數(shù)據(jù)、PMC 的port2,以及其它一些信息。
按我們的例子來說,初始化元數(shù)據(jù)如:
:phone: 嘿!worker 執(zhí)行腳本,請(qǐng)你用{num: 5}這樣的 worker 數(shù)據(jù)運(yùn)行一下worker-simple.js好嗎?也請(qǐng)你把 PMC 的port2傳遞給它,這樣 worker 就能從 PMC 讀取數(shù)據(jù)啦。
下面的小片段展示了初始化數(shù)據(jù)如何被寫入 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 的端點(diǎn)。盡管 worker 初始化腳本向 IMC 寫入了數(shù)據(jù),但 worker 執(zhí)行腳本仍無法訪問該數(shù)據(jù)。
運(yùn)行步驟
此時(shí),初始化已告一段落;接下來 worker 初始化腳本調(diào)用 C++ 并啟動(dòng) worker 線程。
1.一個(gè)新的V8 isolate被創(chuàng)建并被分配給 worker。前面講過,一個(gè) “v8 isolate” 就是 chrome V8 runtime 的一個(gè)單獨(dú)實(shí)例。這使得 worker 線程的執(zhí)行上下文隔離于應(yīng)用代碼中的其它部分。
2.libuv被初始化。這確保了 worker 線程保有其自己獨(dú)立于應(yīng)用中的其它部分事件循環(huán)。
3.worker 執(zhí)行腳本被執(zhí)行,并且 worker 的事件循環(huán)被啟動(dòng)。
4.worker 執(zhí)行腳本調(diào)用 C++ 并從 IMC 中讀取初始化元數(shù)據(jù)。
5.worker 執(zhí)行腳本執(zhí)行對(duì)應(yīng)文件或代碼(在我們的例子中就是worker-simple.js),以作為一個(gè) worker 開始運(yùn)行。
看看下面的代碼片段,worker 執(zhí)行腳本是如何從 IMC 讀取數(shù)據(jù)的:
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對(duì)象呢?后者是在 worker 執(zhí)行腳本中由require('worker_threads')引入的。
這就是為何workerData和parentPort屬性只在子 worker 線程內(nèi)部可用,而在父 worker 的代碼中不可用了。
如果嘗試在父 worker 代碼中訪問這兩個(gè)屬性,都會(huì)返回null。
充分利用 worker 線程
現(xiàn)在我們理解 Node.js 的 worker 線程是如何工作的了,這的確能幫助我們?cè)谑褂?Worker 線程時(shí)獲得最佳性能。當(dāng)編寫比worker-simple.js更復(fù)雜的應(yīng)用時(shí),需要記住以下兩個(gè)主要的關(guān)注點(diǎn):
盡管 worker 線程比真正的進(jìn)程更輕量,但如果頻繁讓 workers 陷入某些繁重的工作仍會(huì)開銷巨大。
使用 worker 線程承擔(dān)并行 I/O 操作仍是不劃算的,因?yàn)?Node.js 原生的 I/O 機(jī)制是比從頭啟動(dòng)一個(gè) worker 線程去做同樣的事更快的方式。
為了克服第 1 點(diǎn)的問題,我們需要實(shí)現(xiàn)“worker 線程池”。
worker 線程池
Node.js 的 worker 線程池是一組正在運(yùn)行且能夠被后續(xù)任務(wù)利用的 worker 線程。當(dāng)一個(gè)新任務(wù)到來時(shí),它可以通過父子消息通道被傳遞給一個(gè)可用的 worker。一旦完成了這個(gè)任務(wù),子 worker 能將結(jié)果通過同樣的消息通道回傳給父 worker。
一旦實(shí)現(xiàn)得當(dāng),由于減少了創(chuàng)建新線程帶來的額外開銷,線程池可以顯著改善性能。同樣值得一提的是,因?yàn)榭杀挥行н\(yùn)行的并行線程數(shù)總是受限于硬件,創(chuàng)建一堆數(shù)目巨大的線程同樣難以奏效。
下圖是對(duì)三臺(tái) Node.js 服務(wù)器的一個(gè)性能比較,它們都接收一個(gè)字符串并返回做了 12 輪加鹽處理的一個(gè) Bcrypt 哈希值。三臺(tái)服務(wù)器分別是:
- 不用多線程
- 多線程,沒有線程池
- 有 4 個(gè)線程的線程池
一眼就能看出,隨著負(fù)載增長,使用一個(gè)線程池?fù)碛酗@著小的開銷。
但是,截止成文之時(shí),線程池仍不是 Node.js 開箱即用的原生功能。因此,你還得依賴第三方實(shí)現(xiàn)或編寫自己的 worker 池。
希望你現(xiàn)在能深入理解了 worker 線程如何工作,并能開始體驗(yàn)并利用 worker 線程編寫你的 CPU 密集型應(yīng)用。
以上就是深入理解Node.js中的Worker線程的詳細(xì)內(nèi)容,更多關(guān)于Node.js的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
node.js中fs文件系統(tǒng)目錄操作與文件信息操作
本篇文章給大家詳細(xì)分析了node.js中fs文件系統(tǒng)目錄操作與文件信息操作的方法以及代碼詳解,需要的讀者可以參考下。2018-02-02Node.js 實(shí)現(xiàn)搶票小工具 & 短信通知提醒功能
這篇文章主要介紹了Node.js 實(shí)現(xiàn)搶票小工具 & 短信通知提醒功能,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2019-10-10node.js中的fs.appendFileSync方法使用說明
這篇文章主要介紹了node.js中的fs.appendFileSync方法使用說明,本文介紹了fs.appendFileSync方法說明、語法、接收參數(shù)、使用實(shí)例和實(shí)現(xiàn)源碼,需要的朋友可以參考下2014-12-12node.js通過axios實(shí)現(xiàn)網(wǎng)絡(luò)請(qǐng)求的方法
下面小編就為大家分享一篇node.js通過axios實(shí)現(xiàn)網(wǎng)絡(luò)請(qǐng)求的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-03-03淺談如何通過node.js對(duì)數(shù)據(jù)進(jìn)行MD5加密
本篇文章將主要針對(duì)于在NODE.JS中如何對(duì)數(shù)據(jù)進(jìn)行MD5加密,MD5是一種常用的哈希算法,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-05-05Node.js API詳解之 module模塊用法實(shí)例分析
這篇文章主要介紹了Node.js API詳解之 module模塊用法,結(jié)合實(shí)例形式分析了Node.js API中module模塊基本功能、原理、用法及操作注意事項(xiàng),需要的朋友可以參考下2020-05-05Node.js實(shí)現(xiàn)在目錄中查找某個(gè)字符串及所在文件
這篇文章主要介紹了Node.js實(shí)現(xiàn)在目錄中查找某個(gè)字符串及所在文件,文中代碼簡(jiǎn)潔,而且速度相當(dāng)?shù)目?需要的朋友可以參考下2014-09-09