Capítulo 1. ¿Por qué distribuido?

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Node.js es un tiempo de ejecución autónomo para ejecutar código JavaScript en el servidor. Proporciona un motor de lenguaje JavaScript y docenas de API, muchas de las cuales permiten que el código de la aplicación interactúe con el sistema operativo subyacente y con el mundo exterior a él. Pero probablemente ya lo sabías.

Este capítulo ofrece una visión general de Node.js, en particular de su relación con este libro. Analiza la naturaleza monohilo de JavaScript, al mismo tiempo una de sus mayores fortalezas y debilidades, y parte de la razón por la que es tan importante ejecutar Node.js de forma distribuida.

También contiene un pequeño par de aplicaciones de ejemplo que se utilizan como línea de base, para ser actualizadas numerosas veces a lo largo del libro. Es probable que la primera iteración de estas aplicaciones sea más sencilla que cualquier otra que hayas enviado previamente a producción.

Si crees que ya conoces la información de estas primeras secciones, puedes pasar directamente a "Ejemplos de aplicaciones".

El lenguaje JavaScript está pasando de ser un lenguaje monohilo a ser un lenguaje multihilo. El objeto Atomics por ejemplo, proporciona mecanismos para coordinar la comunicación entre distintos subprocesos, mientras que las instancias de SharedArrayBuffer pueden escribirse y leerse en distintos subprocesos. Dicho esto, en el momento de escribir esto, JavaScript multihilo aún no ha calado en la comunidad. Hoy en día,JavaScript es multihilo, pero la naturaleza del lenguaje y delecosistema sigue siendo monohilo.

La naturaleza monohilo de JavaScript

JavaScript, como la mayoría de los lenguajes de programación , hace un uso intensivo de las funciones. Las funciones son una forma de combinar unidades de trabajo relacionadas. Las funciones también pueden llamar a otras funciones. Cada vez que una función llama a otra función, añade cuadros a la pila de llamadas, que es una forma elegante de decir que la pila de funciones actualmente ejecutadas se hace más alta. Cuando escribes accidentalmente una función recursiva que, de otro modo, se ejecutaría eternamente, sueles recibir un error RangeError: Maximum call stack size exceeded error. Cuando esto ocurre, has alcanzado el límite máximo de cuadros en la pila de llamadas de .

Nota

El tamaño máximo de la pila de llamadas es normalmente intrascendente y lo elige el motor JavaScript. El motor JavaScript V8 utilizado por Node.js v14 tiene un tamaño máximo de pila de llamadas de más de 15.000 fotogramas.

Sin embargo, JavaScript se diferencia de otros lenguajes en que no se limita a ejecutarse dentro de una única pila de llamadas durante toda la vida útil de una aplicación JavaScript. Por ejemplo, cuando escribí PHP hace varios años, toda la vida útil de un script PHP (una vida útil vinculada directamente al tiempo que se tarda en servir una solicitud HTTP) se correlacionaba con una única pila, que crecía y decrecía para desaparecer una vez finalizada la solicitud.

JavaScript maneja la concurrencia -realizar varias cosas al mismo tiempo- mediante un bucle de eventos. El bucle de eventos que utiliza Node.js se explica con más detalle en "El bucle de eventos de Node.js", pero por ahora piensa en él como un bucle que se ejecuta infinitamente y que comprueba continuamente si hay algo que hacer. Cuando encuentra algo que hacer, comienza su tarea -en este caso ejecuta una función con una nueva pila de llamadas- y una vez completada la función, espera hasta que haya más trabajo listo para realizar.

La muestra de código del Ejemplo 1-1 es un ejemplo de que esto ocurre. En primer lugar, ejecuta la función a() en la pila actual. También llama a la función setTimeout() que pondrá en cola la función x(). Una vez que se completa la pila actual, el bucle de eventos comprueba si hay más trabajo que hacer. El bucle de eventos sólo comprueba si hay más trabajo que hacer una vez que se completa la pila. No comprueba, por ejemplo, después de cada instrucción. Como no hay mucho que hacer en este sencillo programa, la función x() será lo siguiente que se ejecute después de que se complete la primera pila.

Ejemplo 1-1. Ejemplo de varias pilas de JavaScript
function a() { b(); }
function b() { c(); }
function c() { /**/ }

function x() { y(); }
function y() { z(); }
function z() { /**/ }

setTimeout(x, 0);
a();

La Figura 1-1 es una visualización de la muestra de código anterior. Observa cómo hay dos pilas separadas y que cada pila aumenta en profundidad a medida que se llaman más funciones. El eje horizontal representa el tiempo; el código dentro de cada función naturalmente tarda en ejecutarse.

Two separate stacks, each three frames deep
Figura 1-1. Visualización de múltiples pilas de JavaScript

La función setTimeout() está diciendo esencialmente: "Intenta ejecutar la función proporcionada dentro de 0 ms". Sin embargo, la función x() no se ejecuta inmediatamente, ya que la pila de llamadas a() aún está en curso. Tampoco se ejecuta inmediatamente después de que se complete la pila de llamadas de a(). El bucle de eventos tarda una cantidad de tiempo distinta de cero en comprobar si hay más trabajo que realizar. También tarda en preparar la nueva pila de llamadas. Así que, aunque x() se programó para ejecutarse en 0 ms, en la práctica pueden pasar unos milisegundos antes de que se ejecute el código, una discrepancia que aumenta a medida que aumenta la carga de la aplicación.

Otra cosa a tener en cuenta es que las funciones pueden tardar mucho tiempo en ejecutarse. Si la función a() tardó 100 ms en ejecutarse, entonces lo más pronto que puedes esperar que se ejecute x() puede ser 101 ms. Por eso, piensa en el argumento tiempo como la primera vez que se puede llamar a la función. Se dice que una función que tarda mucho en ejecutarse bloquea elbucle de eventos : comola aplicación está atascada procesando código síncrono lento, el bucle de eventos es temporalmente incapaz de procesar más tareas .

Ahora que las pilas de llamadas están fuera del camino, es el momento de la parte interesante de esta sección.

Dado que las aplicaciones JavaScript se ejecutan mayoritariamente de forma monohilo, no existirán dos pilas de llamadas al mismo tiempo, que es otra forma de decir que dos funciones no pueden ejecutarse en paralelo.1 Esto implica que varias copias de una aplicación deben ejecutarse simultáneamente por algún medio para que la aplicación pueda escalar.

Hay varias herramientas disponibles para facilitar la gestión de varias copias de una aplicación. "El módulo Cluster" examina el uso del módulo incorporado cluster para dirigir las solicitudes HTTP entrantes a diferentes instancias de la aplicación. El módulo incorporado worker_threads también ayuda a ejecutar múltiples instancias de JavaScript a la vez. El módulo child_process también puede utilizarse para generar y gestionar un proceso Node.js completo.

Sin embargo, con cada uno de estos enfoques , JavaScript sólo puede ejecutar una única línea de JavaScript a la vez dentro de una aplicación. Esto significa que, con cada solución, cada entorno JavaScript sigue teniendo sus propias variables globales distintas, y no se pueden compartir referencias a objetos entre ellos.

Puesto que los objetos no pueden compartirse directamente con los tres enfoques mencionados, se necesita algún otro método de comunicación entre los distintos contextos aislados de JavaScript. Tal función existe y se denomina paso de mensajes. El paso de mensajes funciona compartiendo algún tipo de representación serializada de un objeto/datos (como JSON) entre los distintos aislados. Esto es necesario porque compartir objetos directamente es imposible, por no mencionar que sería una experiencia de depuración dolorosa si dos aislados separados pudieran modificar el mismo objeto al mismo tiempo. Este tipo de problemas se conocen en como bloqueos y condiciones de carrera.

Nota

Utilizando worker_threads es posible compartir memoria entre dos instancias diferentes de JavaScript. Esto puede hacerse creando una instancia de SharedArrayBuffer y pasándola de un subproceso a otro utilizando el mismo método postMessage(value) utilizado para el paso de mensajes del subproceso trabajador. El resultado es una matriz de bytes que ambos subprocesos pueden leer y escribir al mismo tiempo.

Con el paso de mensajes se incurre en una sobrecarga al serializar y deserializar los datos. Esta sobrecarga no tiene por qué existir en los lenguajes que admiten un multihilo adecuado, ya que los objetos pueden compartirse directamente.

Éste es uno de los mayores factores que hacen necesario ejecutar aplicaciones Node.js de forma distribuida. Para manejar la escala, es necesario ejecutar suficientes instancias para que una sola instancia de un proceso Node.js no sature completamente su CPU disponible.

Ahora que ya has visto JavaScript -el lenguaje que hace funcionar Node.js- es hora de ver Node.js en sí.

La solución a la pregunta de la entrevista sorpresa aparece en la Tabla 1-1. La parte más importante es el orden en que se imprimen los mensajes, y la bonificación es el tiempo que tardan en imprimirse. Considera que tu respuesta de bonificación es correcta si estás a unos milisegundos del tiempo.

Tabla 1-1. Solución de la entrevista sorpresa

Registro

B

E

A

D

C

Tiempo

1ms

501ms

502ms

502ms

502ms

Lo primero que ocurre en es que la función para registrar A se programa con un tiempo de espera de 0 ms. Recuerda que esto no significa que la función se ejecute en 0 ms, sino que se programa para que se ejecute en 0 milisegundos, pero después de que termine la pila actual. A continuación, se llama directamente al método log B, por lo que es el primero en imprimir. A continuación, la función log C se programa para que se ejecute tan pronto como en 100 ms, y la función log D se programa para que se ejecute tan pronto como en 0 ms.

A continuación, la aplicación se ocupa de hacer cálculos con el bucle while, que consume medio segundo de tiempo de CPU. Una vez concluido el bucle, se realiza directamente la última llamada al registro E, que ahora es el segundo en imprimirse. La pila actual ya está completa. En este punto, sólo se ha ejecutado una pila.

Una vez hecho esto, el bucle de eventos busca más trabajo que hacer. Comprueba la cola y ve que hay tres tareas programadas. El orden de los elementos en la cola se basa en el valor del temporizador proporcionado y en el orden en que se realizaron las llamadas a setTimeout(). Por lo tanto, primero procesa la función log A. En este punto, el script ha estado ejecutándose durante aproximadamente medio segundo, y ve que el log A se ha retrasado unos 500 ms, por lo que se ejecuta esa función. El siguiente elemento de la cola es la función log D, que también se ha retrasado unos 500 ms. Por último, se ejecuta la función log C, que tiene un retraso de unos 400 ms.

Visión general rápida de Node.js

Node.js adopta plenamente el patrón de Continuación-Pasada (CPS) en todos sus módulos internos mediante llamadas de retorno -funciones que son pasadas e invocadas por el bucle de eventos una vez que se ha completado una tarea. En la jerga de Node.js, se dice que las funciones que se invocan en el futuro con una nueva pila se ejecutan de forma asíncrona. Por el contrario, cuando una función llama a otra función en la misma pila, se dice que ese código se ejecuta de forma sincrónica.

Los tipos de tareas que son de larga duración suelen ser tareas de E/S. Por ejemplo, imagina que tu aplicación quiere realizar dos tareas. La Tarea A es leer un archivo del disco, y la Tarea B es enviar una solicitud HTTP a un servicio de terceros. Si una operación depende de que se realicen ambas tareas -una operación como responder a una solicitud HTTP entrante-, la aplicación puede realizar las operaciones en paralelo, como se muestra en la Figura 1-2. Si no pudieran realizarse al mismo tiempo -si tuvieran que ejecutarse secuencialmente-, el tiempo total que se tarda en responder a la solicitud HTTP entrante sería mayor.

Sequential vs Parallel I/O Diagram
Figura 1-2. Visualización de la E/S secuencial frente a la paralela

Al principio esto parece violar la naturaleza monohilo de JavaScript. ¿Cómo puede una aplicación Node.js leer datos del disco y hacer una solicitud HTTP al mismo tiempo si JavaScript es monohilo?

Aquí es donde las cosas empiezan a ponerse interesantes. El propio Node. js es multihilo. Los niveles inferiores de Node.js están escritos en C++. Esto incluye herramientas de terceros como libuv, que gestiona las abstracciones del sistema operativo y la E/S, así como V8 (el motor de JavaScript) y otros módulos de terceros. La capa superior, la capa de enlace de Node.js, también contiene un poco de C++. Sólo las capas más altas de Node.js están escritas enJavaScript, como las partes de las API de Node.js que tratan directamente con objetos proporcionados por userland.2 La Figura 1-3 muestra la relación entre estas distintas capas.

Node.js is a combination of C++ and JavaScript
Figura 1-3. Las capas de Node.js

Internamente, libuv mantiene un pool de hilos para gestionar las operaciones de E/S, así como las operaciones que consumen mucha CPU, como crypto y zlib. Se trata de un pool de tamaño finito en el que se permite realizar operaciones de E/S. Si el pool sólo contiene cuatro hilos, sólo se podrán leer cuatro archivos al mismo tiempo. Considera el Ejemplo 1-3, en el que la aplicación intenta leer un archivo, realiza otras tareas y, a continuación, se ocupa del contenido del archivo. Aunque el código JavaScript dentro de la aplicación puede ejecutarse, un hilo dentro de las entrañas de Node.js está ocupado leyendo el contenido del archivo desde el disco a la memoria.

Ejemplo 1-3. Hilos de Node.js
#!/usr/bin/env node

const fs = require('fs');

fs.readFile('/etc/passwd', 1
  (err, data) => { 4
    if (err) throw err;
    console.log(data);
});

setImmediate( 2
  () => { 3
    console.log('This runs while file is being read');
});
1

Node.js lee /etc/passwd. Está programado por libuv.

2

Node.js ejecuta una llamada de retorno en una nueva pila. Está programada por V8.

3

Cuando termina la pila anterior, se crea una nueva pila e imprime un mensaje.

4

Una vez finalizada la lectura del archivo, libuv pasa el resultado al bucle de eventos V8.

Consejo

El tamaño del grupo de hilos de libuv es por defecto cuatro, tiene un máximo de 1.024, y puede anularse configurando la variable de entorno UV_THREADPOOL_SIZE=<threads>. En la práctica, no es tan habitual modificarlo y sólo debe hacerse después de evaluar los efectos en una réplica perfecta de la producción. Una aplicación que se ejecute localmente en un portátil macOS se comportará de forma muy diferente a una que esté en un contenedor en un servidor Linux.

Internamente, Node.js mantiene una lista de tareas asíncronas que aún deben completarse. Esta lista se utiliza para mantener el proceso en marcha. Cuando una pila se completa y el bucle de eventos busca más trabajo que hacer, si no quedan más operaciones para mantener vivo el proceso, éste saldrá. Por eso, una aplicación muy sencilla que no haga nada asíncrono puede salir cuando termine la pila. He aquí un ejemplo de unaaplicación de este tipo:

console.log('Print, then exit');

Sin embargo, una vez creada una tarea asíncrona, es suficiente para mantener vivo un proceso, como en este ejemplo:

setInterval(() => {
  console.log('Process will run forever');
}, 1_000);

Hay muchas llamadas a la API de Node.js que dan lugar a la creación de objetos que mantienen vivo el proceso. Como otro ejemplo de esto, cuando se crea un servidor HTTP, también se mantiene el proceso en marcha para siempre. Un proceso que se cerrara inmediatamente después de crear un servidor HTTP no sería muy útil.

Hay un patrón común en las APIs de Node.js en el que se pueden configurar estos objetos para que dejen de mantener vivo el proceso. Algunos son más obvios que otros. Por ejemplo, si se cierra un puerto de servidor HTTP a la escucha, el proceso puede optar por terminar. Además, muchos de estos objetos tienen un par de métodos adjuntos, .unref() y .ref(). El primer método se utiliza para indicar al objeto que deje de mantener vivo el proceso, mientras que el segundo hace lo contrario.El ejemplo 1-4 demuestra que esto ocurre.

Ejemplo 1-4. Los métodos comunes .ref() y .unref()
const t1 = setTimeout(() => {}, 1_000_000); 1
const t2 = setTimeout(() => {}, 2_000_000); 2
// ...
t1.unref(); 3
// ...
clearTimeout(t2); 4
1

Ahora hay una operación asíncrona que mantiene vivo a Node.js. El proceso debería terminar en 1.000 segundos.

2

Ahora hay dos operaciones de este tipo. Ahora el proceso debería terminar en 2.000segundos.

3

El temporizador t1 ha sido sin referencia. Su llamada de retorno puede seguir ejecutándose en 1.000 segundos, pero no mantendrá vivo el proceso.

4

El temporizador t2 se ha borrado y nunca se ejecutará. Un efecto secundario de esto es que ya no mantiene vivo el proceso. Sin operaciones asíncronas que mantengan vivo el proceso, la siguiente iteración del bucle de eventos finaliza el proceso.

Este ejemplo también pone de relieve otra característica de Node.js: no todas las API que existen en el JavaScript del navegador se comportan de la misma manera en Node.js. La función setTimeout(), por ejemplo, devuelve un número entero en los navegadores web. La implementación de Node.js devuelve un objeto con varias propiedades y métodos.

El bucle de eventos se ha mencionado unas cuantas veces, pero realmente merece que lo analicemos con mucho más detalle.

El bucle de eventos de Node.js

Tanto el JavaScript que se ejecuta en tu navegador como el JavaScript que se ejecuta en Node.js vienen con una implementación de un bucle de eventos. Se parecen en que ambos programan y ejecutan tareas asíncronas en pilas separadas. Pero también son diferentes, ya que el bucle de eventos utilizado en un navegador está optimizado para alimentar aplicaciones modernas de una sola página, mientras que el de Node.js se ha ajustado para su uso en un servidor. Esta sección cubre, a alto nivel, el bucle de eventos utilizado en Node.js. Comprender los fundamentos del bucle de eventos es beneficioso porque maneja toda la programación del código de tu aplicación, y los conceptos erróneos pueden conducir a un rendimiento deficiente.

Como su nombre indica, el bucle de eventos se ejecuta en bucle. La idea es que gestiona una cola de eventos que se utilizan para activar llamadas de retorno y hacer avanzar la aplicación. Pero, como cabría esperar, la implementación es mucho más matizada que eso. Ejecuta las retrollamadas de cuando se producen eventos de E/S, como la recepción de un mensaje en un socket, el cambio de un archivo en el disco, que una retrollamada de setTimeout() esté lista para ejecutarse, etc.

A bajo nivel, el sistema operativo notifica al programa que ha ocurrido algo. Entonces, el código libuv dentro del programa cobra vida y averigua qué hacer. Si es apropiado, el mensaje burbujea entonces hasta el código en una API Node.js, y esto puede desencadenar finalmente una llamada de retorno en el código de la aplicación. El bucle de eventos es una forma de permitir que estos eventos en C++ de bajo nivel crucen la frontera y ejecuten código en JavaScript.

Fases del bucle de eventos

El bucle de eventos tiene varias fases diferentes. Algunas de estas fases no se ocupan directamente del código de la aplicación; por ejemplo, algunas pueden implicar la ejecución de código JavaScript del que se ocupan las API internas de Node.js. En la Figura 1-4 se ofrece una visión general de las fases que se encargan de la ejecución de código userland.

Cada una de estas fases mantiene una cola de llamadas de retorno que deben ejecutarse. Las devoluciones de llamada se destinan a distintas fases en función de cómo las utilice la aplicación. Aquí tienes algunos detalles sobre estas fases:

Encuesta

La fase de sondeo ejecuta las llamadas de retorno relacionadas con la E/S. Esta es la fase en la que es más probable que se ejecute el código de la aplicación. Cuando el código principal de tu aplicación comienza a ejecutarse, lo hace en esta fase.

Consulta

En esta fase, se ejecutan las llamadas a que se activan a través de setImmediate().

Cerrar

Esta fase ejecuta las llamadas de retorno que se activan a través de los eventos EventEmitter close . Por ejemplo, cuando un servidor net.Server TCP se cierra, emite un evento close que ejecuta una llamada de retorno en esta fase.

Temporizadores

Las llamadas de retorno programadas mediante setTimeout() y setInterval() se ejecutan en esta fase.

Pendiente

Los eventos especiales del sistema se ejecutan en esta fase, como cuando un socket TCP net.Socket lanza un error ECONNREFUSED.

Para complicar un poco más las cosas , también hay dos colas especiales de microtareas a las que se pueden añadir retrollamadas mientras se ejecuta una fase. La primera cola de microtareas gestiona las devoluciones de llamada que se han registrado utilizando process.nextTick().3 La segunda cola de microtareas gestiona las promesas que rechazan o resuelven. Las devoluciones de llamada de las colas de microtareas tienen prioridad sobre las devoluciones de llamada de la cola normal de la fase, y las devoluciones de llamada de la siguiente cola de microtareas de ticks se ejecutan antes que las devoluciones de llamada de la cola de microtareas de promesas.

Five stages of the Node.js event loop: Timers, Pending, Poll, Check, Close
Figura 1-4. Fases notables del bucle de eventos de Node.js

Cuando la aplicación empieza a ejecutarse, también se inicia el bucle de eventos y las fases se gestionan de una en una. Node.js añade retrollamadas a diferentes colas, según convenga, mientras se ejecuta la aplicación. Cuando el bucle de eventos llegue a una fase, ejecutará todas las llamadas de retorno de la cola de esa fase. Una vez agotadas todas las llamadas de retorno de una fase determinada, el bucle de eventos pasa a la siguiente fase. Si la aplicación se queda sin nada que hacer pero está esperando a que se completen las operaciones de E/S, se quedará en la fase de sondeo.

Ejemplo de código

La teoría es bonita y todo, pero para entender realmente cómo funciona el bucle de eventos, vas a tener que ensuciarte las manos. Este ejemplo utiliza las fases de sondeo, comprobación y temporizadores. Crea un archivo llamado event-loop-phases.js y añádele el contenido del Ejemplo 1-5.

Ejemplo 1-5. event-loop-phases.js
const fs = require('fs');

setImmediate(() => console.log(1));
Promise.resolve().then(() => console.log(2));
process.nextTick(() => console.log(3));
fs.readFile(__filename, () => {
  console.log(4);
  setTimeout(() => console.log(5));
  setImmediate(() => console.log(6));
  process.nextTick(() => console.log(7));
});
console.log(8);

Si te sientes inclinado, intenta adivinar el orden de salida, pero no te sientas mal si tu respuesta no coincide. Este es un tema un poco complejo.

El script comienza a ejecutarse línea a línea en la fase de sondeo. En primer lugar, se necesita el módulo fs, y entre bastidores ocurre un montón de magia. A continuación, se ejecuta la llamadasetImmediate() que añade la impresión de la devolución de llamada 1 a la cola de comprobación. A continuación, la promesa se resuelve, añadiendo la devolución de llamada 2 a la cola de microtareas de la promesa. process.nextTick() A continuación, se ejecuta la llamada de retorno 3, que añade la llamada de retorno 3 a la cola de microtareas de verificación. Una vez hecho esto, la llamada a fs.readFile() indica a las API de Node.js que empiecen a leer un archivo, colocando su devolución de llamada en la cola de sondeo una vez que esté listo. Por último, se llama directamente al log número 8 y se imprime en la pantalla.

Eso es todo para la pila actual. Ahora se consultan las dos colas de microtareas. Siempre se consulta primero la cola de microtareas de siguiente tic, y se llama a la llamada de retorno 3. Como sólo hay una llamada de retorno en la cola de microtareas de siguiente tic, se consulta a continuación la cola de microtareas de promesa. Aquí se ejecuta la llamada de retorno 2. Esto finaliza las dos colas de microtareas, y se completa la fase de sondeo actual.

Ahora el bucle de eventos entra en la fase de comprobación. Esta fase contiene la llamada de retorno 1, que se ejecuta. Ambas colas de microtareas están vacías en este punto, por lo que la fase de comprobación finaliza. A continuación se comprueba la fase de cierre, pero está vacía, por lo que el bucle continúa. Lo mismo ocurre con la fase de temporizadores y la fase pendiente, y el bucle de eventos continúa de vuelta a la fase de sondeo.

Una vez que vuelve a la fase de sondeo, la aplicación no tiene mucho más que hacer, así que básicamente espera a que termine de leerse el archivo. Una vez que esto ocurre, se ejecuta lafs.readFile() se ejecuta la llamada de retorno.

El número 4 se imprime inmediatamente, ya que es la primera línea de la llamada de retorno. A continuación, se realiza la llamada a setTimeout() y la llamada de retorno 5 se añade a la cola de temporizadores. La llamada asetImmediate() a continuación, se añade la llamada de retorno 6 a la cola de comprobación. Por último, se realiza laprocess.nextTick() se realiza la llamada, añadiendo la devolución de llamada 7 a la cola de microtareas de tic siguiente. La cola de sondeo ha terminado, y se consultan de nuevo las colas de microtareas. La llamada de retorno 7 se ejecuta desde la cola de tic siguiente, se consulta la cola de promesas y se encuentra vacía, y finaliza la fase de sondeo.

De nuevo, el bucle de eventos pasa a la fase de comprobación, en la que se encuentra la llamada de retorno 6. Se imprime el número, se determina que las colas de microtareas están vacías y finaliza la fase. La fase de cierre se comprueba de nuevo y se encuentra vacía. Por último, se consulta la fase de temporizadores, en la que se ejecuta la llamada de retorno 5. Una vez hecho esto, la aplicación no tiene más trabajo que hacer y sale.

Las declaraciones de registro se han impreso en este orden: 8, 3, 2, 1, 4, 7, 6, 5.

Cuando se trata de funciones async, y operaciones que utilizan la palabra clave await, el código sigue jugando con las mismas reglas de bucle de eventos. La principal diferencia acaba siendo la sintaxis.

Aquí tienes un ejemplo de código complejo que intercala sentencias esperadas con sentencias que programan devoluciones de llamada de forma más directa. Repásalo y escribe el orden en que crees que se imprimirán las sentencias de registro:

const sleep_st = (t) => new Promise((r) => setTimeout(r, t));
const sleep_im = () => new Promise((r) => setImmediate(r));

(async () => {
  setImmediate(() => console.log(1));
  console.log(2);
  await sleep_st(0);
  setImmediate(() => console.log(3));
  console.log(4);
  await sleep_im();
  setImmediate(() => console.log(5));
  console.log(6);
  await 1;
  setImmediate(() => console.log(7));
  console.log(8);
})();

Cuando se trata de funciones y sentencias de async precedidas de await, casi puedes pensar que son azúcar sintáctico para el código que utiliza devoluciones de llamada anidadas, o incluso como una cadena de llamadas a .then(). El siguiente ejemplo es otra forma de pensar en el ejemplo anterior. De nuevo, mira el código y escribe el orden en que crees que se imprimirán los comandos de registro:

setImmediate(() => console.log(1));
console.log(2);
Promise.resolve().then(() => setTimeout(() => {
  setImmediate(() => console.log(3));
  console.log(4);
  Promise.resolve().then(() => setImmediate(() => {
    setImmediate(() => console.log(5));
    console.log(6);
    Promise.resolve().then(() => {
      setImmediate(() => console.log(7));
      console.log(8);
    });
  }));
}, 0));

¿Se te ocurrió una solución diferente cuando leíste este segundo ejemplo? ¿Te ha parecido más fácil de razonar? Esta vez, puedes aplicar más fácilmente las mismas reglas sobre el bucle de eventos que ya se han tratado. En este ejemplo, espero que quede más claro que, aunque las promesas resueltas hacen que parezca que el códigoque sigue debería ejecutarse mucho antes, todavía tienen que esperar a que las llamadas subyacentessetTimeout() o setImmediate() subyacentes antes de que el programa pueda continuar.

Las declaraciones de registro se han impreso en este orden: 2, 1, 4, 3, 6, 8, 5, 7.

Consejos para el bucle de eventos

Cuando se trata de construir una aplicación Node.js, no necesitas necesariamente conocer este nivel de detalle sobre el bucle de eventos. En muchos casos "simplemente funciona" y normalmente no necesitas preocuparte de qué llamadas de retorno se ejecutan primero. Dicho esto, hay algunas cosas importantes que debes tener en cuenta cuando se trata del bucle de eventos.

No dejes morir de hambre al bucle de eventos. Ejecutar demasiado código en una sola pila bloqueará el bucle de eventos e impedirá que se ejecuten otras llamadas de retorno. Una forma de solucionarlo es dividir las operaciones que consumen mucha CPU en varias pilas. Por ejemplo, si necesitas procesar 1.000 registros de datos, puedes dividirlos en 10 lotes de 100 registros, utilizando setImmediate() al final de cada lote ( ) para continuar procesando el siguiente lote. Dependiendo de la situación, puede tener más sentido descargar el procesamiento a un proceso hijo.

Nunca debes dividir ese trabajo utilizando process.nextTick(). Si lo haces, crearás una cola de microtareas que nunca se vaciará: ¡tu aplicación quedará atrapada en la misma fase para siempre! A diferencia de una función infinitamente recursiva, el código no lanzará un RangeError. En su lugar, seguirá siendo un proceso zombi que se come la CPU. Echa un vistazo a lo siguiente para ver un ejemplo de esto:

const nt_recursive = () => process.nextTick(nt_recursive);
nt_recursive(); // setInterval will never run

const si_recursive = () => setImmediate(si_recursive);
si_recursive(); // setInterval will run

setInterval(() => console.log('hi'), 10);

En este ejemplo, el setInterval() representa cierto trabajo asíncrono que realiza la aplicación, como responder a las solicitudes HTTP entrantes. Una vez ejecutada la función nt_recursive(), la aplicación acaba con una cola de microtareas que nunca se vacía y el trabajo asíncrono nunca se procesa. Pero la versión alternativa si_recursive() no tiene el mismo efecto secundario. Hacer llamadas a setImmediate() dentro de una fase de comprobación añade las devoluciones de llamada a la cola de la fase de comprobación de la siguiente iteración del bucle de eventos, no a la cola de la fase actual.

No introduzcas Zalgo. Cuando expone un método que toma una llamada de retorno, esa llamada de retorno debe ejecutarse siempre de forma asíncrona. Por ejemplo, es demasiado fácil escribir algo como esto

// Antipattern
function foo(count, callback) {
  if (count <= 0) {
    return callback(new TypeError('count > 0'));
  }
  myAsyncOperation(count, callback);
}

La llamada de retorno se llama a veces de forma sincrónica, como cuando count se pone a cero, y a veces de forma asincrónica, como cuando count se pone a uno. En su lugar, asegúrate de que la llamada de retorno se ejecuta en una nueva pila, como en este ejemplo:

function foo(count, callback) {
  if (count <= 0) {
    return process.nextTick(() => callback(new TypeError('count > 0')));
  }
  myAsyncOperation(count, callback);
}

En este caso, tanto como setImmediate() o process.nextTick() están bien; sólo asegúrate de no introducir accidentalmente la recursividad. Con este ejemplo reelaborado, la llamada de retorno se ejecuta siempre de forma asíncrona. Asegurarse de que la llamada de retorno se ejecuta de forma coherente es importante debido a la siguiente situación:

let bar = false;
foo(3, () => {
  assert(bar);
});
bar = true;

Esto puede parecer un poco artificioso, pero esencialmente el problema es que cuando la llamada de retorno se ejecuta unas veces de forma sincrónica y otras de forma asincrónica, el valor de bar puede haberse modificado o no. En una aplicación real, esto puede ser la diferencia entre acceder a una variable que puede o no haber sido inicializada correctamente.

Ahora que estás un poco más familiarizado con el funcionamiento interno de Node.js, es hora de crear algunas aplicaciones de ejemplo.

Ejemplos de aplicaciones

En esta sección construirás un par de pequeñas aplicaciones Node.js de ejemplo. Son intencionadamente sencillas y carecen de características que requieren las aplicaciones reales. A continuación, aumentarás la complejidad de estas aplicaciones base a lo largo del resto del libro.

Me costó tomar la decisión de evitar el uso de paquetes de terceros en estos ejemplos (por ejemplo, limitarme al módulo interno http ), pero el uso de estos paquetes reduce la burocracia y aumenta la claridad. Dicho esto, siéntete libre de elegir el framework o biblioteca de petición que prefieras; la intención de este libro no es prescribir nunca un paquete concreto.

Si construyes dos servicios en lugar de uno solo, podrás combinarlos después de formas interesantes, como elegir el protocolo con el que se comunican o la forma en que se descubren mutuamente.

La primera aplicación, la recipe-api, representa una API interna a la que no se accede desde el mundo exterior; sólo accederán a ella otras aplicaciones internas. Como eres propietario tanto del servicio como de los clientes que accedan a él, luego tienes libertad para tomar decisiones sobre el protocolo. Esto es válido para cualquier servicio interno de una organización.

La segunda aplicación representa una API a la que acceden terceros a través de Internet. Expone un servidor HTTP para que los navegadores web puedan comunicarse fácilmente con ella. Esta aplicación se llama web-api.

Relación de servicio

El servicio web-api está aguas abajo del recipe-api y, a la inversa, el recipe-api está aguas arriba del web-api. La Figura 1-5 es una visualización de la relación entre estos dos servicios.

Relationship between web-api and recipe-api
Figura 1-5. La relación entre web-api y recipe-api

Estas dos aplicaciones pueden denominarse servidores porque ambas están a la escucha activa de las solicitudes entrantes de la red. Sin embargo, al describir la relación específica entre las dos API (flecha B en la Figura 1-5), se puede referir a la web-api como el cliente/consumidor y a la recipe-api como el servidor/productor. El capítulo 2 se centra en esta relación. Cuando nos referimos a la relación entre el navegador web y la web-api (flecha A en la Figura 1-5), el navegador se denomina cliente/consumidor, y la web-api se denomina entonces servidor/productor.

Ahora es el momento de examinar en el código fuente de los dos servicios. Puesto que estos dos servicios evolucionarán a lo largo del libro, ahora sería un buen momento para crear algunos proyectos de ejemplo para ellos. Crea un directorio distributed-node/ para albergar todos los ejemplos de código que crearás para este libro. La mayoría de los comandos que ejecutarás requieren que estés dentro de este directorio, a menos que se indique lo contrario. Dentro de este directorio, crea un directorio web-api/, un directorio recipe-api/ y un directorio shared/. Los dos primeros directorios contendrán diferentes representacionesdel servicio. El directorio shared/ contendrá archivos compartidos para facilitar la aplicación de los ejemplos de este libro.4

También tendrás que instalar las dependencias necesarias. Dentro de ambos directorios del proyecto, ejecuta el siguiente comando:

$ npm init -y

Esto crea archivos package.json básicos por ti. Una vez hecho esto, ejecuta los comandos npm install apropiados del comentario superior de los ejemplos de código. Los ejemplos de código utilizan esta convención en todo el libro para indicar qué paquetes deben instalarse, por lo que tendrás que ejecutar los comandos init e install por tu cuenta después de esto. Ten en cuenta que cada proyecto empezará a contener dependencias superfluas, ya que los ejemplos de código reutilizan directorios. En un proyecto del mundo real, sólo deben figurar como dependencias los paquetes necesarios.

Servicio al Productor

Ahora que la configuración está completa, es hora de ver el código fuente. El Ejemplo 1-6 es un servicio interno de la API Receta, un servicio ascendente que proporciona datos. Para este ejemplo simplemente proporcionará datos estáticos. En cambio, una aplicación del mundo real podría recuperar datos de una base de datos.

Ejemplo 1-6. recipe-api/productor-http-basic.js
#!/usr/bin/env node

// npm install fastify@3.2
const server = require('fastify')();
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 4000;

console.log(`worker pid=${process.pid}`);

server.get('/recipes/:id', async (req, reply) => {
  console.log(`worker request pid=${process.pid}`);
  const id = Number(req.params.id);
  if (id !== 42) {
    reply.statusCode = 404;
    return { error: 'not_found' };
  }
  return {
    producer_pid: process.pid,
    recipe: {
      id, name: "Chicken Tikka Masala",
      steps: "Throw it in a pot...",
      ingredients: [
        { id: 1, name: "Chicken", quantity: "1 lb", },
        { id: 2, name: "Sauce", quantity: "2 cups", }
      ]
    }
  };
});

server.listen(PORT, HOST, () => {
  console.log(`Producer running at http://${HOST}:${PORT}`);
});
Consejo

La primera línea de estos archivos es , conocida como shebang. Cuando un archivo empieza por esta línea y se hace ejecutable (ejecutando chmod +x filename.js), se puede ejecutar ejecutando ./filename.js. Como convención en este libro, siempre que el código contenga un shebang, representa un archivo utilizado como punto de entrada para una aplicación.

Una vez que este servicio esté listo, puedes trabajar con él en dos ventanas de terminal diferentes.5 Ejecuta los siguientes comandos; el primero inicia el servicio recipe-api, y el segundo comprueba que se está ejecutando y puede devolver datos:

$ node recipe-api/producer-http-basic.js # terminal 1
$ curl http://127.0.0.1:4000/recipes/42  # terminal 2

Entonces deberías ver una salida JSON como la siguiente (se han añadido espacios en blanco para mayor claridad):

{
  "producer_pid": 25765,
  "recipe": {
    "id": 42,
    "name": "Chicken Tikka Masala",
    "steps": "Throw it in a pot...",
    "ingredients": [
      { "id": 1, "name": "Chicken", "quantity": "1 lb" },
      { "id": 2, "name": "Sauce", "quantity": "2 cups" }
    ]
  }
}

Servicio al consumidor

El segundo servicio, un servicio Web API de cara al público, no contiene tantos datos, pero es más complejo, ya que va a realizar una solicitud saliente. Copia el código fuente del Ejemplo 1-7 en el archivo ubicado en web-api/consumer-http-basic.js.

Ejemplo 1-7. web-api/consumer-http-basic.js
#!/usr/bin/env node

// npm install fastify@3.2 node-fetch@2.6
const server = require('fastify')();
const fetch = require('node-fetch');
const HOST = process.env.HOST || '127.0.0.1';
const PORT = process.env.PORT || 3000;
const TARGET = process.env.TARGET || 'localhost:4000';

server.get('/', async () => {
  const req = await fetch(`http://${TARGET}/recipes/42`);
  const producer_data = await req.json();

  return {
    consumer_pid: process.pid,
    producer_data
  };
});

server.listen(PORT, HOST, () => {
  console.log(`Consumer running at http://${HOST}:${PORT}/`);
});

Asegúrate de que el servicio recipe-api sigue ejecutándose. A continuación, una vez que hayas creado el archivo y hayas añadido el código, ejecuta el nuevo servicio y genera una petición utilizando los siguientes comandos:

$ node web-api/consumer-http-basic.js # terminal 1
$ curl http://127.0.0.1:3000/         # terminal 2

El resultado de esta operación es un superconjunto del JSON proporcionado por la solicitud anterior:

{
  "consumer_pid": 25670,
  "producer_data": {
    "producer_pid": 25765,
    "recipe": {
      ...
    }
  }
}

Los valores pid de las respuestas son los ID numéricos de proceso de cada servicio. Los sistemas operativos utilizan estos valores PID para diferenciar los procesos en ejecución. Se incluyen en las respuestas para hacer evidente que los datos proceden de dos procesos distintos. Estos valores son únicos en un determinado sistema operativo en ejecución, lo que significa que no debería haber duplicados en la misma máquina en ejecución, aunque sí habrá colisiones en máquinas separadas, virtuales o no.

1 Incluso una aplicación multihilo está constreñida por las limitaciones de una sola máquina.

2 "Userland" es un término tomado de los sistemas operativos, que significa el espacio fuera del núcleo donde pueden ejecutarse las aplicaciones de un usuario. En el caso de los programas Node.js, se refiere al código de la aplicación y a los paquetes npm -básicamente, a todo lo que no está incorporado en Node.js.

3 Un "tick" se refiere a una pasada completa por el bucle de eventos. Por confusión, setImmediate() tarda un "tick" en ejecutarse, mientras que process.nextTick() es más inmediata, por lo que las dos funciones merecen un intercambio de nombres.

4 En el mundo real, los archivos compartidos deben registrarse mediante el control de código fuente o cargarse como dependencia externa mediante un paquete npm.

5 Muchos de los ejemplos de este libro requieren que ejecutes varios procesos, algunos como clientes y otros como servidores. Por esta razón, a menudo necesitarás ejecutar procesos en ventanas de terminal separadas. En general, si ejecutas un comando y no sale inmediatamente, probablemente requiera un terminal dedicado.

Get Sistemas distribuidos con Node.js now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.