El Bucle de Eventos de Node.js
¿Qué es el Bucle de Eventos?
El bucle de eventos permite a Node.js realizar operaciones de E/S no bloqueantes —a pesar de que por defecto se utiliza un único hilo de JavaScript— al descargar las operaciones al núcleo del sistema siempre que sea posible.
Dado que la mayoría de los núcleos modernos son multihilo, pueden manejar múltiples operaciones que se ejecutan en segundo plano. Cuando una de estas operaciones se completa, el núcleo se lo comunica a Node.js para que la devolución de llamada apropiada pueda añadirse a la cola de sondeo y, finalmente, ejecutarse. Explicaremos esto con más detalle más adelante en este tema.
El Bucle de Eventos Explicado
Cuando Node.js se inicia, inicializa el bucle de eventos, procesa el script de entrada proporcionado (o entra en el REPL, que no se trata en este documento), que puede realizar llamadas a la API asincrónica, programar temporizadores o llamar a process.nextTick()
, y luego comienza a procesar el bucle de eventos.
El siguiente diagrama muestra una visión general simplificada del orden de las operaciones del bucle de eventos.
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
TIP
A cada recuadro se le denominará "fase" del bucle de eventos.
Cada fase tiene una cola FIFO de devoluciones de llamada que ejecutar. Si bien cada fase es especial a su manera, en general, cuando el bucle de eventos entra en una fase determinada, realizará cualquier operación específica de esa fase y luego ejecutará las devoluciones de llamada en la cola de esa fase hasta que la cola se haya agotado o se haya ejecutado el número máximo de devoluciones de llamada. Cuando la cola se ha agotado o se alcanza el límite de devoluciones de llamada, el bucle de eventos pasa a la siguiente fase, y así sucesivamente.
Dado que cualquiera de estas operaciones puede programar más operaciones y los nuevos eventos procesados en la fase de sondeo son puestos en cola por el núcleo, los eventos de sondeo pueden ponerse en cola mientras se procesan los eventos de sondeo. Como resultado, las devoluciones de llamada de larga duración pueden permitir que la fase de sondeo se ejecute mucho más tiempo que el umbral de un temporizador. Consulte las secciones de temporizadores y sondeo para obtener más detalles.
TIP
Existe una ligera discrepancia entre la implementación de Windows y la de Unix/Linux, pero eso no es importante para esta demostración. Las partes más importantes están aquí. En realidad hay siete u ocho pasos, pero los que nos importan —los que Node.js realmente utiliza— son los anteriores.
Visión general de las fases
- temporizadores: esta fase ejecuta las devoluciones de llamada programadas por
setTimeout()
esetInterval()
. - devoluciones de llamada pendientes: ejecuta las devoluciones de llamada de E/S aplazadas a la siguiente iteración del bucle.
- inactivo, preparación: solo se utiliza internamente.
- sondeo: recupera nuevos eventos de E/S; ejecuta las devoluciones de llamada relacionadas con E/S (casi todas, excepto las devoluciones de llamada de cierre, las programadas por temporizadores y
setImmediate()
); Node se bloqueará aquí cuando sea apropiado. - comprobación: las devoluciones de llamada
setImmediate()
se invocan aquí. - devoluciones de llamada de cierre: algunas devoluciones de llamada de cierre, por ejemplo,
socket.on('close', ...)
.
Entre cada ejecución del bucle de eventos, Node.js comprueba si está esperando alguna E/S asincrónica o temporizadores y se cierra limpiamente si no hay ninguno.
Fases en detalle
temporizadores
Un temporizador especifica el umbral después del cual se puede ejecutar una devolución de llamada proporcionada en lugar de la hora exacta en que una persona desea que se ejecute. Las devoluciones de llamada de los temporizadores se ejecutarán tan pronto como sea posible después de que haya transcurrido el tiempo especificado; sin embargo, la programación del sistema operativo o la ejecución de otras devoluciones de llamada pueden retrasarlas.
TIP
Técnicamente, la fase sondeo controla cuándo se ejecutan los temporizadores.
Por ejemplo, digamos que programa un temporizador para que se ejecute después de un umbral de 100 ms, luego su script comienza a leer un archivo de forma asincrónica, lo que tarda 95 ms:
const fs = require('node:fs')
function someAsyncOperation(callback) {
// Supongamos que esto tarda 95 ms en completarse
fs.readFile('/path/to/file', callback)
}
const timeoutScheduled = Date.now()
setTimeout(() => {
const delay = Date.now() - timeoutScheduled
console.log(`${delay}ms have passed since I was scheduled`)
}, 100)
// realiza someAsyncOperation que tarda 95 ms en completarse
someAsyncOperation(() => {
const startCallback = Date.now()
// hacer algo que tomará 10ms...
while (Date.now() - startCallback < 10) {
// no hacer nada
}
})
Cuando el bucle de eventos entra en la fase de sondeo, tiene una cola vacía (fs.readFile()
no se ha completado), por lo que esperará la cantidad de ms restantes hasta que se alcance el umbral del temporizador más próximo. Mientras espera que pasen 95 ms, fs.readFile()
termina de leer el archivo y su devolución de llamada, que tarda 10 ms en completarse, se agrega a la cola de sondeo y se ejecuta. Cuando la devolución de llamada finaliza, no hay más devoluciones de llamada en la cola, por lo que el bucle de eventos verá que se ha alcanzado el umbral del temporizador más próximo y luego volverá a la fase de temporizadores para ejecutar la devolución de llamada del temporizador. En este ejemplo, verá que el retraso total entre la programación del temporizador y la ejecución de su devolución de llamada será de 105 ms.
TIP
Para evitar que la fase de sondeo agote el bucle de eventos, libuv (la biblioteca C que implementa el bucle de eventos de Node.js y todos los comportamientos asíncronos de la plataforma) también tiene un máximo absoluto (dependiente del sistema) antes de que deje de sondear más eventos.
callbacks pendientes
Esta fase ejecuta callbacks para algunas operaciones del sistema, como tipos de errores TCP. Por ejemplo, si un socket TCP recibe ECONNREFUSED
al intentar conectarse, algunos sistemas *nix quieren esperar para informar el error. Esto se pondrá en cola para ejecutarse en la fase de callbacks pendientes.
sondeo (poll)
La fase de sondeo (poll) tiene dos funciones principales:
- Calcular cuánto tiempo debe bloquearse y sondear la E/S, luego
- Procesar eventos en la cola de sondeo (poll).
Cuando el bucle de eventos entra en la fase de sondeo (poll) y no hay temporizadores programados, ocurrirán dos cosas:
Si la cola de sondeo (poll) no está vacía, el bucle de eventos iterará a través de su cola de callbacks ejecutándolos sincrónicamente hasta que la cola se haya agotado o se alcance el límite máximo dependiente del sistema.
Si la cola de sondeo (poll) está vacía, ocurrirán dos cosas más:
Si se han programado scripts con
setImmediate()
, el bucle de eventos terminará la fase de sondeo (poll) y continuará con la fase de verificación para ejecutar esos scripts programados.Si los scripts no se han programado con
setImmediate()
, el bucle de eventos esperará a que se agreguen callbacks a la cola y luego los ejecutará inmediatamente.
Una vez que la cola de sondeo (poll) está vacía, el bucle de eventos verificará los temporizadores cuyos umbrales de tiempo se han alcanzado. Si uno o más temporizadores están listos, el bucle de eventos volverá a la fase de temporizadores para ejecutar los callbacks de esos temporizadores.
verificación (check)
Esta fase permite ejecutar callbacks inmediatamente después de que la fase de sondeo (poll) haya finalizado. Si la fase de sondeo (poll) se queda inactiva y se han puesto en cola scripts con setImmediate()
, el bucle de eventos puede continuar con la fase de verificación en lugar de esperar.
setImmediate()
es en realidad un temporizador especial que se ejecuta en una fase separada del bucle de eventos. Utiliza una API de libuv que programa callbacks para que se ejecuten después de que la fase de sondeo (poll) haya finalizado.
Generalmente, a medida que se ejecuta el código, el bucle de eventos eventualmente llegará a la fase de sondeo (poll) donde esperará una conexión entrante, una solicitud, etc. Sin embargo, si se ha programado un callback con setImmediate()
y la fase de sondeo (poll) se queda inactiva, terminará y continuará con la fase de verificación en lugar de esperar eventos de sondeo (poll).
Retrollamadas de cierre
Si un socket o manejador se cierra abruptamente (por ejemplo, socket.destroy()
), el evento 'close'
se emitirá en esta fase. De lo contrario, se emitirá a través de process.nextTick()
.
setImmediate()
vs setTimeout()
setImmediate()
y setTimeout()
son similares, pero se comportan de manera diferente dependiendo de cuándo se llaman.
setImmediate()
está diseñado para ejecutar un script una vez que se completa la fase actual de sondeo (poll).setTimeout()
programa la ejecución de un script después de que haya transcurrido un umbral mínimo en ms.
El orden en que se ejecutan los temporizadores variará dependiendo del contexto en el que se llamen. Si ambos se llaman desde el módulo principal, el tiempo estará limitado por el rendimiento del proceso (que puede verse afectado por otras aplicaciones que se ejecutan en la máquina).
Por ejemplo, si ejecutamos el siguiente script que no está dentro de un ciclo de E/S (es decir, el módulo principal), el orden en que se ejecutan los dos temporizadores no es determinista, ya que está limitado por el rendimiento del proceso:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
Sin embargo, si mueve las dos llamadas dentro de un ciclo de E/S, la devolución de llamada inmediata siempre se ejecuta primero:
// timeout_vs_immediate.js
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
La principal ventaja de usar setImmediate()
sobre setTimeout()
es que setImmediate()
siempre se ejecutará antes que cualquier temporizador si se programa dentro de un ciclo de E/S, independientemente de cuántos temporizadores estén presentes.
process.nextTick()
Comprender process.nextTick()
Puede que haya notado que process.nextTick()
no se mostró en el diagrama, aunque es parte de la API asíncrona. Esto se debe a que process.nextTick()
técnicamente no forma parte del bucle de eventos. En cambio, la nextTickQueue
se procesará después de que se complete la operación actual, independientemente de la fase actual del bucle de eventos. Aquí, una operación se define como una transición desde el manejador subyacente de C/C++ y el manejo de JavaScript que necesita ser ejecutado.
Mirando nuestro diagrama, cada vez que llame a process.nextTick()
en una fase determinada, todas las devoluciones de llamada pasadas a process.nextTick()
se resolverán antes de que el bucle de eventos continúe. Esto puede crear algunas situaciones negativas porque le permite "matar de hambre" su E/S al realizar llamadas recursivas a process.nextTick()
, lo que impide que el bucle de eventos llegue a la fase de sondeo.
¿Por qué se permitiría eso?
¿Por qué se incluiría algo así en Node.js? Parte de ello es una filosofía de diseño donde una API siempre debe ser asíncrona incluso cuando no tiene que serlo. Tome este fragmento de código como ejemplo:
function apiCall(arg, callback) {
if (typeof arg !== 'string') return process.nextTick(callback, new TypeError('argument should be string'))
}
El fragmento realiza una comprobación de argumentos y, si no es correcta, pasará el error a la devolución de llamada. La API se actualizó hace relativamente poco para permitir el paso de argumentos a process.nextTick()
, lo que permite que cualquier argumento pasado después de la devolución de llamada se propague como argumentos a la devolución de llamada para que no tenga que anidar funciones.
Lo que estamos haciendo es pasar un error al usuario, pero solo después de haber permitido que se ejecute el resto del código del usuario. Al usar process.nextTick()
, garantizamos que apiCall()
siempre ejecuta su devolución de llamada después del resto del código del usuario y antes de que se permita que el bucle de eventos prosiga. Para lograr esto, se permite que la pila de llamadas de JS se desenrolle y luego se ejecute inmediatamente la devolución de llamada proporcionada, lo que permite a una persona realizar llamadas recursivas a process.nextTick()
sin llegar a un RangeError: Maximum call stack size exceeded from v8
.
Esta filosofía puede llevar a algunas situaciones potencialmente problemáticas. Tome este fragmento como ejemplo:
let bar
// esto tiene una firma asíncrona, pero llama a la devolución de llamada de forma síncrona
function someAsyncApiCall(callback) {
callback()
}
// la devolución de llamada se llama antes de que se complete `someAsyncApiCall`.
someAsyncApiCall(() => {
// como someAsyncApiCall no se ha completado, bar no ha recibido ningún valor
console.log('bar', bar) // undefined
})
bar = 1
El usuario define someAsyncApiCall()
para que tenga una firma asíncrona, pero en realidad opera de forma síncrona. Cuando se llama, la devolución de llamada proporcionada a someAsyncApiCall()
se llama en la misma fase del bucle de eventos porque someAsyncApiCall()
en realidad no hace nada de forma asíncrona. Como resultado, la devolución de llamada intenta hacer referencia a bar aunque es posible que aún no tenga esa variable en el ámbito, porque el script no ha podido ejecutarse hasta su finalización.
Al colocar la devolución de llamada en un process.nextTick()
, el script aún tiene la capacidad de ejecutarse hasta su finalización, permitiendo que todas las variables, funciones, etc., se inicialicen antes de que se llame a la devolución de llamada. También tiene la ventaja de no permitir que el bucle de eventos continúe. Puede ser útil para que el usuario sea alertado de un error antes de que se permita que el bucle de eventos continúe. Aquí está el ejemplo anterior usando process.nextTick()
:
let bar
function someAsyncApiCall(callback) {
process.nextTick(callback)
}
someAsyncApiCall(() => {
console.log('bar', bar) // 1
})
bar = 1
Aquí hay otro ejemplo del mundo real:
const server = net.createServer(() => {}).listen(8080)
server.on('listening', () => {})
Cuando solo se pasa un puerto, el puerto se enlaza inmediatamente. Por lo tanto, la devolución de llamada 'listening'
podría llamarse inmediatamente. El problema es que la devolución de llamada .on('listening')
no se habrá establecido para ese momento.
Para solucionar esto, el evento 'listening'
se pone en cola en un nextTick()
para permitir que el script se ejecute hasta su finalización. Esto permite al usuario establecer cualquier manejador de eventos que desee.
process.nextTick()
vs setImmediate()
Disponemos de dos llamadas que son similares desde el punto de vista del usuario, pero sus nombres son confusos.
process.nextTick()
se ejecuta inmediatamente en la misma fase.setImmediate()
se ejecuta en la siguiente iteración o 'tick' del bucle de eventos.
En esencia, los nombres deberían intercambiarse. process.nextTick()
se ejecuta más inmediatamente que setImmediate()
, pero esto es un artefacto del pasado que es improbable que cambie. Hacer este cambio rompería un gran porcentaje de los paquetes en npm. Cada día se añaden más módulos nuevos, lo que significa que cada día que esperamos, se producen más posibles roturas. Si bien son confusos, los nombres en sí mismos no cambiarán.
TIP
Recomendamos a los desarrolladores usar setImmediate()
en todos los casos porque es más fácil de razonar.
¿Por qué usar process.nextTick()
?
Hay dos razones principales:
Permitir a los usuarios manejar errores, limpiar cualquier recurso innecesario o quizás intentar la solicitud de nuevo antes de que continúe el bucle de eventos.
A veces es necesario permitir que una devolución de llamada se ejecute después de que la pila de llamadas se haya descargado, pero antes de que continúe el bucle de eventos.
Un ejemplo es igualar las expectativas del usuario. Ejemplo simple:
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})
Digamos que listen()
se ejecuta al principio del bucle de eventos, pero la devolución de llamada de escucha se coloca en un setImmediate()
. A menos que se pase un nombre de host, la vinculación al puerto ocurrirá inmediatamente. Para que el bucle de eventos continúe, debe llegar a la fase de sondeo, lo que significa que existe una probabilidad distinta de cero de que se haya recibido una conexión que permita que se dispare el evento de conexión antes del evento de escucha.
Otro ejemplo es extender un EventEmitter
y emitir un evento desde dentro del constructor:
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
this.emit('event')
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log('¡Se produjo un evento!')
})
No se puede emitir un evento desde el constructor inmediatamente porque el script no habrá procesado hasta el punto en que el usuario asigne una devolución de llamada a ese evento. Por lo tanto, dentro del constructor mismo, puede usar process.nextTick()
para establecer una devolución de llamada para emitir el evento después de que el constructor haya finalizado, lo que proporciona los resultados esperados:
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
// use nextTick to emit the event once a handler is assigned
process.nextTick(() => {
this.emit('event')
})
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log('¡Se produjo un evento!')
})