Überblick über blockierende vs. nicht-blockierende Aufrufe
Dieser Überblick behandelt den Unterschied zwischen blockierenden und nicht-blockierenden Aufrufen in Node.js. Dieser Überblick wird sich auf die Event-Loop und libuv beziehen, aber es sind keine Vorkenntnisse dieser Themen erforderlich. Es wird davon ausgegangen, dass die Leser ein grundlegendes Verständnis der JavaScript-Sprache und des Node.js Callback-Musters haben.
INFO
"I/O" bezieht sich hauptsächlich auf die Interaktion mit der Festplatte und dem Netzwerk des Systems, die von libuv unterstützt wird.
Blockierend
Blockierend ist, wenn die Ausführung von zusätzlichem JavaScript im Node.js-Prozess warten muss, bis eine nicht-JavaScript-Operation abgeschlossen ist. Dies geschieht, weil die Event-Loop nicht mit der Ausführung von JavaScript fortfahren kann, während eine blockierende Operation stattfindet.
In Node.js wird JavaScript, das aufgrund von CPU-Intensität anstatt des Wartens auf eine nicht-JavaScript-Operation, wie z. B. I/O, eine schlechte Leistung zeigt, typischerweise nicht als blockierend bezeichnet. Synchrone Methoden in der Node.js-Standardbibliothek, die libuv verwenden, sind die am häufigsten verwendeten blockierenden Operationen. Native Module können auch blockierende Methoden haben.
Alle I/O-Methoden in der Node.js-Standardbibliothek bieten asynchrone Versionen an, die nicht-blockierend sind und Callback-Funktionen akzeptieren. Einige Methoden haben auch blockierende Gegenstücke, deren Namen mit Sync
enden.
Code vergleichen
Blockierende Methoden werden synchron ausgeführt und nicht-blockierende Methoden werden asynchron ausgeführt.
Am Beispiel des Dateisystem-Moduls ist dies ein synchroner Dateilesevorgang:
const fs = require('node:fs')
const data = fs.readFileSync('/file.md') // blockiert hier, bis die Datei gelesen wurde
Und hier ist ein äquivalentes asynchrones Beispiel:
const fs = require('node:fs')
fs.readFile('/file.md', (err, data) => {
if (err) throw err
})
Das erste Beispiel erscheint einfacher als das zweite, hat aber den Nachteil, dass die zweite Zeile die Ausführung von zusätzlichem JavaScript blockiert, bis die gesamte Datei gelesen wurde. Beachten Sie, dass in der synchronen Version ein Fehler abgefangen werden muss, oder der Prozess stürzt ab. In der asynchronen Version ist es dem Autor überlassen zu entscheiden, ob ein Fehler wie gezeigt ausgelöst werden soll.
Erweitern wir unser Beispiel ein wenig:
const fs = require('node:fs')
const data = fs.readFileSync('/file.md') // blockiert hier, bis die Datei gelesen wurde
console.log(data)
moreWork() // wird nach console.log ausgeführt
Und hier ist ein ähnliches, aber nicht äquivalentes asynchrones Beispiel:
const fs = require('node:fs')
fs.readFile('/file.md', (err, data) => {
if (err) throw err
console.log(data)
})
moreWork() // wird vor console.log ausgeführt
Im ersten Beispiel oben wird console.log
vor moreWork()
aufgerufen. Im zweiten Beispiel ist fs.readFile()
nicht-blockierend, sodass die JavaScript-Ausführung fortgesetzt werden kann und moreWork()
zuerst aufgerufen wird. Die Fähigkeit, moreWork()
auszuführen, ohne auf den Abschluss des Dateilesevorgangs warten zu müssen, ist eine wichtige Designentscheidung, die einen höheren Durchsatz ermöglicht.
Gleichzeitigkeit und Durchsatz
Die JavaScript-Ausführung in Node.js ist Single-Threaded, daher bezieht sich Gleichzeitigkeit auf die Fähigkeit der Ereignisschleife, JavaScript-Callback-Funktionen nach Abschluss anderer Aufgaben auszuführen. Jeder Code, der auf gleichzeitige Weise ausgeführt werden soll, muss der Ereignisschleife erlauben, weiterhin zu laufen, während nicht-JavaScript-Operationen wie I/O auftreten.
Betrachten wir beispielsweise einen Fall, in dem jede Anfrage an einen Webserver 50 ms zur Fertigstellung benötigt und 45 ms dieser 50 ms Datenbank-I/O sind, der asynchron erfolgen kann. Die Wahl nicht-blockierender asynchroner Operationen gibt 45 ms pro Anfrage frei, um andere Anfragen zu bearbeiten. Dies ist ein signifikanter Unterschied in der Kapazität, nur durch die Wahl nicht-blockierender anstelle von blockierenden Methoden.
Die Ereignisschleife unterscheidet sich von Modellen in vielen anderen Sprachen, in denen zusätzliche Threads erstellt werden können, um gleichzeitige Arbeit zu erledigen.
Gefahren der Vermischung von blockierendem und nicht-blockierendem Code
Es gibt einige Muster, die bei der Verarbeitung von I/O vermieden werden sollten. Betrachten wir ein Beispiel:
const fs = require('node:fs')
fs.readFile('/file.md', (err, data) => {
if (err) throw err
console.log(data)
})
fs.unlinkSync('/file.md')
Im obigen Beispiel wird fs.unlinkSync()
wahrscheinlich vor fs.readFile()
ausgeführt, was file.md
löschen würde, bevor es tatsächlich gelesen wird. Eine bessere Möglichkeit, dies zu schreiben, die vollständig nicht-blockierend ist und garantiert in der richtigen Reihenfolge ausgeführt wird, ist:
const fs = require('node:fs')
fs.readFile('/file.md', (readFileErr, data) => {
if (readFileErr) throw readFileErr
console.log(data)
fs.unlink('/file.md', unlinkErr => {
if (unlinkErr) throw unlinkErr
})
})
Das Obige platziert einen nicht-blockierenden Aufruf an fs.unlink()
innerhalb des Callbacks von fs.readFile()
, was die korrekte Reihenfolge der Operationen garantiert.