详解如何在Node.js中执行CPU密集型任务
作者:阿镇吃橙子
本文是转译自一位国外大佬的文章,感觉对node.js任务执行机制的解释非常清楚透彻。
原文作者:Yarin Ronel
原文链接:Running CPU-Bound Tasks in Node.js: Introduction to Worker Threads
正文
Node.js通常被认为不适合CPU密集型应用程序。Node.js的工作原理使其在处理I/O密集型任务时大放异彩。但相对的也导致其在处理CPU密集型任务中功亏一篑。话虽如此,虽然执行CPU密集型任务肯定不是Node的主要使用场景,但是我们依旧有方法来改善这些问题。Node.js自诞生以来在这方面已经在这些问题处理上取得了重大进展,现在我们应该能够做到基于合理的性能场景来执行CPU密集型任务。
首先我们需要先思考以下三个问题:
- 1.什么是cpu密集型任务?
- 2.为什么 Node.js在执行cpu密集型任务表现不佳?
- 3.如何克服这些问题,并且在 Node.js中有效运行cpu密集型任务?
1.什么是 CPU 密集型(I/O 密集型)任务?
首先,CPU 密集型任务,专业术语是"CPU-bound";I/O 密集型任务,专业术语是"I/O-bound"。大多数程序要么受 CPU 限制,要么受 I/O 限制,这意味着它们的执行速度受 CPU 或 I/O(输入/输出)子系统(磁盘、网络等)的限制。CPU密集型任务
的示例包括需要大量计算或操作的任务,例如:图像处理、压缩算法、矩阵乘法或是非常长(可能是嵌套)的循环。I/O 密集型任务
的一些示例包括从磁盘中读取文件、发出网络请求或者查询数据库等。
Node.js 由于其架构和执行模型,在处理 I/O 密集型任务时表现出色,但在处理 CPU 密集型任务时往往效果不尽如人意。
2.为什么 Node.js在执行cpu密集型任务表现不佳?
为了理解Node.js在执行cpu密集型任务时遇到问题的原因,我们需要更深入的来讨论它的内部工作原理。根因在于 Node.js 的设计方式,通过查看其架构和执行模型,我们可以了解Node.js在这些特定情况下的执行方式。
Node.js 的工作原理是怎样的?
Node.js 本质上是单线程的,它基于事件驱动架构,并提供 API 来访问非阻塞 I/O。大多数其他编程语言在运行时会使用多线程来处理它们的并发性,而 Node.js 则是使用事件循环和非阻塞 I/O 来实现并发
的。简单来说,例如用 Java 编写的 Web 服务器可能会为每个传入请求(或为每个客户端)分配一个单独的线程,而 Node.js 是在单个线程中处理所有的传入请求并使用事件循环来协调程序工作。
一个经典的 Java 服务器可能如下图所示,每个客户端分配自己的线程,这意味着允许服务器去并行处理多个客户端的请求。线程有时往往并未被充分利用,当特定客户端没有任务处理时,就会处于空闲状态:
而一个 Node.js 服务器则可能如下图所示,它使用单个线程来处理来自所有不同客户端的请求,这会导致更高的利用率(因为线程只有在没有任何工作要执行时才会处于空闲状态):
在 Node.js 的单个实例中,由于只有一个线程在执行 JavaScript,因此该工作实际上并非并行执行的。
那么 Node.js 是如何在单线程的情况下实现足够的性能的呢?它又是如何一次处理多个请求的呢?
这便是异步非阻塞 I/O 和事件循环
的作用: Node.js 以非阻塞的方式运行 I/O 操作,这也就意味着它可以在 I/O 操作进行时去执行其他的代码(甚至是其他的 I/O 操作),Node.js不必“等待” I/O 操作完成(甚至基本上会浪费CPU周期闲置),而是可以利用这些时间来执行其他任务。当 I/O 操作完成时,事件循环负责将控制权交还给等待该 I/O 操作结果的代码段。
简单来说,就是代码的 CPU 绑定部分在单个线程中同步执行,但 I/O 操作是异步执行
的,不会阻塞主线程中的执行(因此也被称为非阻塞)。事件循环负责协调工作,并决定在任何给定时间应该执行什么,本质上,这就是 Node.js 实现并发
的方式!
如果 Node.js 是单线程的,那么它又怎么可能会有非阻塞 I/O 呢 ?当I/O操作在当前正在执行中时,如何去执行其他代码和其他I/O操作呢?
回答上述问题之前,首先我们要明确一点: Node.js 并不完全是单线程的
。Node.js 的确是单线程执行模型,即只有单一的 JavaScript 指令可以在任何特定时间运行在同一个范围内(这种单线程执行模型并不是 JavaScript 语言本身的产物,事实上 JavaScript 作为一种语言既不是天生的单线程也不是多线程的,语言规范也不需要,最终决定取决于运行时环境,例如:Node.js 或者浏览器)。但是这并不意味着 Node.js 不能利用额外的线程去运行代码甚至是产生额外的线程在单独的上下文环境中运行 JavaScript
。
首先,当我们谈到 Node.js 的底层的模型和原理时,脑海中第一反应想到的便是 JavaScript 的V8引擎,它是在 Node.js 中实际执行 JavaScript 代码的组件,V8 引擎基于 C++ 编写、以高度优化著称的开源引擎
,它由 Google 维护并被用作 Chrome 中的 JavaScript 引擎。
其次,Node.js 存在 libuv 的依赖项,它是一个原生的开源库,并为 Node.js 提供了异步、非阻塞的 I/O,还实现了 Node.js 的事件循环。最早由 Node.js 的作者开发,专门为 Node.js 提供多平台下的异步 I/O 支持,其本身是由 C++ 语言实现的。在 Windows 环境下,libuv 直接使用 Windows 的 IOCP 来实现异步 I/O;在非 Windows 环境下,libuv 使用多线程来模拟异步 I/O。 Node.js 的异步调用是由 libuv 来支持
的,例如在读取文件数据的过程中,读取文件实质的系统调用是由 libuv 来完成的,Node.js 只是负责调用 libuv 的接口,等待接口数据返回后再执行对应的回调方法。
目前 Node.js 运行的各类操作系统已经能够为大多数 I/O 操作提供了非阻塞机制,但是每个操作系统的实现方式却各不相同,甚至即使在同一个操作系统中,有些 I/O 操作也与其他操作不同,有些甚至还没有非阻塞机制。为了解决各类操作系统的这些不一致性,libuv 提供了一个围绕非阻塞 I/O 的抽象概念,它统一了跨不同操作系统和 I/O 操作的非阻塞行为,从而允许 Node.js 与所有主流操作系统兼容。libuv 还维护着一个内部线程池,用于卸载在操作系统级别不存在非阻塞变体的 I/O 操作,另外,它还利用这个线程池来实现一些常见的 CPU 密集型操作的非阻塞任务,例如:crypto(散列)和 zlib 模块(压缩)。
现在我们应该大概能理解为什么在 Node.js 中运行 CPU 密集型任务会性能表现不佳了。Node.js 使用单个线程来处理多个客户端,虽然在I/O 操作上的确可以异步执行,但在 CPU 密集型代码方面却不能
,这也意味着太多或太长的 CPU 密集型任务可能会使得主线程忙于处理其他请求而导致阻塞。
Node.js 的执行机制旨在满足绝大多数 Web 服务器的需求,这些服务器往往是 I/O 密集型的,为了达到这一目标,它牺牲了高效运行 CPU 密集型代码的能力。这也代表着,我们为了能充分利用 Node.js 的能力,需要保证不要阻塞事件循环,即确保每个回调方法都能快速完成以便最小化 CPU 密集型任务
。
3.如何克服这些问题,并且在 Node.js中有效运行cpu密集型任务?
既然我们知道为什么在Node.js中运行CPU密集型任务会具有极大的挑战性,那么让我们来举个例子来说明。对于这个例子,我们选择一个计算成本很高的例子来展示这个问题,增加难度。可以让我们了解在Node.js中运行CPU密集型代码所涉及的问题。为了产生一个有足够计算成本、可能需要足够时长的任务来阻塞事件循环,我们可以选择计算斐波那契数列
(其时间复杂度为O(2^N),随着 N 值的增加,这个算法执行起来会变得非常缓慢)。
现在,假设我们想要创建一个http服务器,该服务器将根据请求生成斐波那契数。与大多数其他web服务器一样,我们的服务器应该能够为多个并发客户端提供服务。除了生成斐波那契数,我们的服务器还应该用Hello World 作为结果来响应任何其他请求。
首先,为我们的新服务器创建一个新目录。在该目录中,创建一个名为fibonacci.js的新文件。在该文件中,我们将实现我们的fibonacci函数并将其导出,以便其他模块可以使用它。
// fibonacci.js function fibonacci(num) { if (num <= 1) return num; return fibonacci(num - 1) + fibonacci(num - 2); } module.exports = fibonacci;
然后再实现一个http服务器,这样我们就可以在网络上公开我们的fibonacci功能了。为了实现我们的服务器,我们将使用Node的内置http模块,让我们首先在一个名为index.js的新模块中实现一个尽可能简单的服务器,并执行node index.js
启动这个服务器:
const http = require('http'); const port = 3000; http .createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); console.log('Incoming request to:', url.pathname); res.writeHead(200); return res.end('Hello World!'); }) .listen(port, () => console.log(`Listening on port ${port}...`));
等服务器启动正常之后,我们可以简单在我们的浏览器中打开 http://localhost:3000/ ,或者使用curl等工具向我们的服务器发送GET请求。在终端窗口中执行:
$ curl --get "http://localhost:3000/" Hello World!
我们的服务器正在工作并响应传入的请求。现在让我们让它做我们真正希望它做的事情:生成斐波那契数!为此,我们将首先将从fibonacci.js导出的fibonacci函数导入到服务器模块中。然后,我们将修改服务器,以便对路径/fibonacci(格式为/fibonacci/?n=<Number>
)发出的传入请求将提取所提供的参数(n),运行导入的fibonacci函数以生成相关的fibonacci数,并将其作为对该请求的响应返回。最后,我们的index.js模块应该是这样的(更改后的行高亮显示):
const http = require('http'); const fibonacci = require('./fibonacci'); const port = 3000; http .createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); console.log('Incoming request to:', url.pathname); if (url.pathname === '/fibonacci') { const n = Number(url.searchParams.get('n')); console.log('Calculating fibonacci for', n); const result = fibonacci(n); res.writeHead(200); return res.end(`Result: ${result}`); } else { res.writeHead(200); return res.end('Hello World!'); } }) .listen(port, () => console.log(`Listening on port ${port}...`));
让我们继续测试我们的服务器新实现的功能:生成斐波那契数!您可能还记得,这些请求应该是这样的:/fibonacci/?n=<数字>
。让我们用curl来试试:
$ curl --get "http://localhost:3000/fibonacci?n=13" Result: 233
不出所料,我们的服务器给出了第13个斐波那契数,即233。服务器几乎立即做出响应,这也是意料之中的事,因为13是一个足够小的输入。如此之小,以至于即使是具有指数复杂性的函数(如我们的fibonacci函数实现)也可以非常快地处理它。但是如果输入更大的数字呢?
$ curl --get "http://localhost:3000/fibonacci?n=46" # About 21 seconds later... Result: 1836311903
执行时间虽然取决于硬件,但我的笔记本电脑返回结果的时间不少于21.384秒。为了说明增长的速度有多快,计算第36个数字只花了202毫秒!这是一个巨大的区别。我们有意实现了一个效率不高的算法,所以执行时间很长是意料之中的事。当我们的服务器必须处理多个并发请求时,会发生什么呢
?
我们将从计算第46个斐波那契数的请求开始,正如我们之前所看到的,这应该需要大约21秒:
$ curl --get "http://localhost:3000/fibonacci?n=46"
当第一个请求仍在运行,没有结束的时候,我们将从另一个终端发送另一个请求:
$ curl --get "http://localhost:3000/"
我们可以注意到第二个请求只是挂起,只有在第一个请求完成后才会处理。我们不得不等待21秒才能完成第二个请求,尽管它所要做的只是回复Hello World 这个简单的字符。我们前面章节中的假设得到了验证:在Node.js中运行CPU密集型任务,会导致阻塞的事件循环。这意味着所有进一步的操作都被阻止,直到阻止操作结束。在我们的例子中,服务器忙于计算第46个斐波那契数,甚至无法响应Hello World!。运行CPU密集型任务的困难是Node.js最大的缺点之一
。但是,正如我们前面提到的,有一些可能的解决方案。
4.可以借鉴的解决方案
既然我们了解了Node.js在运行CPU密集型任务遇到问题的原因,并且我们已经了解了它在现实场景中的表现,那么让我们来谈谈解决方案。
- 使用 setImmediate()(单线程)拆分任务
- 开启新的子进程
- 使用工作线程
4.1 拆分任务 setImmediate()
与其他方法不同,这种方法不使用额外的CPU内核。
了解了 Node.js 运行 CPU 密集型任务时表现不佳的本质原因,我们可以很清楚地看到它们都是源于一个基本事实: 这些长时间运行的任务会阻塞事件循环,使其保持忙碌状态以至于它会完全停滞其他的所有任务,甚至会拒绝执行一个循环
。阻塞事件循环意味着其他任务完全停滞而不仅仅只是减速。但是,我们可以将这些长时间运行的的任务“拆分”为更小的、耗时更少的部分。这样基本上是可以解决我们的问题,虽然在吞吐量上不会增加,但是拆分长时间运行的任务可以显著提高服务器的响应能力,不会再有“轻量级”任务被一些长时间运行的、受 CPU 限制的“庞然大物”无限期地阻塞。
要想实现这样的“拆分”效果,我们可以使用内置的 setImmediate() 函数,通过将我们的 CPU 绑定操作划分为好几个步骤,并用setImmediate() 来调用每个步骤,这样我们基本上可以使事件循环在每个步骤之间进行控制,事件循环将会在继续下一步操作之前处理挂起的任务。这是在 Node.js 中处理 CPU 密集型任务的最基本的方法,但利弊相依,它也有着一些明显的缺点:
- 效率低下。延迟的每个步骤都会引入一些开销,随着步骤数量的增加,这种开销会变得越来越大;
- 仅当一次运行单个 CPU 密集型任务时才会有用。这种方法确实可以帮助保持服务器的响应,但是它显然不会使同时运行的多个 CPU 密集型任务运行得更快,因为我们仍然是在使用单线程;
- 操作麻烦。将算法拆分成步骤有时很难顾全大局,可能会降低代码的可读性。
这种方法只有在是针对偶尔运行一次的 CPU 密集型任务时才是最简单、最有效的方法
。
4.2 开启新的子进程
我们如果想要完全避免在主应用程序中运行昂贵的CPU绑定任务,可以开启另一个单独的进程来运行这些任务。通过这种方式,可以让我们的主应用程序做Node.js最擅长的事情。我们遵守Node的规则,避免阻塞事件循环。作为对Node.js的正确的使用方式的回报,我们的主要应用程序将在性能方面获得很大的提升。除此之外,在新的进程中运行的任务也会更快。
Node.js 提供了专门的 child_process 模块来支持创建子进程、管理它们并与它们通信。每个衍生的 Node.js 子进程都是独立的,并且拥有自己的内存、事件循环和 V8 实例,子进程和父进程之间的唯一连接是两者之间建立的通信通道。
首先,新创建一个名为fibonacci-fork.js的新模块。这是我们的子进程将运行的模块:
function fibonacci(num) { if (num <= 1) return num; return fibonacci(num - 1) + fibonacci(num - 2); } process.on('message', (message) => { process.send(fibonacci(message)); process.exit(0); });
然后,我们将修改我们之前的的服务器主程序(index.js)。它应该处理如下事情:
- 基于我们新创建的模块(fibonacci-fork.js),使用fork()创建一个子进程
- 使用childProcess.send() 向子进程发送一条包含所需参数(n)的消息
- 使用childProcess.on('message', () => {}) 监听来自子进程的消息
- 使用从子进程接收到的结果并且响应请求
// index.js const http = require('http'); const path = require('path'); const { fork } = require('child_process'); const port = 3000; http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); console.log('Incoming request to:', url.pathname); if (url.pathname === '/fibonacci') { const n = Number(url.searchParams.get('n')); console.log('Calculating fibonacci for', n); // 使用fork()基于fibonacci创建新子进程 const childProcess = fork(path.join(__dirname, 'fibonacci')); // 接收从子进程收到的结果响应请求 childProcess.on('message', (message) => { res.writeHead(200); return res.end(`Result: ${message}`); }); // 向子进程发送消息 childProcess.send(n); } else { res.writeHead(200); return res.end('Hello World!'); } }).listen(port, () => console.log(`Listening on port ${port}...`));
启动服务器之后,首先在终端中执行:
$ curl --get "http://localhost:3000/fibonacci?n=46"
当第一个请求仍在运行,没有结束的时候,我们将从另一个终端发送另一个请求:
$ curl --get "http://localhost:3000/"
即使当前正在进行/fibonacci请求,我们也能立刻拿到第二个请求的返回 hello world! 我们的JavaScript代码首次可以利用多个CPU,实现了并发处理多任务的功能。
4.3 使用工作线程
工作线程 API 位于 worker_threads 模块当中,允许使用线程并行执行 JavaScript。工作线程与子进程非常相似,两者都是通过将执行任务委托给单独的 Node.js 实例,可用于在主应用程序的事件循环之外运行 CPU 密集型任务。当然,两者之间还是有一个根本的区别,即子进程是在完全不同的进程中执行工作,但工作线程却是在主应用程序的同一个进程内执行工作
。
这个关键性的差异导致工作线程对于子进程而言有一些非常好的优点:
与子进程相比,工作线程是更轻量级的
。它可以消耗更少的内存并且启动速度更快,但由于创建工作线程是十分昂贵的,所以它们通常应该被慎重使用工作线程之间可以共享内存
。除了我们在子进程中看到的基本的消息传递之外,工作线程还可以传输 ArrayBuffer 实例,甚至共享SharedArrayBuffer 实例,这两个对象都用于保存原始的二进制数据(ArrayBuffer 和SharedArrayBuffer 之间的区别可以归结为传输和共享之间的区别。一旦 ArrayBuffer 被转移到一个工作线程,原来的 ArrayBuffer 就会被清除,并且不再由主线程或者其他任何工作线程访问或操作。SharedArrayBuffer 的内容实际上是共享的,并且可以由主线程和任意数量的工作线程同时访问和操作,在这种情况下,同步则必须由开发人员手动来管理)。
Node.js 中的工作线程和其他经典的线程编程语言(如:Java 或 C++)中的“经典”线程完全不同。在 Node.js 中,每个工作线程都有属于自己的独立的 Node.js 运行实例 (包括自己的 V8 实例、事件循环等)和自己的隔离上下文
。而后者往往是在相同的运行时中运行并处理主线程的相同上下文。
在线程编程语言中,默认情况下任何线程都可以随时访问和操作任何变量,即使有不同的线程当前正在使用该变量。而之所以会发生这种情况,是因为所有的线程都在同一上下文中运行,这可能会导致一些严重的错误。作为开发人员,我们需要使用语言的同步机制来确保不同的线程之间协同工作。
而工作线程是在它自己的隔离上下文中运行的,默认情况下(除去 SharedArrayBuffer)它不与主线程或者其他任何工作线程共享任何内容,因此我们通常不需要考虑线程同步。
两者之间的这种差异既有消极的影响,也有积极的影响。一方面,工作线程可能会被认为不如“经典”线程强大,因为它不够灵活;但另一方面,工作线程又比“经典”线程安全得多得多。一般来说,“经典”的多线程编程被认为是困难且容易出错的(有些错误很难被检测出来),工作线程由于其在单独的上下文中运行并且在默认情况下不共享任何内容,因此基本上已经消除了一整类潜在的与多线程相关的错误,例如:竞争条件和死锁,同时对绝大多数的用例保持足够的灵活性。
worker_threads 模块中的一些核心术语和方法(来源于官方文档):
- new Worker(filename, [options])。用于创建新工作线程的构造函数,创建的工作线程将执行由 filename 引用的模块的内容,options 可选,常见的选择是 workerData;
- worker.workerData。可以是任何 JavaScript 值,这是使用该 workerData 选项传递给此工作线程构造函数的数据的克隆,本质上是一种在创建时为新的工作线程提供一些数据的方法;
- worker.isMainThread。如果代码不在工作线程中运行而是在主线程中运行,则为真;
- worker.parentPort。工作线程和父线程之间双向通信通道的工作端,从工作线程发送的消息parentPort.postMessage() 可以在主线程中使用 worker.on(‘message’),反之从主线程发送的消息 worker.postMessage() 可以在工作线程中使用 parentPort.on(‘message’)。
我们通过如下例子来实现。首先,我们必须创建一个新的模块。我们称之为fibonacci-worker.js。这个模块将包含最终将在工作线程中执行的代码。
const { Worker, isMainThread, parentPort, workerData, } = require('worker_threads'); function fibonacci(num) { if (num <= 1) return num; return fibonacci(num - 1) + fibonacci(num - 2); } if (isMainThread) { module.exports = (n) => new Promise((resolve, reject) => { const worker = new Worker(__filename, { workerData: n, }); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', (code) => { if (code !== 0) { reject(new Error(`Worker stopped with exit code $[code]`)); } }); }); } else { const result = fibonacci(workerData); parentPort.postMessage(result); process.exit(0); }
我们已经实现了worker模块,剩下要做的就是更新服务器模块(index.js):
const http = require('http'); const fibonacciWorker = require('./fibonacci-worker'); const port = 3000; http .createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); console.log('Incoming request to:', url.pathname); if (url.pathname === '/fibonacci') { const n = Number(url.searchParams.get('n')); console.log('Calculating fibonacci for', n); const result = await fibonacciWorker(n); res.writeHead(200); return res.end(`Result: ${result}\n`); } else { res.writeHead(200); return res.end('Hello World!'); } }) .listen(port, () => console.log(`Listening on port ${port}...`));
请注意,此处使用async/await,因此使用await调用了fibonacciWorker()(第15行),并声明了服务器的回调函数async(第7行)。当然,也可以使用传统的.then(…)语法。
启动服务器之后,首先在终端中执行:
$ curl --get "http://localhost:3000/fibonacci?n=46"
当第一个请求仍在运行,没有结束的时候,我们将从另一个终端发送另一个请求:
$ curl --get "http://localhost:3000/"
正如预期的那样,服务器保持响应并立即响应第二个请求,尽管当前有一个CPU密集型任务正在运行(由第一个请求启动)。我们还可以发送多个/fibonacci请求,并让它们并行执行。
4.4 使用工作池
在上述子进程
和工作线程
的示例代码中,我们给每个传入的请求创建了一个新的进程/工作线程并且只使用了一次,这其实并不合适,主要因为:
- 创建一个新的子进程/工作线程是很昂贵的,为了获得最佳的性能,我们应该对其慎重使用;
- 我们无法控制创建的子进程/工作线程的数量,这会使我们容易受到 DOS 攻击。
我们可以使用工作池来管理我们的子进程/工作线程,例如:workerpool 它适用于子进程和工作线程。
总结
Node.js需要通过最大限度地减少运行cpu密集型任务以提升服务器性能,虽然目前的状态对许多应用程序来说已经足够了,但Node.js可能永远不会完全适合真正的CPU密集型应用程序。其实并没有没关系,因为这不是Node的设计初衷。我们知道没有一个软件项目适用于所有情况。成功的软件项目会进行权衡,使他们能够在核心用例中脱颖而出,同时(希望)留下一些灵活性的空间。
其实这也是Node.js正在做的事情,它在其处理I/O密集型任务方面非常出色。虽然它不擅长处理CPU密集型应用程序,但它仍然为开发者提供了灵活、合理的方法来改善这一点。
以上就是详解如何在Node.js中执行CPU密集型任务的详细内容,更多关于在Node.js中执行CPU任务的资料请关注脚本之家其它相关文章!