Single Thread von JavaScript gegen den Mehrprozess-Engine des Browsers
Erfahren Sie, warum JavaScript in einem Thread ausgeführt wird, während Browser über das Event-Loop-Prinzip gleichzeitig Netzwerkverbindungen, Rendering und Timer verarbeiten.
JavaScript läuft auf einem einzigen Thread.
Sie haben diesen Satz wahrscheinlich schon unzählige Male gelesen.
Doch wenn Sie eine moderne Webseite laden, können Sie gleichzeitig all das beobachten:
Eine Netzwerkanfrage wird gesendet.
Ein Bild wird heruntergeladen und decodiert.
Eine CSS-Animation läuft reibungslos ab.
Die Seite reagiert sofort auf einen Klick.
Inhalt wird auf den Bildschirm gezeichnet.
Und Ihr JavaScript-Code läuft die ganze Zeit weiter.
Was geschieht also wirklich im Hintergrund?
Falls JavaScript nur einen Thread hat, wer kümmert sich dann um alles andere?
Die Beantwortung dieser Frage enthüllt eine der wichtigsten Erkenntnisse darüber, wie Browser tatsächlich funktionieren:
JavaScript und der Browser sind nicht dasselbe.
JavaScript verfügt über einen Hauptthread
Wenn Entwickler JavaScript als eingleisig beschreiben, beziehen sie sich dabei konkret auf die Art und Weise, wie JavaScript-Code ausgeführt wird.
Ihr Code läuft auf einem einzigen Haupt-JavaScript-Thread.
Nehmen Sie diesen Codeausschnitt als Beispiel:
console.log("One");
console.log("Two");
console.log("Three");
Jede Zeile wird streng nach der vorherigen ausgeführt.
JavaScript wird diese drei Anweisungen nicht zur gleichen Zeit auf demselben Thread ausführen.
Es gibt nur einen Aufrufstapel, mit dem gearbeitet werden kann.
Zu jedem Zeitpunkt wird nur ein Teil des JavaScript-Codes ausgeführt.
Das ist im Kontext das Wesen von eingleisig sein.
Aber hier wird es etwas komplizierter.
Der Browser selbst ist für weitaus mehr verantwortlich als nur für die Ausführung Ihrer Skripte.
Der Browser hat mehr Aufgaben als nur die Ausführung von JavaScript
Ein Browser ist ein weitaus größeres System als allein der JavaScript-Engine.
Er muss Aufgaben wie folgende verwalten:
- Daten über das Netzwerk abrufen
- Timer planen
- Eingaben von Tastatur und Zeiger erfassen
- Frames auf dem Bildschirm anzeigen
- Bilder entschlüsseln
- Klang abspielen
- Videoabspielung verwalten
- Daten auf der Festplatte speichern
- Seitenlayout berechnen
- Pixel während des Zeichnens darstellen
- Ebenen bei der Komposition kombinieren
- Mehrere weitere Aufgaben, die vom Browser und Betriebssystem erledigt werden
Ihr JavaScript-Code ist nicht der, der all das direkt ausführt.
Stattdessen kann JavaScript die Arbeit an den Browser delegieren, damit dieser sich um die Details kümmert.
Zum Beispiel:
fetch("/api/users");
Ihr Skript startet die Anfrage.
Aber Ihr JavaScript öffnet nicht manuell einen Socket oder überträgt einzelne Bytes direkt vom Server.
Diese Verantwortung liegt beim Browser sowie den darunterliegenden Systemen.
Sobald die Antwort zurückkommt, plant der Browser den Ausführungstermin für Ihre Callback-Funktion oder die Fortsetzung des Promises auf dem JavaScript-Thread.
Dies unterscheidet sich stark von der Vorstellung, dass:
"JavaScript kümmert sich um absolut alles."
Betrachten Sie JavaScript als einen Arbeiter innerhalb eines viel größeren Systems
Bilden Sie sich ein Restaurant als mentales Modell vor.
JavaScript ist dabei ein einzelner Server, der Bestellungen entgegennimmt.
Der Browser repräsentiert den gesamten Betrieb des Restaurants.
Viele weitere Arbeiter sind für verschiedene Aufgaben im Hintergrund verantwortlich.
JavaScript könnte sagen:
"Ich brauche diese Daten vom Server."
Zu diesem Zeitpunkt übernimmt der Browser die Netzwerkaufgabe.
JavaScript muss nicht abwarten, bis jedes Byte eingetroffen ist.
Es kann sofort mit anderen Aufgaben fortfahren.
Sobald die Operation abgeschlossen ist und von JavaScript verarbeitet werden kann, wird die entsprechende Aufgabe in der Warteschlange für den JavaScript-Thread platziert.
Das ist die Grundlage dafür, wie asynchrone Browser-APIs funktionieren.
Was passiert also mit fetch()?
Nehmen wir folgendes Beispiel:
console.log("Start");
fetch("/api/users")
.then(() => {
console.log("Users received");
});
console.log("End");
Anfänger stellen sich oft etwas in dieser Art vor:
Start
↓
fetch()
↓
wait for server
↓
Users received
↓
End
Aber das ist kein genaues Bild dessen, was tatsächlich geschieht.
Die Ausführung von JavaScript pausiert nicht bei der Aufrufung von fetch(), um auf die Antwort zu warten.
Ein genauereres Bild sieht so aus:
JavaScript
│
├── Start fetch
│
▼
Browser handles network work
│
│
└───────────────┐
│
JavaScript │
continues │
│
▼ │
console.log("End") │
│
▼
Response becomes available
│
▼
Promise continuation
gets scheduled
│
▼
JavaScript runs it
Angesichts dieses Ablaufs sieht die Konsoleingabe in der Regel so aus:
Start
End
Users received
Der entscheidende Punkt hier ist nicht nur, dass fetch() asynchron arbeitet.
Am wichtigsten ist wer tatsächlich wartet.
Der JavaScript-Thread wird während des Wartens auf das Netzwerk niemals blockiert.
Der Event-Loop verbindet die Komponenten
Genau hier kommt der Event-Loop ins Spiel.
Man kann sich die JavaScript-Umgebung als einen Ort vorstellen, an dem Code ausgeführt wird, zusammen mit Koordinierungsmechanismen, die entscheiden, wann asynchrone Aufgaben wieder aufgenommen werden dürfen.
Eine vereinfachte Version dieses Systems sieht so aus:
Browser
│
┌──────────┼───────────┐
│ │ │
Network Timers User Input
│ │ │
└──────────┼───────────┘
│
▼
Scheduling queues
│
▼
Event Loop
│
▼
Call Stack
│
▼
JavaScript
Denken Sie daran, dass dieses Diagramm eine Vereinfachung ist.
Die tatsächlichen Interna eines Browsers sind erheblich komplexer, und verschiedene Engines implementieren diese Mechanismen auf ihre eigene Weise.
Trotzdem fasst es die grundlegende Idee zusammen:
Das Ausführen von JavaScript ist nur ein Teil eines viel größeren Systems.
Timer führen Ihren Code nicht heimlich im Hintergrund aus
Nehmen wir folgendes Beispiel:
setTimeout(() => {
console.log("Done");
}, 1000);
Eine verlockende Art, sich das vorzustellen, ist:
„JavaScript startet einen separaten Thread, der eine Sekunde herunterzählt.“
Dieses mentale Modell führt Sie in die Irre.
Es ist der Browser und nicht JavaScript selbst, der das Timer-Verhalten umsetzt. Sobald die angegebene Verzögerung vorbei ist, wird der Callback als möglicher Ausführungspunkt in Betracht gezogen. Erst dann führt JavaScript ihn tatsächlich aus – und nur, wenn der Hauptthread frei ist, um ihn zu bearbeiten.
Das bedeutet, Code wie dieser ist eine schlechte Idee:
setTimeout(() => {
console.log("Done");
}, 1000);
while (true) {}
Warum führt das zu Problemen?
Sobald die Verzögerung vorbei ist, wird der Callback als bereit zum Ausführen markiert, doch der Hauptthread steckt in einer endlosen Schleife fest und wird nie verfügbar. Der Callback hat keine Möglichkeit, sich Zutritt zu verschaffen und das derzeit ausgeführte JavaScript zu unterbrechen.
Der Browser kann durchaus wissen, dass:
„Der Timer ist abgelaufen.“
Doch dieses Wissen reicht nicht aus – JavaScript benötigt immer noch eine Gelegenheit, tatsächlich ausgeführt zu werden:
Main JavaScript thread
while (true) {
// never finishes
}
↓
Timer becomes ready
↓
Callback waits
↓
JavaScript never becomes available
Das ist einer der grundlegenden Unterschiede, die man sich einprägen sollte.
Assynchron zu sein bedeutet nicht, dass Ihr Callback auf einem anderen Thread ausgeführt wird.
Warum ist die Seite dann nicht ständig eingefroren?
Weil die Ausführung von JavaScript nur eine von vielen Aufgaben ist, mit denen sich der Browser zu jedem Zeitpunkt beschäftigt.
Doch es gibt einen Haken, den man erwähnen sollte.
Vieler Arbeit, die darüber entscheidet, wie reaktiv Ihre Seite wirkt – einschließlich der Ausführung Ihres JavaScripts und bestimmter Schritte im Rendering-Prozess – findet auf derselben Hauptthread statt.
Deshalb blockiert Code wie dieser die Seite weiterhin:
const start = performance.now();
while (performance.now() - start < 5000) {
// expensive work
}
Über eine volle Fünfsekundenzeit ist der Hauptthread mit der Ausführung dieser Schleife beschäftigt. In der Zwischenzeit müssen alle Interaktionsverarbeitungen oder Rendering-Schritte, die ebenfalls den Hauptthread benötigen, warten.
Genau diese Situation meinen die Leute, wenn sie sagen:
„JavaScript ist single-threaded.“
Konkurrenz auf Browser-Ebene, Single-Threading auf JavaScript-Ebene
Das ist die entscheidende Unterscheidung, die man im Hinterkopf behalten muss.
Ein Browser als Ganzes kann verschiedene Arbeiten über mehrere Threads verteilen. Die Details unterscheiden sich von einem Browser und Betriebssystem zum anderen, doch im Grunde sind moderne Browser so konzipiert, dass sie viele Aufgaben gleichzeitig verarbeiten können.
Im Groben könnte die Verteilung der Arbeit wie folgt aussehen:
Browser
│
├── JavaScript execution
├── Network activity
├── Rendering-related work
├── Image/media processing
├── Browser services
└── Other internal tasks
Doch nichts davon bietet eine Möglichkeit, etwas wie Folgendes zu schreiben:
runThisFunctionOnAnotherBrowserThread();
und dafür zu sorgen, dass beliebiger JavaScript-Code auf einen anderen Thread wechselt.
Ihr regulärer JavaScript-Code läuft weiterhin nach dem bisherigen eindimensionalen Ausführungsmodell.
Falls Sie gezielt aufwändige Berechnungen im JavaScript vom Hauptthread wegverlegen müssen, sind dafür Web Workers gedacht.
Fazit
Eindimensionales JavaScript bedeutet nicht, dass der Browser als Ganzes auf einen einzigen Thread beschränkt ist.
Ihr Code wird in einem einzigen Hauptthread ausgeführt, während der Browser selbst durch seine eigenen internen Mechanismen eine Vielzahl weiterer Aufgaben übernimmt.
Aufgaben wie Netzwerkaufrufe, Timer, Verarbeitung von Benutzereingaben, Rendering sowie Dekodierung von Medien können alle unabhängig von der Ausführung Ihres JavaScripts stattfinden.
Sobald eine dieser Hintergrundaufgaben weitergeführt werden kann, ordnet der Browser den entsprechenden Callback oder die Fortsetzung der Promise an, damit Ihr JavaScript darauf reagieren kann.
Trotzdem trägt der Hauptthread weiterhin eine große Last.
Lange laufende JavaScript-Programme in diesem Thread verzögern alles andere, das Ressourcen beansprucht – Interaktionen, Rendering sowie weitere ausstehende Aufgaben ebenso.
Daher haben wir einen insgesamt recht koncurrenten Browser, der jedoch eine strikt eingleisige JavaScript-Ausführung beibehält.
Das Verständnis dieser Trennung erklärt sowohl, wozu JavaScript in Browsern fähig ist, als auch wo ihre Grenzen liegen.
Kernpunkte
Bemerken Sie sich diese drei Punkte gut:
- JavaScript selbst ist eingleisig. Ihr Code wird sequenziell, Schritt für Schritt, auf der Hauptthread ausgeführt.
- Der Browser ist ein größeres, paralleles Umfeld. Neben der Ausführung Ihres JavaScripts verwaltet er gleichzeitig Netzwerkverbindungen, Timer, Rendering, Eingaben und Medienverarbeitung.
- Asynchron bedeutet nicht eine mehrthreadige Ausführung Ihres Callbacks. Der Browser übernimmt das Warten und gibt die Kontrolle an JavaScript zurück, sobald der Hauptthread wieder frei ist.
Eine hilfreiche Art, es zu formulieren:
Browser
│
├── JavaScript execution
├── Network activity
├── Timers
├── User input
├── Rendering
├── Media processing
└── Other browser systems
JavaScript ist nur ein Bestandteil, der innerhalb dieses größeren Systems arbeitet – nicht das System selbst.
Sobald diese Grundlagen vorhanden sind, werden Konzepte wie der Event-Loop, asynchrone APIs, das Einfrieren der Benutzeroberfläche sowie die Konkurrenz auf Browser-Ebene viel leichter verständlich.
Verwandte Artikel
- Wie process.nextTick() den Node.js Event-Loop stillschweigend lahmlegt – Erklärt, warum rekursive Aufrufe von process.nextTick() die Poll-Phase von libuv vollständig blockieren und wie man das Problem mit setImmediate() behebt.
- Verständnis von JavaScript-Closures und dem Event-Loop – Erfahren Sie, wie Closures externe Variablen speichern und wie der Event-Loop den Call Stack, Microtasks sowie Macrotasks mithilfe praktischer Codebeispiele ordnet.