Analisi comparativa di una promessa di Node.js

Pubblicato: 2022-03-11

Viviamo in un mondo nuovo e coraggioso. Un mondo pieno di JavaScript. Negli ultimi anni, JavaScript ha dominato il web prendendo d'assalto l'intero settore. Dopo l'introduzione di Node.js, la comunità JavaScript è stata in grado di utilizzare la semplicità e la dinamicità del linguaggio per essere l'unico linguaggio per fare tutto, gestendo lato server e lato client, e ha persino rivendicato una posizione per l'apprendimento automatico. Ma JavaScript è cambiato drasticamente come lingua negli ultimi anni. Sono stati introdotti nuovi concetti che non c'erano mai stati prima, come le funzioni e le promesse delle frecce.

Ah, promesse. L'intero concetto di promessa e richiamata non aveva molto senso per me quando ho iniziato a imparare Node.js. Ero abituato al modo procedurale di eseguire il codice, ma col tempo ho capito perché era importante.

Questo ci porta alla domanda, perché sono stati comunque introdotti richiami e promesse? Perché non possiamo semplicemente scrivere codice eseguito in sequenza in JavaScript?

Beh, tecnicamente puoi. Ma dovresti?

In questo articolo, darò una breve introduzione su JavaScript e il suo runtime e, cosa più importante, testerò la convinzione diffusa nella comunità JavaScript che il codice sincrono abbia prestazioni inferiori alla media e, in un certo senso, semplicemente malvagio, e non dovrebbe mai essere utilizzato. Questo mito è davvero vero?

Prima di iniziare, questo articolo presuppone che tu abbia già familiarità con le promesse in JavaScript, tuttavia, se non lo sei o hai bisogno di un aggiornamento, consulta Promesse JavaScript: un tutorial con esempi

NB Questo articolo è stato testato su un ambiente Node.js, non puro JavaScript. Esecuzione di Node.js versione 10.14.2. Tutti i benchmark e la sintassi faranno molto affidamento su Node.js. I test sono stati eseguiti su un MacBook Pro 2018 con un processore Intel i5 Quad-Core di ottava generazione con velocità di clock di base di 2,3 GHz.

Il ciclo degli eventi

Benchmarking di Node.js Promise: illustrazione di Node.js Event Loop

Il problema con la scrittura di JavaScript è che il linguaggio stesso è a thread singolo. Ciò significa che non puoi eseguire più di una singola procedura alla volta a differenza di altri linguaggi, come Go o Ruby, che hanno la capacità di generare thread ed eseguire più procedure contemporaneamente, sia sui thread del kernel che sui thread di processo .

Per eseguire codice, JavaScript si basa su una procedura chiamata ciclo di eventi che è composta da più fasi. Il processo JavaScript passa attraverso ogni fase e, alla fine, ricomincia da capo. Puoi leggere di più sui dettagli nella guida ufficiale di node.js qui.

Ma JavaScript ha qualcosa in asso per combattere il problema del blocco. Richiamate I/O.

La maggior parte dei casi d'uso reali che richiedono la creazione di un thread sono il fatto che stiamo richiedendo alcune azioni di cui il linguaggio non è responsabile, ad esempio, il recupero di alcuni dati dal database. Nelle lingue multithread, il thread che ha creato la richiesta si blocca semplicemente o attende la risposta dal database. Questo è solo uno spreco di risorse. Inoltre, pone un onere per lo sviluppatore nella scelta del numero corretto di thread in un pool di thread. Questo per prevenire perdite di memoria e l'allocazione di molte risorse quando l'app è molto richiesta.

JavaScript eccelle in una cosa più di qualsiasi altro fattore, gestendo le operazioni di I/O. JavaScript consente di chiamare un'operazione di I/O come la richiesta di dati da un database, la lettura di un file in memoria, la scrittura di un file su disco, l'esecuzione di un comando della shell, ecc. Al termine dell'operazione, si esegue una richiamata. Oppure, in caso di promesse, risolvi la promessa con il risultato o la rifiuti con un errore.

La comunità di JavaScript ci consiglia sempre di non utilizzare mai codice sincrono durante le operazioni di I/O. Il motivo noto è che NON vogliamo impedire al nostro codice di eseguire altre attività. Poiché è a thread singolo, se abbiamo un pezzo di codice che legge un file in modo sincrono, il codice bloccherà l'intero processo fino al completamento della lettura. Invece, se ci affidiamo al codice asincrono, possiamo eseguire più operazioni di I/O e gestire la risposta di ciascuna operazione individualmente una volta completata. Nessun blocco di sorta.

Ma sicuramente in un ambiente in cui non ci interessa affatto gestire molti processi, l'utilizzo di codice sincrono e asincrono non fa alcuna differenza, giusto?

Prova delle prestazioni

Il test che eseguiremo mirerà a fornirci benchmark su quanto velocemente viene eseguito il codice di sincronizzazione e asincrono e se c'è una differenza nelle prestazioni.

Ho deciso di scegliere la lettura di un file come operazione di I/O da testare.

Innanzitutto, ho scritto una funzione che scriverà un file casuale pieno di byte casuali generati con il modulo Node.js Crypto.

 const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )

Questo file fungerebbe da costante per il nostro passaggio successivo che consiste nel leggere il file. Ecco il codice

 const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()

L'esecuzione del codice precedente ha prodotto i seguenti risultati:

Correre # Sincronizza Asincrono Rapporto asincrono/sincronizzazione
1 0,278 ms 3.829 ms 13.773
2 0,335 ms 3.801 ms 11.346
3 0,403 ms 4.498 ms 11.161

Questo è stato inaspettato. Le mie aspettative iniziali erano che avrebbero dovuto impiegare lo stesso tempo. Bene, che ne dici di aggiungere un altro file e leggere 2 file invece di 1?

Ho replicato il file generato test.txt e l'ho chiamato test2.txt. Ecco il codice aggiornato:

 function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }

Ho semplicemente aggiunto un'altra lettura per ciascuno di essi e, nelle promesse, stavo aspettando le promesse di lettura che avrebbero dovuto essere eseguite in parallelo. Questi sono stati i risultati:

Correre # Sincronizza Asincrono Rapporto asincrono/sincronizzazione
1 1.659 ms 6,895 ms 4.156
2 0,323 ms 4.048 ms 12.533
3 0,324 ms 4.017 ms 12.398
4 0,333 ms 4.271 ms 12.826

Il primo ha valori completamente diversi rispetto alle 3 esecuzioni che seguono. La mia ipotesi è che sia correlato al compilatore JIT JavaScript che ottimizza il codice ad ogni esecuzione.

Quindi, le cose non sembrano così buone per le funzioni asincrone. Forse se rendiamo le cose più dinamiche e magari sottolineiamo un po' di più l'app, potremmo ottenere un risultato diverso.

Quindi il mio prossimo test prevede la scrittura di 100 file diversi e la lettura di tutti.

Innanzitutto, ho modificato il codice per scrivere 100 file prima dell'esecuzione del test. I file sono diversi a ogni esecuzione, pur mantenendo quasi le stesse dimensioni, quindi cancelliamo i vecchi file prima di ogni esecuzione.

Ecco il codice aggiornato:

 let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }

E per la pulizia e l'esecuzione:

 let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()

E corriamo.

Ecco la tabella dei risultati:

Correre # Sincronizza Asincrono Rapporto asincrono/sincronizzazione
1 4.999 ms 12.890 ms 2.579
2 5.077 ms 16.267 ms 3.204
3 5.241 ms 14.571 ms 2.780
4 5.086 ms 16.334 ms 3.213

Questi risultati iniziano a trarre una conclusione qui. Indica che con l'aumento della domanda o della concorrenza, le promesse generali iniziano ad avere un senso. Per l'elaborazione, se stiamo eseguendo un server Web che dovrebbe eseguire centinaia o forse migliaia di richieste al secondo per server, l'esecuzione di operazioni di I/O utilizzando la sincronizzazione inizierà a perdere il suo vantaggio abbastanza rapidamente.

Solo per motivi di sperimentazione, vediamo se è effettivamente un problema con le promesse stesse o è qualcos'altro. Per questo, ho scritto una funzione che calcolerà il tempo per risolvere una promessa che non fa assolutamente nulla e un'altra che risolve 100 promesse vuote.

Ecco il codice:

 function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
Correre # Unica promessa 100 promesse
1 1.651 ms 3.293 ms
2 0,758 ms 2.575 ms
3 0,814 ms 3.127 ms
4 0,788 ms 2.623 ms

Interessante. Sembra che le promesse non siano la causa principale del ritardo, il che mi fa intuire che la fonte del ritardo siano i thread del kernel che eseguono la lettura effettiva. Ciò potrebbe richiedere un po' più di sperimentazione per arrivare a una conclusione decisiva sul motivo principale del ritardo.

Un'ultima parola

Quindi dovresti usare le promesse o no? La mia opinione sarebbe la seguente:

Se stai scrivendo uno script che verrà eseguito su una singola macchina con un flusso specifico attivato da una pipeline o da un singolo utente, vai con il codice di sincronizzazione. Se stai scrivendo un server Web che sarà responsabile della gestione di molto traffico e richieste, il sovraccarico derivante dall'esecuzione asincrona supererà le prestazioni del codice di sincronizzazione.

Puoi trovare il codice per tutte le funzioni in questo articolo nel repository.

Il prossimo passo logico nel tuo viaggio per sviluppatori JavaScript, dalle promesse, è la sintassi async/await. Se desideri saperne di più e come siamo arrivati ​​qui, vedi JavaScript asincrono: da Callback Hell a Async e Await .