首頁  >  文章  >  web前端  >  淺析Node處理CPU密集型任務的方法

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

青灯夜游
青灯夜游轉載
2022-09-13 19:25:522028瀏覽

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的單執行緒指的是什麼。 【相關教學推薦:nodejs影片教學

Node不是單執行緒的

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

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

註: 上圖是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  {
  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介面:

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

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

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

我們發現在/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  {
    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服務:

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

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

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

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

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

#我們可以看到前面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  {
  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  {
  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相關知識,請造訪:nodejs 教學

以上是淺析Node處理CPU密集型任務的方法的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.cn。如有侵權,請聯絡admin@php.cn刪除