Obwohl das Schreiben von Produktionscode in Sprachen wie C++ und C ein tiefes Verständnis erfordert, kann JavaScript oft schon mit grundlegenden Kenntnissen der Sprachmöglichkeiten verwendet werden.
Konzepte wie das Übergeben von Rückruffunktionen oder das Schreiben von asynchronem Code sind häufig recht einfach umzusetzen, weshalb viele JavaScript-Entwickler sich weniger damit beschäftigen, was im Hintergrund abläuft. Sie ignorieren oft die Komplexität, die ihnen durch die Sprache abstrahiert wird.
Für JavaScript-Entwickler wird es jedoch immer wichtiger zu verstehen, was wirklich unter der Oberfläche geschieht und wie diese abstrahierten Komplexitäten funktionieren. Dieses Wissen ermöglicht fundiertere Entscheidungen, die die Leistung des Codes erheblich steigern können.
Dieser Artikel beleuchtet eines der wichtigsten, aber oft missverstandenen Konzepte in JavaScript: die EREIGNISSCHLEIFE!
Asynchroner Code ist in JavaScript unvermeidlich. Aber was bedeutet asynchrone Ausführung eigentlich? Die Antwort liegt in der Ereignisschleife.
Bevor wir die Funktionsweise der Ereignisschleife verstehen können, müssen wir zunächst die Grundlagen von JavaScript selbst klären!
Was ist JavaScript?
Lassen Sie uns einen Schritt zurück zu den Basics machen: Was ist JavaScript eigentlich? Eine Definition könnte lauten:
JavaScript ist eine interpretierte, Single-Threaded, nicht-blockierende, asynchrone und nebenläufige High-Level-Programmiersprache.
Eine formale Definition, nicht wahr? 🤔
Lassen Sie uns die Begriffe aufschlüsseln!
Die Schlüsselwörter, die für diesen Artikel relevant sind, sind: Single-Threaded, Non-Blocking, Concurrent und Asynchronous.
Single-Threaded
Ein Ausführungs-Thread ist die kleinste Einheit von Anweisungen, die unabhängig vom Scheduler verwaltet werden kann. Eine Single-Threaded-Programmiersprache kann nur eine Aufgabe oder Operation zu einem Zeitpunkt ausführen. Dies bedeutet, dass ein Prozess von Anfang bis Ende ohne Unterbrechung oder Stopp des Threads durchläuft.
Im Gegensatz dazu können Multithreading-Sprachen mehrere Prozesse gleichzeitig auf verschiedenen Threads ausführen, ohne sich gegenseitig zu blockieren.
Wie kann JavaScript gleichzeitig Single-Threaded und nicht-blockierend sein?
Was bedeutet eigentlich „blockieren“?
Nicht-blockierend
Eine allgemeingültige Definition für „blockieren“ gibt es nicht. Es bedeutet im Grunde, dass etwas langsam auf dem Thread ausgeführt wird. Nicht-blockierend bedeutet demnach, dass Aktionen schnell auf dem Thread ablaufen.
Aber Moment mal, ich habe gesagt, JavaScript läuft in einem einzigen Thread? Und dann, dass es nicht blockierend ist, was bedeutet, dass Aufgaben schnell im Aufrufstapel erledigt werden? Wie geht das? Was ist mit Timern oder Schleifen?
Keine Sorge, wir finden es gleich heraus 😉.
Nebenläufig
Nebenläufigkeit bedeutet, dass Code von mehr als einem Thread gleichzeitig ausgeführt wird.
Okay, jetzt wird es verwirrend. Wie kann JavaScript Single-Threaded und trotzdem nebenläufig sein, also Code mit mehr als einem Thread ausführen?
Asynchron
Asynchrone Programmierung bedeutet, dass Code in einer Ereignisschleife ausgeführt wird. Bei einer blockierenden Operation wird ein Ereignis ausgelöst. Der blockierende Code wird ohne Blockierung des Hauptausführungsthreads ausgeführt. Sobald der blockierende Code abgeschlossen ist, wird das Ergebnis in eine Warteschlange gestellt und zum Aufrufstapel zurückgeschoben.
Aber JavaScript hat doch nur einen Thread? Wer führt dann den blockierenden Code aus, während andere Codes im Thread laufen?
Fassen wir das Gesagte zusammen:
- JavaScript ist Single-Threaded.
- JavaScript ist nicht-blockierend, d.h. langsame Prozesse blockieren die Ausführung nicht.
- JavaScript ist nebenläufig, d.h. Code wird mit mehr als einem Thread gleichzeitig ausgeführt.
- JavaScript ist asynchron, d.h. blockierender Code wird an anderer Stelle ausgeführt.
Das alles scheint nicht ganz zusammenzupassen. Wie kann eine Single-Threaded-Sprache nicht-blockierend, nebenläufig und asynchron sein?
Gehen wir tiefer und betrachten die JavaScript-Laufzeitumgebungen, insbesondere V8. Vielleicht gibt es dort versteckte Threads, von denen wir nichts wissen.
V8-Engine
Die V8-Engine ist eine leistungsstarke Open-Source-WebAssembly-Laufzeitumgebung für JavaScript, die von Google in C++ geschrieben wurde. Die meisten Browser verwenden die V8-Engine, und auch die populäre Node.js-Laufzeitumgebung nutzt sie.
Einfach gesagt, V8 ist ein C++-Programm, das JavaScript-Code entgegennimmt, kompiliert und ausführt.
V8 erledigt zwei wichtige Dinge:
- Heap-Speicherverwaltung
- Ausführungskontext des Aufrufstapels
Leider ist unser Verdacht falsch. V8 hat nur einen Call-Stack. Stellen Sie sich den Call-Stack als den Thread vor.
Ein Thread === ein Call-Stack === eine Ausführung gleichzeitig.
Bild – Hacker Mittag
Wenn V8 nur einen Aufrufstapel hat, wie kann JavaScript nebenläufig und asynchron laufen, ohne den Hauptausführungsthread zu blockieren?
Versuchen wir, es anhand eines einfachen asynchronen Codebeispiels zu analysieren.
JavaScript führt Code Zeile für Zeile nacheinander aus (Single-Threaded). Wie erwartet wird die erste Zeile in der Konsole ausgegeben. Aber warum wird die letzte Zeile vor dem Timeout-Code ausgegeben? Warum wartet der Ausführungsprozess nicht auf den Timeout-Code (Blockierung), bevor er mit der Ausführung der letzten Zeile fortfährt?
Offensichtlich hat uns ein anderer Thread bei der Ausführung des Timeouts geholfen, da wir ziemlich sicher sind, dass ein Thread zu einem Zeitpunkt nur eine Aufgabe ausführen kann.
Werfen wir einen kurzen Blick in den V8-Quellcode.
Was??!!! Es gibt keine Timer-Funktionen in V8, kein DOM, keine Ereignisse, kein AJAX?… Yeeeesss!!!
Ereignisse, DOM, Timer usw. gehören nicht zur Kernimplementierung von JavaScript. JavaScript folgt den EcmaScript-Spezifikationen und die verschiedenen Versionen werden oft nach den EcmaScript-Spezifikationen (ES X) bezeichnet.
Ausführungs-Workflow
Ereignisse, Timer und AJAX-Anforderungen werden clientseitig von den Browsern bereitgestellt und oft als Web-APIs bezeichnet. Sie ermöglichen es Single-Threaded-JavaScript, nicht-blockierend, nebenläufig und asynchron zu sein! Aber wie?
Der Ausführungs-Workflow eines JavaScript-Programms besteht aus drei Hauptabschnitten: dem Call-Stack, der Web-API und der Aufgabenwarteschlange.
Der Call-Stack
Ein Stack ist eine Datenstruktur, bei der das zuletzt hinzugefügte Element als erstes entfernt wird. Es ist wie ein Plattenstapel, bei dem die oberste Platte zuerst entfernt wird. Ein Call-Stack ist einfach eine solche Datenstruktur, in der Aufgaben oder Code ausgeführt werden.
Betrachten wir das folgende Beispiel:
Quelle – https://youtu.be/8aGhZQkoFbQ
Wenn Sie die Funktion printSquare()
aufrufen, wird sie auf den Call-Stack gelegt. printSquare()
ruft die Funktion square()
auf, die ebenfalls auf den Stack gelegt wird und die Funktion multiply()
aufruft. multiply()
wird auf den Stack gelegt. Da multiply()
das Letzte ist, was auf den Stack gelegt wurde, wird sie zuerst aufgelöst und vom Stack entfernt, gefolgt von square()
und dann printSquare()
.
Die Web-API
Hier wird Code ausgeführt, der nicht von der V8-Engine verarbeitet wird, um den Hauptausführungs-Thread nicht zu „blockieren“. Wenn der Call-Stack auf eine Web-API-Funktion trifft, wird der Prozess an die Web-API übergeben, wo er ausgeführt wird, und gibt den Call-Stack frei, damit andere Operationen durchgeführt werden können.
Kehren wir zu unserem setTimeout
-Beispiel zurück.
Wenn wir den Code ausführen, wird die erste console.log
-Zeile auf den Stack gelegt und wir erhalten die Ausgabe fast sofort. Beim Timeout werden Timer vom Browser verarbeitet und sind nicht Teil der Kernimplementierung von V8. Sie werden stattdessen an die Web-API übergeben, wodurch der Stack freigegeben wird, damit er andere Operationen ausführen kann.
Während der Timeout noch läuft, fährt der Stack mit der nächsten Aktion fort und führt die letzte console.log
-Anweisung aus, was erklärt, warum wir diese vor der Ausgabe des Timers sehen. Sobald der Timer abgelaufen ist, geschieht etwas. Das console.log
innerhalb des Timers taucht auf magische Weise wieder im Call-Stack auf!
Wie?
Die Ereignisschleife
Bevor wir uns mit der Ereignisschleife beschäftigen, betrachten wir die Funktion der Aufgabenwarteschlange.
Zurück zu unserem Timeout-Beispiel: Sobald die Web-API die Ausführung einer Aufgabe beendet hat, überträgt sie sie nicht einfach automatisch zurück zum Call-Stack. Sie geht in die Aufgabenwarteschlange.
Eine Warteschlange ist eine Datenstruktur, die nach dem First-In-First-Out-Prinzip arbeitet. Aufgaben, die in die Warteschlange geschoben werden, kommen in der gleichen Reihenfolge wieder heraus. Aufgaben, die von den Web-APIs ausgeführt und in die Aufgabenwarteschlange verschoben werden, kehren zum Aufrufstapel zurück, um ihr Ergebnis auszugeben.
Aber halt, was ist diese verdammte Ereignisschleife?
Quelle – https://youtu.be/8aGhZQkoFbQ
Die Ereignisschleife ist ein Prozess, der darauf wartet, dass der Call-Stack geleert wird, bevor Rückrufe von der Aufgabenwarteschlange zum Call-Stack verschoben werden. Sobald der Stack leer ist, wird die Ereignisschleife ausgelöst und die Aufgabenwarteschlange auf verfügbare Rückrufe geprüft. Wenn welche vorhanden sind, schiebt sie diese zum Call-Stack, wartet darauf, dass der Call-Stack wieder leer ist, und wiederholt denselben Vorgang.
Quelle – https://www.quora.com/How-does-an-event-loop-work/answer/Timothy-Maxwell
Das obige Diagramm zeigt den grundlegenden Ablauf zwischen der Ereignisschleife und der Aufgabenwarteschlange.
Fazit
Obwohl dies eine sehr grundlegende Einführung ist, bietet das Konzept der asynchronen Programmierung in JavaScript genügend Einblicke, um klar zu verstehen, was im Hintergrund vor sich geht und wie JavaScript mit nur einem einzigen Thread gleichzeitig und asynchron ausgeführt werden kann.
JavaScript ist ein stetig wachsendes Feld, und wenn Sie neugierig sind, empfehle ich Ihnen, diesen Udemy-Kurs zu besuchen.