🚀 Nuova versione beta disponibile! Feedback o problemi? Contattaci

Controllo del flusso asincrono

Codegrind TeamNov 22 2023

Controllo del flusso asincrono

Al suo nucleo, JavaScript è progettato per essere non bloccante sul “thread” principale, che è dove vengono renderizzate le viste. Puoi immaginare l’importanza di questo nel browser. Quando il thread principale diventa bloccato, si verifica la famigerata “congelazione” che gli utenti finali temono, e nessun altro evento può essere inviato, comportando la perdita di acquisizione dati, ad esempio.

Ciò crea alcune vincoli unici che solo uno stile di programmazione funzionale può curare. Questo è dove entrano in gioco i callback.

Tuttavia, i callback possono diventare difficili da gestire in procedure più complesse. Questo porta spesso a un “inferno dei callback” in cui molte funzioni nidificate con callback rendono il codice più difficile da leggere, debuggare, organizzare, ecc.

async1(function (input, result1) {
  async2(function (result2) {
    async3(function (result3) {
      async4(function (result4) {
        async5(function (output) {
          // fai qualcosa con l'output
        });
      });
    });
  });
});

Naturalmente, nella vita reale ci sarebbero molto probabilmente ulteriori linee di codice per gestire result1, result2, ecc., quindi la lunghezza e la complessità di questo problema di solito si traducono in un codice che appare molto più disordinato rispetto all’esempio sopra.

Ed è qui che le funzioni entrano in gioco. Operazioni più complesse sono composte da molte funzioni:

  1. stile iniziatore / input
  2. middleware
  3. terminatore

Lo “stile iniziatore / input” è la prima funzione nella sequenza. Questa funzione accetterà l’input originale, se presente, per l’operazione. L’operazione è una serie eseguibile di funzioni, e l’input originale sarà principalmente:

  1. variabili in un ambiente globale
  2. invocazione diretta con o senza argomenti
  3. valori ottenuti da richieste di sistema di file o di rete

Le richieste di rete possono essere richieste in arrivo avviate da una rete esterna, da un’altra applicazione sulla stessa rete o dall’app stessa sulla stessa o su una rete esterna.

Una funzione middleware restituirà un’altra funzione, e una funzione terminale invocherà il callback. Di seguito viene illustrato il flusso delle richieste di rete o di sistema di file. Qui la latenza è 0 perché tutti questi valori sono disponibili in memoria.

function finale(someInput, callback) {
  callback(`${someInput} e terminato eseguendo il callback `);
}

function middleware(someInput, callback) {
  return finale(`${someInput} toccato dal middleware `, callback);
}

function inizializza() {
  const someInput = "ciao, questa è una funzione ";
  middleware(someInput, function (result) {
    console.log(result);
    // richiede il callback per `restituire` il risultato
  });
}

inizializza();

Gestione dello stato

Le funzioni possono essere o meno dipendenti dallo stato. La dipendenza dallo stato sorge quando l’input o un’altra variabile di una funzione dipende da una funzione esterna.

In questo modo ci sono due strategie principali per la gestione dello stato:

  1. passaggio diretto di variabili a una funzione e
  2. acquisizione di un valore di variabile da una cache, sessione, file, database, rete o altra fonte esterna.

Nota, non ho menzionato la variabile globale. Gestire lo stato con variabili globali è spesso un anti-pattern disordinato che rende difficile o impossibile garantire lo stato. Le variabili globali nei programmi complessi dovrebbero essere evitate quando possibile.

Controllo del flusso

Se un oggetto è disponibile in memoria, è possibile l’iterazione e non ci sarà alcun cambio nel flusso di controllo:

function getSong() {
  let _song = "";
  let i = 100;
  for (i; i > 0; i -= 1) {
    _song += `${i} bottiglie al muro, ne prendi una giù e la passi, ${
      i - 1
    } bottiglie di birra al muro\n`;
    if (i === 1) {
      _song += "Ehi, prendiamo un po' di più di birra";
    }
  }

  return _song;
}

function singSong(_song) {
  if (!_song) throw new Error("la canzone è vuota, DAMMI UNA CANZONE!");
  console.log(_song);
}

const song = getSong();
// questo funzionerà
singSong(song);

Tuttavia, se i dati esistono al di fuori della memoria, l’iterazione non funzionerà più:

function getSong() {
  let _song = "";
  let i = 100;
  for (i; i > 0; i -= 1) {
    /* eslint-disable no-loop-func */
    setTimeout(function () {
      _song += `${i} bottiglie al muro, ne prendi una giù e la passi, ${
        i - 1
      } bottiglie di birra al muro\n`;
      if (i === 1) {
        _song += "Ehi, prendiamo un po' di più di birra";
      }
    }, 0);
    /* eslint-enable no-loop-func */
  }

  return _song;
}

function singSong(_song) {
  if (!_song) throw new Error("la canzone è vuota, DAMMI UNA CANZONE!");
  console.log(_song);
}

const song = getSong("birra");
// questo non funzionerà
singSong(song);
// Uncaught Error: la canzone è vuota, DAMMI UNA CANZONE!

Perché è successo questo? setTimeout istruisce la CPU a memorizzare le istruzioni altrove sul bus e a pianificare il ritiro dei dati in un momento successivo. Passano migliaia di cicli della CPU prima che la funzione colpisca di nuovo al marcatore di 0 millisecondi, la CPU preleva le istruzioni dal bus e le esegue. L’unico problema è che la canzone (‘’) è stata restituita migliaia di cicli prima.

La stessa situazione si presenta nel trattare con i sistemi di file e le richieste di rete. Il thread principale semplicemente non può essere bloccato per un periodo di tempo indeterminato, quindi utilizziamo i callback per pianificare l’esecuzione del codice nel tempo in modo controllato.

Sarai in grado di eseguire quasi tutte le tue operazioni con i seguenti 3 pattern:

  1. In serie: le funzioni verranno eseguite in un rigoroso ordine sequenziale, questo è più simile ai loop for.
// operazioni definite altrove e pronte per essere eseguite
const operations = [
  { func: function1, args: args1 },
  { func: function2, args: args2 },
  { func: function3, args: args3 },
];

function executeFunctionWithArgs(operation, callback) {
  // esegue la funzione
  const { args, func } = operation;
  func(args, callback);
}

function proceduraSeriale(operation) {
  if (!operation) process.exit(0); // finito
  executeFunctionWithArgs(operation, function (result) {
    // continua DOPO il callback
    proceduraSeriale(operations.shift());
  });
}

proceduraSeriale(operations.shift());
  1. Completamente parallelo: quando l’ordine non è un problema, come inviare e-mail a una lista di 1.000.000 di destinatari.
let count = 0;
let success = 0;
const failed = [];
const recipients = [
  { name: "Bart", email: "bart@tld" },
  { name: "Marge", email: "marge@tld" },
  { name: "Homer", email: "homer@tld" },
  { name: "Lisa", email: "lisa@tld" },
  { name: "Maggie", email: "maggie@tld" },
];

function dispatch(recipient, callback) {
  // `sendEmail` è un client SMTP ipotetico
  sendMail(
    {
      subject: "Cena stasera",
      message: "Abbiamo un sacco di cavoli nel piatto. Vieni?",
      smtp: recipient.email,
    },
    callback
  );
}

function finale(result) {
  console.log(`Risultato: ${result.count} tentativi \
      e ${result.success} e-mail riuscite`);
  if (result.failed.length)
    console.log(`Impossibile inviare a: \
        \n${result.failed.join("\n")}\n`);
}

recipients.forEach(function (recipient) {
  dispatch(recipient, function (err) {
    if (!err) {
      success += 1;
    } else {
      failed.push(recipient.name);
    }
    count += 1;

    if (count === recipients.length) {
      finale({
        count,
        success,
        failed,
      });
    }
  });
});
  1. Parallelo limitato: parallelo con limite, come inviare con successo 1.000.000 di destinatari da una lista di 10^7 utenti.
let successCount = 0;

function finale() {
  console.log(`spedite ${successCount} e-mail`);
  console.log("finito");
}

function dispatch(recipient, callback) {
  // `sendEmail` è un client SMTP ipotetico
  sendMail(
    {
      subject: "Cena stasera",
      message: "Abbiamo un sacco di cavoli nel piatto. Vieni?",
      smtp: recipient.email,
    },
    callback
  );
}

function inviaUnMilioneDiEmail() {
  getListaDiDieciMilioniDiGrandiEmail(function (err, bigList) {
    if (err) throw err;

    function seriale(recipient) {
      if (!recipient || successCount >= 1000000) return finale();
      dispatch(recipient, function (_err) {
        if (!_err) successCount += 1;
        seriale(bigList.pop());
      });
    }

    seriale(bigList.pop());
  });
}

inviaUnMilioneDiEmail();

Ognuno ha i suoi casi d’uso, vantaggi e problemi che puoi sperimentare e approfondire ulteriormente. Ricorda soprattutto di modularizzare le tue operazioni e utilizzare i callback! Se hai dubbi, tratta tutto come se fosse middleware!