Node se creó originalmente para construir servidores web de alto rendimiento. Como tiempo de ejecución del lado del servidor para JavaScript, tiene características como E/S asincrónicas controladas por eventos y subproceso único. El modelo de programación asincrónica basado en el bucle de eventos permite a Node manejar una alta concurrencia y mejorar en gran medida el rendimiento del servidor. Al mismo tiempo, debido a que mantiene las características de un solo subproceso de JavaScript, Node no necesita lidiar con problemas como la sincronización de estado y. punto muerto en subprocesos múltiples No hay sobrecarga de rendimiento causada por el cambio de contexto de subproceso. Con base en estas características, Node tiene las ventajas inherentes de alto rendimiento y alta concurrencia, y se pueden construir varias plataformas de aplicaciones de red escalables y de alta velocidad en base a él.
Este artículo profundizará en la implementación y el mecanismo de ejecución subyacentes del bucle de eventos y asíncrono de Node. Espero que le resulte útil.
¿Por qué Node utiliza asíncrono como modelo de programación principal?
Como se mencionó anteriormente, Node se creó originalmente para construir servidores web de alto rendimiento. Suponiendo que hay varios conjuntos de tareas no relacionadas que deben completarse en el escenario empresarial, existen dos soluciones modernas principales:
la ejecución en serie de un solo subproceso.
Completado en paralelo con múltiples hilos.
La ejecución en serie de un solo subproceso es un modelo de programación sincrónico, aunque está más en línea con la forma de pensar del programador en secuencia y hace que sea más fácil escribir código más conveniente, porque ejecuta E/S sincrónicamente, pero solo puede procesar E/S. al mismo tiempo, una sola solicitud hará que el servidor responda lentamente y no se puede aplicar en escenarios de aplicaciones de alta concurrencia. Además, debido a que bloquea la E/S, la CPU siempre esperará a que se complete la E/S y no podrá hacerlo. Otras cosas, que limitarán la capacidad de procesamiento de la CPU, eventualmente conducirán a una baja eficiencia,
y el modelo de programación multiproceso también causará dolores de cabeza a los desarrolladores debido a problemas como la sincronización de estado y el estancamiento en la programación. Aunque el subproceso múltiple puede mejorar efectivamente la utilización de la CPU en CPU de múltiples núcleos.
Aunque el modelo de programación de ejecución en serie de un solo subproceso y ejecución en paralelo de subprocesos múltiples tiene sus propias ventajas, también tiene desventajas en términos de rendimiento y dificultad de desarrollo.
Además, partiendo de la velocidad de respuesta a las solicitudes del cliente, si el cliente obtiene dos recursos al mismo tiempo, la velocidad de respuesta del método síncrono será la suma de las velocidades de respuesta de los dos recursos y la velocidad de respuesta del El método asincrónico será el medio de los dos. El más grande, la ventaja de rendimiento es muy obvia en comparación con la sincronización. A medida que aumenta la complejidad de la aplicación, este escenario evolucionará para responder a n solicitudes al mismo tiempo y se resaltarán las ventajas de lo asincrónico en comparación con lo sincronizado.
En resumen, Node da su respuesta: use un solo subproceso para evitar bloqueos de múltiples subprocesos, sincronización de estado y otros problemas; use E/S asíncrona para evitar que un solo subproceso se bloquee y pueda usar mejor la CPU; Es por eso que Node utiliza asíncrono como modelo de programación central.
Además, para compensar la deficiencia de un solo subproceso que no puede utilizar CPU de múltiples núcleos, Node también proporciona un subproceso similar a Web Workers en el navegador, que puede utilizar eficientemente la CPU a través de procesos de trabajo.
Después de hablar sobre por qué deberíamos usar asincrónico, ¿cómo implementarlo?
Hay dos tipos de operaciones asincrónicas que normalmente llamamos: una son operaciones relacionadas con E/S, como E/S de archivos y E/S de red, la otra son operaciones no relacionadas con E/S, como setTimeOut
y setInterval
. Obviamente, lo asincrónico que estamos discutiendo se refiere a operaciones relacionadas con E/S, es decir, E/S asincrónicas.
Se propone E/S asincrónica con la esperanza de que las llamadas de E/S no bloqueen la ejecución de programas posteriores, y el tiempo original de espera para que se complete la E/S se asignará a otros negocios requeridos para su ejecución. Para lograr este objetivo, necesita utilizar E/S sin bloqueo.
Bloquear E/S significa que después de que la CPU inicia una llamada de E/S, se bloqueará hasta que se complete la E/S. Conocer las E/S con bloqueo y las E/S sin bloqueo es fácil de entender. La CPU regresará inmediatamente después de iniciar la llamada de E/S en lugar de bloquear y esperar. La CPU puede manejar otras transacciones antes de que se complete la E/S. Obviamente, en comparación con las E/S con bloqueo, las E/S sin bloqueo tienen más mejoras de rendimiento.
Entonces, dado que se utiliza E/S sin bloqueo y la CPU puede regresar inmediatamente después de iniciar la llamada de E/S, ¿cómo sabe que la E/S se ha completado? La respuesta son las encuestas.
Para obtener el estado de las llamadas de E/S a tiempo, la CPU llamará continuamente a las operaciones de E/S repetidamente para confirmar si las E/S se han completado. Esta tecnología de llamadas repetidas para determinar si la operación se completó se llama sondeo. .
Obviamente, el sondeo hará que la CPU realice juicios de estado repetidamente, lo que supone un desperdicio de recursos de la CPU. Además, el intervalo de sondeo es difícil de controlar. Si el intervalo es demasiado largo, la operación de E/S no recibirá una respuesta oportuna, lo que indirectamente reducirá la velocidad de respuesta de la aplicación; Inevitablemente, la CPU se gastará en sondeos. Lleva más tiempo y reduce la utilización de los recursos de la CPU.
Por lo tanto, aunque el sondeo cumple con el requisito de que las E/S sin bloqueo no bloqueen la ejecución de programas posteriores, para la aplicación, solo puede considerarse como un tipo de sincronización, porque la aplicación aún necesita esperar la E/S. O para regresar por completo. Todavía pasé mucho tiempo esperando.
La E/S asincrónica perfecta que esperamos debería ser que la aplicación inicie una llamada sin bloqueo. No es necesario consultar continuamente el estado de la llamada de E/S mediante sondeo. En cambio, la siguiente tarea se puede procesar directamente. Se completa la E/S. Simplemente pase los datos a la aplicación a través de un semáforo o devolución de llamada.
¿Cómo implementar esta E/S asincrónica? La respuesta es el grupo de subprocesos.
Aunque este artículo siempre ha mencionado que Node se ejecuta en un solo subproceso, aquí un solo subproceso significa que el código JavaScript se ejecuta en un solo subproceso. Para partes como las operaciones de E/S que no tienen nada que ver con la lógica empresarial principal. ejecutar en otra implementación en forma de subprocesos no afectará ni bloqueará la ejecución del subproceso principal. Por el contrario, puede mejorar la eficiencia de ejecución del subproceso principal y realizar E / S asincrónicas.
A través del grupo de subprocesos, deje que el subproceso principal solo realice llamadas de E/S, deje que otros subprocesos realicen E/S de bloqueo o E/S sin bloqueo más tecnología de sondeo para completar la adquisición de datos y luego utilice la comunicación entre subprocesos para completar la I. /O Se pasan los datos obtenidos, lo que implementa fácilmente E/S asincrónicas:
El subproceso principal realiza llamadas de E/S, mientras que el grupo de subprocesos realiza operaciones de E/S, completa la adquisición de datos y luego pasa los datos al subproceso principal a través de la comunicación entre subprocesos para completar una llamada de E/S y el subproceso principal. reutiliza La función de devolución de llamada expone los datos al usuario, quien luego los usa para completar operaciones en el nivel de lógica empresarial. Este es un proceso de E/S asincrónico completo en Node. Para los usuarios, no hay necesidad de preocuparse por los engorrosos detalles de implementación de la capa subyacente. Solo necesitan llamar a la API asincrónica encapsulada por Node y pasar la función de devolución de llamada que maneja la lógica empresarial, como se muestra a continuación:
const fs = require. ("fs"); fs.readFile('ejemplo.js', (datos) => { // Lógica empresarial del proceso});
El mecanismo de implementación subyacente asíncrono de Nodejs es diferente en diferentes plataformas: en Windows, IOCP se utiliza principalmente para enviar llamadas de E/S al kernel del sistema y obtener operaciones de E/S completas desde el kernel. con un bucle de eventos para completar el proceso de E/S asíncrono; este proceso se implementa a través de epoll en Linux, a través de kqueue en FreeBSD y a través de puertos de eventos en Solaris. El grupo de subprocesos lo proporciona directamente el kernel (IOCP) en Windows, mientras que la serie *nix
la implementa el propio libuv.
Debido a la diferencia entre la plataforma Windows y la plataforma *nix
, Node proporciona libuv como una capa de encapsulación abstracta, de modo que esta capa completa todos los juicios de compatibilidad de la plataforma, asegurando que el Nodo de la capa superior y el grupo de subprocesos personalizados de la capa inferior y el IOCP son independientes entre sí. Node determinará las condiciones de la plataforma durante la compilación y compilará selectivamente los archivos fuente en el directorio Unix o en el directorio Win en el programa de destino:
Lo anterior es la implementación asincrónica de Node.
(El tamaño del grupo de subprocesos se puede configurar a través de la variable de entorno UV_THREADPOOL_SIZE
. El valor predeterminado es 4. El usuario puede ajustar el tamaño de este valor según la situación real).
Entonces la pregunta es, después de obtener los datos pasados por el Grupo de subprocesos, ¿cómo funciona el subproceso principal? ¿Cuándo se llama a la función de devolución de llamada? La respuesta es el bucle de eventos.
Dado queutiliza funciones de devolución de llamada para procesar datos de E/S, inevitablemente implica la cuestión de cuándo y cómo llamar a la función de devolución de llamada. En el desarrollo real, a menudo están involucrados escenarios de llamadas de E/S asincrónicas de múltiples tipos. Cómo organizar razonablemente las llamadas de estas devoluciones de llamadas de E/S asincrónicas y garantizar el progreso ordenado de las devoluciones de llamadas asincrónicas es un problema difícil. E/S asincrónicas Además de /O, también hay llamadas asincrónicas que no son de E/S, como los temporizadores. Estas API son en gran medida en tiempo real y, en consecuencia, tienen prioridades más altas. ¿Cómo programar devoluciones de llamadas con diferentes prioridades?
Por lo tanto, debe haber un mecanismo de programación para coordinar tareas asincrónicas de diferentes prioridades y tipos para garantizar que estas tareas se ejecuten de manera ordenada en el hilo principal. Al igual que los navegadores, Node ha elegido el bucle de eventos para realizar este trabajo pesado.
Node divide las tareas en siete categorías según su tipo y prioridad: Temporizadores, Pendientes, Inactivas, Preparar, Sondear, Verificar y Cerrar. Para cada tipo de tarea, hay una cola de tareas de primero en entrar, primero en salir para almacenar las tareas y sus devoluciones de llamada (los temporizadores se almacenan en un pequeño montón superior). Con base en estos siete tipos, Node divide la ejecución del bucle de eventos en las siguientes siete etapas:
La prioridad de ejecución de esta etapa de
En esta etapa, el bucle de eventos verificará la estructura de datos (montón mínimo) que almacena el temporizador, recorrerá los temporizadores que contiene, comparará el tiempo actual y el tiempo de vencimiento uno por uno y determinará si el temporizador ha expirado. , el temporizador será La función de devolución de llamada se extrae y se ejecuta.
La faseejecutará devoluciones de llamada cuando se produzcan excepciones de red, IO y otras. Algunos errores reportados por *nix
serán manejados en esta etapa. Además, algunas devoluciones de llamadas de E/S que deberían ejecutarse en la fase de sondeo del ciclo anterior se pospondrán a esta fase.
solo se utilizan dentro del bucle de eventos.
recupera nuevos eventos de E/S; ejecuta devoluciones de llamada relacionadas con E/S (casi todas las devoluciones de llamada excepto las de apagado, las devoluciones de llamada programadas por temporizador y setImmediate()
) el nodo se bloqueará aquí en el momento adecuado).
Encuesta, es decir, la etapa de sondeo es la etapa más importante del bucle de eventos. Las devoluciones de llamada para E/S de red y E/S de archivos se procesan principalmente en esta etapa. Esta etapa tiene dos funciones principales:
calcular durante cuánto tiempo esta etapa debe bloquear y sondear E/S.
Manejar devoluciones de llamadas en la cola de E/S.
Cuando el bucle de eventos ingresa a la fase de sondeo y no se configura ningún temporizador:
si la cola de sondeo no está vacía, el bucle de eventos atravesará la cola y los ejecutará de forma sincrónica hasta que la cola esté vacía o se alcance el número máximo que se puede ejecutar.
Si la cola de sondeo está vacía, sucederá una de dos cosas más:
si hay setImmediate()
que debe ejecutarse, la fase de sondeo finaliza inmediatamente y se ingresa a la fase de verificación para ejecutar la devolución de llamada.
Si no hay devoluciones de llamada setImmediate()
para ejecutar, el bucle de eventos permanecerá en esta fase esperando que se agreguen devoluciones de llamada a la cola y luego las ejecutará inmediatamente. El bucle de eventos esperará hasta que expire el tiempo de espera. La razón por la que elijo detenerme aquí es porque Node maneja principalmente IO, para que pueda responder a IO de manera más oportuna.
Una vez que la cola de sondeo está vacía, el bucle de eventos busca temporizadores que hayan alcanzado su umbral de tiempo. Si uno o más temporizadores alcanzan el umbral de tiempo, el bucle de eventos volverá a la fase de temporizadores para ejecutar las devoluciones de llamada para estos temporizadores.
fase ejecutará las devoluciones de llamada de setImmediate()
en secuencia.
Esta fase ejecutará algunas devoluciones de llamada para cerrar recursos, como socket.on('close', ...)
. El retraso en la ejecución de esta etapa tendrá poco impacto y tiene la más baja prioridad.
Cuando se inicia el proceso del Nodo, inicializará el bucle de eventos, ejecutará el código de entrada del usuario, realizará las llamadas API asincrónicas correspondientes, programará el temporizador, etc., y luego comenzará a ingresar al bucle de eventos:
┌───────── ── ────────────────┐ ┌─>│ temporizadores │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ devoluciones de llamada pendientes │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ inactivo, prepárate │ │ └─────────────┬────────────┘ ┌────────── ──────┐ │ ┌─────────────┴────────────┐ │ entrante: │ │ │ encuesta │<─────┤ conexiones, │ │ └─────────────┬─────────────┘ │ datos, etc. │ ┌─────────────┴────────────┐ └────────── ──────┘ │ │ comprobar │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ cerrar devoluciones de llamada │ └─────────────────────────────┘Cada
iteración del bucle de eventos (a menudo llamado tick) será la indicada anteriormente. La prioridad La orden ingresa a las siete etapas de ejecución. Cada etapa ejecutará una cierta cantidad de devoluciones de llamada en la cola. La razón por la cual solo se ejecuta una cierta cantidad pero no todas es para evitar que el tiempo de ejecución de la etapa actual sea demasiado largo. evitar el fracaso de la siguiente etapa.
Bien, lo anterior es el flujo de ejecución básico del bucle de eventos. Ahora veamos otra pregunta.
Para el siguiente escenario:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
cuando el servicio se vincula con éxito al puerto 8000, es decir, cuando se llama listen()
con éxito, la devolución de llamada del evento listening
aún no se ha vinculado, por lo que una vez que el puerto se vincula con éxito, la devolución de llamada del evento listening
que pasamos no se ejecutará.
Pensando en otra pregunta, es posible que tengamos algunas necesidades durante el desarrollo, como manejar errores, limpiar recursos innecesarios y otras tareas con baja prioridad. Si estas lógicas se ejecutan de manera sincrónica, afectará la eficiencia de ejecución de la tarea actual. si setImmediate()
se pasa de forma asincrónica, como en forma de devoluciones de llamada, no se puede garantizar el tiempo de ejecución y el rendimiento en tiempo real no es alto. Entonces, ¿cómo abordar estas lógicas?
Con base en estos problemas, Node tomó referencia del navegador e implementó un conjunto de mecanismos de microtareas. En Node, además de llamar new Promise().then()
la función de devolución de llamada pasada se encapsulará en una microtarea. La devolución de llamada de process.nextTick()
también se encapsulará en una microtarea y la prioridad de ejecución de la misma. el último será mayor que el primero.
Con las microtareas, ¿cuál es el proceso de ejecución del bucle de eventos? En otras palabras, ¿cuándo se ejecutan las microtareas?
En el nodo 11 y versiones posteriores, una vez que se ejecuta una tarea en una etapa, la cola de microtareas se ejecuta inmediatamente y la cola se borra.
La ejecución de la microtarea comienza después de que se haya ejecutado una etapa antes del nodo 11.
Por lo tanto, con las microtareas, cada ciclo del bucle de eventos primero ejecutará una tarea en la etapa de temporizadores y luego borrará las colas de microtareas de process.nextTick()
y new Promise().then()
en orden, y luego continuará ejecutando la siguiente tarea en la etapa de temporizadores o la siguiente etapa, es decir, una tarea en la etapa pendiente, y así sucesivamente en este orden.
Usando process.nextTick()
, Node puede resolver el problema de enlace de puerto anterior: dentro del método listen()
, la emisión del evento listening
se encapsulará en una devolución de llamada y se pasará a process.nextTick()
, como se muestra en el siguiente pseudo código:
función escuchar() { // Realizar operaciones del puerto de escucha... // Encapsula la emisión del evento `listening` en una devolución de llamada y pásala a `process.nextTick()` en Process.nextTick(() => { emitir('escuchando'); }); };
Después de ejecutar el código actual, la microtarea comenzará a ejecutarse, emitiendo así listening
y activando la llamada de la devolución de llamada del evento.
debido a la imprevisibilidad y complejidad del propio asincrónico, en el proceso de uso de la API asincrónica proporcionada por Node, aunque dominamos el principio de ejecución del bucle de eventos, todavía puede haber algunos fenómenos que no son intuitivos o no esperados. .
Por ejemplo, el orden de ejecución de los temporizadores ( setTimeout
, setImmediate
) diferirá según el contexto en el que se llamen. Si ambos se llaman desde el contexto de nivel superior, su tiempo de ejecución depende del rendimiento del proceso o de la máquina.
Veamos el siguiente ejemplo:
setTimeout(() => { console.log('tiempo de espera'); }, 0); setImmediate(() => { console.log('inmediato'); });
¿Cuál es el resultado de la ejecución del código anterior? De acuerdo con nuestra descripción del bucle de eventos de ahora, es posible que tenga esta respuesta: dado que la fase de temporizadores se ejecutará antes de la fase de verificación, la devolución de llamada de setTimeout()
se ejecutará primero y luego la devolución de llamada de setImmediate()
será ejecutado.
De hecho, el resultado de salida de este código es incierto. El tiempo de espera puede generarse primero o lo inmediato puede generarse primero. Esto se debe a que ambos temporizadores se llaman en el contexto global. Cuando el bucle de eventos comienza a ejecutarse y se ejecuta en la etapa de temporizadores, el tiempo actual puede ser mayor que 1 ms o menor que 1 ms, según el rendimiento de ejecución de la máquina. , en realidad no está claro setTimeout()
se ejecutará en la primera etapa del temporizador, por lo que aparecerán resultados de salida diferentes.
(Cuando el valor de delay
(el segundo parámetro de setTimeout
) es mayor que 2147483647
o menor que 1
, delay
se establecerá en 1
)
Veamos el siguiente código:
const fs = require('fs'); fs.readFile(__nombre de archivo, () => { setTimeout(() => { console.log('tiempo de espera'); }, 0); setImmediate(() => { console.log('inmediato'); }); });
se puede ver que en este código, ambos temporizadores se encapsulan en funciones de devolución de llamada y se pasan a readFile
. Es obvio que cuando se llama a la devolución de llamada, el tiempo actual debe ser mayor que 1 ms, por lo que la devolución de llamada de setTimeout
será. ser más largo que la devolución de llamada de setImmediate
La devolución de llamada se llama primero, por lo que el resultado impreso es: timeout immediate
.
Lo anterior son cosas relacionadas con los temporizadores a los que debes prestar atención cuando usas Node. Además, también debe prestar atención al orden de ejecución de process.nextTick()
, new Promise().then()
y setImmediate()
. Dado que esta parte es relativamente simple, se ha mencionado antes y no se repetirá. .
: El artículo comienza con una explicación más detallada de los principios de implementación del bucle de eventos de Node desde las dos perspectivas de por qué se necesita la asincronía y cómo implementarla, y menciona algunos asuntos relacionados que espero que sean útiles. tú.