back to top

I processi in Node.js e il modulo cluster

Abbiamo visto nelle precedenti lezioni che Node.js è single-threaded, può eseguire infatti una sola operazione alla volta. Tuttavia, in alcune situazioni è necessario creare nuovi processi per eseguire operazioni più lunghe e complesse. È possibile realizzare applicazioni che usano più processi e che sono quindi in grado di sfruttare al meglio le risorse hardware della macchina su cui girano. Node.js fornisce diversi strumenti per generare nuovi processi. In particolare vedremo come utilizzare il modulo child_process per creare uno o più processi figli i quali permetteranno, fra le altre cose, di eseguire i comandi che solitamente lanciamo nel terminale.

Ancora una volta faremo largo uso degli Stream e Event Emitter. Concluderemo poi la lezione parlando del modulo cluster, il cui funzionamento è basato sul metodo fork() del modulo child_process. Il modulo cluster consente di creare un server HTTP con un processo Master e più processi worker e implementare la tecnica del bilanciamento del carico in cui ogni richiesta viene servita da un diverso processo worker.

Creare un processo figlio con il modulo child_process

Node.js, e in particolare il modulo child_process, mette a disposizione vai metodi per creare un processo figlio, ognuno dei quali ha delle peculiarità che lo rendono più vantaggioso degli altri a seconda delle situazioni in cui viene invocato. Sono quattro i metodi di cui parleremo.

metodi modulo child process
  • exec
  • execFile
  • spawn
  • fork

I metodi exec e execFile

Il metodo exec(comando[, options][, callback]) permette di eseguire il comando passato come argomento impiegando la stessa sintassi della shell. Ciò può essere utile, ma nello stesso tempo può risultare insicuro se si eseguono dei comandi usando come input delle fonti esterne. In questi casi bisogna opportunamente verificare i dati acquisiti, prima di passarli alla funzione exec(). Oltre a un oggetto opzioni, che possiamo usare per la configurarazione del processo figlio, la funzione exec() può ricevere come argomento una callback. Quest’utlima viene poi invocata con tre argomenti (errore, stdout, stderr). La funzione exec() salva l’intero output del processo figlio all’interno di un buffer che ha una dimensione predefinita pari a 200KB. Se l’output del processo figlio supera tale dimensione, si verifica un errore.

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

const childProcess = exec('node  --version');

childProcess.stdout.pipe(process.stdout); // v8.4.0
childProcess.stderr.pipe(process.stderr);

La funzione exec() restituisce un’istanza di ChildProcess. Nell’esempio riportato sopra, inviamo l’output del processo figlio allo standard output del processo corrente. Verrà quindi stampata la versione di Node.js nella shell.

In alternativa possiamo passare una funzione callback come terzo argomento, come mostrato nell’esempio in basso. A tale funzione vengono passati tre argomenti: errore, stdout, stderr.

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

const callback = (errore, stdout, stderr) => {
  if (errore) {
    console.error('Errore:', errore);
    return;
  }
  console.log(`stdout: ${stdout}`);
  console.log(`stderr: ${stderr}`);
}

const childProcess = exec('node  --version', callback);

Sintetizzando quanto abbiamo appena visto, la funzione exec() consente di eseguire un comando usando la stessa sintassi della shell. È la scelta ideale se l’output del comando eseguito è di dimensioni ridotte.

Un metodo simile è child_process.execFile(file[, args][, options][, callback]) che al contrario di exec() non permette di usare la sintassi della shell, ma accetta come primo argomento il nome o il percorso di un file eseguibile e come secondo un array di argomenti di tipo Stringa che verranno passati all’eseguibile. La funzione execFile() è inoltre più efficiente di exec(). Riprendendo l’esempio visto in precedenza, possiamo riscriverlo nel seguente modo.

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

const callback = (errore, stdout, stderr) => {
  if (errore) {
    console.error('Errore:', errore);
    return;
  }
  console.log(`stdout: ${stdout}`);
  console.log(`stderr: ${stderr}`);
}

const childProcess = execFile('node',  ['--version'], callback);

L’output è identico all’esempio visto precedentemente.

$ node execFile.js

stdout: v8.4.0

stderr:

Utilizzando il metodo util.promisify(), possiamo ottenere una versione di exec() o execFile() che restituisce una Promise. L’oggetto Promise ottenuto avrà due proprietà stdout e stderr.

const util = require('util');
const { execFile } = require('child_process');

const execFileWithPromise = util.promisify(execFile);

async function versioneNode() {
  const { stdout, stderr } = await execFileWithPromise('node', ['--version']);
  console.log(`stdout: ${stdout}`);
  console.log(`stderr: ${stderr}`);
}
versioneNode();

Creare un processo figlio con la funzione spawn()

Il secondo metodo che illustriamo è spawn(comando[, args][, options]) che crea, in modo asincrono, un nuovo processo figlio per l’esecuzione del comando passato come primo argomento. È possibile inoltre specificare, attraverso l’array opzionale args, una lista di argomenti che verranno poi passati al comando da eseguire. Attraverso un terzo oggetto opzionale possiamo specificare una serie di configurazioni che verranno usate per il nuovo processo. (È disponibile anche spawnSync, una versione sincrona del metodo spawn)

Il metodo spawn è più efficiente delle funzioni viste in precedenza e permette di usare diverse configurazioni che lo rendono estremamente flessibile. Anche in questo caso viene restituito un oggetto di tipo ChildProcess. Si tratta di un EventEmitter che rappresenta il processo figlio appena creato. Possiamo accedere all’output del processo figlio creato dalla dalla funzione spawn() in due modi: registrando una funzione listener per l’evento ‘data’ emesso o facendo uso degli Stream e della funzione pipe(). Vediamo subito un primo esempio.

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

const cat = spawn('cat', ['testo.txt']);

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

cat.stderr.on('data', (data) => {
  console.log(`stderr: ${data}`);
});

cat.on('close', (code, signal) => {
  console.log('Evento close');
});

cat.on('exit', (code, signal) => {
  console.log('Evento exit');
});

Dopo aver importato la funzione spawn(), creiamo un nuovo processo per eseguire il comando cat che riceve come argomento il file testo.txt. Registriamo quindi una funzione listener per gli eventi ‘data’ emessi dallo standard output e standard error del nuovo processo creato. In questo modo possiamo ottenere l’output del comando cat per il quale registriamo anche una funzione per i due eventi ‘close’ ed ‘exit’. Il primo dei due eventi è emesso quando gli stream stdio (input, output, error) vengono chiusi. Il secondo si verifica invece al termine del processo figlio. Dopo aver avviato l’esecuzione, all’interno della shell ritroviamo il seguente output.

stdout: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla vitae elit libero, a pharetra augue...

Evento exit

Evento close

Possiamo anche creare due o più processi figli e inviare l’output di uno in ingresso all’altro tramite l’uso degli Stream e della funzione pipe(). (Così come faremmo nella shell) Vediamo un esempio in cui eseguiamo il seguente comando:

cat testo.txt | grep Lorem

Vogliamo cioè trovare tutte le occorrenze della parola ‘Lorem’ all’interno del file testo.txt e mostrarle a video.

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

const cat = spawn('cat', ['testo.txt']);
const grep = spawn('grep', ['Lorem']);

cat.stdout.pipe(grep.stdin);
cat.stderr.pipe(process.stderr);

grep.stdout.pipe(process.stdout);
grep.stderr.pipe(process.stderr);

Nell’esempio appena visto, facciamo in modo che eventuali messaggi di errore dei comandi eseguiti vengano stampati nella shell. Inoltre, inviamo l’output del comando cat (cat prende il contenuto del file e tramite pipe lo manda al comando grep) in ingresso al comando grep. (grep permette di trovare le occorrenze della parola ‘Lorem’ nel testo ricevuto dal comando cat) Otterremo un risultato simile a quello mostrato in basso.

Lorem ipsum dolor sit amet, consectetur...

Vediamo un altro esempio in cui usiamo sempre la funzione spawn().

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

// ls non accetta -y come opzione
// cwd è un percorso che non esiste
const ls = spawn(
  'ls', 
  ['-y'] ,
  {cwd: '/percorso/che/non/esiste'}
);

ls.stdout.pipe(process.stdout);
ls.stderr.pipe(process.stderr);

ls.on('error', 
  (errore) => console.log(`Codice errore: ${errore.code}`)
);

In questo esempio, abbiamo creato un nuovo processo figlio e abbiamo stampato i messaggi del suo stdout e stderr nella shell. Alla funzione spawn abbiamo passato un array contente un argomento non valido che verrà passato al comando ls (mostra i file presenti nella directory specificata come argomento) e un oggetto opzioni contenente la proprietà cwd (current working directory) con cui indichiamo quale deve essere la cartella di lavoro del processo figlio. Il valore di cwd corrisponde però a un percorso inesistente. Se eseguiamo lo script di sopra, verrà emesso dal processo figlio un evento ‘error’. (L’evento error viene lanciato da un ChildProcess se il processo non può essere creato oppure non è possibile terminarlo o non si riesce a inviare un messaggio a un processo figlio)

Codice errore: ENOENT

Se invece correggiamo il valore della proprietà cwd con un percorso valido, rimane ancora il problema dell’opzione ‘-y’ passata al comando ls. Quest volta Node.js riesce a creare un nuovo processo, ma il messaggio di stderr del processo figlio viene stampato nella shell.

ls: illegal option -- y
usage: ls [-ABCFGHLOPRSTUWabcdefghiklmnopqrstuwx1] [file ...]

Risolviamo anche questo problema come mostrato nel frammento di codice riportato sotto.

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

const ls = spawn(
  'ls', 
  {cwd: '/Users/claudio/Desktop'}
);

ls.stdout.pipe(process.stdout);
ls.stderr.pipe(process.stderr);

ls.on('error', 
  (errore) => console.log(`Codice errore: ${errore.code}`)
);

In questo caso, verranno finalmente stampati a video i nomi dei file presenti sul Desktop.

La funzione spawn() ci permette di adoperare la sintassi della shell così come avevamo già fatto con il metodo exec(). Per far ciò dobbiamo passare alla funzione spawn() un oggetto javascript contenente la proprietà ‘shell’ settata al valore ‘true’. Nell’esempio che segue useremo anche la proprietà ‘env’ per impostare la variabile d’ambiente NODE_ENV pari al valore ‘production’.

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

const child = spawn('node app.js', {
  shell: true,
  env: { NODE_ENV: 'production' },
});

child.stdout.pipe(process.stdout);

child.on('error', (errore) => console.log(errore));

Abbiamo lanciato un nuovo processo figlio per eseguire il comando ‘node app.js’. All’interno del file app.js ci limitiamo a stampare a video il valore di process.env.NODE_ENV che sarà uguale a ‘production’. Abbiamo inoltre rediretto l’output del processo figlio nella shell in cui visualizzeremo il seguente messaggio.

Il valore di process.env.NODE_ENV è 'production'

Quando viene creato un processo figlio con la funzione spawn(), i suoi stdin, stdout e stderr sono configurati in modo che il processo padre vi possa accedere tramite il meccanismo delle Pipe. Come abbiamo evidenziato nei precedenti esempi, possiamo eseguire child.stdout.pipe(process.stdout) per visualizzare l’output del processo figlio nella shell. Bisogna notare che al contrario del processo padre, per l’I/O del processo figlio valgono le seguenti proprietà:

  • child_process.stdin è uno Stream di tipo Writable
  • child_process.stdout è uno Stream di tipo Readable
  • child_process.sterr è uno Stream di tipo Readable

Per la funzione spawn(), è possibile configurare i valori di stdio del processo figlio attraverso l’omonima proprietà dell’oggetto opzioni passato come argomento. Vediamo un esempio.

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

const errorFd = fs.openSync('./error.log', 'a');

const options = {
  cwd: '/Users/claudio/Documents',
  stdio: ['ignore', 'inherit', errorFd]
}

// ls non accetta una proprietà -y
// Il messaggio generato su stderr verrà inserito nel file error.log
const ls = spawn('ls', ['-y'] ,options);

ls.on('error', (errore) => console.log(errore));

Nell’esempio riportato sopra, vogliamo eseguire il comando ls con un’opzione non valida in modo da generare un errore. Settiamo il valore di stdio del processo figlio pari a un array contenente tre valori. In particolare, child_process.stdin sarà ignorato e rediretto verso /dev/null. Il processo figlio userà come stdout quello del processo padre. (non è quindi necessario configurare la pipe) e child_process.stderr appenderà i messaggi di errore all’interno del file error.log.

La funzione fork()

Vediamo un ultimo metodo che possiamo usare per creare un processo figlio, ovvero il metodo child_process.fork() che è un caso particolare di child_process.spawn(). Come nei casi precedenti, viene restituito un’istanza di ChildProcess e viene aperto un canale IPC (inter-process communication) che consentirà al processo figlio di scambiare dei messaggi con il processo padre. Useremo child_process.on(‘message’, callback) per leggere i messaggi ricevuti dal processo figlio a cui il padre potrà inviarne uno usando child_process.send(messaggio). Allo stesso modo, il processo figlio potrà comunicare col padre usando i due metodi: process.send(messaggio_da_inviare_al_padre) e process.on(‘message’, callback). (per accedere al messaggio ricevuto dal padre)

// parent.js

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

child_process = fork('child.js');

child_process.on('message', (data) => {
 console.log('Processo PADRE ha ricevuto: ', data);
});

child_process.send({
  message: `Inviato da processo padre con PID: ${process.pid}n`
});
// child.js

process.on('message', (data) => {
  console.log('Processo Figlio ha ricevuto: ', data);
  process.disconnect()
});

process.send({ it: 'Ciao', en: 'Hello' });

Nell’esempio riportato, il processo padre crea un processo figlio con la funzione fork(), invia un messaggio al processo figlio e resta in attesa di una risposta. All’interno del file child.js, inviamo un messaggio al processo padre e registriamo una funzione listener per stampare un messaggio nella shell nel momento in cui riceviamo un messaggio dal processo padre. A questo punto il processo figlio chiude il canale IPC che usava per comunicare col padre, e, se non ci sono altri collegamenti che lo mantengono vivo, termina.

Il modulo cluster

Il funzionamento del modulo cluster si basa sulla funzione fork() vista in precedenza. Può essere usato insieme al modulo http per creare un server che utilizza la tecnica del bilanciamento del carico (load balancing) per gestire le richieste dei client. Grazie al modulo cluster è possibile sfruttare al meglio le risorse della macchina su cui gira la nostra applicazione. Per far ciò useremo un processo Master e più processi Worker. Il processo Master si limita a creare e gestire i Worker. Inoltre riceve tutte le richieste degli utenti e, usando un certo algoritmo (solitamente round-robin), le smista ai processi Worker.

Schema di funzionamento del modulo cluster in Node.js

Vediamo subito un semplice esempio in cui creiamo due file: cluster.js e worker.js come mostrato in basso.

// worker.js

const http = require('http');
const log = require('./logger')('yellow');
const pid = process.pid;
// aspettiamo almeno due minuti prima di terminare il processo
const time = 120000 + Math.round(Math.random() * 5000);

const server = http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end(`Risposta ricevuta dal Worker con PID ${pid}n`);
});

server.listen(
  8888, 
  '127.0.0.1',
  () => log(`# Worker con PID ${pid} avviaton`)
);

process.on('message', (data) => {
  log(`# Processo Figlio ${pid} ha ricevuto: ${data.message}n`);
  process.send({message: 'MESSAGGIO_RICEVUTO', pid})
})

setTimeout(process.exit.bind(null, 1), time);

Nel file worker.js abbiamo inserito il codice che verrà eseguito da ogni processo worker. Abbiamo usato il modulo http per creare un semplicissimo server il quale risponderà alle richieste del client con un messaggio che indica quale processo worker ha servito la richiesta. Ogni processo worker riceverà un messaggio dal processo Master e invierà un messaggio di risposta. Inoltre, dopo circa due minuti il processo Worker verrà terminato con codice di terminazione pari a 1.

// cluster.js

const cluster = require('cluster');
const log = require('./logger')('green');

const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  log(`>> Processo Master ${process.pid} in esecuzionen`);

  // Crea nuovi workers
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
    log(`>> Sto creando il Worker #${i}n`)
  }

  // invia un messaggio ad ogni worker e 
  // registra una funzione da eseguire alla ricezione di una risposta
  Object.values(cluster.workers).forEach((worker) => {
    worker.on('message', 
      (data) => log(`>> Worker ${data.pid} ha inviato: ${data.message}n`)
    );
    worker.send({message: 'Buon lavoro'});
  })

  cluster.on('listening', (worker, address) => {
    log(
      `>> Nuovo Worker in ascolto: ${address.address}:${address.port}n`
    );
  });

  cluster.on('exit', (worker, code, signal) => {
    log(`>> Worker ${worker.process.pid} terminato con codice ${code}n`);
  });
} else {
  // porzione di codice eseguita dai processi Worker
  require('./worker.js')
}

Nel file cluster.js ricaviamo innanzitutto il numero di core della cpu usando la funzione os.cpus() del modulo os. Usiamo poi la proprietà cluster.isMaster per separare il frammento di codice che viene eseguito dal processo Master da quello eseguito dai vari processi Worker. Il processo Master creerà con un ciclo for tanti processi Worker quanti sono i core della cpu. Vengono inoltre registrate varie funzioni listener per differenti eventi. Per esempio, per ogni messaggio inviato da un processo Worker, viene stampato un messaggio contenente il PID del processo e il contenuto del messaggio. Viene invece usato l’evento listening per stampare un messaggio nella shell quando un nuovo processo Worker è pronto per rispondere alle richieste del client. Infine quando un processo Worker termina la sua esecuzione, viene invocata la funzione listener per l’evento ‘exit’ emesso dall’oggetto cluster.

Lanciando il comando node cluster.js vedremo un output simile a quello mostrato in basso.

output nella shell del processo master e dei workers creati con il modulo cluster

Una volta in esecuzione, i processi worker sono pronti per rispondere alle richieste dei client. Avremmo potuto aprire il browser e visitare più volte l’indirizzo ‘127.0.0.1: 8888’ e visualizzare la risposta del server. In questo caso, invece, eseguiamo il comando curl per 15 volte e come potete notare, ad ogni richiesta risponde un diverso processo Worker.

output del comando curl in seguito alle richieste servite dai processi worker

Infine dopo circa due minuti, ciascun processo worker termina la sua esecuzione con codice di terminazione pari a 1.

output nella shell del processo master e dei workers che hanno terminato la loro esecuzione

Conclusioni

In questa lezione abbiamo visto come usare varie funzioni per creare dei processi figli e abbiamo analizzato un semplice esempio che fa uso del modulo cluster per implementare la tecnica del load balancing. Dopo aver introdotto alcuni degli argomenti chiave di Node.js, a partire dalla prossima lezione, parleremo di Express, uno dei framework più popolari per la realizzazione di applicazioni web in Node.js.

Pubblicitร