Benchmarking Ein Node.js-Versprechen

Veröffentlicht: 2022-03-11

Wir leben in einer schönen neuen Welt. Eine Welt voller JavaScript. In den letzten Jahren hat JavaScript das Web dominiert und die gesamte Branche im Sturm erobert. Nach der Einführung von Node.js war die JavaScript-Community in der Lage, die Einfachheit und Dynamik der Sprache zu nutzen, um als einzige Sprache alles zu tun, die Server- und Clientseite zu handhaben, und ging sogar mutig vor und beanspruchte eine Position für maschinelles Lernen. Aber JavaScript hat sich als Sprache in den letzten Jahren drastisch verändert. Es wurden neue Konzepte eingeführt, die vorher nie da waren, wie Pfeilfunktionen und Versprechungen.

Ach, versprochen. Das ganze Konzept von Promise und Callback machte für mich nicht viel Sinn, als ich anfing, Node.js zu lernen. Ich war an die prozedurale Art der Codeausführung gewöhnt, aber mit der Zeit verstand ich, warum das wichtig war.

Das bringt uns zu der Frage, warum wurden Rückrufe und Zusagen überhaupt eingeführt? Warum können wir nicht einfach sequentiell ausgeführten Code in JavaScript schreiben?

Nun, technisch können Sie. Aber sollten Sie?

In diesem Artikel werde ich eine kurze Einführung in JavaScript und seine Laufzeit geben und, was noch wichtiger ist, den weit verbreiteten Glauben in der JavaScript-Community testen, dass synchroner Code in Bezug auf die Leistung unterdurchschnittlich und in gewissem Sinne einfach nur böse ist und niemals sollte verwendet werden. Ist dieser Mythos wirklich wahr?

Bevor Sie beginnen, wird in diesem Artikel davon ausgegangen, dass Sie bereits mit Promises in JavaScript vertraut sind. Wenn Sie dies jedoch nicht sind oder eine Auffrischung benötigen, lesen Sie bitte JavaScript Promises: A Tutorial with Examples

Hinweis: Dieser Artikel wurde in einer Node.js-Umgebung getestet, nicht in einer reinen JavaScript-Umgebung. Ausführen von Node.js-Version 10.14.2. Alle Benchmarks und Syntax werden sich stark auf Node.js verlassen. Die Tests wurden auf einem MacBook Pro 2018 mit einem Intel i5 Quad-Core-Prozessor der 8. Generation mit einer Basistaktfrequenz von 2,3 GHz durchgeführt.

Die Ereignisschleife

Benchmarking von Node.js Promise: Darstellung der Node.js-Ereignisschleife

Das Problem beim Schreiben von JavaScript ist, dass die Sprache selbst Single-Threading ist. Dies bedeutet, dass Sie nicht mehr als eine einzelne Prozedur gleichzeitig ausführen können, im Gegensatz zu anderen Sprachen wie Go oder Ruby, die Threads erzeugen und mehrere Prozeduren gleichzeitig ausführen können, entweder auf Kernel-Threads oder auf Prozess-Threads .

Um Code auszuführen, verlässt sich JavaScript auf eine Prozedur namens Ereignisschleife, die aus mehreren Phasen besteht. Der JavaScript-Prozess durchläuft jede Phase und beginnt am Ende von vorne. Sie können mehr über die Details im offiziellen Leitfaden von node.js hier lesen.

Aber JavaScript hat etwas in petto, um das Blockierungsproblem zu bekämpfen. E/A-Callbacks.

Die meisten realen Anwendungsfälle, bei denen wir einen Thread erstellen müssen, sind die Tatsache, dass wir eine Aktion anfordern, für die die Sprache nicht verantwortlich ist – zum Beispiel das Anfordern einiger Daten aus der Datenbank. In Multithread-Sprachen bleibt der Thread, der die Anfrage erstellt hat, einfach hängen oder wartet auf die Antwort von der Datenbank. Das ist reine Ressourcenverschwendung. Es belastet den Entwickler auch bei der Auswahl der richtigen Anzahl von Threads in einem Thread-Pool. Dies dient dazu, Speicherlecks und die Zuweisung vieler Ressourcen zu verhindern, wenn die App stark nachgefragt wird.

JavaScript zeichnet sich mehr als jeder andere Faktor durch die Verarbeitung von E/A-Operationen aus. Mit JavaScript können Sie eine E/A-Operation aufrufen, z. B. Daten aus einer Datenbank anfordern, eine Datei in den Speicher lesen, eine Datei auf die Festplatte schreiben, einen Shell-Befehl ausführen usw. Wenn die Operation abgeschlossen ist, führen Sie einen Rückruf aus. Oder bei Promises lösen Sie das Promise mit dem Ergebnis auf oder lehnen es mit einem Fehler ab.

Die JavaScript-Community rät uns immer, niemals synchronen Code für I/O-Operationen zu verwenden. Der bekannte Grund dafür ist, dass wir unseren Code NICHT daran hindern wollen, andere Aufgaben auszuführen. Da es sich um einen Single-Thread handelt, blockiert der Code, wenn wir einen Code haben, der eine Datei synchron liest, den gesamten Prozess, bis das Lesen abgeschlossen ist. Wenn wir uns stattdessen auf asynchronen Code verlassen, können wir mehrere E/A-Operationen ausführen und die Antwort jeder Operation nach Abschluss einzeln verarbeiten. Keinerlei Sperrung.

Aber in einer Umgebung, in der wir uns überhaupt nicht darum kümmern, viele Prozesse zu handhaben, macht die Verwendung von synchronem und asynchronem Code doch überhaupt keinen Unterschied, oder?

Benchmark

Der Test, den wir durchführen werden, soll uns Benchmarks dafür liefern, wie schnell synchroner und asynchroner Code ausgeführt wird und ob es einen Unterschied in der Leistung gibt.

Ich entschied mich für das Lesen einer Datei als E/A-Vorgang zum Testen.

Zuerst habe ich eine Funktion geschrieben, die eine zufällige Datei schreibt, die mit zufälligen Bytes gefüllt ist, die mit dem Crypto-Modul von Node.js generiert wurden.

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

Diese Datei würde als Konstante für unseren nächsten Schritt dienen, nämlich das Lesen der Datei. Hier ist der Code

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

Das Ausführen des vorherigen Codes führte zu den folgenden Ergebnissen:

Laufen # Synchronisieren Asynchron Async/Sync-Verhältnis
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

Das war unerwartet. Meine anfängliche Erwartung war, dass sie die gleiche Zeit in Anspruch nehmen sollten. Nun, wie wäre es, wenn wir eine weitere Datei hinzufügen und 2 Dateien statt 1 lesen?

Ich habe die generierte Datei test.txt repliziert und sie test2.txt genannt. Hier ist der aktualisierte Code:

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

Ich habe einfach für jeden von ihnen eine weitere Lektüre hinzugefügt und in Versprechungen auf die Lektüreversprechungen gewartet, die parallel laufen sollten. Das waren die Ergebnisse:

Laufen # Synchronisieren Asynchron Async/Sync-Verhältnis
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

Der erste hat völlig andere Werte als die 3 folgenden Läufe. Ich vermute, dass es mit dem JavaScript-JIT-Compiler zusammenhängt, der den Code bei jedem Durchlauf optimiert.

Für asynchrone Funktionen sieht es also nicht so gut aus. Vielleicht könnten wir ein anderes Ergebnis erzielen, wenn wir die Dinge dynamischer gestalten und die App vielleicht etwas mehr belasten.

Mein nächster Test besteht also darin, 100 verschiedene Dateien zu schreiben und sie dann alle zu lesen.

Zuerst habe ich den Code geändert, um 100 Dateien vor der Ausführung des Tests zu schreiben. Die Dateien sind bei jedem Durchlauf unterschiedlich, obwohl sie fast dieselbe Größe beibehalten, daher löschen wir die alten Dateien vor jedem Durchlauf.

Hier ist der aktualisierte Code:

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

Und zum Aufräumen und Ausführen:

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

Und lass uns laufen.

Hier die Ergebnistabelle:

Laufen # Synchronisieren Asynchron Async/Sync-Verhältnis
1 4,999 ms 12.890ms 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

Diese Ergebnisse beginnen hier, eine Schlussfolgerung zu ziehen. Es weist darauf hin, dass mit der Zunahme der Nachfrage oder Parallelität Overhead-Starts sinnvoll werden. Zur Erläuterung: Wenn wir einen Webserver betreiben, der Hunderte oder vielleicht Tausende von Anfragen pro Sekunde und Server ausführen soll, verliert das Ausführen von E/A-Operationen mit Sync ziemlich schnell seinen Nutzen.

Lassen Sie uns nur zum Experimentieren sehen, ob es sich tatsächlich um ein Problem mit den Versprechen selbst handelt oder um etwas anderes. Dafür habe ich eine Funktion geschrieben, die die Zeit berechnet, um ein Versprechen aufzulösen, das absolut nichts tut, und ein anderes, das 100 leere Versprechen auflöst.

Hier ist der Code:

 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()
Laufen # Einziges Versprechen 100 Versprechen
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

Interessant. Es scheint, dass Versprechungen nicht die Hauptursache für die Verzögerung sind, was mich vermuten lässt, dass die Quelle der Verzögerung die Kernel-Threads sind, die das eigentliche Lesen durchführen. Dies könnte etwas mehr Experimentieren erfordern, um zu einer endgültigen Schlussfolgerung über den Hauptgrund für die Verzögerung zu gelangen.

Ein letztes Wort

Sollten Sie also Versprechen verwenden oder nicht? Meine Meinung wäre folgende:

Wenn Sie ein Skript schreiben, das auf einem einzelnen Computer mit einem bestimmten Flow ausgeführt wird, der von einer Pipeline oder einem einzelnen Benutzer ausgelöst wird, verwenden Sie Synchronisierungscode. Wenn Sie einen Webserver schreiben, der für die Verarbeitung eines großen Datenverkehrs und vieler Anforderungen verantwortlich ist, überwindet der Overhead, der durch die asynchrone Ausführung entsteht, die Leistung des Synchronisierungscodes.

Den Code für alle Funktionen in diesem Artikel finden Sie im Repository.

Der logische nächste Schritt auf Ihrer Reise als JavaScript-Entwickler ist die async/await-Syntax. Wenn Sie mehr darüber und darüber erfahren möchten, wie wir dazu gekommen sind, lesen Sie Asynchronous JavaScript: From Callback Hell to Async and Await .