对Node.js Promise进行基准测试

本文概述

  • 事件循环
  • 基准测试
  • 本文总结
我们生活在一个勇敢的新世界中。一个充满JavaScript的世界。近年来, JavaScript主导了整个网络, 席卷了整个行业。在引入Node.js之后, JavaScript社区就能够利用该语言的简单性和动态性, 将其作为唯一的语言来处理所有事情, 包括处理服务器端, 客户端, 甚至大胆地宣称自己可以从事机器学习。但是在过去的几年中, JavaScript作为一种语言已经发生了巨大的变化。引入了从未有过的新概念, 例如箭头功能和Promise。
啊, 保证。当我刚开始学习Node.js时, Promise和回调的整个概念对我来说并没有多大意义。我习惯了执行代码的过程方式, 但是随着时间的推移, 我了解了为什么它很重要。
这带给我们一个问题, 为什么无论如何都要引入回调和promise?为什么我们不能只用JavaScript编写顺序执行的代码?
好吧, 从技术上讲你可以。但是你应该吗?
在本文中, 我将简要介绍一下JavaScript及其运行时, 更重要的是, 测试一下JavaScript界普遍认为同步代码的性能不及标准的, 并且在某种意义上说只是普通的邪恶, 并且永远不要使用。这是真的神话吗?
在开始之前, 本文假定你已经熟悉JavaScript中的Promise, 但是, 如果你不熟悉或需要复习, 请参阅JavaScript Promises:带有示例的教程
N.B.本文已在Node.js环境(而非纯JavaScript环境)上经过测试。运行Node.js版本10.14.2。所有基准测试和语法都将严重依赖于Node.js。测试是在MacBook Pro 2018上运行的, 该处理器采用了Intel i5第八代四核处理器, 基本时钟速度为2.3 GHz。
事件循环
对Node.js Promise进行基准测试

文章图片
编写JavaScript的问题是语言本身是单线程的。这意味着你不能一次执行一个以上的过程, 而与其他语言(例如Go或Ruby)不同, 它们可以在内核线程或进程线程上生成线程并同时执行多个过程。 。
为了执行代码, JavaScript依赖于一个称为事件循环的过程, 该过程由多个阶段组成。 JavaScript过程经历了每个阶段, 最后, 又重新开始。你可以在此处阅读有关node.js官方指南的详细信息。
但是JavaScript可以解决阻塞问题。 I / O回调。
【对Node.js Promise进行基准测试】要求我们创建线程的大多数实际用例都是这样的事实, 即我们正在请求某种语言不负责的操作, 例如, 请求从数据库中获取某些数据。在多线程语言中, 创建请求的线程只是挂起或等待数据库的响应。这只是浪费资源。这也给开发人员增加了在线程池中选择正确数量的线程的负担。这是为了防止内存泄漏以及在应用程序需求量很大时分配大量资源。
JavaScript在处理I / O操作方面胜于其他任何方面。 JavaScript使你可以调用I / O操作, 例如从数据库请求数据, 将文件读入内存, 将文件写至磁盘, 执行Shell命令等。操作完成后, 你将执行回调。或者, 在有诺言的情况下, 你可以用结果来解决诺言或以错误拒绝它。
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.278毫秒 3, 829毫秒 13.773
2 0.335毫秒 3.801毫秒 11.346
3 0.403毫秒 4.498毫秒 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.659毫秒 6.895毫秒 4.156
2 0.323毫秒 4.048毫秒 12.533
3 0.324毫秒 4.017毫秒 12.398
4 0.333毫秒 4.271毫秒 12.826
第一个具有与随后的三个运行完全不同的值。我的猜测是, 它与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.077毫秒 16.267毫秒 3.204
3 5.241毫秒 14.571毫秒 2.780
4 5, 086毫秒 16.334毫秒 3.213
这些结果在这里开始得出结论。它表明随着需求或并发性的增加, Promise开销开始变得有意义。详细说来, 如果我们正在运行一个Web服务器, 该服务器应该每秒每服务器运行数百或数千个请求, 那么使用同步运行I / O操作将开始很快失去其优势。
仅出于实验目的, 让我们看看这是否确实是诺言本身的问题, 还是其他原因。为此, 我编写了一个函数, 该函数将计算时间来解决一个绝对不做任何Promise的Promise, 另一个解决100个空Promise的Promise。
这是代码:
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()

运行# 单一Promise 100个Promise
1 1.651毫秒 3.293毫秒
2 0.758毫秒 2.575毫秒
3 0.814毫秒 3.127毫秒
4 0.788毫秒 2.623毫秒
有趣。看来诺言不是造成延迟的主要原因, 这让我猜测延迟的来源是内核线程在进行实际读取。对于延迟的主要原因, 这可能需要更多的实验才能得出决定性的结论。
本文总结 那你应该不使用诺言吗?我的看法如下:
如果你要编写的脚本可以在单台计算机上运行, ??且管道或单个用户触发了特定的流程, 那么请使用同步代码。如果你要编写一个负责处理大量流量和请求的Web服务器, 则异步执行产生的开销将克服同步代码的性能。
你可以在存储库中找到本文中所有功能的代码。
从Promise开始, JavaScript开发者旅程中的合乎逻辑的下一步就是async / await语法。如果你想详细了解它以及我们如何到达这里, 请参阅异步JavaScript:从回调地狱到异步和等待。

    推荐阅读