21 Event Loop und Concurrency-Modell

JavaScript ist eine Single-Thread-Sprache, kann aber dennoch effizient asynchrone Operationen ausführen. Dieses scheinbare Paradoxon wird durch das Event Loop-Modell gelöst, das das Herzstück der Nebenläufigkeit (Concurrency) in JavaScript bildet. In diesem Abschnitt untersuchen wir, wie JavaScript unter der Haube arbeitet und wie asynchrone Operationen koordiniert werden.

21.1 Die Single-Thread-Natur von JavaScript

Im Gegensatz zu Sprachen wie Java oder C#, die mehrere Threads parallel ausführen können, arbeitet JavaScript grundsätzlich mit einem einzigen Ausführungsthread. Das bedeutet:

Dieser Ansatz vereinfacht die Programmierung, da Entwickler sich nicht mit komplexen Problemen der Thread-Synchronisation wie Race Conditions, Deadlocks oder Livelocks beschäftigen müssen. Gleichzeitig stellt er jedoch eine Herausforderung für Operationen dar, die potenziell lange dauern könnten.

21.2 Komponenten des JavaScript-Laufzeitsystems

Um zu verstehen, wie JavaScript asynchrone Operationen trotz Single-Thread-Natur bewältigt, müssen wir die Hauptkomponenten des Laufzeitsystems betrachten:

  1. Call Stack (Aufrufstapel): Verfolgt die aktuell ausgeführten Funktionen
  2. Heap (Speicherhaufen): Speichert Objekte und Variablen
  3. Callback Queue (Warteschlange): Speichert Callback-Funktionen, die ausgeführt werden sollen
  4. Event Loop: Überprüft kontinuierlich den Call Stack und die Callback Queue
  5. Web APIs / Node APIs: Bieten Zugriff auf asynchrone Funktionalitäten außerhalb des JavaScript-Engines

21.3 Der Call Stack

Der Call Stack ist eine Datenstruktur, die den Ausführungskontext von JavaScript-Code verfolgt:

function zweiteEbene() {
  console.log("Zweite Ebene");
}

function ersteEbene() {
  console.log("Erste Ebene - Start");
  zweiteEbene();
  console.log("Erste Ebene - Ende");
}

console.log("Hauptprogramm - Start");
ersteEbene();
console.log("Hauptprogramm - Ende");

Die Ausführung dieses Codes würde folgenden Call Stack-Verlauf erzeugen:

  1. Push: main() (anonymes globales Skript)
  2. Ausführen: console.log("Hauptprogramm - Start")
  3. Push: ersteEbene()
  4. Ausführen: console.log("Erste Ebene - Start")
  5. Push: zweiteEbene()
  6. Ausführen: console.log("Zweite Ebene")
  7. Pop: zweiteEbene()
  8. Ausführen: console.log("Erste Ebene - Ende")
  9. Pop: ersteEbene()
  10. Ausführen: console.log("Hauptprogramm - Ende")
  11. Pop: main()

21.4 Web APIs und Callback Queue

Wenn JavaScript auf asynchrone Operationen trifft (z.B. Timer, Netzwerkanfragen, I/O-Operationen), delegiert es diese an die zugrunde liegende Umgebung (Browser oder Node.js):

console.log("Start");

setTimeout(() => {
  console.log("Timer abgelaufen");
}, 2000);

console.log("Ende");

Die Ausführung dieses Codes:

  1. console.log("Start") wird ausgeführt
  2. setTimeout wird an die Web APIs / Node.js APIs delegiert
  3. console.log("Ende") wird ausgeführt
  4. Nach 2 Sekunden wird die Callback-Funktion in die Callback Queue gestellt
  5. Der Event Loop überprüft, ob der Call Stack leer ist
  6. Die Callback-Funktion wird in den Call Stack verschoben und ausgeführt

Die Ausgabe ist daher:

Start
Ende
Timer abgelaufen

21.5 Der Event Loop: Das Herzstück des Concurrency-Modells

Der Event Loop ist ein fortlaufender Prozess, der kontinuierlich prüft, ob der Call Stack leer ist. Wenn der Stack leer ist, nimmt er die erste Callback-Funktion aus der Queue und legt sie auf den Stack zur Ausführung.

Hier ist eine vereinfachte Darstellung des Event Loop:

while (true) {
  if (callStack.isEmpty() && callbackQueue.isNotEmpty()) {
    // Nimm die erste Callback-Funktion aus der Queue
    const callback = callbackQueue.dequeue();
    // Führe sie im Call Stack aus
    callStack.execute(callback);
  }
}

Der Event Loop stellt sicher, dass: - Der JavaScript-Code niemals unterbrochen wird - Asynchrone Callbacks erst ausgeführt werden, wenn der Call Stack leer ist - Das System reaktionsfähig bleibt, auch wenn asynchrone Operationen im Hintergrund laufen

21.6 Macrotasks und Microtasks

Der JavaScript Event Loop unterscheidet zwischen verschiedenen Arten von asynchronen Aufgaben:

  1. Macrotasks (Tasks): Dazu gehören:
  2. Microtasks: Dazu gehören:

Der wesentliche Unterschied liegt in der Priorität: - Nach jeder Macrotask werden alle anstehenden Microtasks ausgeführt - Erst wenn die Microtask-Queue leer ist, wird die nächste Macrotask verarbeitet

console.log("Start");

setTimeout(() => {
  console.log("Timeout (Macrotask)");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("Promise 1 (Microtask)");
  })
  .then(() => {
    console.log("Promise 2 (Microtask)");
  });

console.log("Ende");

Die Ausgabe ist:

Start
Ende
Promise 1 (Microtask)
Promise 2 (Microtask)
Timeout (Macrotask)

Dies erklärt, warum Promise-Callbacks in der Regel schneller ausgeführt werden als setTimeout-Callbacks, selbst wenn der Timeout auf 0 gesetzt ist.

21.7 Visualisierung des Event Loop

Betrachten wir ein umfassenderes Beispiel:

console.log("Script start");

setTimeout(() => {
  console.log("setTimeout 1");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("Promise 1");
    
    // Mehr asynchrone Operationen innerhalb eines Promise-Callbacks
    setTimeout(() => {
      console.log("setTimeout 2");
    }, 0);
    
    Promise.resolve().then(() => {
      console.log("Promise 2");
    });
  });

console.log("Script end");

Die Ausführungsreihenfolge ist:

  1. console.log("Script start")
  2. setTimeout wird registriert (Macrotask 1)
  3. Promise wird aufgelöst, .then-Callback wird in Microtask-Queue gestellt
  4. console.log("Script end")
  5. Ende der aktuellen Macrotask, Ausführung aller Microtasks
  6. console.log("Promise 1")
  7. setTimeout wird registriert (Macrotask 2)
  8. Nested Promise wird aufgelöst, .then-Callback wird in Microtask-Queue gestellt
  9. console.log("Promise 2")
  10. Alle Microtasks abgeschlossen, Weiter zur nächsten Macrotask
  11. console.log("setTimeout 1")
  12. Keine weiteren Microtasks, Weiter zur nächsten Macrotask
  13. console.log("setTimeout 2")

Die Ausgabe ist daher:

Script start
Script end
Promise 1
Promise 2
setTimeout 1
setTimeout 2

21.8 Blockierende Operationen und deren Auswirkungen

Da JavaScript mit einem einzigen Thread arbeitet, können rechenintensive Operationen oder lang andauernde synchrone Aufgaben das gesamte Programm blockieren:

console.log("Start einer rechenintensiven Operation");

// Eine blockierende Operation
function blockierendeOperation() {
  const start = Date.now();
  // Schleife, die ca. 5 Sekunden läuft
  while (Date.now() - start < 5000) {
    // Nichts tun, nur CPU-Zeit verschwenden
  }
}

blockierendeOperation();

// Diese Ausgabe wird verzögert
console.log("Ende der rechenintensiven Operation");

// Dieser Timer wird erst nach der blockierenden Operation ausgeführt
setTimeout(() => {
  console.log("Timer abgelaufen");
}, 0);

Während der blockierendeOperation() steht die gesamte JavaScript-Ausführung still - keine Event-Handler werden ausgeführt, keine UI-Updates finden statt, und keine asynchronen Callbacks werden verarbeitet.

21.8.1 Strategien zur Vermeidung von Blockierungen

  1. Aufteilen langer Aufgaben: Verwenden von setTimeout mit einer Verzögerung von 0 ms, um den Call Stack zu leeren
function verarbeiteGrosseDaten(daten, callback) {
  const ergebnisse = [];
  const chunkGroesse = 1000;
  let index = 0;
  
  function verarbeiteChunk() {
    const endIndex = Math.min(index + chunkGroesse, daten.length);
    
    // Verarbeite einen Chunk der Daten
    for (let i = index; i < endIndex; i++) {
      ergebnisse.push(daten[i] * 2);
    }
    
    index = endIndex;
    
    if (index < daten.length) {
      // Plane die Verarbeitung des nächsten Chunks für den nächsten Event-Loop-Zyklus
      setTimeout(verarbeiteChunk, 0);
    } else {
      // Alle Chunks wurden verarbeitet, rufe den Callback auf
      callback(ergebnisse);
    }
  }
  
  // Starte die Verarbeitung
  setTimeout(verarbeiteChunk, 0);
}

// Verwendung
const grossesDatenArray = Array(1000000).fill(1).map((_, i) => i);
console.log("Verarbeitung startet");

verarbeiteGrosseDaten(grossesDatenArray, (ergebnis) => {
  console.log("Verarbeitung abgeschlossen, Länge des Ergebnisses:", ergebnis.length);
});

console.log("Hauptprogramm läuft weiter");
  1. Web Workers: Auslagern von CPU-intensiven Aufgaben in separate Threads
// Hauptthread (main.js)
console.log("Hauptthread: Erstelle einen Worker");

const worker = new Worker('worker.js');

worker.onmessage = function(event) {
  console.log("Hauptthread: Ergebnis vom Worker erhalten:", event.data);
};

worker.postMessage({
  aufgabe: 'berechnung',
  daten: Array(1000000).fill(1).map((_, i) => i)
});

console.log("Hauptthread: Weiter mit anderen Aufgaben");

// Worker-Thread (worker.js)
self.onmessage = function(event) {
  console.log("Worker: Nachricht erhalten");
  
  const { aufgabe, daten } = event.data;
  
  if (aufgabe === 'berechnung') {
    console.log("Worker: Starte Berechnung");
    
    // CPU-intensive Berechnung
    const ergebnis = daten.map(x => x * x).reduce((sum, val) => sum + val, 0);
    
    // Sende Ergebnis zurück zum Hauptthread
    self.postMessage(ergebnis);
  }
};

Web Workers ermöglichen echte Parallelität, haben jedoch einige Einschränkungen: - Kein direkter Zugriff auf das DOM - Kein gemeinsamer Speicher mit dem Hauptthread (Daten werden kopiert) - Kommunikation nur über Nachrichtenaustausch

21.9 Der Event Loop in verschiedenen Umgebungen

21.9.1 Browser

In Browsern umfasst der Event Loop zusätzliche Phasen für die Bildschirmaktualisierung:

function animiere() {
  // Ändere den DOM
  element.style.left = (parseInt(element.style.left) || 0) + 1 + 'px';
  
  // Plane den nächsten Frame
  requestAnimationFrame(animiere);
}

// Starte die Animation
requestAnimationFrame(animiere);

requestAnimationFrame ist speziell für Animationen optimiert und wird synchronisiert mit der Bildschirmwiederholrate ausgeführt, typischerweise kurz vor dem nächsten Repaint.

21.9.2 Node.js

Node.js verwendet libuv für seinen Event Loop, der zusätzliche Phasen hat:

  1. Timers: Ausführung von setTimeout und setInterval Callbacks
  2. Pending Callbacks: Ausführung von verschobenen I/O-Callbacks
  3. Idle, Prepare: Interne Verwendung
  4. Poll: Abrufen neuer I/O-Events, Ausführung von I/O-bezogenen Callbacks
  5. Check: Ausführung von setImmediate Callbacks
  6. Close Callbacks: Ausführung von close-Event-Callbacks
const fs = require('fs');

console.log('Start');

// setTimeout (Timer-Phase)
setTimeout(() => {
  console.log('Timeout');
}, 0);

// setImmediate (Check-Phase)
setImmediate(() => {
  console.log('Immediate');
});

// I/O-Operation (Poll-Phase)
fs.readFile(__filename, () => {
  console.log('Datei gelesen');
  
  // Nested Timer und Immediate
  setTimeout(() => {
    console.log('Timeout innerhalb I/O');
  }, 0);
  
  setImmediate(() => {
    console.log('Immediate innerhalb I/O');
  });
});

console.log('Ende');

Die Ausgabereihenfolge kann variieren, aber typischerweise:

Start
Ende
Timeout
Immediate
Datei gelesen
Immediate innerhalb I/O
Timeout innerhalb I/O

Bei verschachtelten I/O-Callbacks wird setImmediate vor setTimeout ausgeführt, weil setImmediate direkt nach der I/O-Callback-Phase (Poll) in der Check-Phase ausgeführt wird, während setTimeout auf den nächsten Event Loop-Durchlauf warten muss.

21.10 process.nextTick() in Node.js

Node.js bietet mit process.nextTick() eine spezielle API, die ähnlich wie Microtasks funktioniert, aber mit höherer Priorität:

console.log('Start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise');
});

process.nextTick(() => {
  console.log('nextTick');
});

console.log('Ende');

Ausgabe:

Start
Ende
nextTick
Promise
setTimeout

process.nextTick() Callbacks werden vor allen anderen asynchronen Callbacks ausgeführt, sogar vor Microtasks wie Promises.

21.11 Praktische Beispiele des Event Loops

21.11.1 Beispiel 1: UI-Aktualisierung und Datenverarbeitung

// Eine große Datenmenge verarbeiten und die UI aktualisieren
function verarbeiteDatenMitUIUpdates(daten) {
  const statusElement = document.getElementById('status');
  const fortschrittElement = document.getElementById('fortschritt');
  const ergebnisElement = document.getElementById('ergebnis');
  
  const gesamtAnzahl = daten.length;
  let verarbeitet = 0;
  const ergebnisse = [];
  
  statusElement.textContent = 'Verarbeitung läuft...';
  
  function verarbeiteChunk() {
    const chunkGroesse = 1000;
    const endIndex = Math.min(verarbeitet + chunkGroesse, gesamtAnzahl);
    
    // Verarbeite einen Chunk
    for (let i = verarbeitet; i < endIndex; i++) {
      ergebnisse.push(daten[i] * 2);
    }
    
    verarbeitet = endIndex;
    
    // UI aktualisieren
    const prozent = Math.round((verarbeitet / gesamtAnzahl) * 100);
    fortschrittElement.textContent = `${prozent}% abgeschlossen`;
    fortschrittElement.style.width = `${prozent}%`;
    
    if (verarbeitet < gesamtAnzahl) {
      // Nächsten Chunk im nächsten Frame verarbeiten
      requestAnimationFrame(verarbeiteChunk);
    } else {
      // Abschluss
      statusElement.textContent = 'Verarbeitung abgeschlossen';
      ergebnisElement.textContent = `Summe der Ergebnisse: ${ergebnisse.reduce((sum, val) => sum + val, 0)}`;
    }
  }
  
  // Starte die Verarbeitung im nächsten Frame
  requestAnimationFrame(verarbeiteChunk);
}

Dieses Beispiel zeigt, wie man eine rechenintensive Aufgabe in kleine Stücke aufteilen kann, um die UI zwischen den Berechnungen zu aktualisieren und ein Einfrieren der Benutzeroberfläche zu vermeiden.

21.11.2 Beispiel 2: Parallele API-Anfragen mit Timeout

// Mehrere API-Anfragen mit Timeout-Schutz
async function ladeMultipleDatenMitTimeout() {
  const apis = [
    'https://api.beispiel.de/benutzer',
    'https://api.beispiel.de/produkte',
    'https://api.beispiel.de/bestellungen'
  ];
  
  // Funktion für Fetch mit Timeout
  function fetchMitTimeout(url, timeout = 5000) {
    return new Promise((resolve, reject) => {
      const controller = new AbortController();
      const { signal } = controller;
      
      // Timeout-Timer setzen
      const timer = setTimeout(() => {
        controller.abort();
        reject(new Error(`Timeout für ${url}`));
      }, timeout);
      
      fetch(url, { signal })
        .then(response => response.json())
        .then(data => {
          clearTimeout(timer);
          resolve(data);
        })
        .catch(error => {
          clearTimeout(timer);
          reject(error);
        });
    });
  }
  
  try {
    // Alle Anfragen parallel starten
    const ergebnisse = await Promise.allSettled(
      apis.map(url => fetchMitTimeout(url))
    );
    
    // Ergebnisse analysieren
    const erfolge = ergebnisse
      .filter(result => result.status === 'fulfilled')
      .map(result => result.value);
      
    const fehler = ergebnisse
      .filter(result => result.status === 'rejected')
      .map(result => result.reason.message);
    
    return {
      erfolge,
      fehler,
      vollstaendig: fehler.length === 0
    };
  } catch (error) {
    console.error('Unerwarteter Fehler:', error);
    throw error;
  }
}

Dieses Beispiel zeigt, wie man mehrere asynchrone Operationen parallel ausführt und dabei Timeouts implementiert, um zu verhindern, dass langsame Anfragen das Gesamtergebnis verzögern.

21.12 Fortgeschrittene Techniken: Priorisierung asynchroner Operationen

JavaScript bietet verschiedene Mechanismen, um asynchrone Operationen mit unterschiedlichen Prioritäten auszuführen:

console.log("1. Synchroner Code");

// Niedrigste Priorität
setTimeout(() => {
  console.log("6. setTimeout - Macrotask");
}, 0);

// Mittlere Priorität (nur in Node.js)
setImmediate(() => {
  console.log("5. setImmediate - spezielle Macrotask (Node.js)");
});

// Höhere Priorität
Promise.resolve().then(() => {
  console.log("3. Promise - Microtask");
});

// Höchste Priorität (nur in Node.js)
process.nextTick(() => {
  console.log("2. nextTick - spezielle Microtask (Node.js)");
});

// Explizite Microtask-Planung
queueMicrotask(() => {
  console.log("4. queueMicrotask - Microtask");
});

console.log("1. Mehr synchroner Code");

Ausgabe in Node.js (ungefähr):

1. Synchroner Code
1. Mehr synchroner Code
2. nextTick - spezielle Microtask (Node.js)
3. Promise - Microtask
4. queueMicrotask - Microtask
5. setImmediate - spezielle Macrotask (Node.js)
6. setTimeout - Macrotask

21.13 Auswirkungen des Event Loop auf Debugging

Das Verständnis des Event Loop ist entscheidend für das Debugging asynchroner JavaScript-Anwendungen:

function fehlerhafte() {
  return Promise.resolve().then(() => {
    throw new Error("Fehler in einem Promise");
  });
}

// Stack-Trace kann irreführend sein
try {
  fehlerhafte();
  console.log("Dieser Code wird ausgeführt");
} catch (fehler) {
  console.error("Dieser Block fängt den Fehler NICHT!", fehler);
}

// Der Fehler erscheint als unbehandelte Promise-Ablehnung

Asynchrone Fehler werden nicht vom umgebenden try-catch-Block erfasst, da sie in einem separaten Call Stack-Frame auftreten. Stattdessen müssen sie mit .catch() oder in einem async/await-Kontext mit try/catch behandelt werden.

21.14 Concurrency und Parallelismus in JavaScript

Obwohl JavaScript selbst single-threaded ist, können moderne JavaScript-Umgebungen echte Parallelität nutzen:

  1. Web Workers: Separate JavaScript-Threads im Browser
  2. Worker Threads: Node.js-Modul für Multi-Threading
  3. Cluster-Modul: Mehrere Node.js-Prozesse für Multi-Core-CPUs
  4. Child Processes: Externe Prozesse in Node.js
// Node.js Worker Threads Beispiel
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
  // Hauptthread-Code
  const worker1 = new Worker(__filename, { workerData: { id: 1 } });
  const worker2 = new Worker(__filename, { workerData: { id: 2 } });
  
  worker1.on('message', (ergebnis) => {
    console.log(`Ergebnis von Worker 1: ${ergebnis}`);
  });
  
  worker2.on('message', (ergebnis) => {
    console.log(`Ergebnis von Worker 2: ${ergebnis}`);
  });
  
  worker1.postMessage({ aufgabe: 'berechnung', wert: 10 });
  worker2.postMessage({ aufgabe: 'berechnung', wert: 20 });
} else {
  // Worker-Thread-Code
  parentPort.on('message', (nachricht) => {
    console.log(`Worker ${workerData.id} hat Aufgabe erhalten:`, nachricht);
    
    if (nachricht.aufgabe === 'berechnung') {
      // CPU-intensive Berechnung
      let ergebnis = 0;
      for (let i = 0; i < 1000000000; i++) {
        ergebnis += nachricht.wert;
      }
      
      // Sende Ergebnis zurück zum Hauptthread
      parentPort.postMessage(ergebnis);
    }
  });
}

Diese Techniken ermöglichen echte Parallelität, wobei jeder Thread oder Prozess seinen eigenen Event Loop hat.

21.15 Das mentale Modell: Effektives Denken über den Event Loop

Um den Event Loop effektiv zu nutzen, ist es hilfreich, folgendes mentales Modell zu verinnerlichen:

  1. Synchroner Code: Wird sofort ausgeführt und kann den Event Loop blockieren
  2. Asynchrone Operationen: Werden an APIs delegiert und ihre Callbacks später ausgeführt
  3. Microtasks: Haben Vorrang vor Macrotasks und werden zwischen Rendering-Updates ausgeführt
  4. Macrotasks: Werden einzeln zwischen Rendering-Updates verarbeitet