Llevas un tiempo trabajando con Node.js. Has construido algunas aplicaciones, jugado con diferentes módulos, e incluso te has sentido cómodo con la programación asíncrona. Pero hay algo que te ha estado molestando como por ejemplo el bucle de eventos. 🤔
Al momento de la publicación de este artículo ya fue lanzado QwikV1. Si tu deseas saber acerca de QwikV1 te dejo el siguiente blog
Si eres como yo, has pasado incontables horas leyendo documentación y viendo vídeos, tratando de entender el bucle de eventos. Pero incluso siendo un desarrollador experimentado, puede ser difícil hacerse una idea completa de cómo funciona todo.
Por eso he creado esta guía visual para ayudarte a entender completamente el bucle de eventos de Node.js. Siéntate, toma una taza de café, y vamos a sumergirnos en el mundo del bucle de eventos de Node.js.
Si tu estás aprendiendo Qwik, recuerda que puedes visitar nuestro curso en
Programación asíncrona en JavaScript
Comenzaremos con un repaso a la programación asíncrona en JavaScript. Aunque JavaScript encuentra su uso en aplicaciones web, móviles y de escritorio, es importante recordar que en su forma más básica, JavaScript es un lenguaje síncrono, de bloqueo y de un solo hilo. Vamos a entender esa línea con un breve fragmento de código. 👏🏻
// index.js
function A() {
console.log("A");
}
function B() {
console.log("B");
}
A()
B()
// Logs A and then B
JavaScript es síncrono
Si tenemos dos funciones que registran mensajes en la consola, el código se ejecuta de arriba abajo, con una sola línea ejecutándose en cada momento. En el fragmento de código, vemos que A se registra antes que B.
JavaScript se bloquea
JavaScript es bloqueante debido a su naturaleza sincrónica. No importa lo que tarde un proceso anterior, los procesos posteriores no se iniciarán hasta que el primero se haya completado.
En el fragmento de código, si la función A tiene que ejecutar un trozo intensivo de código, JavaScript tiene que terminarlo sin pasar a la función B. Incluso si ese código tarda 10 segundos o 1 minuto.
Es posible que hayas experimentado esto en el navegador. Cuando una aplicación web se ejecuta en un navegador y ejecuta un trozo intensivo de código sin devolver el control al navegador, éste puede parecer congelado. Esto se llama bloqueo.
El navegador está bloqueado y no puede seguir gestionando las entradas del usuario ni realizar otras tareas hasta que la aplicación web devuelve el control del procesador.
JavaScript es monohilo
Un hilo es simplemente un proceso que tu programa JavaScript puede utilizar para ejecutar una tarea. Y cada hilo sólo puede realizar una tarea a la vez. A diferencia de otros lenguajes que soportan multihilos y pueden ejecutar múltiples tareas en paralelo, JavaScript sólo tiene un hilo llamado hilo principal para ejecutar cualquier código.
Esperar a JavaScript
Como habrás adivinado, este modelo de JavaScript crea un problema porque tenemos que esperar a que se obtengan los datos antes de poder continuar con la ejecución del código. 🫠
Esta espera puede durar varios segundos, durante los cuales no podemos ejecutar más código. Si JavaScript continúa sin esperar, nos encontraremos con un error. Necesitamos una forma de tener un comportamiento asíncrono en JavaScript. Entra Node.js.
Tiempo de ejecución de Node.js
El tiempo de ejecución de Node.js es un entorno en el que se puede utilizar y ejecutar un programa JavaScript fuera de un navegador. En su núcleo, el tiempo de ejecución de Node consta de tres componentes principales.
- Dependencias externas: Como V8, libuv, crypto - requeridas por Node.js para su funcionamiento.
- Funciones C++: Que proporcionan funcionalidades como el acceso al sistema de archivos y a la red.
- Una biblioteca JavaScript que proporciona funciones y utilidades para aprovechar las características de C++ desde tu código JavaScript.
Aunque todas las partes son importantes, el componente clave para la programación asíncrona en Node.js es la dependencia externa, libuv.
Si tu quieres aprender más acerca de NodeJS, recuerda que puedes visitar el curso
Libuv
Libuv es una librería multiplataforma de código abierto escrita en C. En el tiempo de ejecución de Node.js, su papel es proporcionar soporte para el manejo de operaciones asíncronas. Veamos cómo funciona. 🤩
Ejecución de código en el tiempo de ejecución de Node.js
Vamos a conceptualizar cómo se ejecuta típicamente el código en el tiempo de ejecución de Node . Cuando ejecutamos código, el motor V8, situado a la izquierda de la imagen, se encarga de la ejecución del código JavaScript. El motor consta de un montón de memoria y una pila de llamadas.
Cada vez que declaramos variables o funciones, se asigna memoria al montón, y cada vez que ejecutamos código, las funciones se introducen en la pila de llamadas. Cuando una función regresa, se retira de la pila de llamadas. Esta es una implementación directa de la estructura de datos de la pila, donde el último elemento añadido es el primero en ser eliminado.
A la derecha de la imagen, tenemos libuv, que es responsable del manejo de métodos asíncronos.
Cada vez que ejecutamos un método asíncrono, libuv se hace cargo de la ejecución de la tarea. Libuv entonces ejecuta la tarea utilizando mecanismos asíncronos nativos del sistema operativo. En caso de que los mecanismos nativos no estén disponibles o sean inadecuados, utiliza su pool de hilos para ejecutar la tarea, asegurándose de que el hilo principal no se bloquea.
Ejecución sincrónica de código
En primer lugar, echemos un vistazo a la ejecución sincrónica de código. El siguiente código consiste en tres sentencias log de consola que registran "Primero", "Segundo" y "Tercero" uno tras otro.
Repasemos el código como si se estuviera ejecutando en tiempo de ejecución.
// index.js
console.log("First");
console.log("Second");
console.log("Third");
A continuación se muestra cómo puede visualizarse la ejecución de código síncrono con el tiempo de ejecución Node.
El hilo principal de ejecución siempre comienza en el ámbito global. La función global, si podemos llamarla así, se coloca en la pila. Luego, en la línea 1, tenemos una declaración de registro de consola.
La función se coloca en la pila. Suponiendo que esto sucede en 1 ms, "Primero" se registra en la consola. Luego, la función es sacada de la pila.
La ejecución llega a la línea 3. Digamos que a los 2 ms, la función de registro se vuelve a colocar en la pila. "Segundo" se registra en la consola, y la función se retira de la pila.
Finalmente, la ejecución está en la línea 5. A los 3ms, la función se coloca en la pila, "Tercero" se registra en la consola, y la función se retira de la pila. No hay más código para ejecutar, y el global también es retirado.
// Dynamically render your components
export function MyPage({ json }) {
return <BuilderComponent content={json} />
}
registerComponents([MyHero, MyProducts])
Ejecución de código asíncrono
A continuación, echemos un vistazo a la ejecución de código asíncrono. Consideremos el siguiente fragmento de código. Hay tres sentencias de registro, pero esta vez la segunda sentencia de registro está dentro de una función callback pasada a fs.readFile()
.
El hilo principal de ejecución siempre comienza en el ámbito global. La función global se coloca en la pila. La ejecución llega entonces a la línea 1. En 1ms, "Primero" se registra en la consola, y la función se retira de la pila. La ejecución pasa a la línea 3.
A los 2ms, el método readFile
es empujado a la pila. Como readFile
es una operación asíncrona, se descarga a libuv.
JavaScript retira el método readFile
de la pila de llamadas porque su trabajo ha terminado en lo que respecta a la ejecución de la línea 3. En segundo plano, libuv comienza a leer el contenido del archivo en un hilo separado. A los 3ms, JavaScript procede a la línea 7, empuja la función log a la pila, "Tercero" se registra en la consola, y la función se retira de la pila.
Aproximadamente a los 4ms, digamos que la tarea de lectura del fichero se ha completado en el pool de hilos. La función callback asociada se ejecuta ahora en la pila de llamadas. Dentro de la función callback, se encuentra la sentencia log
.
Esta se empuja a la pila de llamadas, "Second" se registra en la consola, y la función de registro se sale. Como no hay más sentencias que ejecutar en la función callback, ésta también se elimina. No hay más código que ejecutar, por lo que la función global también se elimina de la pila.
La salida de la consola va a leer "Primero", "Tercero", y luego "Segundo".
Libuv y las operaciones asíncronas
Está bastante claro que libuv ayuda a manejar operaciones asíncronas en Node.js. Para operaciones asíncronas como manejar una petición de red, libuv se apoya en las primitivas del sistema operativo. Para operaciones asíncronas como leer un archivo que no tiene soporte nativo del sistema operativo, libuv confía en su pool de hilos para asegurar que el hilo principal no se bloquea. Sin embargo, esto inspira algunas preguntas.
Cuando una tarea asíncrona se completa en libuv:
1. ¿en qué momento decide Node ejecutar la función callback asociada en la pila de llamadas?
2. ¿Espera Node a que la pila de llamadas esté vacía antes de ejecutar la función callback, o interrumpe el flujo normal de ejecución para ejecutar la función callback?
3. ¿Qué ocurre con otros métodos asíncronos como setTimeout
y setInterval
, que también retrasan la ejecución de una función callback?
4. Si dos tareas asíncronas como setTimeout
y readFile
se completan al mismo tiempo, ¿cómo decide Node qué función callback ejecutar primero en la pila de llamadas? ¿Una tiene prioridad sobre la otra?
Todas estas preguntas pueden ser respondidas mediante la comprensión de la parte central de libuv, que es el bucle de eventos.
¿Qué es el bucle de eventos?
Técnicamente, el bucle de eventos es sólo un programa en C. Pero, puedes pensar en él como un patrón de diseño que orquesta o coordina la ejecución de código síncrono y asíncrono en Node.js.
Visualización del bucle de eventos
El bucle de eventos es un bucle que se ejecuta mientras tu aplicación Node.js está en marcha. Hay seis colas diferentes en cada bucle, cada una de las cuales contiene una o más funciones callback que necesitan ser ejecutadas en la pila de llamadas eventualmente.
En primer lugar, está la cola del temporizador (técnicamente un min-heap), que contiene callbacks asociados con setTimeout
y setInterval
.
En segundo lugar, está la cola I/O que contiene callbacks asociados con todos los métodos asíncronos como los métodos asociados con los módulos fs
y http
.
Tercero, está la cola check que contiene callbacks asociados con la función setImmediate
, que es específica de Node.
En cuarto lugar, está la cola de cierre que contiene callbacks asociados con el evento de cierre de una tarea asíncrona.
Por último, está la cola microtask que contiene dos colas separadas.La cola nextTick
que contiene callbacks asociados con la función process.nextTick.
Promise que contiene las llamadas de retorno asociadas con la Promise nativa de JavaScript.
Es importante tener en cuenta que las colas timer
, I/O
, check
y close
forman parte de libuv. Las dos colas de microtarea, sin embargo, no forman parte de libuv. Sin embargo, siguen siendo parte del tiempo de ejecución de Node y juegan un papel importante en el orden de ejecución de las llamadas de retorno. Hablando de eso, vamos a entenderlo a continuación.
Cómo funciona el bucle de eventos
Las puntas de flecha ya nos delatan, pero es fácil confundirse. Permítanme explicar el orden de prioridad de las colas. En primer lugar, debes saber que todo el código JavaScript síncrono escrito por el usuario tiene prioridad sobre el código asíncrono que el tiempo de ejecución desea ejecutar.
Esto significa que sólo cuando la pila de llamadas está vacía entra en juego el bucle de eventos.
Dentro del bucle de eventos, la secuencia de ejecución sigue ciertas reglas. Hay un buen número de reglas para envolver su cabeza alrededor, así que vamos a ir sobre ellos uno a la vez:
- Cualquier llamada de retorno en la cola de microtareas se ejecuta. Primero, las tareas en la cola
nextTick
y sólo después las tareas en la colapromise.
- Se ejecutan todas las devoluciones de llamada en la cola del temporizador.
- Las devoluciones de llamada en la cola de microtareas (si están presentes) se ejecutan después de cada devolución de llamada en la cola del temporizador.
- Primero, las tareas en la cola
nextTick
, y luego las tareas en la cola promise. - Se ejecutan todas las llamadas de retorno de la cola de
E/S
. - Se ejecutan las callbacks en las colas de microtareas (si están presentes), comenzando con
nextTickQueue
y luego con la colaPromise
. - Se ejecutan todas las callbacks en la cola de comprobación.
- Las devoluciones de llamada en las colas de microtareas (si están presentes) se ejecutan después de cada devolución de llamada en la cola de comprobación. En primer lugar, las tareas de la cola
nextTick
y, a continuación, las tareas de la colapromise
. - Se ejecutan todas las retrollamadas de la cola de cierre.
Por última vez en el mismo bucle, se ejecutan las colas de microtareas. En primer lugar, las tareas de la cola nextTick
y, a continuación, las tareas de la cola promise.
Si en este punto hay más callbacks que procesar, el bucle se mantiene vivo para una ejecución más, y se repiten los mismos pasos. Por otro lado, si se ejecutan todas las retrollamadas y no hay más código que procesar, el bucle de eventos sale.
Este es el papel que juega el bucle de eventos de libuv en la ejecución de código asíncrono en Node.js. Con estas reglas en mente, podemos volver a las preguntas de antes.
Cuando una tarea asíncrona se completa en libuv, ¿en qué momento decide Node ejecutar la función callback asociada en la pila de llamadas?
Respuesta:
Las funciones callback se ejecutan sólo cuando la pila de llamadas está vacía.
¿Espera Node a que la pila de llamadas esté vacía antes de ejecutar la función callback, o interrumpe el flujo normal de ejecución para ejecutar la función callback?
Respuesta:
El flujo normal de ejecución no será interrumpido para ejecutar una función callback.
¿Qué ocurre con otros métodos asíncronos como setTimeout
y setInterval
, que también retrasan la ejecución de una función callback?
Respuesta:
Las retrollamadas setTimeout
y setInterval
tienen prioridad.
¿Qué ocurre con otros métodos asíncronos como setTimeout
y setInterval
, que también retrasan la ejecución de una función callback?
Respuesta:
Las devoluciones de llamada setTimeout
y setInterval
tienen prioridad.
Si dos tareas asíncronas como setTimeout
y readFile
se completan al mismo tiempo, ¿cómo decide Node qué función callback se ejecuta primero en la pila de llamadas? ¿Una tiene prioridad sobre la otra?
Respuesta:
Las callbacks de temporizador se ejecutan antes que las callbacks de E/S, incluso si ambas están listas exactamente al mismo tiempo.
Aprendimos mucho más, pero me gustaría que grabaras en tu mente esta representación visual de abajo (que es la misma que la de arriba), ya que muestra cómo Node.js ejecuta código asíncrono bajo el capó.
"Pero espera, ¿dónde está el código que verifica esta visualización?", te preguntarás. Bueno, cada cola en el bucle de eventos tiene matices en la ejecución, por lo que es óptimo tratar con ellos de uno en uno.
Conclusión
El bucle de eventos es una parte fundamental de Node.js que permite la programación asíncrona asegurando que el hilo principal no se bloquee. Entender cómo funciona el bucle de eventos puede ser un reto, pero es esencial para construir aplicaciones de alto rendimiento.
Esta guía visual ha cubierto los fundamentos de la programación asíncrona en JavaScript, el tiempo de ejecución de Node.js y libuv, que es responsable del manejo de las operaciones asíncronas. Con este conocimiento, puedes construir un modelo mental sólido del bucle de eventos, que te ayudará a escribir código que aproveche la naturaleza asíncrona de Node.js.