Node.js sta diventando sempre più popolare come ambiente d’esecuzione run-time lato server. Specialmente per siti web ad alto traffico, come ci mostrano le statistiche.
Inoltre la disponibilità di molti framework framework lo rendono un buon ambiente anche per la prototipazione rapida.
Node.js ha un’architettura event-driven, basata sugli eventi, che sfrutta delle API di Input/Output non bloccanti e che dunque permettono di processare le richieste in modo asincrono.

Una delle caratteristiche più importanti e meno evidenziati di Node.js è la sua scalabilità. Infatti, è la ragione per cui molte grandi compagnie ad alto traffico integrano Node.js nella loro piattaforma (ex. Microsoft, Yahoo, Uber, Walmart) o addirittura basano completamente le loro operazioni lato server su Node.js (ex. PayPal, eBay, Groupon etc.).

Ogni processo Node.js gira in un singolo thread e di default ha una memoria limitata a 512MB sui sistemi a 32bit e 1 GB sui sistemi a 64 bit. Anche se i limiti di memoria possono essere portati a 1 GB sui 32bit e 1.7 GB sui 64 bit, sia la memoria che il potere di calcolo possono rappresentare un collo di bottiglia per certi processi. Girando su un singolo thread il processo è eseguito su un singolo core! e questo rappresenta un enorme spreco di risorse se eseguito sulle moderne architetture multi-core.

La soluzione elegante che fornisce Node.js per scalare le applicazioni è quella di suddividere un singolo processo in processi multipli o workers, per usare una terminologia comune.
Questo lo si può ottenere con il modulo cluster. Questo modulo permette di creare dei processi figli (workers), che condividono tutti le stesse porte del processo principale (master).

In questo articolo si mostra come creare un cluster in Node.js per velocizzare e ottimizzare le vostre applicazioni.

Cos’è e come funziona cluster

Il cluster è un insieme di workers che girano sotto un unico processo padre. I workers sono creati usando il metodo fork() del modulo child_processes. Questo significa che i workers possono usare IPC (Inter-process communication) per comunicare con il processo padre.

Il processo master è incaricato di instanziare i workers e controllarli. Puoi creare un numero arbitrario di workers nel tuo processo master. Inoltre, ricorda che di default le connessioni in ingresso sono distribuite con un approccio round-robin fra i workers (tranne che in Windows).

Usare un cluster può sembrare complesso in teoria, nella pratica è molto semplice con Node.

var cluster = require('cluster');

Il modulo cluster esegue lo stesso processo Node.js più volte. Quindi la prima cosa da fare è identificare quale porzione del codice appartiene al processo master e quale porzione è per i workers. Il modulo cluster permette di identificare il master con semplicità:

if(cluster.isMaster) { ... }

Il processo master è il processo che voi inizializzate e che poi a sua volta inizializza i workers. Per avviare un processo worker all’interno di un processo master, si usa il metodo fork():

cluster.fork();

Questo metodo restituisce un oggetto worker che contiene alcuni metodi e proprietà sul worker appena avviato. Nella sezione che segue alcuni esempi in proposito.

Il modulo cluster contiene diversi eventi. Due eventi comuni correlati ai momenti in cui avviene l’avvio o la terminazione di un worker sono: online e exit.
L’evento ‘online’ è emesso quando il worker è forkato e invia il messaggio che annuncia di essere online. L’evento ‘exit’ è emesso quando un processo muore.

Mettiamo assieme tutto ciò che abbiamo visto fino a questo punto e mostriamo un esempio di funzionamento completo.

Esempi

In questa sezione mostriamo 2 esempi. Il primo mostra come usare cluster all’interno di un’app Node.js. Il secondo è un esempio di server Express che sfrutta il modulo Cluster per utilizzare appieno ogni core della nostra macchina, spawnando un processo figlio (worker) per ogni core disponibile. Questa è una pratica molto diffusa se non obbligatoria in produzione.

Come usare il modulo Cluster in Node.js

In questo primo esempio configuriamo un semplice server che risponde a tutte le richieste in entrata con un messaggio contenente il process ID del worker che ha processato la richiesta.
Il processo master forka 4 workers. In ognuno di essi restiamo in ascolto di richieste sulla porta 8000.

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

E’ possibile testare questo server eseguendolo con node server.js e navigando sull’URL http://127.0.0.1:8000. Quando le richieste sono ricevute, sono distribuite una per volta ad ogni worker. Se un worker è disponibile, immediatamente processerà la richiesta, altrimenti verrà aggiunta in coda.

Ci sono una serie di punti che non sono efficenti nell’esempio mostrato sopra. Per esempio, immaginiamo che un worker muoia per qualche ragione. In questo caso, si perderà 1 worker, e se la cosa si verifica anche per gli altri worker, nessuno sarà in grado di gestire le richieste. L’altro problema è relativo al numero di workers. Ci sono numeri differenti di core/thread in base al sistema su cui deployamo la nostra app. Nell’esempio sopra ne abbiamo assunti 4. Se questo numero cambiasse dovremmo manualmente modificare lo script e aggiornare il numero per garantire le performance migliori.
Nell’esempio che segue miglioriamo i punti appena esposti.

Server Express altamente scalabile

Express per chi non lo conoscesse è il framework web più popolare per Node.js.

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

La prima aggiunta a questo esempio è quella di ottenere il numero di CPU Core usando il modulo os di Node.js. Questo modulo contiene la funzione cpus() che restituisce un array di core. Usando questo approccio determiniamo dinamicamente il numero di workers da istanziare, in base alle specifiche della macchina, così da massimizzarne l’utilizzo.

Una seconda e più importante aggiunta è la gestione della morte dei worker. Quando un worker muore, il modulo cluster emette un evento exit. Può essere gestito rimanendo in ascolto dell’evento e chiamando una funzione di callback all’occorrenza. In questo modo, cluster.on('exit', callback);.

Performance

Ci sono vari strumenti per poter effettuare il benchmark di API. Qui ho usato Apache Banchmark per evidenziare come, usando il modulo cluster si possano avere benefici per l’applicazione.
Il server express usato nel benchmark contiene una sola rotta ed una sola callback per questa rotta che esegue un’operazione banale e ne restituisce il risultato.
Ci sono due versioni di questo server: una senza alcun worker aggiuntivo, in cui ogni cosa accade nel processo master, l’altra con 8 workers (su una macchina con 8 core).
La tabella sotto mostra proprio come usando il modulo cluster si possano aumentare il numero di richieste processate per secondo.

Connessioni concorrenti 1 2 4 8 16
Singolo Processo 654 711 783 776 754
8 Workers 594 1198 2110 3010 3024

I numeri in tabella evidenziano le richieste processate per secondo.

Operazioni Avanzate

Ci sono altre operazioni che si possono effettuare quando si usano i workers. Per esempio, si può evitare che la nostra app vada giù anche per pochi secondi, garantendo un certo grado di robustezza. Vedremo come effettuare queste operazioni a breve.

Comunicazione fra master e workers

Occasionalmente potreste aver necessità di inviare un messaggio da master ad un worker per assegnare un certo task o eseguire delle operazioni. In cambio, i worker potrebbero aver bisogno di informare il master che il task è stato completato.
Per farlo, è sufficente restare in ascolto sull’evento chiamato message, sia sul master che sul worker:

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

L’oggetto worker è la referenza restituita dal metodo fork(). Per restare in ascolto di messaggi dal master, nel worker avremo:

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

I messaggi possono essere stringhe oppure oggetti JSON. Per inviare un messaggio da un master ad un worker specifico, si può scrivere un codice del tipo:

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

Similmente, per inviare un messaggio da un worker al master si può scrivere:

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

In Node.js, i messaggi sono generici e non hanno una tipologia specifica. Quindi è buona prassi inviare un messaggio come oggetto JSON e accludervi all’interno delle informazioni circa la tipologia del messaggio, il mittente etc. Ad esempio:

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

Un punto importante da notare è che le callbacks eseguite alla ricezione di un evento di tipo “message” sono gestite in modo asincrono. Non c’è un ordine d’esecuzione predefinito.

Zero Down-time

Un risultato importante che si può raggiungere usando i workers è quello di avere un tempo di offline praticamente nullo.
All’interno del processo master si possono terminare e riavviare i worker uno per volta. Questo permette di avere una vecchia versione in esecuzione su alcuni workers, mentre altri si riavviano caricando la nuova versione.

Per essere in grado di riavviare la propria applicazione durante l’esecuzione si devono tenere in mente due punti precisi. Primo, il processo master gira tutto il tempo, solo i workers vengono terminati e riavviati. Quindi è importante mantenere il processo master quanto più snello possibile, atto solamente alla gestione dei workers.
Secondo, dovete notificare il processo master in qualche modo, della necessità di riavviare i workers. Ci sono diversi metodi per farlo.

Il mio suggerimento per riavviare i workers è quello di cercare di chiuderli in modo sicuro; se non si chiudono con le buone, allora possiamo forzare la chiusura e killarli. Esempio:

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

E definire lo spegnimento sicuro nel gestore dei messaggi del worker:

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

Per poterlo fare per tutti i workers, si può usare la proprietà workers del modulo cluster, che mantiene una referenza verso tutti i workers in esecuzione. Dunque scriviamo una funzione che permetta il riavvio dei 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);
    });
};

L’oggetto workers è dinamicamente aggiornato quando i workers sono terminati e riavviati.
In particolare si noti il setTimeout finale che fa si che se dopo 5 secondi il worker è ancora in esecuzione, e dunque esiste nell’oggetto workers allora chiamiamo la funzione kill sul worker che ne forza la chiusura.

Conclusioni

Le applicazioni Node.js possono essere parallelizzate usando il modulo cluster, in modo che usino le risorse di sistema in modo più efficente. Far girare più processi allo stesso tempo può essere fatto con poche linee di codice e dunque rende la migrazione relativamente semplice.
Come mostrato nella sezione performance, ci sono le potenzialità per un chiaro miglioramento nelle performance dell’applicazione. In aggiunta alle performance si migliora anche l’affidabilità dell’applicazione e l’uptime, riavviando i workers mentre l’applicazione ancora gira.

Una cosa importante da notare, bisogna stare attenti anche quando si usa il modulo cluster. La pià grande raccomandazione è quella di usarlo per i web server. In altri casi va studiata attentamente la situazione per pensare come meglio distribuire i task fra i vari worker e come comunicare con efficenza i progressi fra master e worker.

Anche la comunicazione stessa fra i worker non può essere lasciata al caso. Vanno utilizzati dei sistemi di persistenza esterni, poichè istanziare e assegnare valori a delle variabili su un worker non garantisce la visibilità delle stesse sugli altri worker.

Un’ultima cosa, se volete approfondire ulteriormente il modulo cluster, consiglio di leggere la documentazione ufficiale completa.