Event Loop in Node.js: Funktionsmechanismus und praktische Beispiele
1. Überblick
Der Event Loop ist der Kernmechanismus, der es Node.js ermöglicht, Tausende von Anfragen gleichzeitig zu bearbeiten, während nur ein einziger JavaScript-Thread verwendet wird. Viele von uns schreiben täglich async/await-, setTimeout- und Promise-Code, aber sind sich nicht sicher, wie die tatsächliche Ausführungsreihenfolge abläuft.
Dieser Artikel erklärt die Struktur des Event Loops durch bestimmte Phasen, unterscheidet zwischen Microtask und Macrotask und veranschaulicht dies mit funktionierendem Code. Nach dem Lesen können Sie die Ausführungsreihenfolge komplexer asynchroner Code-Segmente genau vorhersagen und häufige Fehler wie das Blockieren des Event Loops vermeiden.
2. Wie Node.js single-threaded läuft
Node.js führt JavaScript auf einem einzigen Hauptthread (main thread) aus, aber I/O-Vorgänge (Dateilesevorgänge, Netzwerkaufrufe, Datenbankabfragen) werden an libuv delegiert – eine C-Bibliothek, die asynchrone I/O durch Thread-Pool oder Kernel-Mechanismen (epoll, kqueue…) verarbeitet.
┌───────────────────────────┐
│ JavaScript (ein Thread) │
└──────────────┬────────────┘
│ fs.readFile, setTimeout, net aufrufen...
▼
┌───────────────────────────┐
│ libuv (C/C++) │
│ - Thread pool │
│ - Event loop │
│ - OS async APIs │
└───────────────────────────┘
Wenn ein I/O-Vorgang abgeschlossen ist, platziert libuv den Callback in die entsprechende Warteschlange. Der Event Loop prüft diese Warteschlangen kontinuierlich und führt Callbacks aus, wenn sie dran sind – das ist genau die “Schleife”.
Hinweis: Ihr JavaScript-Code wird immer auf einem Thread ausgeführt. Wenn Sie Code mit hohem Rechenaufwand schreiben (z.B. große Schleife zur Berechnung), wird dieser den gesamten Event Loop blockieren und alle anderen Anfragen müssen warten.
3. Die Phasen des Event Loops
Der Event Loop führt nicht einfach eine Schleife aus, sondern durchläuft bei jeder Iteration (Tick) 6 Phasen in einer festen Reihenfolge:
┌───────────────────────┐
┌─>│ timers │ setTimeout, setInterval
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ ausstehende Callbacks │ I/O Callback verzögert
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ idle, prepare │
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ poll │ I/O events (Datei lesen, Netzwerk)
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ check │ setImmediate
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
└──┤ Callbacks schließen │ socket.on('close')
└─────────────────────────┘
3.1. Kurzbeschreibung jeder Phase
- timers: Führt Callbacks von
setTimeout/setIntervalaus, deren Zeit abgelaufen ist. - ausstehende Callbacks: Führt I/O-Callbacks aus, die vom vorherigen Zyklus verzögert wurden.
- poll: Wartet auf und verarbeitet neue I/O-Events (Datei gelesen, Netzwerk-Response erhalten…). Dies ist die zeitintensivste Phase.
- check: Führt Callbacks von
setImmediateaus. - Callbacks schließen: Behandelt Schließ-Events, z.B.
socket.on('close', ...).
Jede Phase hat eine eigene Warteschlange (Queue). Der Event Loop verarbeitet alle (oder bis zu einem Limit) Callbacks in der Warteschlange der aktuellen Phase und wechselt dann zur nächsten Phase.
4. Microtask Queue: Promise und process.nextTick
Zusätzlich zu den 6 oben genannten Macrotask-Phasen hat Node.js auch eine Microtask-Queue, die zwischen jeder Phase verarbeitet wird – Sie müssen nicht auf eine vollständige Loop-Iteration warten.
Es gibt zwei Arten von Microtasks:
process.nextTick()– eigene Warteschlange, wird immer vor allen anderen Microtasks verarbeitet.Promise.then/catch/finally– Standard-Microtask-Warteschlange (nach ECMAScript-Spezifikation).
console.log('1 - sync start');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - promise'));
process.nextTick(() => console.log('4 - nextTick'));
console.log('5 - sync end');
Erklärung:
- Die
console.log-Anweisungen am Anfang laufen sofort aus (1,5). setTimeoutschiebt den Callback in die timers-Warteschlange – wird in der nächsten Phase ausgeführt.process.nextTickundPromise.thensind beide Microtasks, abernextTickhat höhere Priorität.- Nach der Ausführung des synchronen Codes leert Node.js die
nextTick-Queue, dann diePromise-Microtask-Queue, bevor die timers-Phase beginnt.
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout
Hinweis: Wenn Sie
process.nextTick()rekursiv hintereinander aufrufen (z.B. einen Callback aufrufen, der wiedernextTickaufruft), wird der Event Loop niemals zur nächsten Phase übergehen – dies wird “I/O starvation” genannt.
5. setTimeout vs setImmediate: Welche Reihenfolge ist richtig?
Dies ist die verwirrendste Frage. Die Reihenfolge zwischen setTimeout(fn, 0) und setImmediate(fn) ist nicht festgelegt, wenn sie auf Top-Level aufgerufen werden, aber ist festgelegt, wenn sie in einem I/O-Callback aufgerufen werden.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
Da dieser Code im Callback von fs.readFile läuft – also in der poll-Phase – wechselt der Event Loop nach Beendigung von poll immer zur check-Phase (setImmediate), bevor die neue Schleife zur timers-Phase zurückkehrt.
immediate
timeout
Wenn Sie die beiden Befehle dagegen auf Top-Level (außerhalb von I/O-Callbacks) platzieren, hängt die Reihenfolge von der Leistung des Node.js-Prozesses zum Zeitpunkt des Starts ab – verlassen Sie sich nicht auf diese Reihenfolge in Production-Code.
6. Event Loop blockieren: Der häufigste Fehler
Da nur ein Thread JavaScript verarbeitet, blockiert jeder synchrone Code, der lange läuft, den gesamten Server.
const express = require('express');
const app = express();
app.get('/fibonacci', (req, res) => {
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
res.send(`Result: ${fib(40)}`); // blockiert!
});
app.listen(3000);
Wenn eine Anfrage /fibonacci aufruft, läuft fib(40) synchron und dauert mehrere Sekunden. In dieser Zeit müssen alle anderen Anfragen (selbst einfaches Ping) warten, da der Event Loop in einer Task steckt.
6.1. Lösung: Heavy Lifting zu Worker Threads auslagern
const { Worker } = require('worker_threads');
app.get('/fibonacci', (req, res) => {
const worker = new Worker('./fib-worker.js', {
workerData: { n: 40 }
});
worker.on('message', (result) => res.send(`Result: ${result}`));
worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
Worker wird auf einem separaten Thread ausgeführt und verbraucht nicht den Main Thread. Der Event Loop für die Anfrage bleibt frei, um andere Anfragen zu bearbeiten, während der Worker rechnet.
Wann Sie KEINE Worker Threads benötigen: für I/O-Vorgänge (DB lesen, API aufrufen, Datei lesen) – diese sind bereits asynchron und benötigen keine Worker. Worker Threads sind nur hilfreich für CPU-gebundene Aufgaben (schwere Berechnungen, Verschlüsselung, Bildverarbeitung…).
7. Event-Loop-Verzögerung mit perf_hooks messen
Um Event-Loop-Blockaden in der Production-Umgebung zu erkennen, können Sie das eingebaute Modul perf_hooks verwenden.
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log(`Event loop delay (mean): ${histogram.mean / 1e6} ms`);
histogram.reset();
}, 5000);
monitorEventLoopDelay erstellt ein Histogram, das misst, wie lange der Event Loop “verzögert” ist, im Vergleich zum idealen Zeitplan. Wenn der Wert mean unerwartet ansteigt (z.B. über 100ms), ist dies ein Zeichen dafür, dass irgendwo Code blockiert.
8. Test schreiben, um die Ausführungsreihenfolge zu überprüfen
Um sicherzustellen, dass Sie die Reihenfolge von Microtask/Macrotask richtig verstanden haben, können Sie einen Test schreiben, der Logs in einem Array erfasst und dann die Reihenfolge überprüft.
// order.js
function recordOrder(log) {
log.push('sync-start');
setTimeout(() => log.push('timeout'), 0);
Promise.resolve().then(() => log.push('promise'));
process.nextTick(() => log.push('next-tick'));
log.push('sync-end');
}
module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');
test('Ausführungsreihenfolge: sync -> nextTick -> promise -> timeout', (done) => {
const log = [];
recordOrder(log);
// Synchroner Code wird sofort ausgeführt
expect(log).toEqual(['sync-start', 'sync-end']);
setImmediate(() => {
expect(log).toEqual([
'sync-start',
'sync-end',
'next-tick',
'promise',
'timeout',
]);
done();
});
});
Dieser Test verwendet Jest mit setImmediate, um zu warten, bis alle Microtasks und die timers-Phase abgeschlossen sind, und überprüft dann die endgültige Reihenfolge von log. Dies ist eine genaue Möglichkeit, das Verständnis des Event Loops zu überprüfen, anstatt nur Logs mit bloßem Auge zu lesen.
npx jest order.test.js
PASS ./order.test.js
✓ Ausführungsreihenfolge: sync -> nextTick -> promise -> timeout (5 ms)
9. Häufig auftretende Fehler
- Missbrauch von
process.nextTickrekursiv: verursacht I/O starvation, Server kann neue Anfragen nicht verarbeiten. - Annahme, dass
setTimeout(fn, 0)sofort läuft: In Wirklichkeit wartet es immer mindestens einen Tick des Event Loops und wartet nach allen Microtasks. - CPU-gebundener Code auf dem Main Thread ausführen: z.B. riesige JSON parsen, bcrypt mit hohem Cost verschlüsseln, Bilder synchron verarbeiten – alles sollte zu Worker Threads oder Subprozessen ausgelagert werden.
- Annahme der Reihenfolge
setTimeoutvssetImmediateauf Top-Level: nicht zuverlässig, vermeiden Sie, Logik darauf zu schreiben.
Wann Sie vorsichtig sein sollten: Wenn Ihre Anwendung Endpunkte hat, die große Datenmengen verarbeiten (Excel exportieren, Statistiken berechnen, Bilder verkleinern), überprüfen Sie regelmäßig
monitorEventLoopDelay, um Probleme früh zu erkennen, bevor der gesamte Server beeinträchtigt wird.
10. Fazit
Der Event Loop ist der Grund, warum Node.js eine große Anzahl von Verbindungen mit geringen Ressourcen verarbeiten kann, aber es gibt auch Einschränkungen: Jeder JavaScript-Code wird auf einem einzigen Thread ausgeführt. Das genaue Verständnis der 6 Phasen (timers, ausstehende Callbacks, poll, check, close) und der Position der Microtask-Queue (process.nextTick, Promise) hilft Ihnen, die Ausführungsreihenfolge vorherzusagen und schwer zu debuggende Fehler zu vermeiden.
Wenn Sie auf CPU-gebundene Aufgaben stoßen, zögern Sie nicht, worker_threads zu verwenden oder es in einen separaten Prozess auszulagern – der Event Loop ist nur stark, wenn Sie ihn nicht ersticken.