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.
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.
Um zu verstehen, wie JavaScript asynchrone Operationen trotz Single-Thread-Natur bewältigt, müssen wir die Hauptkomponenten des Laufzeitsystems betrachten:
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:
main() (anonymes globales Skript)console.log("Hauptprogramm - Start")ersteEbene()console.log("Erste Ebene - Start")zweiteEbene()console.log("Zweite Ebene")zweiteEbene()console.log("Erste Ebene - Ende")ersteEbene()console.log("Hauptprogramm - Ende")main()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:
console.log("Start") wird ausgeführtsetTimeout wird an die Web APIs / Node.js APIs
delegiertconsole.log("Ende") wird ausgeführtDie Ausgabe ist daher:
Start
Ende
Timer abgelaufen
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
Der JavaScript Event Loop unterscheidet zwischen verschiedenen Arten von asynchronen Aufgaben:
setTimeout, setInterval.then(), .catch(),
.finally())queueMicrotask()MutationObserver-CallbacksDer 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.
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:
console.log("Script start")setTimeout wird registriert (Macrotask 1).then-Callback wird in
Microtask-Queue gestelltconsole.log("Script end")console.log("Promise 1")setTimeout wird registriert (Macrotask 2).then-Callback wird in
Microtask-Queue gestelltconsole.log("Promise 2")console.log("setTimeout 1")console.log("setTimeout 2")Die Ausgabe ist daher:
Script start
Script end
Promise 1
Promise 2
setTimeout 1
setTimeout 2
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.
setTimeout mit einer Verzögerung von 0 ms, um den Call
Stack zu leerenfunction 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");// 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
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.
Node.js verwendet libuv für seinen Event Loop, der zusätzliche Phasen hat:
setTimeout und
setInterval CallbackssetImmediate
Callbacksclose-Event-Callbacksconst 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.
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.
// 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.
// 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.
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
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-AblehnungAsynchrone 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.
Obwohl JavaScript selbst single-threaded ist, können moderne JavaScript-Umgebungen echte Parallelität nutzen:
// 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.
Um den Event Loop effektiv zu nutzen, ist es hilfreich, folgendes mentales Modell zu verinnerlichen: