Evaluarea comparativă a unei promisiuni Node.js

Publicat: 2022-03-11

Trăim într-o lume nouă și curajoasă. O lume plină de JavaScript. În ultimii ani, JavaScript a dominat web-ul, luând cu asalt întreaga industrie. După introducerea Node.js, comunitatea JavaScript a putut să utilizeze simplitatea și dinamica limbajului pentru a fi singurul limbaj care să facă totul, gestionând partea serverului, partea clientului și chiar a mers cu îndrăzneală și a revendicat o poziție pentru învățarea automată. Dar JavaScript s-a schimbat drastic ca limbă în ultimii ani. Au fost introduse concepte noi care nu au existat niciodată înainte, cum ar fi funcțiile săgeților și promisiunile.

Ah, promisiuni. Întregul concept de promisiune și apel invers nu avea prea mult sens pentru mine când am început să învăț Node.js. Eram obișnuit cu modul procedural de a executa codul, dar în timp am înțeles de ce era important.

Acest lucru ne aduce la întrebarea, de ce oricum au fost introduse apeluri și promisiuni? De ce nu putem scrie cod executat secvenţial în JavaScript?

Ei bine, tehnic poți. Dar ar trebui?

În acest articol, voi face o scurtă introducere despre JavaScript și timpul său de rulare și, mai important, voi testa credința larg răspândită în comunitatea JavaScript că codul sincron este sub egal în performanță și, într-un fel, pur și simplu rău și nu ar trebui niciodată. fi folosit. Este adevărat acest mit?

Înainte de a începe, acest articol presupune că sunteți deja familiarizat cu promisiunile în JavaScript, totuși, dacă nu sunteți sau aveți nevoie de o actualizare, consultați Promisiuni JavaScript: un tutorial cu exemple

NB Acest articol a fost testat într-un mediu Node.js, nu într-un mediu JavaScript pur. Rularea Node.js versiunea 10.14.2. Toate benchmark-urile și sintaxa se vor baza în mare măsură pe Node.js. Testele au fost efectuate pe un MacBook Pro 2018 cu un procesor Intel i5 a 8-a generație Quad-Core care rulează o viteză de bază de 2,3 GHz.

Bucla evenimentului

Evaluare comparativă Promisiunea Node.js: Ilustrație a buclei de evenimente Node.js

Problema cu scrierea JavaScript este că limbajul în sine are un singur thread. Aceasta înseamnă că nu puteți executa mai mult de o singură procedură în același timp, spre deosebire de alte limbi, cum ar fi Go sau Ruby, care au capacitatea de a genera fire și de a executa mai multe proceduri în același timp, fie pe fire de nucleu, fie pe fire de proces. .

Pentru a executa cod, JavaScript se bazează pe o procedură numită bucla de evenimente, care este compusă din mai multe etape. Procesul JavaScript trece prin fiecare etapă și, la sfârșit, începe totul din nou. Puteți citi mai multe despre detalii în ghidul oficial al node.js aici.

Dar JavaScript are ceva în mânecă pentru a combate problema blocării. Reapeluri I/O.

Cele mai multe dintre cazurile de utilizare din viața reală care ne solicită să creăm un fir de execuție sunt faptul că solicităm o acțiune pentru care limba nu este responsabilă, de exemplu, solicitarea preluării unor date din baza de date. În limbile multithreaded, firul care a creat cererea pur și simplu se blochează sau așteaptă răspunsul din baza de date. Aceasta este doar o risipă de resurse. De asemenea, pune o povară dezvoltatorului în alegerea numărului corect de fire într-un pool de fire. Acest lucru este pentru a preveni scurgerile de memorie și alocarea multor resurse atunci când aplicația este la mare căutare.

JavaScript excelează într-un lucru mai mult decât orice alt factor, gestionarea operațiunilor I/O. JavaScript vă permite să apelați o operație I/O, cum ar fi solicitarea de date dintr-o bază de date, citirea unui fișier în memorie, scrierea unui fișier pe disc, executarea unei comenzi shell etc. Când operațiunea este finalizată, executați un apel invers. Sau in cazul promisiunilor, rezolvi promisiunea cu rezultatul sau o respingi cu o eroare.

Comunitatea JavaScript ne sfătuiește întotdeauna să nu folosim niciodată cod sincron atunci când facem operațiuni I/O. Motivul binecunoscut pentru aceasta este că NU vrem să ne blocăm codul să ruleze alte sarcini. Deoarece este cu un singur thread, dacă avem o bucată de cod care citește un fișier în mod sincron, codul va bloca întregul proces până când citirea este completă. În schimb, dacă ne bazăm pe cod asincron, putem face mai multe operații I/O și putem gestiona răspunsul fiecărei operațiuni în mod individual când este completă. Niciun fel de blocare.

Dar cu siguranță într-un mediu în care nu ne pasă deloc de gestionarea multor procese, utilizarea codului sincron și asincron nu face deloc diferența, nu?

Benchmark

Testul pe care îl vom rula va avea ca scop să ne ofere benchmark-uri cu privire la cât de repede rulează codul de sincronizare și asincron și dacă există o diferență de performanță.

Am decis să aleg citirea unui fișier ca operație I/O de testat.

Mai întâi, am scris o funcție care va scrie un fișier aleatoriu umplut cu octeți aleatori generați cu modulul Crypto Node.js.

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

Acest fișier ar acționa ca o constantă pentru următorul nostru pas, care este citirea fișierului. Iată codul

 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()

Rularea codului anterior a dus la următoarele rezultate:

Alerga # Sincronizare Async Raport asincron/sincronizare
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

Acest lucru a fost neașteptat. Așteptările mele inițiale au fost că ar trebui să dureze același timp. Ei bine, ce zici să adăugăm un alt fișier și să citim 2 fișiere în loc de 1?

Am replicat fișierul generat test.txt și l-am numit test2.txt. Iată codul actualizat:

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

Pur și simplu am adăugat încă o lectură pentru fiecare dintre ele, iar în promisiuni, așteptam promisiunile de lectură care ar trebui să fie difuzate în paralel. Acestea au fost rezultatele:

Alerga # Sincronizare Async Raport asincron/sincronizare
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

Prima are valori complet diferite față de cele 3 runde care urmează. Bănuiesc că este legat de compilatorul JavaScript JIT care optimizează codul la fiecare rulare.

Deci, lucrurile nu arată atât de bine pentru funcțiile asincrone. Poate dacă facem lucrurile mai dinamice și poate stresăm puțin mai mult aplicația, am putea obține un rezultat diferit.

Deci următorul meu test implică scrierea a 100 de fișiere diferite și apoi citirea lor pe toate.

Mai întâi, am modificat codul pentru a scrie 100 de fișiere înainte de executarea testului. Fișierele sunt diferite la fiecare rulare, deși păstrând aproape aceeași dimensiune, așa că ștergem fișierele vechi înainte de fiecare rulare.

Iată codul actualizat:

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

Și pentru curățare și execuție:

 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()

Și hai să alergăm.

Iată tabelul cu rezultate:

Alerga # Sincronizare Async Raport asincron/sincronizare
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

Aceste rezultate încep să tragă o concluzie aici. Indică faptul că odată cu creșterea cererii sau a concurenței, promisiunile încep să aibă sens. Pentru elaborare, dacă rulăm un server web care ar trebui să ruleze sute sau poate mii de solicitări pe secundă pe server, rularea operațiunilor de I/O folosind sincronizare va începe să-și piardă beneficiile destul de repede.

Doar de dragul experimentării, să vedem dacă este de fapt o problemă cu promisiunile în sine sau este altceva. Pentru asta, am scris o funcție care va calcula timpul pentru a rezolva o promisiune care nu face absolut nimic și alta care rezolvă 100 de promisiuni goale.

Iată codul:

 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()
Alerga # Promisiune unică 100 de promisiuni
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

Interesant. Se pare că promisiunile nu sunt cauza principală a întârzierii, ceea ce mă face să presupun că sursa întârzierii sunt firele de nucleu care fac citirea efectivă. Acest lucru ar putea necesita ceva mai multă experimentare pentru a ajunge la o concluzie decisivă cu privire la motivul principal din spatele întârzierii.

Un cuvânt final

Deci ar trebui să folosești promisiuni sau nu? Parerea mea ar fi urmatoarea:

Dacă scrieți un script care va rula pe o singură mașină cu un flux specific declanșat de o conductă sau de un singur utilizator, atunci mergeți cu codul de sincronizare. Dacă scrieți un server web care va fi responsabil pentru gestionarea multor trafic și solicitări, atunci suprasarcina care vine de la execuția asincronă va depăși performanța codului de sincronizare.

Puteți găsi codul pentru toate funcțiile din acest articol în depozit.

Următorul pas logic în călătoria dvs. de dezvoltator JavaScript, de la promisiuni, este sintaxa async/wait. Dacă doriți să aflați mai multe despre acesta și despre cum am ajuns aici, consultați JavaScript asincron: de la Callback Hell la Async și Await .