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

背景介绍

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

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

集体技术动静

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

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

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理