Softwareentwicklung · Interaktiv ·
Der JavaScript-Event-Loop: Sag die Ausgabe voraus
Rate, was dieser Code loggt, und geh dann Schritt für Schritt durch Call Stack und Queues. Rätsel, ein Live-Stepper, ein echter Async-Bug und eine Freeze-Demo.
Diesen Artikel anhören
9 Abschnitte, je ein Clip
Inhalt
Kurz gesagt
JavaScript führt immer nur eine Sache auf einmal aus. Die Reihenfolge deiner Logs hängt vom Call Stack, der Microtask-Queue und der Task-Queue ab, und genau diese Reihenfolge erklärt viele echte Bugs.
Ein Spinner, der lügt
Ein typischer Bug. Stell dir ein Formular mit einem Button „Alle speichern“ vor. Du klickst. Der Spinner blinkt kurz auf, dann erscheint die Erfolgsmeldung. Gespeichert ist aber noch nichts. Drei Requests sind noch unterwegs.
Der Code nach der Schleife hat nicht auf sie gewartet. Er lief zuerst. Die nächste Ansicht liest die Daten, und die Daten sind nicht da.
Um zu verstehen, warum, spielen wir ein bisschen. Jedes Rätsel zeigt Code. Klick die Konsolenzeilen in der Reihenfolge an, die du erwartest, und prüf dann. Erst aus dem Bauch heraus. Ein falscher Tipp bringt dir mehr als ein richtiger.
Rätsel 1: Was loggt das?
JavaScript
console.log('1')setTimeout(() => console.log('2'), 0)Promise.resolve().then(() => console.log('3'))console.log('4')Klick die Konsolenzeilen in der Reihenfolge an, in der du sie erwartest.
Was ist da passiert?
Geh es Schritt für Schritt durch. Drück auf Weiter oder nutz die Pfeiltasten und sieh dir an, wie sich die Listen ändern.
Code
console.log('1')setTimeout(() => console.log('2'), 0)Promise.resolve().then(() => console.log('3'))console.log('4')Call Stack
- leer
Microtask-Queue
- leer
Task-Queue
- leer
Konsole
- leer
Noch ist nichts gelaufen. JavaScript beginnt in der ersten Zeile und führt immer nur eine Sache aus.
Drei Dinge sind wichtig:
- Der Call Stack enthält die Funktion, die gerade läuft. JavaScript führt immer nur eine Sache aus, und eine laufende Funktion wird nie unterbrochen.
- Die Microtask-Queue enthält Promise-Callbacks (
.thenund den Code nachawait) sowie alles, was du anqueueMicrotaskübergibst. - Die Task-Queue enthält Callbacks wie Timer und Events.
Hier ist die Regel. Wenn der aktuelle Code fertig und der Call Stack leer ist, führt der Browser alle Microtasks aus. Erst danach nimmt er den nächsten Task.
Darum verliert setTimeout(fn, 0) gegen .then. Nicht weil es langsam ist, sondern weil es in einer anderen Queue wartet.
Rätsel 2: async und await
Gleiches Spiel, jetzt mit async und await. Vorsicht: Der Funktionsrumpf läuft nicht am Stück.
JavaScript
async function load() {console.log('A')await nullconsole.log('B')}console.log('C')load()console.log('D')Klick die Konsolenzeilen in der Reihenfolge an, in der du sie erwartest.
Code
async function load() { console.log('A') await null console.log('B')}console.log('C')load()console.log('D')Call Stack
- leer
Microtask-Queue
- leer
Task-Queue
- leer
Konsole
- leer
Noch ist nichts gelaufen. Die Funktionsdeklaration loggt nichts, der erste Aufruf steht in Zeile 6.
Eine async-Funktion läuft bis zum ersten await wie eine normale Funktion. Am await pausiert sie, und der Aufrufer macht weiter. Der Rest der Funktion wird als Microtask eingeplant. Deshalb kommt B zuletzt.
Merk dir diesen Satz: Der Code nach await ist ein Microtask.
Rätsel 3: Microtasks, die Microtasks anlegen
Noch eins. Achte auf die Reihenfolge der Microtasks und darauf, was mit dem passiert, der in einem anderen angelegt wird.
JavaScript
setTimeout(() => console.log('timeout'), 0)queueMicrotask(() => console.log('micro 1'))Promise.resolve().then(() => {console.log('micro 2')queueMicrotask(() => console.log('micro 3'))})console.log('sync')Klick die Konsolenzeilen in der Reihenfolge an, in der du sie erwartest.
Der Browser nimmt den nächsten Task erst, wenn die Microtask-Queue leer ist. Das gilt auch für Microtasks, die andere Microtasks angelegt haben.
Das hat einen Preis. Ein Microtask, der ständig neue Microtasks anlegt, sorgt dafür, dass die Queue nie leer wird. Der Browser kommt nie zum nächsten Task und kann den Bildschirm nicht aktualisieren. Die Seite friert ein, und im Code steht nirgends eine lange Schleife.
Zurück zum Spinner
Hier ist der Bug vom Anfang.
async function saveAll(items: Item[]) {
setSaving(true)
items.forEach(async item => {
await save(item)
})
setSaving(false)
showToast('Saved')
}forEach ruft deinen async-Callback für jedes Element auf. Jeder Aufruf läuft bis zum ersten await und gibt ein Promise zurück. forEach schaut sich diese Promises nicht an. Es wartet nicht auf sie. Die Zeilen danach laufen also sofort.
Probier es auf drei Arten aus. Die Speichervorgänge sind fake: Timer mit 400, 700 und 300 ms. Die Zahlen werden auf deinem Rechner gemessen, ein paar Millisekunden Rauschen sind normal.
items.forEach(async item => { await save(item) })
Spinner aus: – · Alles gespeichert: –
for (const item of items) { await save(item) }
Spinner aus: – · Alles gespeichert: –
await Promise.all(items.map(save))
Spinner aus: – · Alles gespeichert: –
forEach schaltet den Spinner nach etwa 0 ms aus, während die Speichervorgänge noch laufen. for...of mit await wartet auf jeden Vorgang nacheinander und braucht deshalb etwa 1400 ms. Promise.all startet alle drei gleichzeitig und braucht ungefähr so lange wie der langsamste, etwa 700 ms.
Zwei Wege zur Lösung:
setSaving(true)
- items.forEach(async item => {
- await save(item)
- })
+ // Fix A: one after the other
+ for (const item of items) {
+ await save(item)
+ }
+
+ // Fix B: all at once (use A or B, not both)
+ await Promise.all(items.map(item => save(item)))
setSaving(false)
showToast('Saved')Nimm for...of, wenn die Reihenfolge zählt oder ein Vorgang das Ergebnis des vorherigen braucht. Nimm Promise.all, wenn die Vorgänge unabhängig sind. Das ist schneller. Beachte, dass es abgelehnt wird, sobald ein Vorgang fehlschlägt.
Den Main Thread spüren
Der Zähler unten zählt Animations-Frames. Er hängt an requestAnimationFrame, das deinen Callback vor dem nächsten Repaint ausführt. Solange er tickt, lebt die Seite.
Gezeichnete Frames: 0
Drück den ersten Button. Eine lange Schleife ist ein langer Task. Der Browser kann den Bildschirm nur zwischen zwei Tasks aktualisieren, also friert der Zähler ein.
Drück den zweiten Button. Es ist dieselbe Arbeit, nur in kleine Häppchen geschnitten. Nach jedem Häppchen gibt der Code den Thread mit scheduler.yield() zurück, oder mit setTimeout(fn, 0), wenn der Browser das nicht kennt. Zwischen den Häppchen kann der Browser zeichnen, und der Zähler läuft weiter.
Tipp: Halte Tasks kurz
Ein Bildschirm mit 60 Hz braucht etwa alle 16 ms ein neues Frame. Dauert ein Task länger, kann die Seite nicht rechtzeitig aktualisieren. Teil die Arbeit in Häppchen auf oder verlagere sie in einen Web Worker.
Achtung: setTimeout(fn, 0) ist nicht sofort
Der Callback wartet trotzdem auf den aktuellen Code und auf alle Microtasks. Danach wartet er, bis er in der Task-Queue an der Reihe ist. Die Verzögerung ist das, worum du bittest, kein Versprechen: Die echte Verzögerung kann länger sein.
Prüf dich selbst
Drei Fragen. Ohne Punkte, aber mit einer Erklärung zu jeder.
Das Wichtigste
- JavaScript führt immer nur eine Sache auf einmal aus. Eine laufende Funktion wird nie unterbrochen.
- Nach dem aktuellen Code führt der Browser alle Microtasks aus, bevor er den nächsten Task nimmt.
.then, der Code nachawaitundqueueMicrotasksind Microtasks. Timer und Events sind Tasks.forEachwartet nicht auf async-Callbacks. Nimmfor...ofoderPromise.all.- Der Bildschirm wird zwischen Tasks aktualisiert. Halte Tasks kurz, sonst friert die Seite ein.
Spickzettel
- Die Reihenfolge: synchroner Code, dann alle Microtasks, dann der nächste Task.
- Microtasks:
Promise.then,catchundfinally, der Code nachawait,queueMicrotask. - Tasks:
setTimeout,setIntervalund Events wie Klicks. - In Schleifen:
for...ofwartet auf jedesawait,Promise.allführt sie zusammen aus,forEachwartet gar nicht.