您好,登錄后才能下訂單哦!
這篇文章主要介紹“Node處理CPU密集型任務的方法是什么”,在日常操作中,相信很多人在Node處理CPU密集型任務的方法是什么問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”Node處理CPU密集型任務的方法是什么”的疑惑有所幫助!接下來,請跟著小編一起來學習吧!
我們日常工作中或多或少聽說過以下的話:
Node是一個
非阻塞I/O
(non-blocking I/O)和事件驅動
(event-driven)的JavaScript運行環境
(runtime),所以它非常適合用來構建I/O密集型應用,例如Web服務等。
不知道當你聽到類似的話時會不會有和我一樣的疑惑:單線程的Node為什么適合用來開發I/O密集型應用?
按道理來說不是那些支持多線程的語言(例如Java和Golang)做這些工作更加有優勢嗎?
要搞明白上面的問題,我們需要知道Node的單線程指的是什么。
其實我們說Node是單線程的,說的只是我們的JavaScript代碼
是在同一個線程(我們可以叫它主線程
)里面運行的,而不是說Node只有一個線程在工作
。實際上Node底層會使用libuv的多線程能力
將一部分工作(基本都是I/O相關操作)放在一些主線程之外
的線程里面執行,當這些任務完成后再以回調函數
的方式將結果返回到主線程的JavaScript執行環境。可以看看示意圖:
注: 上圖是Node事件循環
(Event Loop)的簡化版,實際上完整的事件循環會有更多的階段例如timers等。
從上面的分析中我們知道Node會將所有的I/O操作通過libuv的多線程能力分散到不同的線程里面執行,其余的操作都放在主線程里面執行。那么為什么這種做法就比Java或者Golang等其它語言更適合做I/O密集型應用呢?我們以開發Web服務為例,Java和Golang等主流后端編程語言的并發模型是基于線程
(Thread-Based)的,這也就意味他們對于每一個網絡請求都會創建一個單獨的線程
來處理。可是對于Web應用來說,主要還是對數據庫的增刪改查,或者請求其它外部服務等網絡I/O操作
,而這些操作最后都是交給操作系統的系統調用來處理的(無需應用線程參與),并且十分緩慢(相對于CPU時鐘周期來說)
,因此被創建出來的線程大多數時間是無事可做
的而且我們的服務還要承擔額外的線程切換
開銷。和這些語言不一樣的是Node沒有為每個請求都創建一個線程,所有請求的處理
都發生在主線程中,因此沒有了線程切換
的開銷,并且它還會通過線程池
的形式異步處理這些I/O
操作,然后通過事件的形式告訴主線程結果從而避免阻塞主線程的執行,因此它理論上
是更高效的。這里值得注意的是我只是說Node理論上
是更快的,實際上真不一定。這是因為現實中一個服務的性能會受到很多方面的影響,我們這里只是考慮了并發模型
這一個因素,而其它因素例如運行時消耗也會影響到服務的性能,舉個例子,JavaScript
是動態語言,數據的類型需要在運行時進行推斷,而Golang
和Java
都是靜態語言它們的數據類型在編譯時就可以確定,所以它們實際執行起來可能會更快,占用內存也會更少。
上面我們提到Node除了I/O相關的操作其余操作都會在主線程里面執行,所以當Node要處理一些CPU密集型
的任務時,主線程會被阻塞住。我們來看一個CPU密集型任務的例子:
// node/cpu_intensive.js const http = require('http') const url = require('url') const hardWork = () => { // 100億次毫無意義的計算 for (let i = 0; i < 10000000000; i++) {} } const server = http.createServer((req, resp) => { const urlParsed = url.parse(req.url, true) if (urlParsed.pathname === '/hard_work') { hardWork() resp.write('hard work') resp.end() } else if (urlParsed.pathname === '/easy_work') { resp.write('easy work') resp.end() } else { resp.end() } }) server.listen(8080, () => { console.log('server is up...') })
在上面的代碼中我們實現了擁有兩個接口的HTTP服務:/hard_work
接口是一個CPU密集型接口
,因為它調用了hardWork
這個CPU密集型
函數,而/easy_work
這個接口則很簡單,直接返回一個字符串給客戶端就可以了。為什么說hardWork
函數是CPU密集型
的呢?這是因為它都是在CPU的運算器
里面對i
進行算術運算而沒有進行任何I/O操作。啟動完我們的Node服務后,我們試著調用一下/hard_word
接口:
我們可以看到/hard_work
接口是會卡住的,這是因為它需要進行大量的CPU
計算,所以需要比較久的時間才會執行完。而這個時候我們再看一下/easy_work
這個接口有沒有影響:
我們發現在/hard_work
占用了CPU資源之后,無辜的/easy_work
接口也被卡死了。原因就是hardWork
函數阻塞了Node的主線程導致/easy_work
的邏輯不會被執行。這里值得一提的是,只有Node這種基于事件循環的單線程執行環境才會有這種問題,Java和Golang等Thread-Based語言是不會存在這種問題的。那如果我們的服務真的需要運行CPU密集型
任務怎么辦?總不能換門語言吧?說好的All in JavaScript
呢?別著急,對于處理CPU密集型任務
,Node已經為我們準備好很多方案了,接下來就讓我為大家介紹三種常用的方案,它們分別是: Cluster Module
,Child Process
和Worker Thread
。
Node很早(v0.8版本)就推出了Cluster模塊。這個模塊的作用就是通過一個父進程啟動一群子進程來對網絡請求進行負載均衡
。因為文章的篇幅限制我們不會細聊Cluster模塊有哪些API,感興趣的讀者后面可以看看官方文檔,這里我們直接看一下如何使用Cluster模塊來優化上面CPU密集型的場景:
// node/cluster.js const cluster = require('cluster') const http = require('http') const url = require('url') // 獲取CPU核數 const numCPUs = require('os').cpus().length const hardWork = () => { // 100億次毫無意義的計算 for (let i = 0; i < 10000000000; i++) {} } // 判斷當前是否是主進程 if (cluster.isMaster) { // 根據當前機器的CPU核數創建同等數量的工作進程 for (var i = 0; i < numCPUs; i++) { cluster.fork() } cluster.on('online', (worker) => { console.log(`worker ${worker.process.pid} is online`) }) cluster.on('exit', (worker, code, signal) => { // 某個工作進程掛了之后,我們需要立馬啟動另外一個工作進程來替代 console.log(`worker ${worker.process.pid} exited with code ${code}, and signal ${signal}, start a new one...`) cluster.fork() }) } else { // 工作進程啟動一個HTTP服務器 const server = http.createServer((req, resp) => { const urlParsed = url.parse(req.url, true) if (urlParsed.pathname === '/hard_work') { hardWork() resp.write('hard work') resp.end() } else if (urlParsed.pathname === '/easy_work') { resp.write('easy work') resp.end() } else { resp.end() } }) // 所有的工作進程都監聽在同一個端口 server.listen(8080, () => { console.log(`worker ${process.pid} server is up...`) }) }
在上面的代碼中我們根據當前設備的CPU核數使用cluster.fork
函數創建了同等數量的工作進程
,而且這些工作進程都是監聽在8080
端口上面的。看到這里你或許會問所有的進程都監聽在同一個端口會不會出現問題,這里其實是不會的,因為Cluster
模塊底層會做一些工作讓最終監聽在8080
端口的是主進程
,而主進程是所有流量的入口
,它會接收HTTP連接并把它們打到不同的工作進程上面。話不多說,讓我們運行一下這個node服務:
從上面的輸出結果來看,cluster啟動了10個worker(我的電腦是10核的)來處理web請求,這個時候我們再來請求一下/hard_work
這個接口:
我們發現這個請求還是卡死的,接著我們再來看看Cluster模塊有沒有解決其它請求也被阻塞
的問題:
我們可以看到前面9個請求
都是很順利就返回結果的,可是到了第10個請求
我們的接口就卡住了,這是為什么呢?原因就是我們一共開了10個工作進程,主進程在將流量打到子進程的時候采用的默認負載均衡策略是round-robin
(輪流),因此第10個請求(其實是第11個,因為包括了第一個hard_work的請求)剛好回到第一個worker,而這個worker還沒處理完hard_work
的任務,因此這個easy_work
的任務也就卡住了。cluster的負載均衡算法可以通過cluster.schedulingPolicy
來修改,有興趣的讀者可以看一下官方文檔。
從上面的結果來看Cluster Module似乎解決了一部分
我們的問題,可是還是有一些請求受到了影響。那么Cluster Module在實際開發里面能不能被用來解決這個CPU密集型
任務的問題呢?我的意見是:看情況。如果你的CPU密集型接口調用不頻繁
而且運算時間不會太長
,你完全可以使用這種Cluster Module來優化。可是如果你的接口調用頻繁并且每個接口都很耗時間的話,可能你需要看一下采用Child Process
或者Worker Thread
的方案了。
最后我們總結一下Cluster Module有什么優點:
資源利用率高
:可以充分利用CPU的多核能力
來提升請求處理效率。
API設計簡單
:可以讓你實現簡單的負載均衡
和一定程度的高可用
。這里值得注意的是我說的是一定程度的高可用,這是因為Cluster Module的高可用是單機版的
,也就是當宿主機器掛了,你的服務也就掛了,因此更高的高可用肯定是使用分布式集群做的。
進程之間高度獨立
,避免某個進程發生系統錯誤導致整個服務不可用。
優點說完了,我們再來說一下Cluster Module不好的地方:
資源消耗大
:每一個子進程都是獨立的Node運行環境
,也可以理解為一個獨立的Node程序,因此占用的資源也是巨大的
。
進程通信開銷大
:子進程之間的通信通過跨進程通信(IPC)
來進行,如果數據共享頻繁是一筆比較大的開銷。
沒能完全解決CPU密集任務
:處理CPU密集型任務時還是有點抓緊見肘
。
在Cluster Module中我們可以通過啟動更多的子進程來將一些CPU密集型的任務負載均衡到不同的進程里面,從而避免其余接口卡死。可是你也看到了,這個辦法治標不治本
,如果用戶頻繁調用CPU密集型的接口,那么還是會有一大部分請求會被卡死的。優化這個場景的另外一個方法就是child_process
模塊。
Child Process
可以讓我們啟動子進程
來完成一些CPU密集型任務。我們先來看一下主進程master_process.js
的代碼:
// node/master_process.js const { fork } = require('child_process') const http = require('http') const url = require('url') const server = http.createServer((req, resp) => { const urlParsed = url.parse(req.url, true) if (urlParsed.pathname === '/hard_work') { // 對于hard_work請求我們啟動一個子進程來處理 const child = fork('./child_process') // 告訴子進程開始工作 child.send('START') // 接收子進程返回的數據,并且返回給客戶端 child.on('message', () => { resp.write('hard work') resp.end() }) } else if (urlParsed.pathname === '/easy_work') { // 簡單工作都在主進程進行 resp.write('easy work') resp.end() } else { resp.end() } }) server.listen(8080, () => { console.log('server is up...') })
在上面的代碼中對于/hard_work
接口的請求,我們會通過fork
函數開啟一個新的子進程
來處理,當子進程處理完畢我們拿到數據后就給客戶端返回結果。這里值得注意的是當子進程完成任務后我沒有釋放子進程的資源,在實際項目里面我們也不應該頻繁創建和銷毀子進程因為這個消耗也是很大的,更好的做法是使用進程池
。下面是子進程
(child_process.js)的實現邏輯:
// node/child_process.js const hardWork = () => { // 100億次毫無意義的計算 for (let i = 0; i < 10000000000; i++) {} } process.on('message', (message) => { if (message === 'START') { // 開始干活 hardWork() // 干完活就通知子進程 process.send(message) } })
子進程的代碼也很簡單,它在啟動后會通過process.on
的方式監聽來自父進程的消息,在接收到開始命令后進行CPU密集型
的計算,得出結果后返回給父進程。
運行上面master_process.js
的代碼,我們可以發現即使調用了/hard_work
接口,我們還是可以任意調用/easy_work
接口并且馬上得到響應的,此處沒有截圖,過程大家腦補一下就可以了。
除了fork
函數,child_process
還提供了諸如exec
和spawn
等函數來啟動子進程,并且這些進程可以執行任何的shell
命令而不只是局限于Node腳本,有興趣的讀者后面可以通過官方文檔了解一下,這里就不過多介紹了。
最后讓我們來總結一下Child Process
的優點有哪些:
靈活
:不只局限于Node進程,我們可以在子進程里面執行任何的shell
命令。這個其實是一個很大的優點,假如我們的CPU密集型操作是用其它語言實現
的(例如c語言處理圖像),而我們不想使用Node或者C++ Binding重新實現一遍的話我們就可以通過shell
命令調用其它語言的程序,并且通過標準輸入輸出
和它們進行通信從而得到結果。
細粒度的資源控制
:不像Cluster Module,Child Process方案可以按照實際對CPU密集型計算的需求大小動態調整子進程的個數,做到資源的細粒度控制,因此它理論上
是可以解決Cluster Module解決不了的CPU密集型接口調用頻繁
的問題。
不過Child Process的缺點也很明顯:
資源消耗巨大
:上面說它可以對資源進行細粒度控制
的優點時,也說了它只是理論上
可以解決CPU密集型接口頻繁調用的問題
,這是因為實際場景下我們的資源也是有限的
,而每一個Child Process都是一個獨立的操作系統進程,會消耗巨大的資源。因此對于頻繁調用的接口我們需要采取能耗更低的方案也就是下面我會說的Worker Thread
。
進程通信麻煩
:如果啟動的子進程也是Node應用的話還好辦點,因為有內置的API
來和父進程通信,如果子進程不是Node應用的話,我們只能通過標準輸入輸出
或者其它方式來進行進程間通信,這是一件很麻煩的事。
無論是Cluster Module還是Child Process其實都是基于子進程的,它們都有一個巨大的缺點就是資源消耗大
。為了解決這個問題Node從v10.5.0版本(v12.11.0 stable)開始就支持了worker_threads
模塊,worker_thread是Node對于CPU密集型操作
的輕量級的線程解決方案
。
Node的Worker Thread
和其它語言的thread是一樣的,那就是并發
地運行你的代碼。這里要注意是并發
而不是并行
。并行
只是意味著一段時間內多件事情同時發生
,而并發
是某個時間點多件事情同時發生
。一個典型的并行
例子就是React的Fiber架構
,因為它是通過時分復用
的方式來調度不同的任務來避免React渲染
阻塞瀏覽器的其它行為的,所以本質上它所有的操作還是在同一個操作系統線程
執行的。不過這里值得注意的是:雖然并發
強調多個任務同時執行,在單核CPU的情況下,并發會退化為并行
。這是因為CPU同一個時刻只能做一件事,當你有多個線程需要執行的話
就需要通過資源搶占
的方式來時分復用
執行某些任務。不過這都是操作系統需要關心的東西,和我們沒什么關系了。
上面說了Node的Worker Thead和其他語言線程的thread類似的地方,接著我們來看一下它們不一樣的地方。如果你使用過其它語言的多線程編程方式,你會發現Node的多線程和它們很不一樣,因為Node多線程數據共享起來
實在是太麻煩了
!Node是不允許你通過共享內存變量
的方式來共享數據的,你只能用ArrayBuffer
或者SharedArrayBuffer
的方式來進行數據的傳遞和共享。雖然說這很不方便,不過這也讓我們不需要過多考慮多線程環境下數據安全等一系列問題
,可以說有好處也有壞處吧。
接著我們來看一下如何使用Worker Thread
來處理上面的CPU密集型任務,先看一下主線程(master_thread.js)的代碼:
// node/master_thread.js const { Worker } = require('worker_threads') const http = require('http') const url = require('url') const server = http.createServer((req, resp) => { const urlParsed = url.parse(req.url, true) if (urlParsed.pathname === '/hard_work') { // 對于每一個hard_work接口,我們都啟動一個子線程來處理 const worker = new Worker('./child_process') // 告訴子線程開始任務 worker.postMessage('START') worker.on('message', () => { // 在收到子線程回復后返回結果給客戶端 resp.write('hard work') resp.end() }) } else if (urlParsed.pathname === '/easy_work') { // 其它簡單操作都在主線程執行 resp.write('easy work') resp.end() } else { resp.end() } }) server.listen(8080, () => { console.log('server is up...') })
在上面的代碼中,我們的服務器每次接收到/hard_work
請求都會通過new Worker
的方式啟動一個Worker
線程來處理,在worker處理完任務之后我們再將結果返回給客戶端,這個過程是異步的。接著再看一下子線程
(worker_thead.js)的代碼實現:
// node/worker_thread.js const { parentPort } = require('worker_threads') const hardWork = () => { // 100億次毫無意義的計算 for (let i = 0; i < 10000000000; i++) {} } parentPort.on('message', (message) => { if (message === 'START') { hardWork() parentPort.postMessage() } })
在上面的代碼中,worker thread在接收到主線程的命令后開始執行CPU密集型
操作,最后通過parentPort.postMessage
的方式告知父線程任務已經完成,從API上看父子線程通信還是挺方便的。
最后我們還是總結一下Worker Thread的優缺點。首先我覺得它的優點是:
資源消耗小
:不同于Cluster Module和Child Process基于進程的方式,Worker Thread是基于更加輕量級的線程的,所以它的資源開銷是相對較小的
。不過麻雀雖小五臟俱全
,每個Worker Thread
都是有自己獨立的v8引擎實例
和事件循環
系統的。這也就是說即使主線程卡死
我們的Worker Thread
也是可以繼續工作的,基于這個其實我們可以做很多有趣的事情。
父子線程通信方便高效
:和前面兩種方式不一樣,Worker Thread不需要通過IPC通信,所有數據都是在進程內部實現共享和傳遞的。
不過Worker Thread也不是完美的:
線程隔離性低
:由于子線程不是在一個獨立的環境
執行的,所以某個子線程掛了還是會影響到其它線程,在這種情況下,你需要做一些額外的措施來保護其余線程不受影響。
線程數據共享實現麻煩
:和其它后端語言比起來,Node的數據共享還是比較麻煩的,不過這其實也避免了它需要考慮很多多線程下數據安全的問題。
到此,關于“Node處理CPU密集型任務的方法是什么”的學習就結束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學習,快去試試吧!若想繼續學習更多相關知識,請繼續關注億速云網站,小編會繼續努力為大家帶來更多實用的文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。