Un bucle de eventos es el mecanismo de Node.js para manejar operaciones de E/S sin bloqueo, aunque JavaScript sea de un solo subproceso, descargando operaciones al kernel del sistema cuando sea posible.
Dado que la mayoría de los núcleos actuales son multiproceso, pueden manejar una variedad de operaciones en segundo plano. Cuando se completa una de las operaciones, el kernel notifica a Node.js que agregue la función de devolución de llamada apropiada a la cola de sondeo y espere la oportunidad de ejecutarse. Lo presentaremos en detalle más adelante en este artículo.
Cuando se inicia Node.js, inicializará el bucle de eventos y procesará el script de entrada proporcionado (o lo lanzará al REPL, que no se trata en este artículo. Puede llamar a algunas API asincrónicas, temporizadores de programación, etc.). o llame process.nextTick()
y luego comience a procesar el bucle de eventos.
El siguiente diagrama muestra una descripción general simplificada de la secuencia de operaciones del bucle de eventos.
┌───────────────────────────┐ ┌─>│ temporizadores │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ devoluciones de llamada pendientes │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ inactivo, prepárate │ │ └─────────────┬────────────┘ ┌────────── ──────┐ │ ┌─────────────┴────────────┐ │ entrante: │ │ │ encuesta │<─────┤ conexiones, │ │ └─────────────┬─────────────┘ │ datos, etc. │ ┌─────────────┴────────────┐ └────────── ──────┘ │ │ comprobar │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ cerrar devoluciones de llamada │ └─────────────────────────────┘
Nota: Cada cuadro se denomina etapa del mecanismo de bucle de eventos.
Cada etapa tiene una cola FIFO para ejecutar devoluciones de llamada. Si bien cada etapa es especial, generalmente cuando el bucle de eventos ingresa a una etapa determinada, realizará cualquier operación específica de esa etapa y luego ejecutará las devoluciones de llamada en la cola de esa etapa hasta que la cola se agote o se haya ejecutado el número máximo de devoluciones de llamada. Cuando la cola se agota o se alcanza el límite de devolución de llamadas, el bucle de eventos pasa a la siguiente fase, y así sucesivamente.
Dado que cualquiera de estas operaciones puede programar más operaciones y nuevos eventos en cola por el núcleo para ser procesados durante la fase de sondeo , los eventos de sondeo pueden ponerse en cola mientras se procesan eventos en la fase de sondeo. Por lo tanto, una devolución de llamada de larga duración puede permitir que la fase de sondeo se ejecute por más tiempo que el umbral de tiempo del temporizador. Consulte la sección Temporizadores y sondeos para obtener más información.
Nota: Existen diferencias sutiles entre las implementaciones de Windows y Unix/Linux, pero esto no es importante para los fines de la demostración. La parte más importante está aquí. En realidad, hay siete u ocho pasos, pero lo que nos importa es que Node.js en realidad utilice algunos de los pasos anteriores.
TemporizadorsetTimeout()
y setInterval()
.setImmediate()
), en otros casos el nodo se bloqueará aquí cuando sea apropiado.setImmediate()
.socket.on('close', ...)
.Entre cada ejecución del bucle de eventos, Node.js comprueba si está esperando E/S asincrónicas o temporizadores y, si no, se apaga por completo.
Los temporizadores especifican el umbral en el que se puede ejecutar la devolución de llamada proporcionada, en lugar del momento exacto en que el usuario desea que se ejecute. Después del intervalo especificado, la devolución de llamada del temporizador se ejecutará lo antes posible. Sin embargo, es posible que se retrasen debido a la programación del sistema operativo u otras devoluciones de llamadas en ejecución.
Nota : La fase de sondeo controla cuándo se ejecuta el temporizador.
Por ejemplo, supongamos que programa un temporizador que se agota después de 100 milisegundos y luego su secuencia de comandos comienza a leer de forma asincrónica un archivo que tarda 95 milisegundos:
const fs = require('fs'); función algunaAsyncOperation (devolución de llamada) { // Supongamos que esto tarda 95 ms en completarse fs.readFile('/ruta/al/archivo', devolución de llamada); } const timeoutScheduled = Fecha.ahora(); setTimeout(() => { retraso constante = Date.now() - timeoutScheduled; console.log(`${delay}ms han pasado desde que estaba programado`); }, 100); // realiza alguna operación Async que tarda 95 ms en completarse algunaOperaciónAsync(() => { const startCallback = Fecha.ahora(); // haz algo que tomará 10 ms... mientras (Fecha.ahora() - startCallback < 10) { // no hacer nada } });
cuando el bucle de eventos ingresa a la fase de sondeo , tiene una cola vacía ( fs.readFile()
aún no se ha completado), por lo que esperará el número restante de milisegundos hasta que se alcance el umbral del temporizador más rápido. Cuando espera 95 milisegundos a que fs.readFile()
termine de leer el archivo, su devolución de llamada, que tarda 10 milisegundos en completarse, se agregará a la cola de sondeo y se ejecutará. Cuando se completa la devolución de llamada, no hay más devoluciones de llamada en la cola, por lo que el mecanismo de bucle de eventos observará el temporizador que alcanzó el umbral más rápido y luego volverá a la fase del temporizador 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 milisegundos.
NOTA: Para evitar que la fase de sondeo muera de hambre el bucle de eventos, libuv (la biblioteca C que implementa el bucle de eventos de Node.js y todo el comportamiento asincrónico de la plataforma) también tiene un máximo estricto (dependiente del sistema).
Esta fase ejecuta devoluciones de llamada para ciertas operaciones del sistema (como tipos de errores TCP). Por ejemplo, algunos sistemas *nix quieren esperar para informar un error si un socket TCP recibe ECONNREFUSED
al intentar conectarse. Esto se pondrá en cola para su ejecución durante la fase de devolución de llamada pendiente .
La fase de sondeo tiene dos funciones importantes:
calcular durante cuánto tiempo se deben bloquear y sondear las E/S.
Luego, maneje los eventos en la cola de sondeo .
Cuando el bucle de eventos ingresa a la fase de sondeo y no hay temporizadores programados, sucederá una de dos cosas:
si la cola de sondeo no está vacía
, el bucle de eventos recorrerá la cola de devolución de llamadas y los ejecutará sincrónicamente hasta que la cola esté vacía. , o se alcanzó un límite estricto relacionado con el sistema.
Si la cola de sondeo está vacía , suceden dos cosas más:
si el script está programado por setImmediate()
, el bucle de eventos finalizará la fase de sondeo y continuará la fase de verificación para ejecutar esos scripts programados.
Si setImmediate()
no programa el script, el bucle de eventos esperará a que se agregue la devolución de llamada a la cola y luego la ejecutará inmediatamente.
Una vez que la cola de sondeo está vacía, el bucle de eventos busca un temporizador que haya alcanzado su umbral de tiempo. Si uno o más temporizadores están listos, el bucle de eventos regresa a la fase del temporizador para ejecutar las devoluciones de llamada para esos temporizadores.
Esta fase permite ejecutar una devolución de llamada inmediatamente después de que se completa la fase de sondeo. Si la fase de sondeo queda inactiva y el script se pone en cola después de usar setImmediate()
, el bucle de eventos puede continuar hasta 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 libuv para programar devoluciones de llamadas que se ejecutarán después de que se complete la fase de sondeo .
Normalmente, al ejecutar código, el bucle de eventos finalmente llega a la fase de sondeo, donde espera conexiones entrantes, solicitudes, etc. Sin embargo, si la devolución de llamada se programó usando setImmediate()
y la fase de sondeo queda inactiva, finalizará esta fase y continuará con la fase de verificación en lugar de continuar esperando el evento de sondeo.
Si el socket o el controlador se cierra repentinamente (por ejemplo, socket.destroy()
), se emitirá el evento 'close'
en esta etapa. De lo contrario, se emitirá mediante process.nextTick()
.
setImmediate()
y setTimeout()
son muy similares, pero se comportan de manera diferente según cuándo se llaman.
setImmediate()
está diseñado para ejecutar el script una vez que se completa la fase de sondeo actual.setTimeout()
ejecuta el script después de que haya pasado un umbral mínimo (en ms).El orden en que se ejecutan los temporizadores variará según el contexto en el que se llamen. Si se llama a ambos desde el módulo principal, el temporizador estará vinculado al rendimiento del proceso (que puede verse afectado por otras aplicaciones en ejecución en la computadora).
Por ejemplo, si ejecuta el siguiente script que no está dentro de un ciclo de E/S (es decir, el módulo principal), el orden en el que se ejecutan los dos temporizadores no es determinista porque está limitado por el rendimiento del proceso:
// timeout_vs_immediate.js setTimeout(() => { console.log('tiempo de espera'); }, 0); setImmediate(() => { console.log('inmediato'); }); $ nodo timeout_vs_immediate.js se acabó el tiempo inmediato $ nodo timeout_vs_immediate.js inmediato timeout
Sin embargo, si coloca estas dos funciones en un bucle de E/S y las llama, setImmediate siempre se llamará primero:
// timeout_vs_immediate.js const fs = requerir('fs'); fs.readFile(__nombre de archivo, () => { setTimeout(() => { console.log('tiempo de espera'); }, 0); setImmediate(() => { console.log('inmediato'); }); }); $ nodo timeout_vs_immediate.js inmediato se acabó el tiempo $ nodo timeout_vs_immediate.js inmediatoLa principal ventaja de usar setImmediate() para
el tiempo de esperasobre setTimeout() es que si setImmediate() se programa durante
setImmediate()
setTimeout()
setImmediate()
/S, se ejecutará antes que cualquier temporizador, dependiendo de cuántos temporizadores haya.
Es posible que hayas notado process.nextTick()
no se muestra en el diagrama, a pesar de que es parte de la API asincrónica. Esto se debe a que process.nextTick()
técnicamente no es parte del bucle de eventos. En cambio, manejará nextTickQueue
después de que se complete la operación actual, independientemente de la etapa actual del bucle de eventos. Una operación aquí se considera una transición desde el procesador C/C++ subyacente y maneja el código JavaScript que debe ejecutarse.
Mirando hacia atrás en nuestro diagrama, cada vez que se llama a process.nextTick()
en una fase determinada, todas las devoluciones de llamada pasadas a process.nextTick()
se resolverán antes de que continúe el bucle de eventos. Esto puede crear algunas situaciones malas, ya que le permite "matar de hambre" su E/S mediante llamadas recursivas a process.nextTick()
, evitando que el bucle de eventos llegue a la etapa de sondeo .
¿Por qué se incluye algo como esto en Node.js? Parte de esto es una filosofía de diseño en la que una API siempre debe ser asincrónica, aunque no sea necesario. Tome este fragmento de código como ejemplo:
function apiCall(arg, callback) { si (tipo de argumento! == 'cadena') proceso de devolución.nextTick( llamar de vuelta, nuevo TypeError('el argumento debe ser una cadena') ); }
Fragmento de código para comprobar parámetros. Si es incorrecto, el error se pasa a la función de devolución de llamada. La API se actualizó recientemente para permitir pasar argumentos a process.nextTick()
lo que le permitirá aceptar cualquier argumento después de la posición de la función de devolución de llamada y pasar los argumentos a la función de devolución de llamada como argumentos a la función de devolución de llamada para que no tenga para anidar la función.
Lo que estamos haciendo es devolver el error al usuario, pero solo después de que se haya ejecutado el resto del código del usuario. Al usar process.nextTick()
, garantizamos que apiCall()
siempre ejecute su función de devolución de llamada después del resto del código de usuario y antes de permitir que continúe el bucle de eventos. Para lograr esto, se permite que la pila de llamadas JS se desenrolle y luego ejecute inmediatamente la devolución de llamada proporcionada, lo que permite realizar llamadas recursivas a process.nextTick()
sin alcanzar RangeError: 超过V8 的最大调用堆栈大小
.
Este principio de diseño puede generar algunos problemas potenciales. Tome este fragmento de código como ejemplo:
let bar; // esto tiene una firma asincrónica, pero llama a la devolución de llamada sincrónicamente función someAsyncApiCall (devolución de llamada) { llamar de vuelta(); } // la devolución de llamada se llama antes de que se complete `someAsyncApiCall`. algunaAsyncApiCall(() => { // desde que se completó someAsyncApiCall, a la barra no se le ha asignado ningún valor console.log('barra', barra); // indefinido }); bar = 1;
El usuario define someAsyncApiCall()
como si tuviera una firma asincrónica, pero en realidad se ejecuta de forma sincrónica. Cuando se llama, la devolución de llamada proporcionada a someAsyncApiCall()
se llama dentro de la misma fase del bucle de eventos porque someAsyncApiCall()
en realidad no hace nada de forma asincrónica. Como resultado, la función de devolución de llamada intenta hacer referencia bar
, pero es posible que la variable aún no esté dentro del alcance porque el script aún no ha terminado de ejecutarse.
Al colocar la devolución de llamada en process.nextTick()
, el script aún tiene la capacidad de ejecutarse hasta su finalización, lo que permite inicializar todas las variables, funciones, etc. antes de llamar a la devolución de llamada. También tiene la ventaja de no permitir que el bucle de eventos continúe y es adecuado para advertir al usuario cuando ocurre un error antes de permitir que el bucle de eventos continúe. Aquí está el ejemplo anterior usando process.nextTick()
:
let bar; función someAsyncApiCall (devolución de llamada) { proceso.nextTick(devolución de llamada); } algunaAsyncApiCall(() => { console.log('barra', barra); // 1 }); bar = 1;
Este es otro ejemplo real:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
solo cuando se pasa el puerto, el puerto se vinculará inmediatamente. Por lo tanto, la devolución de llamada 'listening'
se puede llamar inmediatamente. El problema es que la devolución de llamada de .on('listening')
no se ha configurado en ese momento.
Para solucionar este problema, el evento 'listening'
se pone en cola dentro de nextTick()
para permitir que el script se ejecute hasta su finalización. Esto permite al usuario configurar los controladores de eventos que desee.
En lo que respecta al usuario, tenemos dos llamadas similares, pero sus nombres son confusos.
process.nextTick()
se ejecuta inmediatamente en la misma etapa.setImmediate()
se activa en la siguiente iteración o 'tic' del bucle de eventos.Esencialmente, los dos nombres deberían intercambiarse porque process.nextTick()
se activa más rápido que setImmediate()
, pero esto es un legado del pasado y, por lo tanto, es poco probable que cambie. Si realiza un cambio de nombre precipitadamente, romperá la mayoría de los paquetes en npm. Cada día se agregan más módulos nuevos, lo que significa que cada día que tengamos que esperar, más daños potenciales pueden ocurrir. Aunque estos nombres son confusos, los nombres en sí no cambiarán.
Recomendamos que los desarrolladores utilicen setImmediate()
en todas las situaciones porque es más fácil de entender.
Hay dos razones principales:
permitir al usuario manejar errores, limpiar los recursos innecesarios o volver a intentar la solicitud antes de que continúe el ciclo de eventos.
A veces es necesario ejecutar la devolución de llamada después de que se desenrolla la pila pero antes de que continúe el bucle de eventos.
Aquí hay un ejemplo simple que cumple con las expectativas del usuario:
const server = net.createServer(); server.on('conexión', (conexión) => {}); servidor.escuchar(8080); server.on('listening', () => {});
Supongamos que listen()
se ejecuta al comienzo del bucle de eventos, pero la devolución de llamada de escucha se coloca en setImmediate()
. A menos que se pase un nombre de host, el puerto se vinculará inmediatamente. Para que el bucle de eventos continúe, debe llegar a la fase de sondeo , lo que significa que es posible que se haya recibido una conexión y que el evento de conexión se haya activado antes del evento de escucha.
Otro ejemplo ejecuta un constructor de funciones que hereda de EventEmitter
y quiere llamar al constructor:
const EventEmitter = require('events'); const utilidad = requerir('util'); función MiEmisor() { EventEmitter.call(esto); this.emit('evento'); } util.inherits(MyEmitter, EventEmitter); const miEmisor = nuevo MiEmisor(); myEmitter.on('evento', () => { console.log('¡ocurrió un evento!'); });
No puede activar el evento inmediatamente desde el constructor porque el script aún no se ha procesado hasta el punto en que el usuario asigna una función de devolución de llamada al evento. Entonces, en el constructor mismo puedes usar process.nextTick()
para configurar una devolución de llamada para que el evento se emita después de que se complete el constructor, que es lo que se espera:
const EventEmitter = require('events'); const utilidad = requerir('util'); función MiEmisor() { EventEmitter.call(esto); // usa nextTick para emitir el evento una vez que se asigna un controlador proceso.nextTick(() => { this.emit('evento'); }); } util.inherits(MyEmitter, EventEmitter); const miEmisor = nuevo MiEmisor(); myEmitter.on('evento', () => { console.log('¡ocurrió un evento!'); });
Fuente: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/