Dieses Kapitel erklärt die Grundlagen der asynchronen Programmierung in JavaScript. Es liefert Hintergrundwissen für das nächste Kapitel über ES6 Promises.
Wenn eine Funktion f eine Funktion g aufruft, muss g wissen, wohin es nach Abschluss zurückkehren soll (innerhalb von f). Diese Information wird üblicherweise mit einem Stack, dem *Call Stack*, verwaltet. Sehen wir uns ein Beispiel an.
function h(z) {
// Print stack trace
console.log(new Error().stack); // (A)
}
function g(y) {
h(y + 1); // (B)
}
function f(x) {
g(x + 1); // (C)
}
f(3); // (D)
return; // (E)
Zu Beginn, wenn das obige Programm gestartet wird, ist der Call Stack leer. Nach dem Funktionsaufruf f(3) in Zeile D enthält der Stack einen Eintrag
Nach dem Funktionsaufruf g(x + 1) in Zeile C enthält der Stack zwei Einträge
fNach dem Funktionsaufruf h(y + 1) in Zeile B enthält der Stack drei Einträge
gfDer in Zeile A ausgegebene Stack Trace zeigt Ihnen, wie der Call Stack aussieht
Error
at h (stack_trace.js:2:17)
at g (stack_trace.js:6:5)
at f (stack_trace.js:9:5)
at <global> (stack_trace.js:11:1)
Anschließend wird jede der Funktionen beendet und jedes Mal wird der oberste Eintrag vom Stack entfernt. Nachdem die Funktion f beendet ist, sind wir zurück im globalen Scope und der Call Stack ist leer. In Zeile E kehren wir zurück und der Stack ist leer, was bedeutet, dass das Programm terminiert.
Vereinfacht ausgedrückt, läuft jeder Browser-Tab (in einigen Browsern der gesamte Browser) in einem einzigen Prozess: der *Event Loop* (Offizielle Spezifikation). Diese Schleife führt browserbezogene Dinge (sogenannte *Tasks*) aus, die ihr über eine *Task Queue* zugeführt werden. Beispiele für Tasks sind
Elemente 2-4 sind Tasks, die JavaScript-Code ausführen, über die in den Browser eingebaute Engine. Sie terminieren, wenn der Code terminiert. Dann kann der nächste Task aus der Queue ausgeführt werden. Das folgende Diagramm (inspiriert von einer Folie von Philip Roberts [1]) gibt einen Überblick, wie all diese Mechanismen miteinander verbunden sind.
Die Event Loop ist von anderen parallel laufenden Prozessen umgeben (Timer, Eingabeverarbeitung usw.). Diese Prozesse kommunizieren mit ihr, indem sie Tasks zu ihrer Queue hinzufügen.
Browser haben Timer. setTimeout() erstellt einen Timer, wartet, bis er ausgelöst wird, und fügt dann einen Task zur Queue hinzu. Er hat die Signatur
setTimeout(callback, ms)
Nach ms Millisekunden wird callback zur Task Queue hinzugefügt. Es ist wichtig zu beachten, dass ms nur angibt, wann der Callback *hinzugefügt* wird, nicht wann er tatsächlich ausgeführt wird. Das kann viel später geschehen, insbesondere wenn die Event Loop blockiert ist (wie später in diesem Kapitel gezeigt wird).
setTimeout() mit ms auf Null gesetzt, ist ein häufig verwendeter Workaround, um etwas sofort zur Task Queue hinzuzufügen. Einige Browser erlauben jedoch nicht, dass ms unter einem Minimum liegt (4 ms in Firefox); sie setzen es *auf* dieses Minimum, wenn es darunter liegt.
Für die meisten DOM-Änderungen (insbesondere solche, die ein Neulayout beinhalten) wird die Anzeige nicht sofort aktualisiert. „Layout occurs off a refresh tick every 16ms“ (@bz_moz) und muss über die Event Loop eine Chance zum Ausführen erhalten.
Es gibt Möglichkeiten, häufige DOM-Updates mit dem Browser zu koordinieren, um eine Kollision mit seinem Layout-Rhythmus zu vermeiden. Konsultieren Sie die Dokumentation zu requestAnimationFrame() für Details.
JavaScript hat die sogenannte Run-to-completion-Semantik: Der aktuelle Task wird immer abgeschlossen, bevor der nächste Task ausgeführt wird. Das bedeutet, dass jeder Task die vollständige Kontrolle über alle aktuellen Zustände hat und sich keine Gedanken über gleichzeitige Modifikationen machen muss.
Betrachten wir ein Beispiel.
setTimeout(function () { // (A)
console.log('Second');
}, 0);
console.log('First'); // (B)
Die Funktion, die in Zeile A beginnt, wird sofort zur Task Queue hinzugefügt, aber erst ausgeführt, nachdem der aktuelle Codeblock beendet ist (insbesondere Zeile B!). Das bedeutet, dass die Ausgabe dieses Codes immer sein wird
First
Second
Wie wir gesehen haben, wird jeder Tab (in einigen Browsern der gesamte Browser) von einem einzigen Prozess verwaltet – sowohl die Benutzeroberfläche als auch alle anderen Berechnungen. Das bedeutet, dass Sie die Benutzeroberfläche einfrieren können, indem Sie eine langlaufende Berechnung in diesem Prozess durchführen. Der folgende Code demonstriert dies.
<a id="block" href="">Block for 5 seconds</a>
<p>
<button>This is a button</button>
<div id="statusMessage"></div>
<script>
document.getElementById('block')
.addEventListener('click', onClick);
function onClick(event) {
event.preventDefault();
setStatusMessage('Blocking...');
// Call setTimeout(), so that browser has time to display
// status message
setTimeout(function () {
sleep(5000);
setStatusMessage('Done');
}, 0);
}
function setStatusMessage(msg) {
document.getElementById('statusMessage').textContent = msg;
}
function sleep(milliseconds) {
var start = Date.now();
while ((Date.now() - start) < milliseconds);
}
</script>
Immer wenn auf den Link am Anfang geklickt wird, wird die Funktion onClick() ausgelöst. Sie verwendet die – synchrone – sleep() Funktion, um die Event Loop für fünf Sekunden zu blockieren. Während dieser Sekunden funktioniert die Benutzeroberfläche nicht. Sie können zum Beispiel nicht auf den „Simple Button“ klicken.
Sie vermeiden Blockaden der Event Loop auf zwei Arten
Erstens führen Sie keine langlaufenden Berechnungen im Hauptprozess durch, sondern verschieben sie in einen anderen Prozess. Dies kann über die Worker API erreicht werden.
Zweitens warten Sie nicht (synchron) auf die Ergebnisse einer langlaufenden Berechnung (Ihres eigenen Algorithmus in einem Worker-Prozess, einer Netzwerkanfrage usw.), sondern fahren mit der Event Loop fort und lassen die Berechnung Sie benachrichtigen, wenn sie abgeschlossen ist. Tatsächlich haben Sie in Browsern normalerweise keine Wahl und müssen die Dinge auf diese Weise tun. Zum Beispiel gibt es keine eingebaute Möglichkeit, synchron zu schlafen (wie das zuvor implementierte sleep()). Stattdessen ermöglicht setTimeout() Ihnen, asynchron zu schlafen.
Der nächste Abschnitt erklärt Techniken, um asynchron auf Ergebnisse zu warten.
Zwei gängige Muster zum asynchronen Empfangen von Ergebnissen sind: Events und Callbacks.
Bei diesem Muster zum asynchronen Empfangen von Ergebnissen erstellen Sie für jede Anfrage ein Objekt und registrieren Ereignisbehandler damit: einen für eine erfolgreiche Berechnung, einen anderen für die Fehlerbehandlung. Der folgende Code zeigt, wie das mit der XMLHttpRequest API funktioniert
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function () {
if (req.status == 200) {
processData(req.response);
} else {
console.log('ERROR', req.statusText);
}
};
req.onerror = function () {
console.log('Network Error');
};
req.send(); // Add request to task queue
Beachten Sie, dass die letzte Zeile die Anfrage nicht tatsächlich ausführt, sondern sie zur Task Queue hinzufügt. Daher könnten Sie diese Methode auch direkt nach open() aufrufen, bevor Sie onload und onerror einrichten. Die Dinge würden aufgrund der Run-to-completion-Semantik von JavaScript genauso funktionieren.
Die Browser-API IndexedDB hat einen etwas eigenartigen Stil der Ereignisbehandlung
var openRequest = indexedDB.open('test', 1);
openRequest.onsuccess = function (event) {
console.log('Success!');
var db = event.target.result;
};
openRequest.onerror = function (error) {
console.log(error);
};
Sie erstellen zunächst ein Anfrageobjekt, dem Sie Ereignis-Listener hinzufügen, die über Ergebnisse benachrichtigt werden. Sie müssen die Anfrage jedoch nicht explizit in die Queue stellen, das geschieht durch open(). Sie wird ausgeführt, nachdem der aktuelle Task beendet ist. Deshalb können Sie Ereignis-Listener *nach* dem Aufruf von open() registrieren (und müssen es sogar).
Wenn Sie mit Multithreading-Programmiersprachen vertraut sind, mag dieser Stil der Anfragenbearbeitung seltsam erscheinen, als ob er anfällig für Race Conditions wäre. Aber aufgrund von Run-to-completion ist alles immer sicher.
Dieser Stil der asynchron berechneten Ergebnisse ist in Ordnung, wenn Sie Ergebnisse mehrmals erhalten. Wenn es jedoch nur ein einzelnes Ergebnis gibt, wird die Ausführlichkeit zum Problem. Für diesen Anwendungsfall sind Callbacks populär geworden.
Wenn Sie asynchrone Ergebnisse über Callbacks verarbeiten, übergeben Sie Callback-Funktionen als nachfolgende Parameter an asynchrone Funktions- oder Methodenaufrufe.
Das Folgende ist ein Beispiel in Node.js. Wir lesen den Inhalt einer Textdatei über einen asynchronen Aufruf von fs.readFile()
// Node.js
fs.readFile('myfile.txt', { encoding: 'utf8' },
function (error, text) { // (A)
if (error) {
// ...
}
console.log(text);
});
Wenn readFile() erfolgreich ist, erhält der Callback in Zeile A ein Ergebnis über den Parameter text. Wenn nicht, erhält der Callback einen Fehler (oft eine Instanz von Error oder einen Unterkonstruktor) über seinen ersten Parameter.
Der gleiche Code im klassischen funktionalen Programmierstil würde so aussehen
// Functional
readFileFunctional('myfile.txt', { encoding: 'utf8' },
function (text) { // success
console.log(text);
},
function (error) { // failure
// ...
});
Der Programmierstil der Verwendung von Callbacks (insbesondere in der zuvor gezeigten funktionalen Art) wird auch als *Continuation-Passing Style* (CPS) bezeichnet, da der nächste Schritt (die *Continuation*) explizit als Parameter übergeben wird. Dies gibt einer aufgerufenen Funktion mehr Kontrolle darüber, was als Nächstes und wann passiert.
Der folgende Code veranschaulicht CPS
console.log('A');
identity('B', function step2(result2) {
console.log(result2);
identity('C', function step3(result3) {
console.log(result3);
});
console.log('D');
});
console.log('E');
// Output: A E B D C
function identity(input, callback) {
setTimeout(function () {
callback(input);
}, 0);
}
Für jeden Schritt setzt sich der Kontrollfluss des Programms innerhalb des Callbacks fort. Dies führt zu verschachtelten Funktionen, die manchmal als *Callback-Hölle* bezeichnet werden. Sie können jedoch oft eine Verschachtelung vermeiden, da JavaScript-Funktionsdeklarationen *gehoisted* (ihre Definitionen werden am Anfang ihres Geltungsbereichs ausgewertet) sind. Das bedeutet, dass Sie vorausrufen und Funktionen aufrufen können, die später im Programm definiert sind. Der folgende Code verwendet Hoisting, um das vorherige Beispiel zu glätten.
console.log('A');
identity('B', step2);
function step2(result2) {
// The program continues here
console.log(result2);
identity('C', step3);
console.log('D');
}
function step3(result3) {
console.log(result3);
}
console.log('E');
Weitere Informationen zu CPS finden Sie in [3].
Im normalen JavaScript-Stil setzen Sie Code-Teile zusammen über
map(), filter() und forEach()for und whileDie Bibliothek Async.js bietet Kombinatoren, mit denen Sie ähnliche Dinge in CPS mit Node.js-Style-Callbacks tun können. Sie wird im folgenden Beispiel verwendet, um den Inhalt von drei Dateien zu laden, deren Namen in einem Array gespeichert sind.
var async = require('async');
var fileNames = [ 'foo.txt', 'bar.txt', 'baz.txt' ];
async.map(fileNames,
function (fileName, callback) {
fs.readFile(fileName, { encoding: 'utf8' }, callback);
},
// Process the result
function (error, textArray) {
if (error) {
console.log(error);
return;
}
console.log('TEXTS:\n' + textArray.join('\n----\n'));
});
Die Verwendung von Callbacks führt zu einem radikal anderen Programmierstil, CPS. Der Hauptvorteil von CPS ist, dass seine grundlegenden Mechanismen leicht zu verstehen sind. Es gibt jedoch auch Nachteile
Callbacks im Node.js-Stil haben drei Nachteile (im Vergleich zu denen in einem funktionalen Stil)
if-Anweisung zur Fehlerbehandlung fügt Ausführlichkeit hinzu.Das nächste Kapitel behandelt Promises und die ES6 Promise API. Promises sind intern komplizierter als Callbacks. Im Gegenzug bringen sie mehrere wesentliche Vorteile und eliminieren die meisten der oben genannten Nachteile von Callbacks.
[1] „Help, I’m stuck in an event-loop“ von Philip Roberts (Video).
[2] „Event loops“ in der HTML-Spezifikation.
[3] „Asynchronous programming and continuation-passing style in JavaScript“ von Axel Rauschmayer.