flowchart LR
Q["Quelltext"] --> L["Lexer: Tokens"]
L --> P["Parser: AST"]
P --> O["Analyse und Optimierung"]
O --> B["Bytecode"]
B --> V["Bytecode-VM interpretiert"]
B --> J["JIT: heißer Code wird zu Maschinencode"]
O --> A["AOT: ganzes Programm zu Maschinencode vor dem Start"]
Wie Programme funktionieren (2b): Ausführungsmodelle, JIT und Paradigmen
Track Konzepte · Laufzeit und Ausführung · ca. 50 Min.
Worum es geht
Was passiert zwischen deinem Quelltext und der Maschinenanweisung, und warum ist die erste Ausführung eines Programms oft langsamer? Das ist die Frage nach Ausführungsmodellen (execution models) und dem JIT-Compiler (Just-in-Time). Dazu kommen die vier Paradigmen (paradigms) und ein kurzer Blick auf Metaprogrammierung (metaprogramming) samt ihren Kosten. In diesem Teil schaust du mit ast und dis in Python hinein, rechnest das Aufwärmverhalten eines JIT in einer Simulation nach und ordnest Randbedingungen den passenden Modellen zu.
Was du aus Teil a brauchst: Die Achsen stark/schwach und statisch/dynamisch (Schritt 1) brauchst du in Übung 3 noch einmal. Die Varianz und die Sum Types bleiben in Teil a. Teil a: Typen als Spezifikation und Varianz.
Am Ende von Teil b kannst du:
- mit
astProgrammstruktur analysieren, ohne den Code auszuführen (Übung 1), - das Aufwärmverhalten eines JIT inklusive Deoptimierung nachrechnen (Übung 2),
- Paradigma, Typsystem, Ausführungsmodell und Metaprogrammierung nach Randbedingungen zuordnen (Übung 3).
Plane ehrlich 50 Minuten ein: etwa 25 Minuten Lesen (drei Schritte), 21 Minuten Übungen (drei Stück).
Von JS/TS her gedacht
| Begriff | JavaScript / TypeScript | Python |
|---|---|---|
| Ausführung | V8: Interpreter plus JIT | CPython: Bytecode-VM |
| Syntaxbaum | @babel/parser, TypeScript Compiler API (hier nicht ausgeführt) |
Modul ast |
| Bytecode ansehen | schwer zugänglich | Modul dis |
| Reflection | Object.keys, Reflect, Proxy |
getattr, inspect.signature |
| Decorators | Vorschlag, in TS verfügbar (bitte prüfen) | @decorator seit Langem Alltag |
Wichtig für das Verständnis: Derselbe Quelltext durchläuft in beiden Sprachen dieselbe Kette (Lexer, Parser, AST), danach gehen die Wege auseinander.
Konzept
Schritt 1: Was zwischen Quelltext und Ausführung passiert
Dein Quelltext ist nur Text. Bevor etwas läuft, durchläuft er eine Kette:
- Lexer (Tokenisierer): zerlegt Text in Wörter (
preis,=,netto,*, …). - Parser: baut daraus den AST (Abstract Syntax Tree, abstrakter Syntaxbaum), einen Baum aus Knoten wie “Zuweisung”, “Addition”, “Name”, “Konstante”. Ab hier ist die Struktur klar, Klammern und Punkte gibt es nicht mehr.
- Analyse und Optimierung: Der Compiler arbeitet am Baum oder an einer Zwischenform (Konstanten zusammenrechnen, toten Code entfernen).
- Bytecode: einfache Anweisungen für eine virtuelle Maschine (VM).
Python lässt dich in diese Kette hineinsehen. Zuerst der AST einer Zeile (ast.dump ist in Python-Versionen leicht unterschiedlich formatiert, die Struktur ist dieselbe):
Ausgabe:
Module(
body=[
Assign(
targets=[
Name(id='preis', ctx=Store())],
value=BinOp(
left=BinOp(
left=Name(id='netto', ctx=Load()),
op=Mult(),
right=Constant(value=1.19)),
op=Add(),
right=Constant(value=2)))])
['Module', 'Assign', 'Name', 'BinOp', 'Store', 'BinOp', 'Add', 'Constant', 'Name', 'Mult', 'Constant', 'Load']
Der Baum zeigt, dass * vor + bindet: Die Multiplikation steht tiefer im Baum. Operatorvorrang ist also keine Magie bei der Ausführung, sondern die Form des Baums.
Mit dem Baum kannst du Code analysieren, ohne ihn auszuführen. Jeder Aufruf ist ein Knoten Call, und sein Feld func sagt, was aufgerufen wird. Bei einem einfachen Namen ist func ein Name mit dem Feld id, bei text.upper() dagegen ein Attribute mit dem Feld attr und ohne id. Außerdem wirft ast.parse bei kaputtem Quelltext einen SyntaxError:
Ausgabe:
['max', 'abs', 'len', 'len']
['Attribute']
SyntaxError
ast.walk geht den Baum ebenenweise ab, darum stehen max und abs (oben im Baum) vor den beiden len (tiefer). Genau so arbeiten Linter und Refactoring-Werkzeuge.
Dann der Bytecode. Und ein Beispiel für eine Optimierung durch den Compiler: 60 * 60 * 24 steht im AST noch als zwei Multiplikationen, im Bytecode aber schon als fertige Konstante:
Ausgabe (Python 3.13, die Namen und Zahlen der Bytecode-Anweisungen ändern sich von Version zu Version, die Idee nicht):
Module(body=[Assign(targets=[Name(id='x', ctx=Store())], value=BinOp(left=BinOp(left=Constant(value=60), op=Mult(), right=Constant(value=60)), op=Mult(), right=Constant(value=24)))])
0 RESUME 0
1 LOAD_CONST 0 (86400)
STORE_NAME 0 (x)
RETURN_CONST 1 (None)
Dort steht 86400, nicht 60, 60, 24. Das nennt man Constant Folding (Konstanten falten). Der Compiler hat gerechnet, bevor das Programm lief. Diese Bytecode-Liste führt die Bytecode-VM (CPython) Anweisung für Anweisung aus. Das ist ein Interpreter im weiteren Sinn.
Die vier Ausführungsmodelle im Vergleich (allgemeines Fachwissen, Einordnung der Beispiele ohne Messung):
| Modell | Idee | Start | Spitzenleistung | Beispiele |
|---|---|---|---|---|
| Interpreter (direkt) | Quelltext oder AST wird Schritt für Schritt ausgeführt | schnell | niedrig | einfache Skriptsprachen |
| Bytecode-VM | Quelltext wird zu Bytecode übersetzt, die VM führt ihn aus | schnell | mittel | CPython |
| JIT (Just-in-Time) | Die VM beobachtet, welcher Code heiß ist, und übersetzt genau den zur Laufzeit in Maschinencode | langsam beim Aufwärmen | hoch | V8 (JavaScript), PyPy, Java |
| AOT (Ahead-of-Time) | Das ganze Programm wird vor dem Start zu Maschinencode | sehr schnell | hoch, ohne Aufwärmen | Go, Rust, C |
CPython 3.13 hat einen experimentellen JIT-Compiler, der standardmäßig nicht aktiv ist (bitte prüfen, da sich das ändern kann).
Schritt 2: Der JIT und die langsame erste Ausführung
Der Prüfstein: Was passiert in einem JIT-Compiler zwischen deinem Quelltext und der ausgeführten Maschinenanweisung, und warum ist die erste Ausführung oft langsamer?
Ein JIT arbeitet in Stufen. Das ist das Grundmodell (allgemeines Fachwissen, vereinfacht):
- Interpretieren und zählen. Jede Funktion startet in der VM. Dabei zählt ein Profiler, wie oft sie läuft und mit welchen Typen. Der Interpreter ist langsam, und das Zählen kostet etwas.
- Schwelle erreicht: Hot Code. Wird eine Funktion oft genug aufgerufen, gilt sie als heiß (hot).
- Kompilieren. Der JIT übersetzt die Funktion in Maschinencode. Das kostet einmalig Zeit (und meist spekulativ: Er nimmt an, dass die Typen immer so bleiben wie beim Profilieren).
- Schnell laufen. Ab jetzt läuft der Maschinencode. Aufrufe sind billig.
- Deoptimierung (deoptimization): Bricht die Annahme (ein anderer Typ kommt an), wirft die VM den Maschinencode weg und fällt auf den Interpreter zurück. Der Zyklus beginnt von vorn.
Darum ist die erste Ausführung langsamer: Es wird interpretiert, es wird profiliert, und das Kompilieren selbst kostet Zeit, bevor sich irgendetwas auszahlt. Wer nur kurz läuft (ein Kommandozeilenwerkzeug), erreicht die Schwelle oft nie und zahlt nur die Nachteile.
Wir rechnen das nach, ohne Zeitmessung (Zeitmessungen im Browser sind unzuverlässig). Stattdessen Kosteneinheiten: Ein interpretierter Aufruf kostet 10, ein kompilierter 1, das Kompilieren einmalig 200. Die Schwelle ist eine Zahl von Aufrufen. Der Aufruf, der die Schwelle erreicht, läuft noch interpretiert, danach wird kompiliert (200 kommen dazu), und alle weiteren Aufrufe kosten 1. Das Gerüst, mit der Kostenliste pro Aufruf:
Ausgabe: [10, 10, 210, 1, 1] 232. Aufruf 1 und 2: interpretiert (10 und 10). Aufruf 3: interpretiert und Kompilieren (10 + 200 = 210). Aufrufe 4 und 5: kompiliert (je 1). Summe 232. Jetzt der Durchschnitt pro Aufruf bei Schwelle 100, und der Vergleich mit reinem Interpretieren (10 pro Aufruf):
Ausgabe:
50 Aufrufe: 500 gesamt, 10.0 pro Aufruf, reines Interpretieren: 500
100 Aufrufe: 1200 gesamt, 12.0 pro Aufruf, reines Interpretieren: 1000
101 Aufrufe: 1201 gesamt, 11.891089108910892 pro Aufruf, reines Interpretieren: 1010
1000 Aufrufe: 2100 gesamt, 2.1 pro Aufruf, reines Interpretieren: 10000
Der JIT zahlt sich ab 123 Aufrufen aus
Lies die Zahlen als Geschichte: Bis 99 Aufrufe passiert gar nichts Besonderes (kein Kompilieren, 10 pro Aufruf). Bei 100 kostet der JIT mehr als reines Interpretieren (1200 gegen 1000), der Aufwand ist noch nicht bezahlt. Erst ab 123 Aufrufen liegt er vorn, und bei 1000 ist er fast fünfmal billiger (2100 gegen 10000). Die Zahlen sind die Rechnung im Modell, keine Messung eines echten JIT. Echte Systeme haben mehrere Stufen und andere Größenverhältnisse. Das Prinzip stimmt: Aufwärmen ist eine Vorauszahlung.
Das Deoptimieren aus Punkt 5 baust du in Übung 2 selbst ein.
Schritt 3: Paradigmen und Metaprogrammierung, kurz
Paradigmen (paradigms) sind Denkstile, keine Sprachen. Die vier Namen kennst du schon aus Lektion 23b. Hier siehst du sie am Code. Dieselbe Aufgabe, “Summe der Quadrate der geraden Zahlen”, in vier Stilen:
Ausgabe: 56 56 56 56. Das Ergebnis ist gleich, der Stil nicht:
| Paradigma | Denkweise | Stärke | Preis |
|---|---|---|---|
| imperativ (imperative) | Befehle, die den Zustand Schritt für Schritt ändern | nah an der Maschine, einfach zu lesen | Zustand wächst unübersichtlich |
| objektorientiert (OOP) | Objekte kapseln Zustand und Verhalten | Modellierung von Fachbegriffen | Zustand versteckt sich in Objekten |
| funktional (functional) | Funktionen ohne Seiteneffekte auf unveränderlichen Daten | testbar, parallelisierbar | Zustand und I/O brauchen Disziplin |
| deklarativ (declarative) | Ergebnis beschreiben, die Engine entscheidet den Weg | Optimierung durch die Engine | Weg und Kosten sind nicht sichtbar |
Python, TypeScript und Kotlin sind Multi-Paradigmen-Sprachen: Du mischst die Stile, je nach Problem. React ist zum Beispiel deklarativ (JSX beschreibt, wie die Oberfläche aussieht) mit funktionalen Komponenten.
Metaprogrammierung (metaprogramming) heißt: Code, der Code oder Programmstruktur zur Laufzeit liest oder verändert. Zwei Werkzeuge, kurz:
- Reflection (Introspektion): Ein Programm fragt seine eigene Struktur ab (
getattr,inspect.signature,hasattr). - Decorators (Dekoratoren): Eine Funktion wird durch eine umhüllende ersetzt (siehe
python/12).
Und ihre Kosten. Ein Decorator ist ein zusätzlicher Funktionsaufruf bei jedem Aufruf. Wir zählen die Python-Funktionsaufrufe mit sys.setprofile für 1000 Aufrufe, einmal ohne und einmal mit einem Decorator:
Ausgabe: 1000 2000. Der Decorator verdoppelt die Aufrufe. Weitere Kosten, die sich nicht in Zahlen zeigen: Stack-Traces werden länger, Werkzeuge zur statischen Analyse sehen die Wirkung des Decorators nicht (bitte prüfen, je nach Werkzeug), und Reflection zur Laufzeit ist ein Aufwand, den du pro Aufruf zahlst. Die Faustregel: Reflection einmal beim Definieren, nicht bei jedem Aufruf. Das brauchst du in Übung 3.
Falle
- Aus kurzen Läufen auf JIT-Leistung schließen. Die erste Sekunde misst Aufwärmen, nicht Spitzenleistung. Und umgekehrt: Für Programme, die nur kurz laufen, ist ein JIT oft Aufwand ohne Gewinn (Übungen 2 und 3).
- Quelltext mit Textsuche analysieren.
grep "f("findet auch Kommentare, Strings und Methoden. Der Syntaxbaum kennt die Struktur (Übung 1). - Reflection bei jedem Aufruf. Annotationen und Signaturen einmal beim Definieren auslesen, nicht im Wrapper (Übung 3).
Übungen
Übung 1: Funktionsaufrufe im AST zählen (ca. 7 Min.)
Ein Linter soll melden, welche Funktionen ein Skript aufruft, ohne es auszuführen. Schreibe aufruf_zaehler(quelltext). Sie bekommt Python-Quelltext als String und gibt ein dict zurück: Schlüssel ist der Name einer aufgerufenen Funktion, Wert die Zahl der Aufrufe im Quelltext.
Regeln:
- Gezählt werden nur Aufrufe, bei denen vor der Klammer ein einfacher Name steht (
f(x)). Aufrufe von Methoden (text.upper()) und Aufrufe von Aufrufergebnissen (f(1)(2): nur das inneref(1)zählt) werden nicht als Name gezählt. - Aufrufe in verschachtelten Funktionen, Lambdas und Comprehensions zählen mit. Die Definition
def f(): ...ist kein Aufruf. - Bei einem Syntaxfehler im Quelltext gibt die Funktion
Nonezurück. - Leerer Quelltext ergibt
{}.
Beispiele: "print(len(a))\nprint(1)" ergibt {"print": 2, "len": 1}. "s = t.strip()\nu = clean(s)" ergibt {"clean": 1}.
Die letzte Zeile gibt die Funktion zurück.
Gehe den Baum mit ast.walk ab und suche den Knotentyp für einen Aufruf. Welches Feld dieses Knotens sagt, was aufgerufen wird, und welche Typen kann dieses Feld haben? Bei welchen davon gibt es ein Attribut id?
import ast
def aufruf_zaehler(quelltext):
try:
baum = ast.parse(quelltext)
except SyntaxError:
return None
zaehler = {}
for knoten in ast.walk(baum):
if isinstance(knoten, ast.Call) and isinstance(knoten.func, ast.Name):
zaehler[knoten.func.id] = zaehler.get(knoten.func.id, 0) + 1
return zaehler
aufruf_zaehlerEntscheidend ist die Prüfung isinstance(knoten.func, ast.Name): Bei text.upper() ist func ein Attribute ohne id, ohne die Prüfung gäbe es einen AttributeError.
Übung 2: JIT mit Deoptimierung simulieren (ca. 8 Min.)
Erweitere das Kostenmodell aus Schritt 2 um die Deoptimierung. Eine Funktion wird mit Argumenttypen aufgerufen, gegeben als Liste von Typnamen, ein Eintrag pro Aufruf (z. B. ["int", "int", "str"]). Schreibe kosten(typen, schwelle), das ein Tupel (gesamtkosten, anzahl_kompilierungen) zurückgibt. Die Konstanten sind INTERP = 10, KOMPILIERT = 1, KOMPILIEREN = 200, DEOPT = 30 (stehen bereit, du musst sie nicht definieren).
Regeln:
- Die Funktion startet interpretiert mit Zähler
0. - Interpretierter Aufruf: kostet
INTERP, der Zähler steigt um 1. Erreicht der Zähler damitschwelle, kostet dieser Aufruf zusätzlichKOMPILIEREN, und die Funktion ist danach kompiliert für den Typ dieses Aufrufs. - Kompilierter Aufruf mit passendem Typ: kostet
KOMPILIERT. - Kompilierter Aufruf mit anderem Typ (Deoptimierung): kostet
INTERP + DEOPT, der Code wird verworfen, die Funktion ist wieder interpretiert, und der Zähler beginnt bei0.
Durchgerechnetes Beispiel: kosten(["int", "int", "int", "str", "int", "int", "int"], 3). Aufrufe 1 bis 3 (int): 10 + 10 + 210 (Kompilieren für int) = 230. Aufruf 4 (str, Deoptimierung): 10 + 30 = 40, Zähler zurück auf 0. Aufrufe 5 bis 7 (int): 10 + 10 + 210 (wieder kompiliert) = 230. Summe 500, zwei Kompilierungen, also (500, 2).
Du brauchst drei Zustandsgrößen: den Zähler, ob und für welchen Typ kompiliert wurde, und die bisherige Summe. Welcher Fall tritt bei jedem Aufruf ein: interpretiert, kompiliert und passend, kompiliert und unpassend? Und was muss bei einer Deoptimierung zurückgesetzt werden?
def kosten(typen, schwelle):
zaehler = 0
kompiliert_fuer = None
gesamt = 0
kompilierungen = 0
for t in typen:
if kompiliert_fuer is None:
gesamt += INTERP
zaehler += 1
if zaehler == schwelle:
gesamt += KOMPILIEREN
kompiliert_fuer = t
kompilierungen += 1
elif kompiliert_fuer == t:
gesamt += KOMPILIERT
else:
gesamt += INTERP + DEOPT
kompiliert_fuer = None
zaehler = 0
return gesamt, kompilierungen
kostenWechselnde Typen sind für einen JIT teuer: Er kompiliert für einen Typ, wirft den Code beim ersten anderen Typ weg und fängt von vorn an. Stabile Typen sind die Voraussetzung für schnellen Maschinencode.
Übung 3: Paradigma, Typen, Ausführungsmodell und Metaprogrammierung zuordnen (ca. 5 Min.)
Wähle in jedem Fall die beste Antwort für die genannten Randbedingungen. Gib ein Tupel mit vier Buchstaben zurück, z. B. ("A", "B", "C", "D").
Fall 1: Du baust die Preisberechnung eines Shops: Positionen filtern, summieren, Rabatte anwenden. Randbedingungen: Dieselbe Eingabe muss immer dieselbe Ausgabe liefern, die Berechnung muss ohne Datenbank und ohne Mocks testbar sein, und das Team will sie später auf mehrere Prozesse verteilen, ohne gemeinsamen Zustand absichern zu müssen.
- A: Objekte mit veränderlichem Zustand, die sich gegenseitig Aufrufe schicken (objektorientiert)
- B: reine Funktionen über unveränderliche Daten, hintereinander geschaltet (funktional)
- C: eine Schleife, die Schritt für Schritt eine gemeinsame Summenvariable ändert (imperativ)
- D: eine Beschreibung des Ergebnisses, die ein Query-Planer auswertet (deklarativ)
Fall 2: Ein Kommandozeilenwerkzeug wird von einem Build-Skript 20000 Mal pro Lauf gestartet, jeder Lauf arbeitet nur wenige Millisekunden. Randbedingungen: Die Startzeit zählt, Spitzenleistung nach dem Aufwärmen ist egal, und das Werkzeug soll als eine Datei ausgeliefert werden, ohne dass auf den Zielrechnern eine Laufzeitumgebung installiert sein muss.
- A: eine JIT-VM, weil heißer Code nach dem Aufwärmen am schnellsten läuft
- B: ein Interpreter, der den Quelltext bei jedem Start neu einliest
- C: eine Bytecode-VM mit einem großen Laufzeitpaket als Beilage
- D: ein AOT-kompiliertes Programm, das ohne Aufwärmen sofort startet
Fall 3: In JavaScript ergibt "5" * 2 die Zahl 10, in Python ergibt "5" + 2 einen TypeError. Wie ordnest du beide Sprachen auf den Achsen stark/schwach und statisch/dynamisch ein?
- A: JavaScript ist schwach und dynamisch, Python ist stark und dynamisch
- B: JavaScript ist stark und statisch, Python ist schwach und dynamisch
- C: Beide sind statisch, nur die Umwandlungsregeln der Operatoren unterscheiden sich
- D: JavaScript ist schwach und statisch, Python ist stark und statisch
Fall 4: Ein Decorator @validiere prüft die Argumente anhand der Typannotationen der dekorierten Funktion. Die Funktion läuft in einer heißen Schleife mit Millionen Aufrufen. Randbedingungen: Die Prüfung soll bleiben, und die Kosten pro Aufruf sollen möglichst klein sein.
- A: Annotationen einmal beim Dekorieren auslesen, daraus eine Prüfliste bauen, im Wrapper nur noch prüfen
- B:
inspect.signatureundget_type_hintsbei jedem Aufruf neu auslesen, damit Änderungen sofort gelten - C: Den Decorator entfernen und die Annahmen nur noch in einem Kommentar festhalten
- D: Den Decorator doppelt anwenden, damit Fehler früher auffallen
Lies pro Fall die Randbedingungen als Liste von Forderungen und streiche Optionen, die eine davon verletzen. Frage dich bei Fall 2 und 4: Wann fällt der Aufwand an, einmal oder bei jedem Aufruf, beim Start oder im Dauerbetrieb?
antwort = ("B", "D", "A", "A")
antwortFall 1: funktional (rein, unveränderliche Daten, kein gemeinsamer Zustand). Fall 2: AOT (kein Aufwärmen, eine Datei, keine Laufzeit nötig). Fall 3: JavaScript schwach und dynamisch, Python stark und dynamisch. Fall 4: Reflection einmal beim Definieren, nicht bei jedem Aufruf.
Merksatz
Ein JIT zahlt für Geschwindigkeit im Voraus (Interpretieren, Zählen, Kompilieren), weshalb die erste Ausführung langsamer ist und sich erst bei oft genutztem Code mit stabilen Typen auszahlt. Welches Modell passt, entscheiden Startzeit, Auslieferung und Laufzeit, nicht Vorliebe.
Prüfstein
Was passiert in einem JIT-Compiler zwischen deinem Quelltext und der ausgeführten Maschinenanweisung, und warum ist die erste Ausführung oft langsamer?
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “1. Wie Programme funktionieren” (Paradigmen: imperativ, objektorientiert, funktional, deklarativ). quellen/konzeptuebersicht-software-fortgeschritten.docx, Abschnitt “1. Sprachen, Typen und Laufzeit” (Ausführungsmodelle: Interpreter, Bytecode-VM, JIT, AOT, Parser, AST, Optimierung; Metaprogrammierung: Reflection, Decorators und ihre Kosten; Prüfstein zum JIT).
Über die Quelle hinaus (allgemeines Fachwissen): die Stufen eines JIT (Profiling, Hot Code, spekulative Optimierung, Deoptimierung) und das Kostenmodell mit seinen Zahlen (eine Rechenvereinfachung, keine Messung eines echten JIT), die Einordnung der Beispiele der Ausführungsmodelle (V8, PyPy, Java, Go, Rust), der experimentelle JIT in CPython 3.13 (bitte prüfen), die Aussage zu statischer Analyse und Decorators (bitte prüfen), die Beschreibung der Paradigmen, die Zeile zu Babel und TypeScript Compiler API in der Vergleichstabelle (nicht ausgeführt, bitte prüfen). Alle Python-Ausgaben dieser Lektion stammen aus dem Ausführen des Codes (Python 3.13.9).