关于前端:一文看懂Node处理CPU密集型任务的方法有哪些

63次阅读

共计 9308 个字符,预计需要花费 24 分钟才能阅读完成。

背景介绍

咱们日常工作中或多或少据说过以下的话:

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 执行环境。能够看看示意图:

注: 上图是 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 接口:

咱们能够看到 /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 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 服务:

从下面的输入后果来看,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 的优缺点

最初咱们总结一下 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 密集型工作。每个计划其实都有利有弊,咱们肯定要依据理论状况进行抉择,永远不要为了要用某个技术而肯定要采取某个计划

集体技术动静

创作不易,如果你从这篇文章中学到货色,请给我点一下赞或者关注,你的反对是我持续创作的最大能源!

同时欢送关注公众号 进击的大葱 一起学习成长

正文完
 0