Node.js: non bloccate l'Event Loop!

JavaScript opera su un singolo thread. Node.js tramite la libreria libuv sfrutta questa caratteristica per gestire l’esecuzione attraverso l’Event Loop che gli consente operazioni che non bloccano il flusso di I/O.

La caratteristica principale di libuv che permette a Node.js di supportare l’esecuzione asincrona di JavaScript, sono sostanzialmente la sua gestione degli eventi delle principali operazioni di I/O del kernel, come i socket TCP e UDP, la risoluzione DNS, le operazioni sul file system e sui file e gli eventi del file system stesso.

In libuv tutte queste operazioni sono asincrone, ma l’aspetto più importante di questa libreria è che tramite epoll, kqueue, IOCP ed eventi fornisce a Node.js le fondamenta per costruire il suo Event Loop (ciclo degli eventi o ciclo ad eventi).

La documentazione sull’Event Loop illustra le fasi di questo ciclo, ma rivela anche un problema insito nel design delle API di Node.js: accanto a metodi tradizionalmente asincroni esistono anche metodi sincroni che di fatto bloccano l’Event Loop in una determinata fase impedendogli di proseguire nell’esecuzione delle fasi successive.

Un tipico esempio è il metodo readFileSync() del modulo core fs che gestisce le operazioni sui file e sul file system. Fintanto che i contenuti di un file sono già predeterminati e le sue dimensioni sono fisse, note e prestabilite (come nel caso della lettura di una chiave privata e di un certificato SSL), questo metodo sincrono non ha un impatto negativo sull’Event Loop. Al contrario, e soprattutto quando la lettura sincrona viene esposta nel frontend tramite HTTP/S (come nel caso delle immagini), si possono avere gravi ripercussioni sulla performance fino alla messa in atto di un attacco DOS che non solo blocca l’esecuzione di Node ma va anche ad impattare sulla performance globale del server che ospita il sito o l’app.

Se sostanzialmente si vuole evitare il callback hell insito nella sintassi stessa dei metodi asincroni, si possono adottare le feature più recenti di ECMAScript, come le Promise e il costrutto async/await che mantengono inalterata l’asincronicità delle operazioni fornendo però una sintassi che ci permette di evitare l’annidamento di funzioni su funzioni.

Ad esempio possiamo creare la seguente classe di utility che fa uso delle Promise:

'use strict';

const path = require('path');
const fs = require('fs');
const ABSPATH = path.dirname(process.mainModule.filename);

class Files {
    static read(path, encoding = 'utf8') {
      return new Promise((resolve, reject) => {
        let readStream = fs.createReadStream(ABSPATH + path, encoding);
        let data = '';
        
        readStream.on('data', chunk => {  
            data += chunk;
        }).on('end', () => {
            resolve(data);
        }).on('error', err => {
            reject(err);
        });
      });  
    }
    
    static create(path, contents) {
        return new Promise((resolve, reject) => {
            fs.writeFile(ABSPATH + path, contents, (err, data) => {
                if(!err) {
                    resolve(data);
                } else {
                    reject(err);
                }
            });
        });
    }

    static remove(path) {
        return new Promise((resolve, reject) => {
            fs.unlink(ABSPATH + path, err => {
                if(!err) {
                    resolve(path);
                } else {
                    reject(err);
                }
            });
        });
    }

    static exists(path) {
        return new Promise((resolve, reject) => {
            fs.access(ABSPATH + path, fs.constants.F_OK, err => {
               if(!err) {
                   resolve(true);
               } else {
                   reject(err);
               }
            });
        });
    }
}

module.exports = Files;

Quindi possiamo coniugare il modello delle Promise con il costrutto async/await:

'use strict';
const app = require('expresss')();
const Files = require('./lib/Files');

app.get('/api/file', async (req, res) => {
    try {
        let data = await Files.read('/log/file.log');
        res.send(data);
    } catch(err) {
        res.sendStatus(500);
    }
});
app.listen(8000);

Come si può notare, tramite l’uso combinato delle Promise e del costrutto async/await abbiamo evitato il callback hell anche grazie ad un attenta separazione e delegazione dei compiti nel nostro codice.

Conclusione

Abbiamo visto come l’Event Loop di Node.js sia basato su operazioni asincrone e come tale asincronicità vada preservata in modo da non interrompere lo stesso Event Loop.