Ausgangslage: Synchroner Code. Einfach zu lesen, klarer Programmablauf:
const ergebnis1 = synchroneFunktion1();
const ergebnis2 = synchroneFunktion2();
tueWasMitErgebnis(ergebnis1, ergebnis2);
Problem 1: Node.js ist eventbasiert. Synchroner Code blockiert den Event-Loop, sprich anderer Code kann nicht parallel ablaufen.
Problem 2: Beide Funktionen laufen nacheinander ab, obwohl sie nicht voneinander abhängen.
Abhilfe schafft in Node.js die callback-Konvention:
callbackFunktion1((ergebnis1) => {
callbackFunktion2((ergebnis2) => {
tueWasMitErgebnis(ergebnis1, ergebnis2);
});
});
Problem 1: gelöst.
Problem 2: Funktionen laufen immer noch nacheinander ab.
Problem 3: Code wird langsam unleserlich.
Um Problem 2 zu lösen, kann man die offenen Callbacks zählen, im Callback Zähler reduzieren, am besten noch den Call stack brechen mit setImmediate
, etc...
function nachCallbacks() {
tueWasMitErgebnis(ergebnis1, ergebnis2);
}
let ergebnis1, ergebnis2;
let offeneCallbacks = 0;
offeneCallbacks++;
callbackFunktion1((ergebnis) => {
ergebnis1 = ergebnis;
offeneCallbacks--;
if (offeneCallbacks === 0) nachCallbacks();
});
callbackFunktion2((ergebnis) => {
ergebnis2 = ergebnis;
offeneCallbacks--;
if (offeneCallbacks === 0) nachCallbacks();
});
Problem 1: gelöst.
Problem 2: gelöst.
Problem 3: Wir stecken richtig tief in der Callback-Hölle... 😨
Die Rettung Promises! Diese sind ein Versprechen auf einen Wert, der später kommt. Funktionen geben sofort einen Promise zurück, der später erfüllt wird. Ablaufsteuerung kann durch Verketten von Promises erfolgen, die dann nacheinander ausgeführt werden:
promiseFunktion1().then((ergebnis1) => {
tueWasMitErgebnis1(ergebnis1);
});
Jede then-Funktion gibt wieder einen Promise zurück (explizit oder implizit), der mit der nächsten .then()
-Funktion abgewartet werden kann.
promiseFunktion()
.then((ergebnis1) => {
const zwischenergebnis = tueWasMitErgebnis1(ergebnis1);
return zwischenergebnis;
})
.then((zwischenergebnis) => {
return zwischenergebnis + 1;
})
.then((zwischenergebnis2) => {
// ...
});
Fehler können über Promise rejections kommuniziert und mit .catch(error)
behandelt werden:
promiseFunktion()
.then((ergebnis1) => {
// Fehler werfen:
return Promise.reject(new Error("Warum?"));
})
.catch((fehler) => {
// Fehler behandeln
});
Achtung! Bitte nicht in Callback-Style verfallen. Also nicht so!
promiseFunktion().then((ergebnis1) => {
return promiseFunktion2(ergebnis1).then((zwischenergebnis) => {
return promiseFunktion3(zwischenergebnis).then((endergebnis) => {
// BITTE NICHT!
tueWasMitErgebnis(endergebnis);
});
});
});
Problem 1: gelöst.
Problem 2: wieder offen, wir können nur noch jeweils einen Wert weitergeben. Für mehrere Zwischenergebnisse müssen wir Objekte oder Arrays verwenden. Nicht schön...
Problem 3: besser, aber nicht gelöst.
Wenn die Ergebnisse aber nicht voneinander abhängig sind, können wir die Promises parallel ablaufen lassen:
const promise1 = promiseFunktion1();
const promise2 = promiseFunktion2();
// Beide Promises in ihre Ergebnis umwandeln:
Promise.all([promise1, promise2]).then(([ergebnis1, ergebnis2]) => {
tueWasMitErgebnis(ergebnis1, ergebnis2);
});
Problem 1: gelöst
Problem 2: gelöst
Problem 3: noch besser, aber so richtig schön ist es immer noch nicht.
Die Lösung: async/await
! Unter der Haube arbeiten immer noch Promises, aber wir werden von den ganzen Unschönheiten verschont.
async
-Funktionen geben implizit einen Promise zurück, sehen aber aus wie eine synchrone Funktion:
async function asynchroneFunktion1() {
return 1;
}
const promise1 = asynchroneFunktion1();
// ^--- Der Rückgabewert ist ein Promise, nicht 1!
Fehler, die mit throw
geworfen werden, werden ebenfalls implizit in einen rejected Promise umgewandelt. Dieser kann theoretisch wieder mit .catch()
abgefangen werden:
async function asynchroneFunktion() {
throw new Error("warum");
}
asynchroneFunktion().catch(e => /* Fehler behandeln */);
So haben wir aber noch gar nichts gewonnen, da immer noch die unschönen Promise-Ketten mit all ihren Problemen benötigt werden. Hierfür gibt es await
- dieses kann aber nur innerhalb von async
-Funktionen verwendet werden. Der Vorteil ist jetzt, dass der Code wieder aussieht als wäre er synchron:
async function main() {
const ergebnis1 = await asynchroneFunktion1();
const ergebnis2 = await asynchroneFunktion2();
tueWasMitErgebnis(ergebnis1, ergebnis2);
}
await
wandelt den zurückgegebenen Promise in den Wert um, den dieser (irgendwann später) enthält. Dazu wird die Funktion solange pausiert, bis der Wert bereit steht. Der Ablauf ist also exakt wie synchroner Code, nur dass parallel ablaufender Code nicht blockiert wird.
await
kann für jede Funktion verwendet werden, die einen Promise zurückgibt. Die aufgerufene Funktion muss nicht zwangsläufig async
sein. async
ist nur für die Funktion nötig, in der das await
-keyword verwendet werden soll.
Außerdem kann man wunderschön ein Array abarbeiten:
async function main() {
const werte = [
/* 1 Millionen Einträge */
];
let summe = 0;
for (const wert of werte) {
summe += await berechneWasMitWert(wert);
}
console.log(summe);
}
Fehlerbehandlung geht wie in synchronem Code (try/catch
):
async function main() {
try {
await wirftVielleichtNenFehler();
} catch (e) {
// Fehler behandeln
}
}
Ein Problem haben wir noch: Mehrere unabhängige async-Funktionen parallel ablaufen lassen. Da await
"blockiert", wartet der Code, selbst wenn es unnötig ist. Abhilfe schafft wieder Promise.all()
. Der folgende Code führt alle async-Funktionen parallel aus und speichert alle Ergebnisse in einem Array:
async function main() {
// Ohne `await` werden Promises zurückgegeben und nicht gewartet!
// Die Funktionen werden sozusagen gestartet.
const promise1 = asynchroneFunktion1();
const promise2 = asynchroneFunktion2();
// ... ne for-Schleife würde es auch tun
const promise10 = asynchroneFunktion10();
// Am Ende kann auf alle Funktionen gewartet werden:
const [ergebnis1, ergebnis2 /* ... */, , ergebnis10] = await Promise.all([
promise1,
promise2,
// ...
promise10,
]);
}
Was, wenn wir mit callback-APIs oder timeouts arbeiten? Hier führt leider kein Weg daran vorbei, manuell einen Promise zu erstellen, und zwar so:
function tueEtwasMitCallbackAsync() {
return new Promise((resolve, reject) => {
// ... callback-API nutzen
});
}
Die Funktion tueEtwasMitCallbackAsync
kann dann mit await
konsumiert werden.
resolve
wird aufgerufen, wenn der Callback erfolgreich zurückgekommen ist, ob mit Wert oder ohne:
function tueEtwasMitCallbackAsync() {
return new Promise((resolve) => {
// ^ reject ist optional
// ... callback-API nutzen
// ein Wert kam zurück:
resolve(wert);
// oder: kein Wert kam zurück:
resolve();
});
}
reject
ist optional und wird aufgerufen, wenn der Callback einen Fehler hatte:
function tueEtwasMitCallbackAsync() {
return new Promise((resolve, reject) => {
// ... callback-API nutzen
reject(/* Fehler */);
});
}
das könnte dann etwa so aussehen:
function tueEtwasMitCallbackAsync() {
return new Promise((resolve, reject) => {
callbackAPIAufrufen(1, 2, 3, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
}
Beim Implementieren darauf achten, dass nur der erste Aufruf von resolve
oder reject
etwas tut:
function funktion1() {
return new Promise((resolve, reject) => {
resolve(1);
resolve();
reject(new Error("nope"));
});
}
async function main() {
const result = await funktion1();
// ^-- result ist 1!
}
Es ist fast nie sinnvoll, Promise-Syntax mit await
zu mischen. So ist nicht sofort klar, was in welcher Reihenfolge abläuft, daher für eine Konvention entscheiden! Im schlimmsten Fall vergisst man noch das await
vor der Promise-Kette und wundert sich später über seltsamen Programmablauf.
// FALSCH:
const ergebnis = await methode1()
.then(zwischenergebnis => zwischenergebnis + 1)
.catch(e => /* Fehler behandeln */);
// RICHTIG:
try {
const ergebnis = await methode1() + 1
} catch (e) {
// Fehler behandlen
}
Versuchen, in den Ablauf innerhalb des Promise-Konstruktors einzugreifen (ist unnötig!)
// FALSCH
function funktion1() {
return new Promise((resolve, reject) => {
callbackFn1((err, result) => {
if (err) {
if (reject) reject(err);
reject = null;
resolve = null;
} else {
if (resolve) resolve(result);
reject = null;
resolve = null;
}
});
callbackFn2((result) => {
if (resolve) resolve(result);
reject = null;
resolve = null;
});
});
}
// RICHTIG -> der erste Aufruf von resolve oder reject gewinnt!
function funktion1() {
return new Promise((resolve, reject) => {
callbackFn1((err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
callbackFn2((result) => {
resolve(result);
});
});
}
Top erklärt. 😃
vor await / async hab ich auch gerne sowas gemacht:
das kam await schon recht nahe (ich mag diese .then() Verschachtelung ähnlich wenig wie callback Verschachtelung). Geht lustig nur schief, wenn man das promise = promise.then mal vergisst. flöt
Und ja, mehrere calls parallel mit callbacks sind die Hölle... 😃