Articolo originale: Node.js Child Processes: Everything you need to know di Samer Buna

Tradotto e adattato da: Angelo Mirabelli

Come usare spawn(), exec(), execFile() e fork()

Le prestazioni a singolo thread e non bloccanti in Node.js funzionano alla grande per un singolo processo. Ma alla fine, un processo in una CPU non sarà sufficiente per gestire il crescente carico di lavoro della tua applicazione.

Indipendentemente dalla potenza del tuo server, un singolo thread può supportare solo un carico limitato.

Il fatto che Node.js venga eseguito in un singolo thread non significa che non possiamo sfruttare più processi e, ovviamente, anche più macchine.

L'uso di più processi è il modo migliore per scalare un'applicazione Node. Node.js è progettato per la creazione di applicazioni distribuite con molti nodi. Questo è il motivo per cui si chiama Node . La scalabilità è incorporata nella piattaforma e non è qualcosa a cui inizi a pensare dopo, nel corso della vita di un'applicazione.

Questo articolo è un riassunto di parte del my Pluralsight course about Node.js. Lì copro contenuti simili in formato video in inglese.

Tieni presente che avrai bisogno di una buona conoscenza degli eventi e degli stream di Node.js prima di leggere questo articolo. Se non l'hai già fatto, ti consiglio di leggere questi altri due articoli prima di leggere questo:

Understanding Node.js Event-Driven Architecture
Most of Node’s objects — like HTTP requests, responses, and streams — implement the EventEmitter module so they can…

Streams: Everything you need to know
Node.js streams have a reputation for being hard to work with, and even harder to understand. Well I’ve got good news…

Il modulo Child Process

Possiamo facilmente far girare un child process usando il modulo di Node child_process e quei child process possono facilmente comunicare tra loro con un sistema di messaggistica.

Il modulo child_process ci consente di accedere alle funzionalità del sistema operativo eseguendo qualsiasi comando di sistema all'interno di un processo figlio.

Possiamo controllare il flusso di input del child process e ascoltare il suo flusso di output. Possiamo anche controllare gli argomenti da passare al comando del sistema operativo sottostante e possiamo fare tutto ciò che vogliamo con l'output di quel comando. Possiamo, ad esempio, reindirizzare l'output di un comando come input a un altro (proprio come facciamo in Linux) poiché tutti gli input e gli output di questi comandi possono essere a noi disponibili utilizzando Node.js streams .

Nota che gli esempi che userò in questo articolo sono tutti basati su Linux. Su Windows, devi cambiare questi i comandi con i loro corrispettivi.

Esistono quattro modi diversi per creare un processo figlio in Node: spawn(), fork(), exec()e execFile().

Vedremo le differenze tra queste quattro funzioni e quando usarle.

Child Process generati

La funzione spawn (generatore) lancia un comando in un nuovo processo e possiamo usarla per passare a quel comando qualsiasi argomento. Ad esempio, ecco il codice per generare un nuovo processo che eseguirà il comando pwd.

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

const child = spawn('pwd');

Estraiamo semplicemente la funzione spawn dal modulo child_process e la eseguiamo con il comando del sistema operativo come primo argomento.

Il risultato dell'esecuzione della funzione spawn (l'oggetto child di sopra) è un'istanza ChildProcess che implementa l' API EventEmitter . Ciò significa che possiamo registrare direttamente i gestori per gli eventi su questo oggetto child. Ad esempio, possiamo fare qualcosa quando il processo child esce registrando un gestore per l'evento exit:

child.on('exit', function (code, signal) {
  console.log('child process exited with ' +
              `code ${code} and signal ${signal}`);
});

Il gestore sopra ci fornisce il code di uscita per il child process e il signal, se presente, che è stato utilizzato per terminare il processo figlio. Questa variabile signal è nulla quando il child process esce normalmente.

Gli altri eventi per i quali possiamo registrare i gestori per le istanze di ChildProcess sono disconnect, error, close e message.

  • L'evento disconnect viene emesso quando il processo genitore chiama manualmente la funzione child.disconnect.
  • L'evento error viene emesso se il processo non può essere generato o terminato.
  • L'evento close viene emesso quando i flussi stdio di un processo figlio vengono chiusi.
  • L'evento message è quello più importante. Viene emesso quando il processo figlio utilizza la funzione process.send() per inviare messaggi. Questo è il modo in cui i processi genitore/figlio possono comunicare tra loro. Vedremo dopo un esempio.

Ogni processo figlio ottiene anche i tre flussi standard stdio, a cui possiamo accedere utilizzando child.stdin, child.stdout e child.stderr.

Quando questi flussi vengono chiusi, il processo figlio che li stava utilizzando emetterà l'evento close. Questo evento close è diverso dall'evento exit perché più processi figlio potrebbero condividere gli stessi flussi stdio e quindi l'uscita di un processo figlio non significa che i canali siano stati chiusi.

Poiché tutti i flussi sono emettitori di eventi, possiamo ascoltare eventi diversi su quei flussi stdio collegati a ogni processo figlio. A differenza di un processo normale, tuttavia, in un processo figlio, i flussi stdout/ stderr sono flussi leggibili mentre il flusso stdin è scrivibile. Questo è fondamentalmente l'inverso di quei tipi che si trovano in un processo principale. Gli eventi che possiamo usare per quei flussi sono quelli standard. Soprattutto, sui flussi leggibili, possiamo ascoltare l'evento data, che avrà l'output del comando o qualsiasi errore riscontrato durante l'esecuzione del comando:

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

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

I due gestori precedenti registreranno entrambi i casi stdout e stderr nel processo principale . Quando eseguiamo la funzione spawn di sopra, l'output del comando pwd viene stampato e il processo figlio esce con code 0, il che significa che non si è verificato alcun errore.

Possiamo passare argomenti al comando eseguito dalla funzione spawn utilizzando il secondo argomento della stessa funzione spawn, che è un array di tutti gli argomenti da passare al comando. Ad esempio, per eseguire il comandofind sulla directory corrente con un argomento -type f (solo per elencare i file), possiamo fare:

const child = spawn('find', ['.', '-type', 'f']);

Se si verifica un errore durante l'esecuzione del comando, ad esempio, gli diamo da trovare una destinazione non valida, il gestore dell'evento child.stderr data verrà attivato e il gestore dell'evento exit segnalerà 1 come codice di uscita, che indica che si è verificato un errore. I valori di errore dipendono dal sistema operativo e dal tipo di errore.

Un processo figlio stdin è un flusso scrivibile. Possiamo usarlo per inviare un comando di input. Proprio come qualsiasi flusso scrivibile, il modo più semplice per usarlo è utilizzare la funzione pipe. Convogliamo semplicemente un flusso leggibile in un flusso scrivibile. Poiché il processo principale stdin è un flusso leggibile, possiamo convogliarlo in un flusso stdin del processo figlio. Per esempio:

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}`);
});

Nell'esempio sopra, il processo figlio richiama il comando wc, che conta righe, parole e caratteri in Linux. Quindi convogliamo il processo principale stdin (che è un flusso leggibile) nel processo figlio stdin (che è un flusso scrivibile). Il risultato di questa combinazione è che otteniamo una modalità di input standard in cui possiamo digitare qualcosa e quando premiamo Ctrl+D, ciò che abbiamo digitato verrà utilizzato come input del comando wc.

1*s9dQY9GdgkkIf9zC1BL6Bg
Gif catturata dal mio corso Pluralsight — Advanced Node.js

Possiamo anche convogliare l'input/output standard di più processi l'uno sull'altro, proprio come possiamo fare con i comandi Linux. Ad esempio, possiamo reindirizzare il stdout del comando find allo stdin del comando wc per contare tutti i file nella directory corrente:

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

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

find.stdout.pipe(wc.stdin);

wc.stdout.on('data', (data) => {
  console.log(`Number of files ${data}`);
});

Ho aggiunto l'argomento -l al comando wc per fargli contare solo le righe. Quando viene eseguito, il codice sopra produrrà un conteggio di tutti i file in tutte le directory dentro quella corrente.

La sintassi della shell e la funzione exec

Per impostazione predefinita, la funzione spawn non crea una shell per eseguire il comando che gli passiamo. Questo la rende leggermente più efficiente della funzione exec, che crea una shell. La funzione exec ha un'altra grande differenza. Memorizza l'output generato dal comando e passa l'intero valore di output a una funzione di callback (invece di utilizzare i flussi, che è ciò che fa invece spawn).

Ecco l'esempio precedente find | wc implementato con una funzione exec.

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

exec('find . -type f | wc -l', (err, stdout, stderr) => {
  if (err) {
    console.error(`exec error: ${err}`);
    return;
  }

  console.log(`Number of files ${stdout}`);
});

Poiché la funzione exec utilizza una shell per eseguire il comando, possiamo usare la sintassi della shell direttamente qui facendo uso della capacità pipe di shell.

Nota che l'uso della sintassi della shell comporta un rischio per la sicurezza se stai eseguendo qualsiasi tipo di input dinamico fornito esternamente. Un utente può semplicemente eseguire un attacco tramite l'iniezione di comandi utilizzando caratteri della sintassi della shell come ; e $ (ad esempio, command + ’; rm -rf ~’)

La funzione exec memorizza nel buffer l'output e lo passa alla funzione di callback (il secondo argomento di exec) come argomento stdout. Questo argomento stdout è l'output del comando che vogliamo stampare.

La funzione exec è una buona scelta se è necessario utilizzare la sintassi della shell e se la dimensione dei dati prevista dal comando è piccola. (Ricorda, exec eseguirà il buffering di tutti i dati in memoria prima di restituirli.)

La funzione spawn è la scelta migliore quando la dimensione dei dati prevista dal comando è grande, perché i dati verranno trasmessi in streaming con gli oggetti IO standard.

Possiamo fare in modo che il processo figlio generato erediti gli oggetti IO standard dei suoi genitori, se lo desideriamo, ma anche, cosa più importante, possiamo fare in modo che la funzione spawn utilizzi anche la sintassi della shell. Ecco lo stesso comando find | wc implementato con la funzione spawn:

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

A causa dell'opzione di sopra stdio: 'inherit', quando eseguiamo il codice, il processo figlio eredita stdin, stdout e stderr dal processo principale. Ciò fa sì che i gestori di eventi dei dati del processo figlio vengano attivati ​​nel canale process.stdout, facendo in modo che lo script emetta immediatamente il risultato.

Grazie all'opzione precedente shell: true, siamo stati in grado di utilizzare la sintassi della shell nel comando precedente, proprio come abbiamo fatto con exec. Ma con questo codice, otteniamo comunque il vantaggio dello streaming di dati che la funzione spawn ci offre. Questo è davvero il meglio di entrambi i mondi.

Ci sono alcune altre buone opzioni che possiamo usare nell'ultimo argomento delle funzioni child_process oltre a shell e stdio. Possiamo, ad esempio, usare l'opzione cwd per cambiare la directory di lavoro dello script. Infatti, ecco lo stesso esempio di conteggio di tutti i file eseguito con una funzione spawn che utilizza una shell e con una directory di lavoro impostata nella mia cartella Downloads. L'opzione cwd qui farà sì che lo script conteggi tutti i file che ho in ~/Downloads:

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

Un'altra opzione che possiamo usare è l'opzione env per specificare le variabili di ambiente che saranno visibili al nuovo processo figlio. L'impostazione predefinita per questa opzione è process.env che fornisce a qualsiasi comando l'accesso all'ambiente del processo corrente. Se vogliamo sovrascrivere quel comportamento, possiamo semplicemente passare un oggetto vuoto all'opzione env o nuovi valori da considerare come le uniche variabili d'ambiente:

const child = spawn('echo $ANSWER', {
  stdio: 'inherit',
  shell: true,
  env: { ANSWER: 42 },
});

Il comando echo di sopra non ha accesso alle variabili di ambiente del processo padre. Ad esempio, non può accedere a $HOME, ma può accedere a $ANSWER perché è stata passata come variabile di ambiente personalizzata tramite l'opzione env.

Un'ultima importante opzione del processo figlio da spiegare qui è l'opzione detached, che fa sì che il processo figlio venga eseguito indipendentemente dal suo processo padre.

Supponendo di avere un file timer.js che mantiene occupato il ciclo degli eventi:

setTimeout(() => {  
  // keep the event loop busy
}, 20000);

Possiamo eseguirlo in background usando l'opzione detached:

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

const child = spawn('node', ['timer.js'], {
  detached: true,
  stdio: 'ignore'
});

child.unref();

L'esatto comportamento dei processi figli distaccati dipende dal sistema operativo. Su Windows, avrà la propria finestra della console mentre su Linux il processo figlio distaccato sarà nominato leader di un nuovo gruppo di processi e sessione.

Se la funzione unref viene chiamata sul processo distaccato, il processo padre può uscire indipendentemente dal figlio. Questo può essere utile se il figlio sta eseguendo un processo di lunga durata, ma per mantenerlo in esecuzione in background le configurazioni stdio del figlio devono anch'esse essere indipendenti dal genitore.

L'esempio sopra eseguirà uno script del nodo ( timer.js) in background staccando e ignorando anche i suoi descrittori di file stdio del padre in modo che il genitore possa terminare mentre il figlio continuerà a essere eseguito in background.

1*WhvMs8zv-WS6v7nDXmDUzw
Gif catturata dal mio corso Pluralsight — Advanced Node.js

La funzione execFile

Se devi eseguire un file senza usare una shell, la funzione execFile è ciò di cui hai bisogno. Si comporta esattamente come la funzione exec, ma non usa una shell, il che la rende un po' più efficiente. Su Windows, alcuni file non possono essere eseguiti da soli, come i file .bat o .cmd. Questi file non possono essere eseguiti con execFile e per eseguirli con exec o spawn è necessario che la shell sia impostata su true.

Le funzioni *Sync

Le funzioni spawn, exec, ed execFile del modulo child_process hanno anche versioni bloccanti sincrone che attenderanno fino all'uscita del processo figlio.

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

Tali versioni sincrone sono potenzialmente utili quando si tenta di semplificare le attività di scripting o qualsiasi attività di elaborazione all'avvio, ma in caso contrario dovrebbero essere evitate.

La funzione fork()

La funzione fork è una variante della funzione spawn per la generazione di nodi di processi. La più grande differenza tra spawn e fork è che viene stabilito un canale di comunicazione con il processo figlio quando si utilizza fork, quindi possiamo utilizzare la funzione send sul processo fork (biforcato) insieme allo stesso oggetto globale process per scambiare messaggi tra il processo padre e il processo fork. Lo facciamo attraverso l'interfaccia EventEmitter del modulo. Ecco un esempio:

Il file principale parent.js,:

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

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

forked.on('message', (msg) => {
  console.log('Message from child', msg);
});

forked.send({ hello: 'world' });

Il file figlio child.js,:

process.on('message', (msg) => {
  console.log('Message from parent:', msg);
});

let counter = 0;

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

Nel file padre di sopra, eseguiamo il fork di child.js(che eseguirà il file con il comando node) e quindi ascoltiamo l'evento message. L'evento message verrà emesso ogni volta che il figlio utilizza process.send, cosa che stiamo facendo ogni secondo.

Per passare i messaggi dal genitore al figlio, possiamo eseguire la funzione send sull'oggetto fork stesso e quindi, nello script figlio, possiamo ascoltare l'evento message sull'oggetto globale process.

Quando si esegue il file parent.js di sopra, questo invierà prima l'oggetto { hello: 'world' } che verrà stampato dal processo figlio il quale invierà un valore contatore incrementato ogni secondo, che verrà stampato dal processo padre.

1*GOIOTAZTcn40qZ3JwgsrNA
Screenshot catturato dal mio corso Pluralsight — Advanced Node.js

Facciamo un esempio più pratico sulla funzione fork.

Supponiamo di avere un server http che gestisce due endpoint. Uno di questi endpoint ( /compute di sotto) è computazionalmente costoso e richiederà alcuni secondi per essere completato. Possiamo simulare ciò usando un lungo ciclo for:

const http = require('http');

const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) {
    sum += i;
  };
  return sum;
};

const server = http.createServer();

server.on('request', (req, res) => {
  if (req.url === '/compute') {
    const sum = longComputation();
    return res.end(`Sum is ${sum}`);
  } else {
    res.end('Ok')
  }
});

server.listen(3000);

Questo programma ha un grosso problema; quando viene richiesto l'endpoint /compute , il server non sarà in grado di gestire altre richieste perché il ciclo di eventi è occupato con la lunga operazione del ciclo for.

Ci sono alcuni modi con cui possiamo risolvere questo problema a seconda della natura dell'operazione lunga, ma una soluzione che funziona per tutte le operazioni è semplicemente spostare l'operazione di calcolo in un altro processo usando fork.

Per prima cosa spostiamo l'intera funzione longComputation nel proprio file e facciamo in modo che invochi quella funzione quando richiesto tramite un messaggio dal processo principale:

In un nuovo file compute.js:

const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) {
    sum += i;
  };
  return sum;
};

process.on('message', (msg) => {
  const sum = longComputation();
  process.send(sum);
});

Ora, invece di eseguire la lunga operazione nel ciclo di eventi del processo principale, possiamo fare il fork del file compute.js e utilizzare l'interfaccia dei messaggi per comunicare i messaggi tra il server e il processo biforcato.

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

const server = http.createServer();

server.on('request', (req, res) => {
  if (req.url === '/compute') {
    const compute = fork('compute.js');
    compute.send('start');
    compute.on('message', sum => {
      res.end(`Sum is ${sum}`);
    });
  } else {
    res.end('Ok')
  }
});

server.listen(3000);

Ora con il codice sopra, quando si verifica una richiesta /compute, inviamo semplicemente un messaggio al processo biforcato per avviare l'esecuzione dell'operazione lunga. Il ciclo di eventi del processo principale non verrà bloccato.

Una volta che il processo fork ha terminato con quella lunga operazione, può inviare il suo risultato al processo padre usando process.send.

Nel processo padre, ascoltiamo l'evento message sul processo figlio biforcato. Quando avremo quell'evento, avremo un valore sum pronto da inviare all'utente richiedente su http.

Il codice sopra è, ovviamente, limitato dal numero di processi che possiamo biforcare, ma quando lo eseguiamo e richiediamo l'endpoint di calcolo lungo su http, il server principale non è affatto bloccato e può accettare ulteriori richieste.

Il modulo cluster di Node, che è l'argomento del mio prossimo articolo, si basa su questa idea di fork del processo figlio e bilanciamento del carico delle richieste tra i molti fork che possiamo creare su qualsiasi sistema.

Questo è tutto per questo argomento. Grazie per aver letto! Alla prossima!

Imparare React o Node? Scopri i miei libri: