淺析Node處理CPU密集型任務的方法

2022-09-13 22:00:30
Node處理CPU密集型任務的方法有哪些?下面本篇文章就來帶大家瞭解一下處理CPU密集型任務的方法,希望對大家有所幫助!

node.js極速入門課程:進入學習

我們日常工作中或多或少聽說過以下的話:

Node是一個非阻塞I/O(non-blocking I/O)和事件驅動(event-driven)的JavaScript執行環境(runtime),所以它非常適合用來構建I/O密集型應用,例如Web服務等。

不知道當你聽到類似的話時會不會有和我一樣的疑惑:單執行緒的Node為什麼適合用來開發I/O密集型應用?按道理來說不是那些支援多執行緒的語言(例如Java和Golang)做這些工作更加有優勢嗎?

要搞明白上面的問題,我們需要知道Node的單執行緒指的是什麼。【相關教學推薦:】

Node不是單執行緒的

其實我們說Node是單執行緒的,說的只是我們的JavaScript程式碼是在同一個執行緒(我們可以叫它主執行緒)裡面執行的,而不是說Node只有一個執行緒在工作。實際上Node底層會使用libuv的多執行緒能力將一部分工作(基本都是I/O相關操作)放在一些主執行緒之外的執行緒裡面執行,當這些任務完成後再以回撥函數的方式將結果返回到主執行緒的JavaScript執行環境。可以看看示意圖:

1.png

注: 上圖是Node事件迴圈(Event Loop)的簡化版,實際上完整的事件迴圈會有更多的階段例如timers等。

Node適合做I/O密集型應用

從上面的分析中我們知道Node會將所有的I/O操作通過libuv的多執行緒能力分散到不同的執行緒裡面執行,其餘的操作都放在主執行緒裡面執行。那麼為什麼這種做法就比Java或者Golang等其它語言更適合做I/O密集型應用呢?我們以開發Web服務為例,Java和Golang等主流後端程式語言的並行模型是基於執行緒(Thread-Based)的,這也就意味他們對於每一個網路請求都會建立一個單獨的執行緒來處理。可是對於Web應用來說,主要還是對資料庫的增刪改查,或者請求其它外部服務等網路I/O操作,而這些操作最後都是交給作業系統的系統呼叫來處理的(無需應用執行緒參與),並且十分緩慢(相對於CPU時鐘週期來說),因此被建立出來的執行緒大多數時間是無事可做的而且我們的服務還要承擔額外的執行緒切換開銷。和這些語言不一樣的是Node沒有為每個請求都建立一個執行緒,所有請求的處理都發生在主執行緒中,因此沒有了執行緒切換的開銷,並且它還會通過執行緒池的形式非同步處理這些I/O操作,然後通過事件的形式告訴主執行緒結果從而避免阻塞主執行緒的執行,因此它理論上是更高效的。這裡值得注意的是我只是說Node理論上是更快的,實際上真不一定。這是因為現實中一個服務的效能會受到很多方面的影響,我們這裡只是考慮了並行模型這一個因素,而其它因素例如執行時消耗也會影響到服務的效能,舉個例子,JavaScript是動態語言,資料的型別需要在執行時進行推斷,而GolangJava都是靜態語言它們的資料型別在編譯時就可以確定,所以它們實際執行起來可能會更快,佔用記憶體也會更少。

Node不適合做CPU密集型任務

上面我們提到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介面:

2.png

我們可以看到/hard_work介面是會卡住的,這是因為它需要進行大量的CPU計算,所以需要比較久的時間才會執行完。而這個時候我們再看一下/easy_work這個介面有沒有影響:

3.png

我們發現在/hard_work佔用了CPU資源之後,無辜的/easy_work介面也被卡死了。原因就是hardWork函數阻塞了Node的主執行緒導致/easy_work的邏輯不會被執行。這裡值得一提的是,只有Node這種基於事件迴圈的單執行緒執行環境才會有這種問題,Java和Golang等Thread-Based語言是不會存在這種問題的。那如果我們的服務真的需要執行CPU密集型任務怎麼辦?總不能換門語言吧?說好的All in JavaScript呢?彆著急,對於處理CPU密集型任務,Node已經為我們準備好很多方案了,接下來就讓我為大家介紹三種常用的方案,它們分別是: Cluster ModuleChild ProcessWorker Thread

Cluster Module

概念介紹

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服務:

4.png

從上面的輸出結果來看,cluster啟動了10個worker(我的電腦是10核的)來處理web請求,這個時候我們再來請求一下/hard_work這個介面:

5.png

我們發現這個請求還是卡死的,接著我們再來看看Cluster模組有沒有解決其它請求也被阻塞的問題:

6.png

我們可以看到前面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的優缺點

最後我們總結一下Cluster Module有什麼優點:

  • 資源利用率高:可以充分利用CPU的多核能力來提升請求處理效率。
  • API設計簡單:可以讓你實現簡單的負載均衡一定程度的高可用。這裡值得注意的是我說的是一定程度的高可用,這是因為Cluster Module的高可用是單機版的,也就是當宿主機器掛了,你的服務也就掛了,因此更高的高可用肯定是使用分散式叢集做的。
  • 程序之間高度獨立,避免某個程序發生系統錯誤導致整個服務不可用。

優點說完了,我們再來說一下Cluster Module不好的地方:

  • 資源消耗大:每一個子程序都是獨立的Node執行環境,也可以理解為一個獨立的Node程式,因此佔用的資源也是巨大的
  • 程序通訊開銷大:子程序之間的通訊通過跨程序通訊(IPC)來進行,如果資料共用頻繁是一筆比較大的開銷。
  • 沒能完全解決CPU密集任務:處理CPU密集型任務時還是有點抓緊見肘

Child Process

在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還提供了諸如execspawn等函數來啟動子程序,並且這些程序可以執行任何的shell命令而不只是侷限於Node指令碼,有興趣的讀者後面可以通過官方檔案瞭解一下,這裡就不過多介紹了。

Child Process的優缺點

最後讓我們來總結一下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應用的話,我們只能通過標準輸入輸出或者其它方式來進行程序間通訊,這是一件很麻煩的事。

Worker Thread

無論是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的優缺點

最後我們還是總結一下Worker Thread的優缺點。首先我覺得它的優點是:

  • 資源消耗小:不同於Cluster Module和Child Process基於程序的方式,Worker Thread是基於更加輕量級的執行緒的,所以它的資源開銷是相對較小的。不過麻雀雖小五臟俱全,每個Worker Thread都是有自己獨立的v8引擎範例事件迴圈系統的。這也就是說即使主執行緒卡死我們的Worker Thread也是可以繼續工作的,基於這個其實我們可以做很多有趣的事情。
  • 父子執行緒通訊方便高效:和前面兩種方式不一樣,Worker Thread不需要通過IPC通訊,所有資料都是在程序內部實現共用和傳遞的。

不過Worker Thread也不是完美的:

  • 執行緒隔離性低:由於子執行緒不是在一個獨立的環境執行的,所以某個子執行緒掛了還是會影響到其它執行緒,在這種情況下,你需要做一些額外的措施來保護其餘執行緒不受影響。
  • 執行緒資料共用實現麻煩:和其它後端語言比起來,Node的資料共用還是比較麻煩的,不過這其實也避免了它需要考慮很多多執行緒下資料安全的問題。

總結

在本篇文章中我為大家介紹了Node為什麼適合做I/O密集型應用而很難處理CPU密集型任務的原因,並且為大家提供了三個可選方案來在實際開發中處理CPU密集型任務。每個方案其實都有利有弊,我們一定要根據實際情況進行選擇,永遠不要為了要用某個技術而一定要採取某個方案

更多node相關知識,請存取:!

以上就是淺析Node處理CPU密集型任務的方法的詳細內容,更多請關注TW511.COM其它相關文章!