>  기사  >  웹 프론트엔드  >  CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

青灯夜游
青灯夜游앞으로
2022-09-13 19:25:522076검색

Node가 CPU 집약적인 작업을 처리하는 방법은 무엇인가요? 다음 문서에서는 Node가 CPU 집약적인 작업을 처리하는 방법을 보여줍니다. 이것이 도움이 되기를 바랍니다.

CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

우리는 일상 업무에서 다음과 같은 말을 어느 정도 들어왔습니다.

노드는 비 차단 I/O(비 차단 I/O)이고 입니다. event 이벤트 중심의 <code>JavaScript 런타임 환경(런타임)이므로 웹 서비스와 같이 I/O 집약적인 애플리케이션을 구축하는 데 매우 적합합니다. 非阻塞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执行环境。可以看看示意图:

CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

注: 上图是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密集型

비슷한 단어를 들으면 나와 같은 의심을 갖게 될지 궁금합니다. 단일 스레드 노드가 I/O 집약적인 애플리케이션 개발에 왜 적합한가요? 논리적으로 말하면 멀티스레딩을 지원하는 언어(예: Java 및 Golang)가 이러한 작업을 수행하는 데 더 유리하지 않을까요? 🎜🎜위 문제를 이해하려면 Node의 단일 스레드가 무엇을 의미하는지 알아야 합니다. [관련 튜토리얼 권장사항: 🎜nodejs 동영상 튜토리얼🎜]🎜

노드는 단일 스레드가 아닙니다

🎜실제로 우리는 Node가 단일 스레드입니다. Node에 하나만 있다고 말하는 것이 아니라 JavaScript 코드가 동일한 스레드(메인 스레드라고 부를 수 있음)에서 실행되고 있다는 것입니다. 스레드가 작동 중입니다. 실제로 Node의 최하위 계층은 libuv의 멀티스레딩 기능을 사용하여 메인 스레드 외부 일부 스레드에서 작업의 일부(기본적으로 I/O 관련 작업)를 실행합니다. 이러한 작업이 완료되면 결과가 콜백 함수 형식으로 메인 스레드의 JavaScript 실행 환경에 반환됩니다. 회로도를 살펴보세요: 🎜🎜CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석🎜🎜참고: 위 그림은 노드 이벤트 루프(이벤트 루프)의 단순화된 버전입니다. 실제로 완전한 이벤트 루프에는 타이머 등과 같은 더 많은 단계가 있습니다. . 🎜

Node는 I/O 집약적인 애플리케이션에 적합합니다

🎜위 분석을 통해 Node가 모든 I/O 작업을 수행한다는 것을 알 수 있습니다. libuv의 멀티스레딩 기능을 통해 실행이 여러 스레드로 분산되고 나머지 작업은 메인 스레드에서 실행됩니다. 그렇다면 이 접근 방식이 Java나 Golang과 같은 다른 언어보다 I/O 집약적인 애플리케이션에 더 적합한 이유는 무엇일까요? 웹 서비스 개발을 예로 들어보겠습니다. Java, Golang과 같은 주류 백엔드 프로그래밍 언어의 동시성 모델은 스레드 기반(스레드 기반)입니다. 처리할 모든 네트워크 요청에 대해 별도의 스레드를 만듭니다. 그러나 웹 애플리케이션의 경우 주요 작업에는 데이터베이스 추가, 삭제, 수정 및 쿼리, 기타 외부 서비스 및 기타 네트워크 I/O 작업 요청이 포함되며 이러한 작업은 궁극적으로 운영 체제를 처리하기 위해 호출되며(애플리케이션 스레드 참여 없이) 속도가 매우 느리므로(CPU 클록 주기에 비해) 생성된 스레드는 할 일이 없습니다. 게다가 우리 서비스는 추가 스레드 전환 오버헤드도 부담해야 합니다. 이러한 언어와 달리 Node는 각 요청에 대해 스레드를 생성하지 않습니다. 모든 요청 처리는 기본 스레드에서 발생하므로 스레드 전환에 대한 오버헤드가 없으며 또한 스레드 풀을 통해 이러한 I/O 작업을 비동기적으로 처리한 다음 메인 스레드의 실행을 차단하지 않도록 결과를 이벤트 형식으로 메인 스레드에 알립니다. 이론적으로 더 효율적입니다. 여기서는 Node가 이론상 더 빠르다고 말했지만 실제로는 반드시 더 빠르지는 않습니다. 이는 실제로 서비스 성능이 여러 측면에 의해 영향을 받기 때문입니다. 여기서는 동시성 모델 요소만 고려하고 런타임 소비와 같은 다른 요소도 서비스 성능에 영향을 미치기 때문입니다. 예를 들어 JavaScript는 동적 언어이고 데이터 유형은 런타임에 추론되어야 하는 반면, GolangJava는 정적 언어입니다. ​​그리고 해당 데이터 유형은 컴파일 타임에 결정되므로 실제로 더 빠르게 실행되고 메모리를 덜 차지할 수 있습니다. 🎜

노드는 CPU 집약적인 작업에 적합하지 않습니다

🎜위에서 I/O 관련 작업을 제외하고 다른 모든 작업은 노드는 메인 서버에서 수행되므로 스레드에서 실행되므로 노드가 일부 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 인터페이스는 hardWork 를 호출하기 때문에 CPU 집약적 인터페이스입니다. 이 CPU 집약적 기능은 /easy_work 인터페이스가 매우 간단하지만 문자열을 클라이언트에 직접 반환하기만 하면 됩니다. hardWork 기능이 CPU 집약적이라고 불리는 이유는 무엇입니까? 이는 I/O 작업을 수행하지 않고 CPU의 연산자에서 i에 대한 산술 연산을 수행하기 때문입니다. Node 서비스를 시작한 후 /hard_word 인터페이스를 호출하려고 합니다: /hard_work接口是一个CPU密集型接口,因为它调用了hardWork这个CPU密集型函数,而/easy_work这个接口则很简单,直接返回一个字符串给客户端就可以了。为什么说hardWork函数是CPU密集型的呢?这是因为它都是在CPU的运算器里面对i进行算术运算而没有进行任何I/O操作。启动完我们的Node服务后,我们试着调用一下/hard_word接口:

CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

我们可以看到/hard_work接口是会卡住的,这是因为它需要进行大量的CPU计算,所以需要比较久的时间才会执行完。而这个时候我们再看一下/easy_work这个接口有没有影响:

CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

我们发现在/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服务:

CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

从上面的输出结果来看,cluster启动了10个worker(我的电脑是10核的)来处理web请求,这个时候我们再来请求一下/hard_work这个接口:

CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

我们发现这个请求还是卡死的,接着我们再来看看Cluster模块有没有解决其它请求也被阻塞的问题:

CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석

我们可以看到前面9个请求都是很顺利就返回结果的,可是到了第10个请求我们的接口就卡住了,这是为什么呢?原因就是我们一共开了10个工作进程,主进程在将流量打到子进程的时候采用的默认负载均衡策略是round-robin(轮流),因此第10个请求(其实是第11个,因为包括了第一个hard_work的请求)刚好回到第一个worker,而这个worker还没处理完hard_work的任务,因此这个easy_work的任务也就卡住了。cluster的负载均衡算法可以通过cluster.schedulingPolicy

🎜🎜 /hard_work 인터페이스가 CPU를 많이 요구하기 때문에 중단되는 것을 볼 수 있습니다. 계산이므로 완료하는 데 시간이 오래 걸립니다. 이때 /easy_work 인터페이스가 어떤 영향을 미치는지 살펴보겠습니다. 🎜🎜CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석🎜🎜 /hard_work가 CPU 리소스를 점유한 후 무해한 /easy_work code> 인터페이스도 멈췄습니다. 그 이유는 <code>hardWork 함수가 Node의 메인 스레드를 차단하여 /easy_work의 로직이 실행되지 않기 때문입니다. Node와 같은 이벤트 루프를 기반으로 하는 단일 스레드 실행 환경에서만 이 문제가 발생한다는 점을 여기서 언급할 가치가 있습니다. Java 및 Golang과 같은 스레드 기반 언어에는 이 문제가 없습니다. 그렇다면 우리 서비스가 실제로 CPU 집약적인 작업을 실행해야 한다면 어떻게 될까요? 언어는 바꿀 수 없잖아요? JavaScript의 모든 것은 어떻습니까? 걱정하지 마세요. Node는 CPU 집약적인 작업을 처리할 수 있도록 많은 솔루션을 준비했습니다. 다음으로 일반적으로 사용되는 세 가지 솔루션을 소개하겠습니다. 클러스터 모듈 , 하위 프로세스작업자 스레드. 🎜

클러스터 모듈

개념 소개

🎜Node는 매우 일찍 클러스터 모듈을 출시했습니다(v0.8 버전). 이 모듈의 기능은 상위 프로세스에서 하위 프로세스 그룹을 시작하여 네트워크 요청의 부하를 분산하는 것입니다. 기사의 길이 제한으로 인해 클러스터 모듈의 API에 대해 자세히 설명하지 않습니다. 관심 있는 독자는 나중에 공식 문서를 읽을 수 있습니다. 여기서는 클러스터 모듈을 사용하여 위 CPU를 최적화하는 방법을 직접 살펴보겠습니다. -집약적 시나리오: 🎜
// 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...')
})
🎜 위의 코드에서는 cluster.fork 함수를 사용하여 클러스터의 CPU 코어 수에 따라 동일한 수의 작업자 프로세스를 생성합니다. 현재 장치이며 이러한 작업자 프로세스는 모두 포트의 8080 에서 수신 대기합니다. 이를 보면 모든 프로세스가 동일한 포트에서 수신 대기하는 경우 문제가 있는지 물을 수 있습니다. 실제로 Cluster 모듈의 하위 계층이 일부 작업을 수행하므로 여기서는 문제가 없습니다. 8080 포트가 메인 프로세스이고, 메인 프로세스가 모든 트래픽의 입구로 HTTP를 수신하게 됩니다. 연결하고 이를 다른 작업자 프로세스로 라우팅합니다. 더 이상 고민하지 말고 다음 노드 서비스를 실행해 보겠습니다. 🎜🎜CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석🎜🎜위 출력에서 ​​클러스터는 웹 요청을 처리하기 위해 10개의 작업자(내 컴퓨터에는 10개의 코어가 있음)를 시작했습니다. 이번에는 /hard_work이 인터페이스를 다시 요청하겠습니다. :🎜🎜5 .png🎜🎜 우리는 이 요청이 여전히 정체되어 있음을 발견했으며, 클러스터 모듈이 다른 요청도 차단되는 문제를 해결할 수 있는지 확인할 것입니다:🎜🎜CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석🎜🎜처음 9개의 요청을 볼 수 있습니다 모두 원활하게 결과를 반환했지만 10번째 요청에 도달했을 때 인터페이스가 중단되었습니다. 이유는 무엇입니까? 그 이유는 총 10개의 작업자 프로세스를 오픈했기 때문입니다. 기본 프로세스에서 하위 프로세스로 트래픽을 보낼 때 사용하는 기본 로드 밸런싱 전략은 라운드 로빈(순차)이므로 10번째 요청입니다. (실제로는 첫 번째 hard_work 요청이 포함되어 있으므로 11번째입니다.) 첫 번째 워커로 돌아가고 이 워커는 hard_work 작업 처리를 완료하지 않았으므로 이 easy_work작업이 중단되었습니다. 클러스터의 로드 밸런싱 알고리즘은 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는 보다 가벼운 스레드를 기반으로 하므로 리소스가 오버헤드는 비교적 작습니다. 그러나 Sparrow는 작고 잘 갖춰져 있으며 각 Worker Thread에는 자체적인 독립적인 v8 엔진 인스턴스이벤트 루프가 있습니다. 코드> >체계적입니다. 이는 메인 스레드가 중단되더라도 작업자 스레드가 계속 작동할 수 있음을 의미하며 이를 기반으로 실제로 많은 흥미로운 작업을 수행할 수 있습니다.
  • 资源消耗小:不同于Cluster Module和Child Process基于进程的方式,Worker Thread是基于更加轻量级的线程的,所以它的资源开销是相对较小的。不过麻雀虽小五脏俱全,每个Worker Thread都是有自己独立的v8引擎实例事件循环系统的。这也就是说即使主线程卡死我们的Worker Thread也是可以继续工作的,基于这个其实我们可以做很多有趣的事情。
  • 父子线程通信方便高效:和前面两种方式不一样,Worker Thread不需要通过IPC通信,所有数据都是在进程内部实现共享和传递的。

不过Worker Thread也不是完美的:

  • 线程隔离性低:由于子线程不是在一个独立的环境执行的,所以某个子线程挂了还是会影响到其它线程,在这种情况下,你需要做一些额外的措施来保护其余线程不受影响。
  • 线程数据共享实现麻烦:和其它后端语言比起来,Node的数据共享还是比较麻烦的,不过这其实也避免了它需要考虑很多多线程下数据安全的问题。

总结

在本篇文章中我为大家介绍了Node为什么适合做I/O密集型应用而很难处理CPU密集型任务的原因,并且为大家提供了三个可选方案来在实际开发中处理CPU密集型任务。每个方案其实都有利有弊,我们一定要根据实际情况进行选择,永远不要为了要用某个技术而一定要采取某个方案아버지-자식 스레드 통신은 편리하고 효율적입니다: 이전 두 가지 방법과 달리 작업자 스레드는 IPC를 통해 통신할 필요가 없으며 모든 데이터가 프로세스 내에서 공유 및 전송됩니다.

그러나 작업자 스레드는 완벽하지 않습니다.

낮은 스레드 격리: 하위 스레드가 독립적인 환경에서 실행되지 않기 때문에, 따라서 특정 하위 스레드가 중단되면 여전히 다른 스레드에 영향을 미칩니다. 이 경우 나머지 스레드가 영향을 받지 않도록 보호하기 위해 몇 가지 추가 조치를 취해야 합니다.

스레드 데이터 공유는 번거롭습니다: 다른 백엔드 언어와 비교할 때 Node의 데이터 공유는 여전히 더 번거롭지만 이는 실제로 다중 환경에서 많은 데이터 보안을 고려할 필요를 피합니다. 스레딩.

🎜Summary🎜

🎜이 기사에서는 Node가 I/O 집약적인 애플리케이션에 적합한 이유를 소개했습니다. CPU 집약적인 작업을 처리하기 어렵고 실제 개발에서 CPU 집약적인 작업을 처리할 수 있는 세 가지 옵션을 제공합니다. 각 솔루션에는 실제로 장단점이 있습니다. 단지 특정 기술을 사용하기 위해 특정 솔루션을 채택하지 마십시오. 🎜🎜노드 관련 지식을 더 보려면 🎜nodejs 튜토리얼🎜을 방문하세요! 🎜

위 내용은 CPU 집약적인 작업을 처리하는 Node의 방법에 대한 간략한 분석의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 juejin.cn에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제