对 Node.js Promise 进行基准测试

已发表: 2022-03-11

我们生活在一个勇敢的新世界。 一个充满 JavaScript 的世界。 近年来,JavaScript 主导了网络,席卷了整个行业。 在引入 Node.js 之后,JavaScript 社区能够利用语言的简单性和动态性成为唯一的语言,处理服务器端、客户端,甚至大胆地在机器学习方面占据一席之地。 但是在过去的几年里,JavaScript 作为一种语言发生了巨大的变化。 引入了以前从未有过的新概念,例如箭头函数和承诺。

啊,承诺。 当我第一次开始学习 Node.js 时,promise 和 callback 的整个概念对我来说并没有多大意义。 我习惯了执行代码的程序化方式,但随着时间的推移,我明白了它为什么很重要。

这给我们带来了一个问题,为什么要引入回调和承诺? 为什么我们不能在 JavaScript 中编写顺序执行的代码?

好吧,从技术上讲,你可以。 但是你应该吗?

在本文中,我将简要介绍 JavaScript 及其运行时,更重要的是,测试 JavaScript 社区普遍认为同步代码在性能上是低于标准的,从某种意义上说,只是邪恶的,不应该使用。 这个神话是真的吗?

在开始之前,本文假设您已经熟悉 JavaScript 中的 Promise,但是,如果您不熟悉或需要复习,请参阅JavaScript Promises: A Tutorial with Examples

注意:本文已经在 Node.js 环境中进行了测试,而不是纯 JavaScript 环境。 运行 Node.js 版本 10.14.2。 所有基准和语法都将严重依赖 Node.js。 测试在配备英特尔 i5 第 8 代四核处理器的 MacBook Pro 2018 上运行,基本时钟速度为 2.3 GHz。

事件循环

基准 Node.js Promise:Node.js 事件循环的说明

编写 JavaScript 的问题在于语言本身是单线程的。 这意味着您不能一次执行多个过程,这与其他语言(例如 Go 或 Ruby)不同,它们能够在内核线程或进程线程上同时生成线程并执行多个过程.

为了执行代码,JavaScript 依赖于一个称为事件循环的过程,该过程由多个阶段组成。 JavaScript 过程经历了每个阶段,最后又重新开始。 您可以在此处阅读更多关于 node.js 官方指南的详细信息。

但是 JavaScript 有一些东西可以解决阻塞问题。 I/O 回调。

大多数需要我们创建线程的实际用例是我们正在请求一些语言不负责的操作,例如,请求从数据库中获取一些数据。 在多线程语言中,创建请求的线程只是挂起或等待来自数据库的响应。 这只是资源的浪费。 它还给开发人员在线程池中选择正确数量的线程带来了负担。 这是为了防止内存泄漏和在应用程序需求量大时分配大量资源。

JavaScript 在处理 I/O 操作方面比其他任何因素都更擅长。 JavaScript 允许您调用 I/O 操作,例如从数据库请求数据、将文件读入内存、将文件写入磁盘、执行 shell 命令等。当操作完成时,您将执行回调。 或者在 Promise 的情况下,您使用结果解决 Promise 或以错误拒绝它。

JavaScript 社区总是建议我们在进行 I/O 操作时永远不要使用同步代码。 众所周知的原因是我们不想阻止我们的代码运行其他任务。 由于是单线程的,如果我们有一段代码同步读取一个文件,代码会阻塞整个过程,直到读取完成。 相反,如果我们依赖异步代码,我们可以执行多个 I/O 操作,并在每个操作完成时单独处理它的响应。 没有任何阻挡。

但可以肯定的是,在我们根本不关心处理大量进程的环境中,使用同步和异步代码根本没有区别,对吧?

基准

我们将要运行的测试旨在为我们提供有关同步和异步代码运行速度以及性能是否存在差异的基准。

我决定选择读取文件作为 I/O 操作进行测试。

首先,我编写了一个函数,该函数将写入一个随机文件,其中填充了由 Node.js Crypto 模块生成的随机字节。

 const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )

该文件将作为我们下一步读取文件的常量。 这是代码

const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()

运行前面的代码会产生以下结果:

跑步 # 同步异步异步/同步比率
1 0.278ms 3.829 毫秒13.773
2 0.335ms 3.801ms 11.346
3 0.403ms 4.498ms 11.161

这是出乎意料的。 我最初的期望是他们应该花同样的时间。 那么,我们添加另一个文件并读取 2 个文件而不是 1 个文件怎么样?

我复制了生成的文件 test.txt 并将其命名为 test2.txt。 这是更新的代码:

 function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }

我只是为它们中的每一个添加了另一个阅读,并且在承诺中,我正在等待应该并行运行的阅读承诺。 结果如下:

跑步 # 同步异步异步/同步比率
1 1.659ms 6.895ms 4.156
2 0.323ms 4.048ms 12.533
3 0.324ms 4.017ms 12.398
4 0.333ms 4.271ms 12.826

第一个具有与随后的 3 个运行完全不同的值。 我的猜测是它与 JavaScript JIT 编译器有关,它会在每次运行时优化代码。

所以,对于异步函数来说,事情看起来并不那么好。 也许如果我们让事情变得更有活力,或者对应用程序施加更多压力,我们可能会产生不同的结果。

所以我的下一个测试涉及编写 100 个不同的文件,然后将它们全部读取。

首先,我在执行测试之前修改了代码写了 100 个文件。 每次运行时文件都不同,但大小几乎相同,因此我们在每次运行前清除旧文件。

这是更新的代码:

 let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }

对于清理和执行:

 let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()

让我们跑吧。

这是结果表:

跑步 # 同步异步异步/同步比率
1 4.999 毫秒12.890 毫秒2.579
2 5.077ms 16.267 毫秒3.204
3 5.241ms 14.571 毫秒2.780
4 5.086ms 16.334 毫秒3.213

这些结果在这里开始得出结论。 它表明随着需求或并发性的增加,promise 开销开始变得有意义。 更详细地说,如果我们运行的 Web 服务器应该在每台服务器每秒运行数百甚至数千个请求,那么使用同步运行 I/O 操作将开始很快失去它的好处。

只是为了实验,让我们看看这实际上是 Promise 本身的问题还是其他问题。 为此,我编写了一个函数来计算解决一个完全不执行任何操作的承诺和另一个解决 100 个空承诺的时间。

这是代码:

 function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
跑步 # 单一承诺100 个承诺
1 1.651ms 3.293ms
2 0.758ms 2.575ms
3 0.814ms 3.127ms
4 0.788ms 2.623ms

有趣的。 看起来承诺不是延迟的主要原因,这让我猜测延迟的来源是内核线程进行实际读取。 这可能需要更多的实验才能得出关于延迟背后主要原因的决定性结论。

最后一句话

那么你应该使用 Promise 还是不使用? 我的意见如下:

如果您正在编写一个脚本,该脚本将在具有由管道或单个用户触发的特定流程的单台机器上运行,那么请使用同步代码。 如果您正在编写一个负责处理大量流量和请求的 Web 服务器,那么来自异步执行的开销将超过同步代码的性能。

您可以在存储库中找到本文中所有功能的代码。

从 Promise 开始,您的 JavaScript 开发者之旅合乎逻辑的下一步是 async/await 语法。 如果您想了解有关它的更多信息以及我们是如何到达这里的,请参阅异步 JavaScript:从回调地狱到异步和等待