Tutorial > Come utilizzare Cluster per aumentare le prestazioni di Node.js

Come utilizzare Cluster per aumentare le prestazioni di Node.js

Pubblicato il: 16 febbraio 2021

Node.js Sviluppo

Introduzione

Node.js è un framework open source per l'esecuzione di codice Javascript lato server, che permette di configurare un ambiente di sviluppo di facile scalabilità.

Una delle migliori soluzioni che permette Node.js è quella di dividere un singolo processo in più sottoprocessi, detti workers. Tramite un modulo Cluster, è possibile quindi dividere questi processi complessi in piccoli processi più semplici, velocizzando sensibilmente le applicazioni in Node.

In questo tutorial troverai tutte le informazioni per iniziare a utilizzare Cluster e sfruttare i vantaggi dei sistemi multi-core con Node.js per migliorare le performance del tuo web server.

Come funziona il modulo Cluster su Node.js

Il cluster è una raccolta di piccoli processi figli ("workers") di un unico processo genitore in Node.

Tramite il metodo fork() del modulo child_processes di Node, i workers vengono creati come processi figli di un processo genitore, che avrà invece il compito di controllarli.

Per iniziare con l'inclusione di un cluster nella tua applicazione, apri il file .js della tua app e inserisci, nella dichiarazione delle variabili:

var cluster = require('cluster');

Ora, il modulo cluster avrà bisogno di capire quale parte del codice sia il processo genitore o master, e quale porzione sia invece dei workers.

Con la seguente sintassi, puoi identificare il master:

if(cluster.isMaster){...}

All'interno delle parentesi graffe, potrai specificare le istruzioni del master. Le prime istruzioni dovranno essere di inizializzazione dei processi figli, tramite la funzione fork() :

cluster.fork();

All'interno di questo metodo, potrai specificare metodi e proprietà riguardanti l'oggetto worker interessato.

Un modulo cluster contiene diversi eventi. I più comuni sono l'evento online, per indicare l'attivazione di un worker, ed exit, per indicare il termine dello stesso.

Di seguito, ti verranno presentati alcuni esempi di utilizzo più comune del modulo Cluster.

Esempi

Come viene usato il modulo Cluster in una app Node.js

In un primo esempio, puoi configurare un piccolo server che risponda alle richieste in arrivo restituendo l'ID del processo worker che ha gestito la tua richiesta.

Il processo genitore, in tal caso, sarà composto da 4 processi figli.

Ecco il codice di esempio che implementa questo caso d'uso:

var cluster = require('cluster');
var http = require('http');
var numCPUs = 4;

if (cluster.isMaster) {
    for (var i = 0; i < numCPUs; i++) {
        cluster.fork();
    }
} else {
    http.createServer(function(req, res) {
        res.writeHead(200);
        res.end('process ' + process.pid + ' says hello!');
    }).listen(8000);
}

Salvando questo codice in un file, che potrai denominare come vuoi, ti basterà poi mandarlo in esecuzione tramite il comando:

$ node file_name.js

Successivamente, collegandoti all'indirizzo IP del tuo server, tramite la porta specificata (8000), potrai controllare l'esecuzione di questo piccolo programma.

Nota: per specificare la porta, basterà inserire come suffisso dell'indirizzo del tuo server la dicitura ":numeroporta".

Come sviluppare un server Express facilmente scalabile

Uno dei framework web più famosi per Node.js è Express, scritto in Javascript e hostato dentro un amiente di runtime Node.js.

In questo secondo esempio, ti viene mostrato come creare un server Express facilmente scalabile e come permettere a un singolo processo del server di sfruttare il modulo cluster con poche linee di codice.

var cluster = require('cluster');

if(cluster.isMaster) {
    var numWorkers = require('os').cpus().length;

    console.log('Master cluster setting up ' + numWorkers + ' workers...');

    for(var i = 0; i < numWorkers; i++) {
        cluster.fork();
    }

    cluster.on('online', function(worker) {
        console.log('Worker ' + worker.process.pid + ' is online');
    });

    cluster.on('exit', function(worker, code, signal) {
        console.log('Worker ' + worker.process.pid + ' died with code: ' + code + ', and signal: ' + signal);
        console.log('Starting a new worker');
        cluster.fork();
    });
} else {
    var app = require('express')();
    app.all('/*', function(req, res) {res.send('process ' + process.pid + ' says hello!').end();})

    var server = app.listen(8000, function() {
        console.log('Process ' + process.pid + ' is listening to all incoming requests');
    });
}

Analizzando e interpretando il codice:

  • Viene recuperato il numero dei core delle CPU tramite il modulo os. Il modulo contiene una funzione ad-hoc chiamata cpus() che restituisce un array costituito dai core e, misurandone la lunghezza, si ottiene il numero di core in utilizzo. Ciò aiuta a capire quanti workers potresti sfruttare per ottimizzare il sistema.
  • Un'altra parte del codice descrive il termine del ciclo di vita del worker, segnalato tramite l'evento exit. Tramite una funzione di callback, può essere posizionato un nuovo worker in risposta alla "morte" di un altro, per mantenere il numero inteso di workers.
  • Infine, è descritto l'evento online per segnalare che un worker sia stato creato e sia pronto per ricevere una nuova richiesta.

Operazioni avanzate

Far comunicare i master e i workers

É importante gestire lo scambio di messaggi tra processi genitori e loro figli per poter assegnare le tasks o eseguire altre operazioni.

Per visualizzare i messaggi, avrai bisogno di impostare una procedura che riconosca l'evento message sia per il lato dei master che per i workers:

worker.on('message', function(message){
	console.log(message);
});

All'interno del codice, l'oggetto worker è il riferimento restituito dal metodo fork().

Ora, per passare all'ascolto dei messaggi da parte del master:

process.on('message', function(message) {
    console.log(message);
});

I messaggi possono essere stringhe o oggetti JSON. Per inviarne uno, per esempio, dal processo genitore al figlio (from master to worker), puoi provare il comando:

worker.send('hello from the master');

Per inviare il messaggio nel senso opposto, puoi scrivere:

process.send('hello from worker with id: ' + process.pid);

Per approfondire il contenuto dei messaggi potresti, inoltre, considerarli come oggetti JSON contenenti informazioni aggiuntive. Per mostrarti un esempio:

worker.send({
    type: 'task 1',
    from: 'master',
    data: {
        // the data that you want to transfer
    }
});

Azzerare i tempi morti

Il miglior risultato ottenibile dallo sfruttamento di cluster è l'azzeramento dei tempi morti.

Il modo per raggiungere questo goal è cercare di gestire il termine e il riavvio dei workers, uno alla volta, a seguito di cambi dell'applicazione. Ciò permetterà di lasciare in esecuzione l'applicazione allo stadio precedente mentre viene caricata quella coi nuovi cambiamenti applicati.

Ci sono dei principi che devi tenere a mente per far si che ciò accada:

  • Il processo master deve continuare ad essere in esecuzione per tutto il tempo e solo i workers sono interessati dal riavvio;
  • Il processo master deve essere corto e deve solo essere preposto alla gestione dei suoi sottoprocessi.
  • C'è bisogno di un modo per far sapere al master che deve riavviare un suo worker: puoi, per esempio, far richiedere un input all'utente o controllare eventuali cambiamenti sui files.

Per riavviare con successo un worker, è fondamentale prima di tutto tentare di chiuderli in maniera sicura e "terminarli" solo nel caso in cui non rispondano.

Per implementare questo metodo, puoi provare a inviare un messaggio specificandone la tipologia shutdown :

workers[wid].send({type: 'shutdown', from: 'master'});

Infine, specificare le istruzioni da eseguire in caso di richiesta dello shutdown:

process.on('message', function(message) {
    if(message.type === 'shutdown') {
        process.exit(0);
    }
});

Completata la funzione che consente di richiedere ed effettuare la chiusura di un worker, occorre ora applicare queste istruzioni a tutti i sottoprocessi esistenti gestiti da quel master.

Il modulo cluster permette di avere una referenza a tutti i workers in esecuzione e puoi, inoltre, raggruppare tutte le istruzioni della procedura di riavvio all'interno di un'unica funzione che verrà richiamata ogni qual volta tu voglia riavviare tutti i workers.

function restartWorkers() {
    var wid, workerIds = [];

    for(wid in cluster.workers) {
        workerIds.push(wid);
    }

    workerIds.forEach(function(wid) {
        cluster.workers[wid].send({
            text: 'shutdown',
            from: 'master'
        });

        setTimeout(function() {
            if(cluster.workers[wid]) {
                cluster.workers[wid].kill('SIGKILL');
            }
        }, 5000);
    });
};

Tramite l'oggetto workers puoi ricavare tutti gli ID dei sottoprocessi in esecuzione in tempo reale.

Con questo codice, infatti, avrai definito le seguenti operazioni:

  • Ricavare l'id dei workers attualmente in esecuzione e salvarlo in un array di nome workerIds;
  • Richiedere l'arresto in sicurezza di ogni worker in esecuzione;
  • Se l'arresto sicuro non avviene entro 5 secondi, allora forzare la chiusura tramite il comando kill.

Conclusioni

A questo punto, dovresti conoscere i vantaggi dell'utilizzo di Cluster, soprattutto con il tuo web server di Node, e alcuni modi per sfruttarlo in maniera efficace per il miglioramento delle performance del tuo web server.

Non dimenticare di approfondire le caratteristiche di Cluster e di capire bene il modo in cui possa essere adattato al tuo web server, per evitare eventuali bug e malfunzionamenti.