Procesos secundarios de Node.js: todo lo que necesita saber

Cómo usar spawn (), exec (), execFile () y fork ()

Captura de pantalla capturada de mi curso Pluralsight - Advanced Node.js
Actualización: Este artículo ahora es parte de mi libro "Node.js Beyond The Basics".
Lea la versión actualizada de este contenido y más sobre Node en jscomplete.com/node-beyond-basics.

El rendimiento de un solo subproceso y sin bloqueo en Node.js funciona muy bien para un solo proceso. Pero eventualmente, un proceso en una CPU no será suficiente para manejar la creciente carga de trabajo de su aplicación.

No importa cuán poderoso sea su servidor, un solo hilo solo puede soportar una carga limitada.

El hecho de que Node.js se ejecute en un solo subproceso no significa que no podamos aprovechar múltiples procesos y, por supuesto, múltiples máquinas también.

Usar múltiples procesos es la mejor manera de escalar una aplicación Node. Node.js está diseñado para crear aplicaciones distribuidas con muchos nodos. Por eso se llama Nodo. La escalabilidad está integrada en la plataforma y no es algo en lo que comiences a pensar más adelante en la vida útil de una aplicación.

Este artículo es una reseña de parte de mi curso Pluralsight sobre Node.js. Cubro contenido similar en formato de video allí.

Tenga en cuenta que necesitará una buena comprensión de los eventos y transmisiones de Node.js antes de leer este artículo. Si aún no lo ha hecho, le recomiendo que lea estos otros dos artículos antes de leer este:

El Módulo de Procesos Infantiles

Podemos girar fácilmente un proceso secundario utilizando el módulo child_process de Node y esos procesos secundarios pueden comunicarse fácilmente entre sí con un sistema de mensajería.

El módulo child_process nos permite acceder a las funcionalidades del sistema operativo ejecutando cualquier comando del sistema dentro de un, bueno, proceso hijo.

Podemos controlar esa secuencia de entrada del proceso secundario y escuchar su secuencia de salida. También podemos controlar los argumentos que se pasarán al comando subyacente del sistema operativo, y podemos hacer lo que queramos con la salida de ese comando. Podemos, por ejemplo, canalizar la salida de un comando como la entrada a otro (al igual que lo hacemos en Linux) ya que todas las entradas y salidas de estos comandos se nos pueden presentar utilizando las secuencias Node.js.

Tenga en cuenta que los ejemplos que utilizaré en este artículo están basados ​​en Linux. En Windows, debe cambiar los comandos que uso con sus alternativas de Windows.

Hay cuatro formas diferentes de crear un proceso hijo en Node: spawn (), fork (), exec () y execFile ().

Vamos a ver las diferencias entre estas cuatro funciones y cuándo usar cada una.

Procesos secundarios generados

La función spawn inicia un comando en un nuevo proceso y podemos usarlo para pasar cualquier comando a ese comando. Por ejemplo, aquí está el código para generar un nuevo proceso que ejecutará el comando pwd.

const {spawn} = require ('child_process');
const child = spawn ('pwd');

Simplemente desestructuramos la función de generación del módulo child_process y la ejecutamos con el comando del sistema operativo como primer argumento.

El resultado de ejecutar la función de generación (el objeto secundario anterior) es una instancia de ChildProcess, que implementa la API EventEmitter. Esto significa que podemos registrar controladores para eventos en este objeto secundario directamente. Por ejemplo, podemos hacer algo cuando el proceso secundario sale al registrar un controlador para el evento de salida:

child.on ('salida', función (código, señal) {
  console.log ('proceso hijo salido con' +
              `code $ {code} y signal $ {signal}`);
});

El controlador anterior nos proporciona el código de salida para el proceso secundario y la señal, si la hay, que se utilizó para finalizar el proceso secundario. Esta variable de señal es nula cuando el proceso hijo sale normalmente.

Los otros eventos para los que podemos registrar controladores con las instancias de ChildProcess son desconexión, error, cierre y mensaje.

  • El evento de desconexión se emite cuando el proceso primario llama manualmente a la función child.disconnect.
  • El evento de error se emite si el proceso no se puede generar o eliminar.
  • El evento de cierre se emite cuando se cierran las secuencias stdio de un proceso secundario.
  • El evento del mensaje es el más importante. Se emite cuando el proceso secundario utiliza la función process.send () para enviar mensajes. Así es como los procesos padre / hijo pueden comunicarse entre sí. Veremos un ejemplo de esto a continuación.

Todos los procesos secundarios también obtienen las tres secuencias stdio estándar, a las que podemos acceder mediante child.stdin, child.stdout y child.stderr.

Cuando esas secuencias se cierran, el proceso secundario que las estaba utilizando emitirá el evento de cierre. Este evento de cierre es diferente al evento de salida porque varios procesos secundarios pueden compartir las mismas secuencias estándar y, por lo tanto, la salida de un proceso secundario no significa que las secuencias se hayan cerrado.

Como todas las transmisiones son emisoras de eventos, podemos escuchar diferentes eventos en esas transmisiones estándar que se adjuntan a cada proceso secundario. Sin embargo, a diferencia de un proceso normal, en un proceso secundario, las secuencias stdout / stderr son secuencias legibles, mientras que la secuencia stdin se puede escribir. Esto es básicamente el inverso de esos tipos que se encuentran en un proceso principal. Los eventos que podemos usar para esas transmisiones son los estándares. Lo más importante, en las transmisiones legibles, podemos escuchar el evento de datos, que tendrá la salida del comando o cualquier error encontrado al ejecutar el comando:

child.stdout.on ('data', (data) => {
  console.log (`child stdout: \ n $ {data}`);
});

child.stderr.on ('data', (data) => {
  console.error (`child stderr: \ n $ {data}`);
});

Los dos controladores anteriores registrarán ambos casos en el proceso principal stdout y stderr. Cuando ejecutamos la función de generación anterior, la salida del comando pwd se imprime y el proceso secundario sale con el código 0, lo que significa que no se produjo ningún error.

Podemos pasar argumentos al comando que ejecuta la función spawn utilizando el segundo argumento de la función spawn, que es una matriz de todos los argumentos que se pasarán al comando. Por ejemplo, para ejecutar el comando find en el directorio actual con un argumento tipo-f (solo para enumerar archivos), podemos hacer:

const child = spawn ('buscar', ['.', '-tipo', 'f']);

Si se produce un error durante la ejecución del comando, por ejemplo, si damos a buscar un destino no válido arriba, se activará el controlador de eventos de datos child.stderr y el controlador de eventos de salida informará un código de salida de 1, lo que significa que un Un error ha ocurrido. Los valores de error realmente dependen del sistema operativo host y del tipo de error.

Un stdin de proceso hijo es una secuencia de escritura. Podemos usarlo para enviar un comando de entrada. Al igual que cualquier flujo de escritura, la forma más fácil de consumirlo es utilizando la función de canalización. Simplemente canalizamos una secuencia legible en una secuencia de escritura. Dado que el stdin principal del proceso es un flujo legible, podemos canalizarlo en un flujo stdin de proceso hijo. Por ejemplo:

const {spawn} = require ('child_process');

const child = spawn ('wc');

process.stdin.pipe (child.stdin)

child.stdout.on ('data', (data) => {
  console.log (`child stdout: \ n $ {data}`);
});

En el ejemplo anterior, el proceso secundario invoca el comando wc, que cuenta líneas, palabras y caracteres en Linux. Luego canalizamos el stdin principal del proceso (que es una secuencia legible) en el stdin secundario del proceso (que es una secuencia grabable). El resultado de esta combinación es que obtenemos un modo de entrada estándar donde podemos escribir algo y cuando presionamos Ctrl + D, lo que escribimos se usará como entrada del comando wc.

Gif capturado de mi curso de Pluralsight - Advanced Node.js

También podemos canalizar la entrada / salida estándar de múltiples procesos entre sí, al igual que podemos hacer con los comandos de Linux. Por ejemplo, podemos canalizar el stdout del comando find al stdin del comando wc para contar todos los archivos en el directorio actual:

const {spawn} = require ('child_process');

const find = spawn ('buscar', ['.', '-tipo', 'f']);
const wc = spawn ('wc', ['-l']);

find.stdout.pipe (wc.stdin);

wc.stdout.on ('data', (data) => {
  console.log (`Número de archivos $ {data}`);
});

Agregué el argumento -l al comando wc para que cuente solo las líneas. Cuando se ejecuta, el código anterior generará un recuento de todos los archivos en todos los directorios bajo el actual.

Shell Syntax y la función exec

Por defecto, la función spawn no crea un shell para ejecutar el comando que le pasamos. Esto lo hace un poco más eficiente que la función exec, que crea un shell. La función ejecutiva tiene otra gran diferencia. Protege la salida generada por el comando y pasa todo el valor de salida a una función de devolución de llamada (en lugar de usar secuencias, que es lo que hace spawn).

Aquí está el hallazgo anterior | wc ejemplo implementado con una función exec.

const {exec} = require ('child_process');

exec ('find. -type f | wc -l', (err, stdout, stderr) => {
  si (err) {
    console.error (`error de ejecución: $ {err}`);
    regreso;
  }

  console.log (`Número de archivos $ {stdout}`);
});

Dado que la función exec usa un shell para ejecutar el comando, podemos usar la sintaxis de shell directamente aquí haciendo uso de la función de canalización de shell.

Tenga en cuenta que el uso de la sintaxis de shell conlleva un riesgo de seguridad si está ejecutando cualquier tipo de entrada dinámica proporcionada externamente. Un usuario puede simplemente realizar un ataque de inyección de comando utilizando caracteres de sintaxis de shell como; y $ (por ejemplo, comando + '; rm -rf ~')

La función exec almacena la salida y la pasa a la función de devolución de llamada (el segundo argumento de exec) como el argumento stdout allí. Este argumento estándar es el resultado del comando que queremos imprimir.

La función exec es una buena opción si necesita usar la sintaxis de shell y si el tamaño de los datos esperados del comando es pequeño. (Recuerde, exec almacenará todos los datos en la memoria antes de devolverlos).

La función de generación es una opción mucho mejor cuando el tamaño de los datos esperados del comando es grande, porque esos datos se transmitirán con los objetos IO estándar.

Si lo deseamos, podemos hacer que el proceso hijo generado herede los objetos IO estándar de sus padres, pero también, lo que es más importante, podemos hacer que la función spawn también use la sintaxis de shell. Aquí está el mismo hallazgo | Comando wc implementado con la función spawn:

const child = spawn ('find. -type f | wc -l', {
  stdio: 'heredar',
  shell: verdadero
});

Debido a la opción stdio: 'heredar' anterior, cuando ejecutamos el código, el proceso hijo hereda el proceso principal stdin, stdout y stderr. Esto hace que los controladores de eventos de datos del proceso secundario se activen en la secuencia principal process.stdout, lo que hace que la secuencia de comandos genere el resultado de inmediato.

Debido a la opción shell: true anterior, pudimos usar la sintaxis de shell en el comando pasado, tal como lo hicimos con exec. Pero con este código, todavía obtenemos la ventaja de la transmisión de datos que nos brinda la función de generación. Esto es realmente lo mejor de ambos mundos.

Hay algunas otras buenas opciones que podemos usar en el último argumento de las funciones child_process además de shell y stdio. Podemos, por ejemplo, usar la opción cwd para cambiar el directorio de trabajo del script. Por ejemplo, aquí está el mismo ejemplo de contar todos los archivos hecho con una función de generación usando un shell y con un directorio de trabajo configurado en mi carpeta de Descargas. La opción cwd aquí hará que el script cuente todos los archivos que tengo en ~ / Descargas:

const child = spawn ('find. -type f | wc -l', {
  stdio: 'heredar',
  shell: cierto
  cwd: '/ Users / samer / Downloads'
});

Otra opción que podemos usar es la opción env para especificar las variables de entorno que serán visibles para el nuevo proceso secundario. El valor predeterminado para esta opción es process.env, que da acceso a cualquier comando al entorno de proceso actual. Si queremos anular ese comportamiento, simplemente podemos pasar un objeto vacío como la opción env o nuevos valores para ser considerados como las únicas variables de entorno:

const child = spawn ('echo $ RESPUESTA', {
  stdio: 'heredar',
  shell: cierto
  env: {RESPUESTA: 42},
});

El comando echo anterior no tiene acceso a las variables de entorno del proceso padre. Por ejemplo, no puede acceder a $ HOME, pero puede acceder a $ ANSWER porque se pasó como una variable de entorno personalizada a través de la opción env.

Una última opción importante del proceso secundario para explicar aquí es la opción separada, que hace que el proceso secundario se ejecute independientemente de su proceso principal.

Suponiendo que tenemos un archivo timer.js que mantiene ocupado el bucle de eventos:

setTimeout (() => {
  // mantener ocupado el ciclo de eventos
}, 20000);

Podemos ejecutarlo en segundo plano usando la opción separada:

const {spawn} = require ('child_process');

const child = spawn ('nodo', ['timer.js'], {
  separado: cierto,
  stdio: 'ignorar'
});

child.unref ();

El comportamiento exacto de los procesos secundarios separados depende del sistema operativo. En Windows, el proceso hijo separado tendrá su propia ventana de consola, mientras que en Linux el proceso hijo separado se convertirá en el líder de un nuevo grupo de procesos y sesión.

Si se llama a la función irref en el proceso separado, el proceso padre puede salir independientemente del hijo. Esto puede ser útil si el niño está ejecutando un proceso de larga duración, pero para mantenerlo ejecutándose en segundo plano, las configuraciones stdio del niño también tienen que ser independientes del padre.

El ejemplo anterior ejecutará un script de nodo (timer.js) en segundo plano al separar e ignorar también sus descriptores de archivo stdio padre para que el padre pueda terminar mientras el niño sigue ejecutándose en segundo plano.

Gif capturado de mi curso de Pluralsight - Advanced Node.js

La función execFile

Si necesita ejecutar un archivo sin usar un shell, la función execFile es lo que necesita. Se comporta exactamente como la función exec, pero no utiliza un shell, lo que lo hace un poco más eficiente. En Windows, algunos archivos no se pueden ejecutar solos, como los archivos .bat o .cmd. Esos archivos no se pueden ejecutar con execFile y se requiere exec o spawn con shell establecido en true para ejecutarlos.

La función * Sync

Las funciones spawn, exec y execFile del módulo child_process también tienen versiones de bloqueo sincrónico que esperarán hasta que finalice el proceso hijo.

const {
  spawnSync,
  execSync,
  execFileSync,
} = require ('child_process');

Esas versiones sincrónicas son potencialmente útiles cuando se intenta simplificar las tareas de secuencias de comandos o cualquier tarea de procesamiento de inicio, pero de lo contrario se deben evitar.

La función fork ()

La función fork es una variación de la función spawn para generar procesos de nodo. La mayor diferencia entre spawn y fork es que se establece un canal de comunicación para el proceso secundario cuando se usa fork, por lo que podemos usar la función de envío en el proceso bifurcado junto con el objeto del proceso global en sí para intercambiar mensajes entre los procesos padre y bifurcado. Hacemos esto a través de la interfaz del módulo EventEmitter. Aquí hay un ejemplo:

El archivo principal, parent.js:

const {fork} = require ('child_process');

const forked = fork ('child.js');

forked.on ('mensaje', (mensaje) => {
  console.log ('Mensaje del hijo', mensaje);
});

forked.send ({hola: 'mundo'});

El archivo hijo, child.js:

process.on ('mensaje', (mensaje) => {
  console.log ('Mensaje del padre:', mensaje);
});

dejar contador = 0;

setInterval (() => {
  proceso.send ({counter: counter ++});
}, 1000);

En el archivo principal anterior, bifurcamos child.js (que ejecutará el archivo con el comando de nodo) y luego escuchamos el evento del mensaje. El evento de mensaje se emitirá cada vez que el niño use process.send, lo que hacemos cada segundo.

Para pasar mensajes del padre al hijo, podemos ejecutar la función de envío en el objeto bifurcado en sí, y luego, en el script hijo, podemos escuchar el evento del mensaje en el objeto de proceso global.

Al ejecutar el archivo parent.js anterior, primero enviará el objeto {hello: 'world'} para que lo imprima el proceso secundario bifurcado y luego el proceso secundario bifurcado enviará un valor de contador incrementado cada segundo para ser impreso por El proceso padre.

Captura de pantalla capturada de mi curso Pluralsight - Advanced Node.js

Hagamos un ejemplo más práctico sobre la función fork.

Supongamos que tenemos un servidor http que maneja dos puntos finales. Uno de estos puntos finales (/ compute abajo) es computacionalmente costoso y tomará unos segundos completarlo. Podemos usar un bucle largo para simular que:

const http = require ('http');
const longComputation = () => {
  dejar suma = 0;
  para (sea i = 0; i <1e9; i ++) {
    suma + = i;
  };
  suma de retorno;
};
servidor const = http.createServer ();
server.on ('request', (req, res) => {
  if (req.url === '/ compute') {
    const sum = longComputation ();
    return res.end (`La suma es $ {sum}`);
  } más {
    res.end ('Ok')
  }
});

server.listen (3000);

Este programa tiene un gran problema; cuando se solicita el punto final / compute, el servidor no podrá manejar ninguna otra solicitud porque el bucle de eventos está ocupado con la operación de bucle larga.

Hay algunas maneras con las que podemos resolver este problema dependiendo de la naturaleza de la operación larga, pero una solución que funciona para todas las operaciones es simplemente mover la operación computacional a otro proceso usando fork.

Primero movemos toda la función longComputation a su propio archivo y hacemos que invoque esa función cuando se lo indique a través de un mensaje del proceso principal:

En un nuevo archivo compute.js:

const longComputation = () => {
  dejar suma = 0;
  para (sea i = 0; i <1e9; i ++) {
    suma + = i;
  };
  suma de retorno;
};

process.on ('mensaje', (mensaje) => {
  const sum = longComputation ();
  proceso.send (suma);
});

Ahora, en lugar de realizar la operación larga en el bucle de eventos del proceso principal, podemos bifurcar el archivo compute.js y usar la interfaz de mensajes para comunicar mensajes entre el servidor y el proceso bifurcado.

const http = require ('http');
const {fork} = require ('child_process');

servidor const = http.createServer ();

server.on ('request', (req, res) => {
  if (req.url === '/ compute') {
    const compute = fork ('compute.js');
    compute.send ('inicio');
    compute.on ('mensaje', suma => {
      res.end (`Suma es $ {sum}`);
    });
  } más {
    res.end ('Ok')
  }
});

server.listen (3000);

Cuando se produce una solicitud / cálculo ahora con el código anterior, simplemente enviamos un mensaje al proceso bifurcado para comenzar a ejecutar la operación larga. El bucle de eventos del proceso principal no se bloqueará.

Una vez que el proceso bifurcado se realiza con esa operación larga, puede enviar su resultado al proceso padre utilizando process.send.

En el proceso padre, escuchamos el evento del mensaje en el proceso hijo bifurcado. Cuando tengamos ese evento, tendremos un valor de suma listo para que lo enviemos al usuario solicitante a través de http.

El código anterior está, por supuesto, limitado por la cantidad de procesos que podemos bifurcar, pero cuando lo ejecutamos y solicitamos el punto final de cálculo largo a través de http, el servidor principal no está bloqueado en absoluto y puede recibir más solicitudes.

El módulo de clúster de Node, que es el tema de mi próximo artículo, se basa en esta idea de bifurcación de proceso secundario y equilibrio de carga de las solicitudes entre los muchos tenedores que podemos crear en cualquier sistema.

Eso es todo lo que tengo para este tema. ¡Gracias por leer! ¡Hasta la proxima vez!

¿Reacción o nodo de aprendizaje? Mira mis libros:

  • Aprenda React.js construyendo juegos
  • Node.js más allá de lo básico