当前位置: 首页 > 后端技术 > Node.js

一文看懂Node处理CPU密集型任务的方法有哪些

时间:2023-04-03 12:31:32 Node.js

一文看懂Node是如何处理CPU密集型任务的是一个事件驱动的JavaScript运行时,因此非常适合构建I/O密集型应用程序,例如Web服务。不知道大家听到类似的话会不会有和我一样的疑惑:为什么单线程Node适合开发I/O密集型应用?按理说,支持多线程的语言(比如Java和Golang)在做这些任务时更有优势?要理解上面的问题,我们需要知道Node的单线程指的是什么。节点不是单线程的。其实我们说Node是单线程的。我们的意思是我们的JavaScript代码运行在同一个线程中(我们可以称之为主线程),而不是Node只有一个线程在工作。实际上,Node底层会利用libuv的多线程能力,在主线程之外的一些线程中执行部分工作(基本是I/O相关的操作)。当这些任务完成后,结果将以回调函数的形式返回。到主线程的JavaScript执行环境。大家可以看看示意图:注:上图是Node事件循环(EventLoop)的简化版。事实上,完整的事件循环还会有更多的阶段,比如定时器。Node适用于I/O密集型应用程序。通过上面的分析,我们知道Node会通过libuv的多线程能力,将所有的I/O操作分散到不同的线程中,其他的操作在主线程中执行。那么为什么这种方式比Java或Golang等其他语言更适合I/O密集型应用呢?我们以Web服务的开发为例。Java、Golang等主流后端编程语言的并发模型都是基于线程(Thread-Based)的,也就是说它们会为每个网络请求创建一个单独的线程来处理。但对于web应用来说,主要是对数据库进行增删改查,或者请求其他外部服务和其他网络I/O操作,这些操作最终都交给了操作系统的系统调用来处理(没有应用线程的参与),而且很慢(相对于CPU时钟周期),所以创建的线程大部分时间是无事可做的,我们的服务不得不承担额外的线程切换开销。与这些语言不同,Node不会为每个请求创建一个线程。所有的请求处理都发生在主线程中,所以没有线程切换开销,而且它还通过线程池异步处理这些I请求。/O操作,然后把结果以事件的形式告诉主线程,避免阻塞主线程的执行,所以理论上效率更高。这里值得注意的是,我只是说Node在理论上更快,在实践中不一定。这是因为在现实中,一个服务的性能会受到很多方面的影响。我们这里只考虑并发模型,运行时消耗等其他因素也会影响服务的性能。比如JavaScript是动态语言,需要在运行时推断数据类型,而Golang和Java是静态语言,数据类型可以在编译时确定,所以它们实际上可能执行得更快,占用内存更少.Node不适合CPU密集型任务。上面我们提到Node除了I/O相关的操作都会在主线程中执行,所以当Node需要处理一些CPU密集型的任务时,主线程就会被阻塞。让我们看一个CPU密集型任务的例子://node/cpu_intensive.jsconsthttp=require('http')consturl=require('url')consthardWork=()=>{//100亿次nothing计算意义for(leti=0;i<10000000000;i++){}}constserver=http.createServer((req,resp)=>{consturlParsed=url.parse(req.url,true)if(urlParsed.pathname==='/hard_work'){hardWork()resp.write('hardwork')resp.end()}elseif(urlParsed.pathname==='/easy_work'){resp.write('easywork')resp.end()}else{resp.end()}})server.listen(8080,()=>{console.log('serverisup...')})在上面的代码中我们用两个接口实现了一个HTTP服务:/hard_work接口是一个CPU密集型接口,因为它调用了CPU密集型函数hardWork,而/easy_work接口很简单,直接返回一个字符串给客户端OK。为什么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密集型任务怎么办?你不能改变语言,是吗?AllinJavaScript怎么样?不用担心,Node已经为我们准备了很多应对CPU密集型任务的解决方案。接下来为大家介绍三种常用的解决方案,分别是:ClusterModule、ChildProcess和WorkerThread。ClusterModule概念介绍Node很早就推出了Cluster模块(v0.8版本)。该模块的作用是通过一个父进程启动一组子进程来负载均衡网络请求。限于篇幅,Cluster模块的API我们就不详细说了。有兴趣的读者可以稍后参考官方文档。这里直接看如何使用Cluster模块优化上述CPU密集型场景://node/cluster.jsconstcluster=require('cluster')consthttp=require('http')consturl=require('url')//获取CPU核数constnumCPUs=require('os').cpus().lengthconsthardWork=()=>{//100亿次无意义计算for(leti=0;i<10000000000;i++){}}//判断当前是否为master进程if(cluster.isMaster){//根据创建相同数量的worker进程for(vari=0;i{console.log(`worker${worker.process.pid}isonline`)})cluster.on('exit',(worker,code,signal)=>{//一个worker进程挂掉后,我们需要立即启动另一个Worker进程来代替console.log(`worker${worker.process.pid}exitedwithcode${code},andsignal${signal},开始一个新的...`)cluster.fork()})}else{//工作进程启动一个HTTP服务器constserver=http.createServer((req,resp)=>{consturlParsed=url.parse(req.url,true)if(urlParsed.pathname==='/hard_work'){hardWork()resp.write('hardwork')resp.end()}elseif(urlParsed.pathname==='/easy_work'){resp.write('easywork')resp.end()}else{resp.end()}})//所有工作进程都在监听同一个端口server.listen(8080,()=>{console.log(`worker${process.pid}serverisup...`)})}上面的代码中,我们使用cluster.fork函数,根据当前设备的CPU核数创建了相同数量的worker进程,这些worker进程都监听在8080端口,看到这里你可能问所有进程都监听同一个端口会不会有问题?其实不是这样的,因为Cluster模块底层会做一些工作,让最终监听8080端口的主进程为主进程,主进程是所有流量的入口。将接收HTTP连接并将它们发送到不同的工作进程。话不多说,我们来运行这个节点服务:从上面的输出来看,集群已经启动了10个worker(我的电脑是10核)来处理web请求。这个时候我们再去请求/hard_work接口:我们发现这个请求还是卡住了,再看看Cluster模块能不能解决其他请求也被阻塞的问题:可以看到前9个请求返回了结果很顺利,但是当我们到了第10个请求后我们的界面就卡住了,这是为什么?原因是我们一共开了10个工作进程。主进程向子进程发送流量时默认采用的负载均衡策略是round-robin(轮流),所以第10个请求(实际上是第11个,因为包含了第一个hard_work请求)刚好返回给了第一个worker,而这个worker还没有处理完hard_work任务,所以easy_work任务卡住了。可以通过cluster.schedulingPolicy修改集群的负载均衡算法。有兴趣的读者可以看看官方文档。从上面的结果来看,ClusterModule似乎解决了我们的一些问题,但是还是有一些请求受到了影响。那么在实际开发中是否可以使用ClusterModule来解决这个CPU密集型任务呢?我的意见是:这取决于。如果你的CPU密集型接口调用不频繁,计算时间不是太长,可以使用这个ClusterModule进行优化。但是如果你的接口调用比较频繁,每个接口都比较耗时,那么你可能需要看看使用ChildProcess或者WorkerThread的解决方案。ClusterModule的优缺点最后总结一下ClusterModule的优点:资源利用率高:可以充分利用CPU的多核能力,提高请求处理效率。API设计简单:它允许您实现简单的负载平衡和一定程度的高可用性。这里值得注意的是,我说的是一定程度的高可用性。这是因为ClusterModule的高可用是单机版的,也就是宿主机挂了,你的服务也会挂掉,所以更高的高可用必须使用分布式集群来完成。进程高度独立,避免某个进程出现系统错误导致整个服务不可用。说完了优点,再来说说ClusterModule的缺点:资源消耗高:每个子进程都是一个独立的Node运行环境,也可以理解为一个独立的Node程序,所以占用的资源也是巨大的。进程通信开销大:子进程之间的通信是通过进程间通信(IPC)进行的,如果数据共享频繁的话,会是一个比较大的开销。未能完全解决CPU密集型任务:在处理CPU密集型任务时还是有点紧张。子进程在ClusterModule中,我们可以启动更多的子进程,将一些CPU密集型的任务负载均衡到不同的进程,避免其他接口被卡住。但是你也看到了,这种方法是治标不治本。如果用户频繁调用CPU密集型接口,大量请求还是会卡住。优化此场景的另一种方法是child_process模块。概念介绍ChildProcess允许我们启动子进程来完成一些CPU密集型任务。我们看一下主进程master_process.js的代码://node/master_process.jsconst{fork}=require('child_process')consthttp=require('http')consturl=require('url')constserver=http.createServer((req,resp)=>{consturlParsed=url.parse(req.url,true)if(urlParsed.pathname==='/hard_work'){//对于hard_work请求,我们开始achildprocessTohandleconstchild=fork('./child_process')//告诉子进程开始工作child.send('START')//接收子进程返回的数据返回给客户端child.on('message',()=>{resp.write('hardwork')resp.end()})}elseif(urlParsed.pathname==='/easy_work'){//执行简单的工作在主进程中resp.write('easywork')resp.end()}else{resp.end()}})server.listen(8080,()=>{console.log('serverisup...')})上面代码中对于/hard_work接口的请求,我们会通过fork函数开启一个新的子进程来处理。子流程处理的时候,我们拿到数据后,将结果返回给客户端。这里值得注意的是,我并没有在子进程完成任务后释放子进程的资源。在实际项目中,我们不应该频繁的创建和销毁子进程,因为这个消耗也是非常大的。更好的方法是使用进程池。下面是子进程(child_process.js)的实现逻辑://node/child_process.jsconsthardWork=()=>{//100亿次无意义计算for(leti=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接口,并立即得到响应。这里就不截图了,大家可以自己补流程。child_process除了fork函数外,还提供了exec、spawn等函数来启动子进程,这些进程可以执行任何shell命令,不限于Node脚本。有兴趣的读者可以稍后通过官方文档了解一下,这里不过介绍太多了。子进程的优缺点最后总结一下子进程的优点:灵活:不仅限于Node进程,我们可以在子进程中执行任何shell命令。这实际上是一个很大的优势。如果我们的CPU密集型操作是用其他语言实现的(比如c语言处理图像),不想用Node或者C++Binding重新实现,我们可以调用其他语言,通过标准的方式与之通信输入和输出以获得结果。细粒度的资源控制:与ClusterModule不同,ChildProcess方案可以根据CPU密集型计算的实际需求动态调整子进程数量,实现细粒度的资源控制,理论上可以解决Cluster的问题Module无法解决CPU密集型接口调用频繁的问题。但是ChildProcess的缺点也很明显:资源消耗巨大:上面说到细粒度控制资源的优点的时候,也说了只能在理论上解决频繁调用CPU密集型的问题接口。这是因为实际场景中我们的资源也是有限的,每个子进程都是一个独立的操作系统进程,消耗的资源非常巨大。因此,对于频繁调用的接口,我们需要采用一种能耗更低的方案,这就是我下面要说的WorkerThread。进程通信的麻烦:如果启动的子进程也是一个Node应用,就比较好办了,因为有内置的API可以和父进程进行通信。如果子进程不是Node应用,我们只能通过标准输入输出或者其他方式处理进程,他们之间的通信是一件很麻烦的事情。WorkerThread,无论是ClusterModule还是ChildProcess,其实都是基于子进程的,它们都有一个巨大的劣势,就是非常消耗资源。为了解决这个问题,Node从v10.5.0(v12.11.0stable)版本开始支持worker_threads模块。worker_thread是Node针对CPU密集型操作的轻量级线程解决方案。概念介绍Node的WorkerThread和其他语言的线程一样,就是并发运行你的代码。这里注意是并发,不是并行。并行只是指在一段时间内同时发生多件事情,而并发是在某个时间点同时发生多件事情。一个典型的并行例子就是React的Fiber架构,因为它通过时分复用的方式来调度不同的任务,避免React渲染阻塞浏览器的其他行为,所以本质上它所有的操作还是在同一个操作系统线程中执行。不过,这里值得注意的是,虽然并发强调的是多个任务同时执行,但是在单核CPU的情况下,并发会退化为并行。这是因为CPU一次只能做一件事。当你有多个线程执行时,你需要进行时分复用,通过资源抢占来执行某些任务。但这是操作系统需要关心的事情,与我们无关。上面说了Node的WorkerThead和其他语言的线程的相同点,接下来我们来看看它们的不同点。如果你用过其他语言的多线程编程,你会发现Node的多线程和它们有很大的不同,因为Node多线程数据共享太麻烦了!Node不允许您通过共享内存变量来共享数据。您只能使用ArrayBuffer或SharedArrayBuffer来传输和共享数据。虽然这样很不方便,但是也让我们不用过多考虑多线程环境下的数据安全等一系列问题。可以说有利有弊。下面我们就来看看如何使用WorkerThread来处理上面的CPU密集型任务。我们先看主线程(master_thread.js)的代码://node/master_thread.jsconst{Worker}=require('worker_threads')consthttp=require('http')consturl=require('url')constserver=http.createServer((req,resp)=>{consturlParsed=url.parse(req.url,true)if(urlParsed.pathname==='/hard_work'){//对于每个hard_work接口,我们启动一个子线程来处理constworker=newWorker('./child_process')//告诉子线程启动任务worker.postMessage('START')worker.on('message',()=>{//收到子线程回复后返回结果给客户端resp.write('hardwork')resp.end()})}elseif(urlParsed.pathname==='/easy_work'){//other在主线程上执行简单操作resp.write('easywork')resp.end()}else{resp.end()}})server.listen(8080,()=>{console.log('serverisup...')})在上面的代码中,我们的服务端每收到一个/hard_work请求,就会启动一个Worker线程,通过newWorker的方式进行处理。worker处理完任务后,我们将结果返回给客户端。这个过程是异步的。然后看子线程(worker_thead.js)的代码实现://node/worker_thread.jsconst{parentPort}=require('worker_threads')consthardWork=()=>{//100亿个无意义的计算for(让i=0;i<10000000000;i++){}}parentPort.on('message',(message)=>{if(message==='START'){hardWork()parentPort.postMessage()}})上述代码中,工作线程在收到主线程的命令后开始执行CPU密集型操作,最后通过parentPort.postMessage通知父线程任务已经完成。从API来看,父子线程之间的通信是相当方便的。WorkerThread的优缺点最后,我们总结一下WorkerThread的优缺点。首先,我认为它的优点是:资源消耗小:与ClusterModule和ChildProcess基于进程的方式不同,WorkerThread是基于更轻量级的线程,所以它的资源开销比较小。不过麻雀虽小五脏俱全,但每个WorkerThread都有自己独立的v8引擎实例和事件循环系统。这意味着即使主线程卡住了,我们的WorkerThread也可以继续工作。基于此,我们其实可以做很多有趣的事情。父子线程通信方便高效:与前面两种方式不同,WorkerThread不需要通过IPC进行通信,所有数据都在进程内部共享传输。但是WorkerThread并不完美:线程隔离度低:由于子线程不是在独立的环境中执行,挂掉一个子线程还是会影响到其他线程。在这种情况下,你需要做一些额外的措施来保护其他线程不被影响。线程数据共享麻烦:相比其他后端语言,Node的数据共享还是比较麻烦的,但这其实避免了多线程下需要考虑很多数据安全问题。小结在本文中,我介绍了为什么Node适用于I/O密集型应用程序但难以处理CPU密集型任务,并提供了三种方案供您在实际开发中处理CPU密集型任务。类型任务。事实上,每种解决方案都有优点和缺点。我们要根据实际情况来选择,绝不能为了使用某种技术而采用某种方案。创造个人技术动力并不容易。如果你从这篇文章中学到了什么,请给我点赞或关注。您的支持是我继续创作的最大动力!同时欢迎关注公众号攻略葱一起学习成长