Startseite / Artikel

Wie JavaScript Ihren Code ausführt: Stack, Heap, Event Loop und Generatoren

Eine geführte Tour durch den JavaScript-Laufzeitumgebung: Aufrufstapel und -rahmen, Heap-Referenzen, der Node.js-Ereigniszyklus, das DOM, Callbacks, Promises, async/await und Generatoren.

Verlag: REACTAPP.TOP13 Min. Lesezeit2982 Wörter

Quellen: JavaScript

Asynchrone JavaScript-Programme verhalten sich oft auf Weise, die Entwickler überraschen: Eine Logzeile erscheint „zu früh“, ein await scheint voranzuspringen, oder ein Generator gibt nur die Hälfte seiner Nachrichten aus. Fast jede dieser Überraschungen verschwindet, sobald man ein klares Modell davon hat, wie der Laufzeitumgebung Code ausführt. Dieser Artikel konstruiert dieses Modell Schritt für Schritt – von dem Aufrufstapel und dem Heap über den Node.js-Eventloop und das DOM bis hin zu Promises, async/await und Generatoren – damit Sie die Ausgabe jedes Codeabschnitts bereits vor dem Lesen vorhersagen können.

Die Bestandteile der Laufzeitumgebung

JavaScript läuft auf einem einzigen Hauptthread, doch Programme warten auf Timer, Netzwerkantworten und Dateien, ohne einzufrieren – denn der Engine arbeitet nicht allein:

  • der Aufrufstapel, der die derzeit ausgeführten Funktionen verfolgt
  • der Heap, in dem Objekte und andere dynamisch allokierte Daten gespeichert sind
  • Host-APIs (Web-APIs in Browsern, Node.js-APIs auf dem Server), die Timer, Dateien und Netzwerkverbindungen verarbeiten
  • Callback-(Task-)Warteschlangen, in denen abgeschlossene Aufgaben darauf warten, ausgeführt zu werden
  • die Microtask-Warteschlange, die hauptsächlich von Promises verwendet wird
  • der Event-Loop, der entscheidet, was als Nächstes ausgeführt wird
  • Ihr Code wird auf dem Aufrufstapel ausgeführt, während die Laufzeit langsame Operationen verarbeitet. Sobald eine abgeschlossen ist, wird ihr Callback in die Warteschlange eingereiht, und der Event-Loop setzt ihn auf den Stapel, sobald dieser frei ist.

    Ein Überblick über den Node.js-Event-Loop

    Das Diagramm zeigt den Node.js-Zyklus. Zuerst wird der synchroner Code ausgeführt, anschließend die in der Warteschlange befindlichen Microtasks, danach die Phasen des Loops: Timer (setTimeout, setInterval), ausstehende Callbacks, Polling (wo die meisten Datei- und Netzwerk-Callbacks ausgeführt werden), Check (setImmediate) sowie Abschluss-Callbacks.

    ┌───────────────────┐
    │    CALL STACK     │
    │    JavaScript     │
    └─────────┬─────────┘
              │
              │ Synchronous code finishes
              ▼
    ┌───────────────────┐
    │  MICROTASK QUEUE  │
    │                   │
    │  Promise.then()   │
    │  Promise.catch()  │
    │  queueMicrotask() │
    └─────────┬─────────┘
              │
              ▼
    ╔═════════════════════════════════════════════╗
    ║                EVENT LOOP                   ║
    ║                                             ║
    ║  ┌───────────────┐                          ║
    ║  │     TIMERS    │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │    PENDING    │                          ║
    ║  │    CALLBACKS  │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     POLL      │                          ║
    ║  │      I/O      │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     CHECK     │                          ║
    ║  │  setImmediate │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     CLOSE     │                          ║
    ║  │    CALLBACKS  │                          ║
    ║  └───────┬───────┘                          ║
    ║          └───────────────────────────────┐  ║
    ║                                          │  ║
    ╚══════════════════════════════════════════╪══╝
                                               │
                                               ▼
                                      ┌───────────────────┐
                                      │    CALL STACK     │
                                      │    Execute JS     │
                                      └───────────────────┘
    

    Zwei Aspekte sind wichtig. Microtasks werden nicht nur einmal pro Schleife verarbeitet: Node leert die Microtask-Queue (sowie zuvor die process.nextTick-Queue) nach jedem Callback, sodass der .then-Handler einer Promise vor dem nächsten Timer-Callback ausgeführt wird. Zudem folgen Browser zwar dem Prinzip von Tasks und Microtasks, verfügen aber weder über setImmediate noch über Libuv-Phasen, und sie mischen die Rendering-Aufgaben miteinander. Zu den spezifischen Node-Eigenschaften siehe wie libuv, der Event-Loop und der Thread-Pool zusammenarbeiten.

    Der Aufrufstapel

    Der Aufrufstapel protokolliert, welche Funktion gerade ausgeführt wird und welche darauf warten, dass sie zurückkehrt. Er ist eine LIFO-Struktur – letztes Eingang, erstes Ausgang: Die kürzlich aufgerufene Funktion muss abgeschlossen sein, bevor die darunterliegende fortfahren kann.

    In dem folgenden Beispiel ruft func1 func2 auf, welches wiederum func3 aufruft, das eine Nachricht protokolliert:

    function func1() {
      func2();
    }
    function func2() {
      func3();
    }
    function func3() {
      console.log("Hello");
    }
    func1();
    

    Die Aufrufe stapeln sich in dieser Reihenfolge auf:

    func1()
       ↓
    func2()
       ↓
    func3()
       ↓
    console.log()
    

    Wie ein Stapel Teller wird console.log zuletzt aufgerufen und zuerst ausgeführt, anschließend geben func3, func2 und func1 nacheinander zurück. Wartende Callbacks können nur dann ausgeführt werden, wenn der Stack leer ist – deshalb blockiert ein langer synchroner Loop alles andere.

    Der Heap

    Der Heap ist Speicher für Daten, deren Lebensdauer nicht an einen einzelnen Aufruf gebunden ist, wie zum Beispiel Objekte, Arrays und Closures.

    Durch das Erstellen eines Objektliterals wird es dort zugewiesen:

    const user = {
      name: "Rayane",
      age: 20
    };
    

    Die Variable enthält lediglich einen Verweis auf das im Heap gespeicherte Objekt:

    CALL STACK                  HEAP
    ┌───────────────┐          ┌────────────────────┐
    │ user variable │─────────▶│ {                  │
    │               │          │   name: "Rayane",  │
    └───────────────┘          │   age: 20          │
                               │ }                  │
                               └────────────────────┘
    

    Durch die Zuweisung von user an eine andere Variable wird lediglich die Referenz kopiert, nicht das Objekt selbst, sodass Änderungen in einer Variante auch in der anderen sichtbar sind. Sobald keine erreichbare Referenz mehr auf ein Objekt verweist, kann der Garbage Collector es freigeben; eine langlebige Schließung, die ein Objekt festhält, sorgt dafür, dass es weiterhin aktiv bleibt.

    Was der Event-Loop koordiniert

    Der Event-Loop verbindet Ihren Code mit der Fertigstellung asynchroner Aufgaben. In Node.js führt V8 Ihren Code aus; ein Aufruf einer Node-API, wie zum Beispiel das Lesen einer Datei, wird an libuv weitergeleitet, welches ihn wiederum an das Betriebssystem oder dessen Thread-Pool übergeben wird. Wenn die Aufgabe abgeschlossen ist, steht der Callback zur Verfügung und der Event-Loop führt ihn schließlich auf dem Stack aus.

    JavaScript code
          │
          ▼
       V8 Engine
          │
          ▼
      Call Stack
          │
          ▼
    Node.js APIs
          │
          ▼
        libuv
          │
          ├───────────────┐
          ▼               ▼
         OS           Thread Pool
          │               │
          └───────┬───────┘
                  ▼
         Asynchronous operation
                  │
                  ▼
         Callback becomes ready
                  │
                  ▼
             Event Loop
                  │
                  ▼
           Callback Queue
                  │
                  ▼
             Call Stack
    

    Die Route hängt von der Operation ab: Sockets werden in der Regel vom Betriebssystem über epoll oder kqueue verarbeitet, während Dateisystemaufgaben sowie einige Verschlüsselungs- und DNS-Aufrufe den Thread-Pool nutzen.

    Stack-Frame

    Jeder Aufruf fügt einen Stack-Frame hinzu, der enthält, was die Funktion während ihrer Ausführung benötigt:

    • ihre Parameter
    • ihre lokalen Variablen
    • die Rückgabeadresse, also der Ort, an dem die Ausführung im Aufrufer fortgesetzt wird
    • Weitere Verwaltungsinformationen des Engines

    Nehmen wir eine Funktion, die zwei Zahlen addiert:

    function add(a, b) {
      const result = a + b;
      return result;
    }
    add(2, 3);
    

    Während add(2, 3) ausgeführt wird, enthält sein Frame die Argumente, das lokale Ergebnis sowie einen Verweis auf den Aufrufer:

         CALL STACK
    ┌─────────────────────┐
    │   stack frame: add  │
    │                     │
    │   a = 2             │
    │   b = 3             │
    │   result = 5        │
    │   return → caller   │
    └─────────────────────┘
    

    Sobald die Funktion zurückgegeben hat, wird der Frame verworfen:

         CALL STACK
    ┌─────────────────────┐
    │   frame removed     │
    └─────────────────────┘
    

    Der gesamte Lebenszyklus ist kompakt:

    Function call
          ↓
    Stack Frame created
          ↓
    Function executes
          ↓
    Function returns
          ↓
    Stack Frame removed
    

    Rekursion, die zu tief geht, stapelt Frame an, bis der Engine aufgrund des Überschreitens der maximalen Stackgröße einen RangeError auslöst.

    Das DOM

    Das Document Object Model ist der laufende Knotenbaum des Browsers, der aus einem HTML-Dokument erstellt wird. Über dieses Modell kann JavaScript:

    • Elemente lesen
    • ihren Inhalt ändern
    • neue Elemente einfügen
    • bestehende entfernen
    • Stile und Attribute modifizieren

    Das DOM ist eine Browser-API und kein Bestandteil der Sprache, weshalb document in reinem Node.js nicht existiert.

    Hier ist ein einzelner Überschriftsabschnitt in HTML:

    <h1 id="title">Hello</h1>
    

    Ein Skript kann über die ID auf dieses Element verweisen:

    const title = document.getElementById("title");
    

    und anschließend seinen Text ersetzen:

    title.textContent = "Hello Rayane!";
    

    Vor der Änderung enthält der Kind-Textknoten des Elements den ursprünglichen String:

    document
       │
       ▼
    <h1 id="title">
       │
       ▼
    "Hello"
    

    Nach Ausführung derselben Zuweisung:

    title.textContent = "Hello Rayane!";
    

    spiegelt der Knotenbaum den neuen Text wider:

    document
       │
       ▼
    <h1 id="title">
       │
       ▼
    "Hello Rayane!"
    

    Dann wird vom Browser neu gezeichnet. In echtem Code sollte man prüfen, ob getElementById bei fehlendem Match null zurückgibt; außerdem sollte man für unzuverlässige Zeichenketten textContent statt innerHTML verwenden, da letzteres niemals HTML parsen wird.

    Callbacks

    Ein Callback ist eine Funktion, die an eine andere Funktion übergeben wird, damit sie später aufgerufen werden kann. In diesem Beispiel wird greet an execute übergeben, welches sie mit einem Namen aufruft:

    function greet(name) {
      console.log(`Hello ${name}`);
    }
    function execute(callback) {
      callback("Ajami");
    }
    execute(greet);
    

    Das Programm gibt aus:

    Hello Ajami
    

    In execute bezieht sich der Parameter mit dem Namen callback auf greet, und diese Zeile ruft sie auf:

    callback("Ajami");
    

    Ein Callback ist nicht per se asynchron; dieser läuft sofort aus. Er wird asynchron, wenn der Empfänger ihn nach einem Ereignis, wie zum Beispiel dem Ablauf eines Timers, auslöst.

    Promises

    Eine Promise repräsentiert das endgültige Ergebnis einer asynchronen Operation. Sie starten die Operation, führen weiteren Code aus und handeln das Ergebnis ab, sobald sich die Promise geklärt hat.

    fetch ist eine typische API, die eine Promise zurückgibt:

    const promise = fetch("https://api.example.com/data");
    

    Man reagiert auf das Ergebnis, indem man Handler hinzufügt:

    • .then() für ein erfolgreiches Ergebnis
    • .catch() bei einem Fehler
    • .finally() für Aufräumarbeiten, die in jedem Fall ausgeführt werden
    fetch("https://api.example.com/data")
      .then((response) => {
        console.log("Request finished");
      })
      .catch((error) => {
        console.log("Something went wrong:", error.message);
      })
      .finally(() => {
        console.log("Request completed");
      });
    

    Das Starten der Anfrage blockiert nicht; die Handler werden später als Microtasks ausgeführt. Beachten Sie, dass fetch nur bei Netzwerkfehlern abgelehnt wird. Ein 404- oder 500-Fehler führt dennoch zur Erfüllung der Promise, daher müssen Sie selbst response.ok überprüfen.

    Promise-Zustände

    Eine Promise befindet sich stets in einem von drei Zuständen:

    PENDING
       │
       ├───────────────┐
       ▼               ▼
    FULFILLED       REJECTED
    
    • Ausstehend: Die Operation ist noch nicht abgeschlossen.
    • Erfüllt: Sie wurde erfolgreich abgeschlossen und weist einen Wert auf.
    • Ablehnt: Sie ist fehlgeschlagen und weist einen Grund auf, in der Regel einen Fehler.

    Eine Promise wird genau einmal abgeschlossen, und später hinzugefügte Handler erhalten den bereits abgeschlossenen Ergebniswert.

    Async/await

    async-Funktion gibt immer eine Promise zurück, und await ermöglicht es, asynchrone Schritte sequenziell auszuführen. Diese Funktion protokolliert etwas, wartet auf eine Anfrage und protokolliert erneut, wobei nach dem Aufruf ein weiteres Protokolleintrag hinzukommt:

    async function leetfinish() {
      console.log("The function starts");
    const res = await fetch("https://api.example.com/finish");
      console.log("The request finished");
    }
    leetfinish();
    console.log("Let's review our code");
    

    Ein möglicher Ausgabeinhalt ist:

    The function starts
    Let's review our code
    The request finished
    

    Warum das äußere Protokoll erst zweitens ausgeführt wird

    Die Funktion gibt ihre erste Meldung aus und erreicht dann diese Zeile:

    const res = await fetch("https://api.example.com/finish");
    

    fetch gibt ein ausstehendes Promise zurück, und await pausiert die Funktion und gibt die Kontrolle an den Aufrufer zurück, ohne den Thread einzufrieren. Der Aufrufer gibt seine Nachricht aus; erst nach Erhalt der Antwort setzt die Funktion ihre Ausführung fort. Daher:

    The function starts
    Let's review our code
    The request finished
    

    await pausiert die Funktion, nicht den Thread. Und „wahrscheinlich“ ist wichtig: Wenn der Anfrage fehlschlägt, wird die letzte Zeile niemals ausgegeben und der nicht gefangene Fehler wird zu einer unverarbeiteten Ablehnung.

    Fehler mit try, catch und finally verarbeiten

    Mit async/await verwendet man die üblichen try/catch/finally-Strukturen. Dieses Beispiel lädt Daten herunter, überprüft die Antwort sowie den Budgetwert und setzt stets ein Ladeflag zurück. Die Kommentare weisen auf zwei Punkte hin: HTTP-Fehler führen nicht zu einer Ablehnung, und response.json() gibt ebenfalls ein Promise zurück.

    let isLoading = true;
    async function checkBudget() {
      try {
        // fetch() returns a Promise.
        // await pauses this function until the Promise settles.
        const response = await fetch(
          "https://api.example.com/khouribga/business/budget"
        );
        // fetch() does NOT reject automatically for HTTP errors
        // such as 404 or 500.
        if (!response.ok) {
          throw new Error("Failed to fetch business data");
        }
        // response.json() also returns a Promise.
        const business = await response.json();
        if (business.budget < 10) {
          throw new Error("Budget is too low");
        }
        console.log("Budget is sufficient:", business.budget);
      } catch (error) {
        console.log("Something went wrong:", error.message);
      } finally {
        // This runs whether the request succeeds or fails.
        isLoading = false;
        console.log("Loading finished:", isLoading);
      }
    }
    checkBudget();
    

    Schritt für Schritt:

    1. fetch() sendet die Anfrage und await pausiert die Funktion.
    2. Sobald die Antwort eintrifft, setzt die Funktion ihre Ausführung fort.
    3. Ein fehlgeschlagener HTTP-Status wird in einen ausgeworfenen Fehler umgewandelt.
    4. Der JSON-Inhalt wird analysiert und der Budgetwert überprüft.
    5. Jeder Fehler innerhalb von try, einschließlich Netzwerkproblemen, gelangt in catch.
    6. finally wird immer ausgeführt und löscht den Ladezustand.

    Übung: Asynchrone Funktionen parallel ausführen

    Eine gute Übung ist das LeetCode-Problem 2721, bei dem Sie mehrere Operationen gleichzeitig starten sollen anstelle darauf zu warten, dass jede einzeln abgeschlossen ist – im Grunde genommen schreiben Sie Ihre eigene Promise.all-Funktion.

    Generatoren

    Eine Generatorfunktion kann pausiert und wieder fortgesetzt werden, wodurch im Laufe der Zeit Werte erzeugt werden. Sie wird mit function* deklariert, wobei Pausepunkte durch yield markiert werden:

    function* generatorAjami() {
      // Instructions ...
      yield i;
      // More instructions ...
      yield i;
      // More instructions ...
    }
    

    Vergleichen Sie yield mit return:

    • return gibt einen Wert aus und beendet die Funktion.
    • yield gibt einen Wert aus und pausiert die Funktion, wobei ihr Zustand beibehalten wird.

    Durchlaufen eines Generators

    Dieser Generator gibt vor jeder der drei yield-Anweisungen eine Nachricht aus:

    function* generatorAjami() {
      console.log("First born in casa");
      yield 10;
      console.log("Second move to Khouribga");
      yield 20;
      console.log("Third live in Khouribga");
      yield 30;
    }
    

    Bei Aufruf wird kein Teil des Funktionskörpers ausgeführt:

    const generator = generatorAjami();
    

    Es entsteht ein Generator-Objekt, ein Iterator, von dem man mit .next() Werte abruft:

    console.log(generator.next());
    

    Der erste Aufruf läuft bis zur ersten yield-Anweisung und gibt die erste Nachricht aus:

    First born in casa
    { value: 10, done: false }
    

    Die Ausführung stoppt an dieser Anweisung:

    yield 10;
    

    und gibt 10 zurück. Jedes Ergebnis von .next() hat zwei Eigenschaften:

    • value, der ausgegebene Wert
  • done, ob der Generator fertig ist
  • Daher das folgende Ergebnis:

    { value: 10, done: false }
    

    bedeutet „hier ist 10, und es kommt noch mehr“.

    Ein zweiter Aufruf von .next():

    console.log(generator.next());
    

    ergibt:

    Second move to Khouribga
    { value: 20, done: false }
    

    Die zweite Nachricht erscheint erst jetzt, weil der Generator direkt nach dem vorherigen yield fortgesetzt wird.

    Ein dritter Aufruf:

    console.log(generator.next());
    

    gibt die dritte Nachricht aus und gibt 30 zurück:

    Third live in Khouribga
    { value: 30, done: false }
    

    und ein vierter:

    console.log(generator.next());
    

    meldet, dass nichts mehr übrig ist:

    { value: undefined, done: true }
    

    Ohne expliziten return ist der finale value undefined.

    Was ein Generator wirklich ist

    Ein Generator gibt nicht mehrfach Ausgabe: Eine Ausführung beginnt und endet einmal, kann aber unterbrochen und wieder aufgenommen werden, um viele Werte zu liefern. Der erste Aufruf läuft bis zu yield 10:

    generator.next()
           ↓
    Execute instructions
           ↓
    yield 10
           ↓
    Pause and return { value: 10, done: false }
    

    Der zweite setzt sich fort und stoppt bei yield 20:

    generator.next()
           ↓
    Resume after yield 10
           ↓
    yield 20
           ↓
    Pause and return { value: 20, done: false }
    

    Der dritte setzt sich fort und stoppt bei yield 30:

    generator.next()
           ↓
    Resume after yield 20
           ↓
    yield 30
           ↓
    Pause and return { value: 30, done: false }
    

    Der vierte findet keine weiteren Anweisungen mehr und meldet die Beendigung:

    generator.next()
           ↓
    No more instructions
           ↓
    Return { value: undefined, done: true }
    

    for...of sowie die Spread-Syntax rufen .next() für Sie ab und stoppen bei done: true. Weitere Muster finden Sie unter pausierbare Funktionen und lazy Iteration mit Generatoren.

    Übung: Ein unendlicher Fibonacci-Generator

    Das LeetCode-Problem 2648 eignet sich hervorragend. Der Generator gibt den aktuellen Wert aus und erhöht anschließend beide laufenden Werte. Die while (true)-Schleife ist sicher, da jede Iteration bei yield pausiert.

    var fibGenerator = function* () {
      let a = 0;
      let b = 1;
      while (true) {
        yield a;
        const next = a + b;
        a = b;
        b = next;
      }
    };
    

    Der Zustand entwickelt sich wie folgt:

    a = 0, b = 1
         ↓
    yield 0
         ↓
    a = 1, b = 1
         ↓
    yield 1
         ↓
    a = 1, b = 2
         ↓
    yield 1
         ↓
    a = 2, b = 3
         ↓
    yield 2
    

    Die Werte werden verspätet berechnet, jeweils bei einem Aufruf von .next(); man sollte sie niemals ohne Limit verbreiten oder iterieren.

    Zusammenfassen des Modells

    Für Node.js: V8 führt Ihren Code mit seinem Stack und Heap aus, die Node-APIs leiten langsame Aufgaben an libuv mit dessen Event-Loop und Thread-Pool weiter, und das Betriebssystem übernimmt die eigentliche Netzwerkarbeit.

    ┌─────────────────────────────────────────────┐
    │                  NODE.JS                    │
    │                                             │
    │   ┌─────────────┐                           │
    │   │     V8      │                           │
    │   │             │                           │
    │   │ Call Stack  │                           │
    │   │ Heap        │                           │
    │   └──────┬──────┘                           │
    │          │                                  │
    │          ▼                                  │
    │   ┌───────────────┐                         │
    │   │  Node.js APIs │                         │
    │   └───────┬───────┘                         │
    │           │                                 │
    │           ▼                                 │
    │   ┌───────────────┐                         │
    │   │     libuv     │                         │
    │   │               │                         │
    │   │  ┌─────────┐  │                         │
    │   │  │ Event   │  │                         │
    │   │  │  Loop   │  │                         │
    │   │  └─────────┘  │                         │
    │   │               │                         │
    │   │  Thread Pool  │                         │
    │   └───────┬───────┘                         │
    │           │                                 │
    └───────────┼─────────────────────────────────┘
                │
                ▼
          ┌───────────────┐
          │      OS       │
          │               │
          │ Networking    │
          │ Kernel        │
          │ epoll/kqueue  │
          └───────────────┘
    

    Wichtige Erkenntnisse

    • Nichts, was in der Warteschlange ist, wird ausgeführt, solange der Aufrufstack nicht leer ist – daher blockiert langer synchroner Code alles.
    • Variable speichern Referenzen auf Heap-Objekte; unerreichbare Objekte werden gesammelt.
  • Die Laufzeit führt zeitaufwändige Aufgaben aus; Promise-Handler werden als Microtasks vor dem nächsten Callback ausgeführt.
  • fetch lehnt nur bei Netzwerkfehlern ab, daher sollte man response.ok überprüfen.
  • await pausiert die Funktion, nicht den Thread.
  • Generatoren pausieren bei yield und setzen ihre Ausführung mit .next() fort, wodurch träge, sogar unendliche Sequenzen möglich sind.