Evaluación comparativa de una promesa de Node.js
Publicado: 2022-03-11Vivimos en un mundo nuevo y valiente. Un mundo lleno de JavaScript. En los últimos años, JavaScript ha dominado la web arrasando con toda la industria. Después de la introducción de Node.js, la comunidad de JavaScript pudo utilizar la simplicidad y el dinamismo del lenguaje para ser el único lenguaje para hacer todo, manejando el lado del servidor, el lado del cliente, e incluso se atrevió y reclamó una posición para el aprendizaje automático. Pero JavaScript ha cambiado drásticamente como lenguaje en los últimos años. Se han introducido nuevos conceptos que nunca antes existían, como funciones de flecha y promesas.
Ah, promesas. Todo el concepto de promesa y devolución de llamada no tenía mucho sentido para mí cuando empecé a aprender Node.js. Estaba acostumbrado a la forma procedimental de ejecutar código, pero con el tiempo entendí por qué era importante.
Esto nos lleva a la pregunta, ¿por qué se introdujeron las devoluciones de llamada y las promesas de todos modos? ¿Por qué no podemos simplemente escribir código ejecutado secuencialmente en JavaScript?
Bueno, técnicamente puedes. ¿Pero deberías?
En este artículo, daré una breve introducción sobre JavaScript y su tiempo de ejecución y, lo que es más importante, probaré la creencia generalizada en la comunidad de JavaScript de que el código síncrono tiene un rendimiento inferior al normal y, en cierto sentido, simplemente es malo, y nunca debería ser usado. ¿Es este mito realmente cierto?
Antes de comenzar, este artículo asume que ya está familiarizado con las promesas en JavaScript; sin embargo, si no lo está o necesita una actualización, consulte Promesas de JavaScript: un tutorial con ejemplos.
NB Este artículo ha sido probado en un entorno Node.js, no en uno de JavaScript puro. Ejecutando Node.js versión 10.14.2. Todos los puntos de referencia y la sintaxis se basarán en gran medida en Node.js. Las pruebas se realizaron en una MacBook Pro 2018 con un procesador Intel i5 de 8.ª generación de cuatro núcleos con una velocidad de reloj base de 2,3 GHz.
El bucle de eventos
El problema de escribir JavaScript es que el lenguaje en sí es de un solo subproceso. Esto significa que no puede ejecutar más de un solo procedimiento a la vez, a diferencia de otros lenguajes, como Go o Ruby, que tienen la capacidad de generar subprocesos y ejecutar varios procedimientos al mismo tiempo, ya sea en subprocesos del kernel o en subprocesos de proceso. .
Para ejecutar el código, JavaScript se basa en un procedimiento llamado bucle de eventos que se compone de varias etapas. El proceso de JavaScript pasa por cada etapa y, al final, comienza de nuevo. Puede leer más sobre los detalles en la guía oficial de node.js aquí.
Pero JavaScript tiene algo bajo la manga para luchar contra el problema de bloqueo. Devoluciones de llamadas de E/S.
La mayoría de los casos de uso de la vida real que requieren que creemos un hilo son el hecho de que estamos solicitando alguna acción de la que el lenguaje no es responsable, por ejemplo, solicitando la obtención de algunos datos de la base de datos. En lenguajes de subprocesos múltiples, el subproceso que creó la solicitud simplemente se cuelga o espera la respuesta de la base de datos. Esto es solo un desperdicio de recursos. También supone una carga para el desarrollador a la hora de elegir el número correcto de subprocesos en un grupo de subprocesos. Esto es para evitar pérdidas de memoria y la asignación de muchos recursos cuando la aplicación tiene una gran demanda.
JavaScript sobresale en una cosa más que en cualquier otro factor, el manejo de operaciones de E/S. JavaScript le permite llamar a una operación de E/S, como solicitar datos de una base de datos, leer un archivo en la memoria, escribir un archivo en el disco, ejecutar un comando de shell, etc. Cuando se completa la operación, ejecuta una devolución de llamada. O en caso de promesas, resuelves la promesa con el resultado o la rechazas con un error.
La comunidad de JavaScript siempre nos aconseja que nunca usemos código síncrono al realizar operaciones de E/S. La razón bien conocida de esto es que NO queremos bloquear nuestro código para que no ejecute otras tareas. Dado que es de un solo subproceso, si tenemos un fragmento de código que lee un archivo sincrónicamente, el código bloqueará todo el proceso hasta que se complete la lectura. En cambio, si confiamos en el código asíncrono, podemos realizar múltiples operaciones de E/S y manejar la respuesta de cada operación individualmente cuando se completa. Sin bloqueo alguno.
Pero seguramente en un entorno en el que no nos importa en absoluto manejar muchos procesos, usar código síncrono y asíncrono no hace ninguna diferencia, ¿verdad?
Punto de referencia
La prueba que vamos a ejecutar tendrá como objetivo proporcionarnos puntos de referencia sobre qué tan rápido se ejecuta el código sincronizado y asíncrono y si hay una diferencia en el rendimiento.
Decidí elegir leer un archivo como la operación de E/S para probar.
Primero, escribí una función que escribirá un archivo aleatorio lleno de bytes aleatorios generados con el módulo Crypto de Node.js.
const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )
Este archivo actuaría como una constante para nuestro siguiente paso, que es leer el archivo. aquí está el código
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()
Ejecutar el código anterior dio como resultado los siguientes resultados:
Correr # | sincronizar | asíncrono | Relación asíncrona/sincrónica |
---|---|---|---|
1 | 0.278ms | 3.829ms | 13.773 |
2 | 0.335ms | 3.801ms | 11.346 |
3 | 0.403ms | 4.498ms | 11.161 |
Esto fue inesperado. Mis expectativas iniciales eran que deberían tomar el mismo tiempo. Bueno, ¿qué tal si agregamos otro archivo y leemos 2 archivos en lugar de 1?

Repliqué el archivo test.txt generado y lo llamé test2.txt. Aquí está el código actualizado:
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") }
Simplemente agregué otra lectura para cada uno de ellos, y en promesas, estaba esperando las promesas de lectura que deberían ejecutarse en paralelo. Estos fueron los resultados:
Correr # | sincronizar | asíncrono | Relación asíncrona/sincrónica |
---|---|---|---|
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 |
La primera tiene valores completamente diferentes a las 3 corridas que siguen. Supongo que está relacionado con el compilador JavaScript JIT que optimiza el código en cada ejecución.
Entonces, las cosas no se ven tan bien para las funciones asíncronas. Tal vez si hacemos las cosas más dinámicas y tal vez enfatizamos un poco más la aplicación, podríamos obtener un resultado diferente.
Entonces, mi próxima prueba consiste en escribir 100 archivos diferentes y luego leerlos todos.
Primero, modifiqué el código para escribir 100 archivos antes de la ejecución de la prueba. Los archivos son diferentes en cada ejecución, aunque mantienen casi el mismo tamaño, por lo que borramos los archivos antiguos antes de cada ejecución.
Aquí está el código actualizado:
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") }
Y para limpieza y ejecución:
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()
Y corramos.
Aquí está la tabla de resultados:
Correr # | sincronizar | asíncrono | Relación asíncrona/sincrónica |
---|---|---|---|
1 | 4.999ms | 12.890ms | 2.579 |
2 | 5.077ms | 16.267ms | 3.204 |
3 | 5.241ms | 14.571ms | 2.780 |
4 | 5.086ms | 16.334ms | 3.213 |
Estos resultados comienzan a sacar una conclusión aquí. Indica que con el aumento de la demanda o concurrencia, las promesas de gastos generales empiezan a tener sentido. Para más detalles, si estamos ejecutando un servidor web que se supone que debe ejecutar cientos o quizás miles de solicitudes por segundo por servidor, la ejecución de operaciones de E/S mediante sincronización comenzará a perder su beneficio con bastante rapidez.
Solo por el bien de la experimentación, veamos si en realidad es un problema con las promesas en sí mismas o si es algo más. Para eso, escribí una función que calculará el tiempo para resolver una promesa que no hace absolutamente nada y otra que resuelve 100 promesas vacías.
Aquí está el código:
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()
Correr # | Promesa única | 100 promesas |
---|---|---|
1 | 1.651ms | 3.293ms |
2 | 0.758ms | 2.575ms |
3 | 0.814ms | 3.127ms |
4 | 0.788ms | 2.623ms |
Interesante. Parece que las promesas no son la causa principal de la demora, lo que me hace suponer que la fuente de la demora son los subprocesos del kernel que realizan la lectura real. Esto podría requerir un poco más de experimentación para llegar a una conclusión decisiva sobre la razón principal detrás de la demora.
Una palabra final
Entonces, ¿deberías usar promesas o no? Mi opinión sería la siguiente:
Si está escribiendo un script que se ejecutará en una sola máquina con un flujo específico activado por una canalización o un solo usuario, vaya con el código de sincronización. Si está escribiendo un servidor web que será responsable de manejar una gran cantidad de tráfico y solicitudes, entonces la sobrecarga que proviene de la ejecución asincrónica superará el rendimiento del código de sincronización.
Puede encontrar el código para todas las funciones en este artículo en el repositorio.
El próximo paso lógico en su viaje como desarrollador de JavaScript, desde las promesas, es la sintaxis async/await. Si desea obtener más información al respecto y cómo llegamos aquí, consulte JavaScript asíncrono: de Callback Hell a Async and Await .