Zum Inhalt springen
kamycoding, Startseite

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.

0 von 4 gewählt

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.

Schritt 1 von 9 · Mit den Pfeiltasten links und rechts steuern

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 (.then und den Code nach await) sowie alles, was du an queueMicrotask ü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.

0 von 4 gewählt

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.

Schritt 1 von 9 · Mit den Pfeiltasten links und rechts steuern

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.

0 von 5 gewählt

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.

save-all.tsx
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.

Drei Fake-Speichervorgänge mit 400, 700 und 300 ms

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:

save-all.diff
   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.

Was läuft zuerst: ein setTimeout-Callback oder ein .then-Callback, die gleichzeitig angelegt wurden?
Der Code nach einem await läuft:
Warum friert eine lange Schleife die Seite ein?

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 nach await und queueMicrotask sind Microtasks. Timer und Events sind Tasks.
  • forEach wartet nicht auf async-Callbacks. Nimm for...of oder Promise.all.
  • Der Bildschirm wird zwischen Tasks aktualisiert. Halte Tasks kurz, sonst friert die Seite ein.

Spickzettel

  1. Die Reihenfolge: synchroner Code, dann alle Microtasks, dann der nächste Task.
  2. Microtasks: Promise.then, catch und finally, der Code nach await, queueMicrotask.
  3. Tasks: setTimeout, setInterval und Events wie Klicks.
  4. In Schleifen: for...of wartet auf jedes await, Promise.all führt sie zusammen aus, forEach wartet gar nicht.

Geschrieben von

Kamyar Zamanfar

Full-Stack-Entwickler bei Köln.