Zum Inhalt springen

cat posts/python-skript-unter-der-haube.md

Was passiert eigentlich bei `python skript.py`?

Vom Quelltext zum laufenden Programm: Tokenizer, AST, Code-Objekte, Bytecode und die CPython-Ausführungsengine – plus ein Blick auf Cache, Spezialisierung, Free-Threading und JIT.

In Teil 1 der Python-Serie hieß es schlicht:

python skript.py

Python liest die Datei ein und führt sie aus. So weit, so unspektakulär.

Aber was geschieht zwischen dem Drücken der Eingabetaste und der ersten Ausgabe? Python gilt als interpretierte Sprache. Das ist nicht falsch, aber nur ein Teil der Wahrheit: CPython kompiliert Deinen Quelltext zunächst zu Bytecode und führt diesen anschließend aus.

Schauen wir uns an, was dabei unter der Haube passiert.

Python ist nicht gleich CPython

Zunächst müssen wir zwei Begriffe auseinanderhalten:

  • Python ist die Programmiersprache.
  • CPython ist die am weitesten verbreitete Implementierung dieser Sprache.

CPython ist größtenteils in C geschrieben und die Variante, die Du normalerweise über python.org oder den Paketmanager Deiner Linux-Distribution installierst.

Daneben gibt es weitere Python-Implementierungen, beispielsweise PyPy oder GraalPy. Sie verstehen weitgehend dieselbe Sprache, können Code intern aber anders ausführen.

Dieser Artikel beschreibt deshalb nicht allgemein jede denkbare Python-Implementierung, sondern den Ablauf in CPython.

Die Pipeline in vier Schritten

Wenn Du

python skript.py

ausführst, durchläuft CPython vereinfacht vier Phasen:

  1. Tokenizing: Der Quelltext wird in einzelne Tokens zerlegt.
  2. Parsing: Aus den Tokens entsteht ein Abstract Syntax Tree.
  3. Compiling: Der Syntaxbaum wird in ein Code-Objekt mit Bytecode übersetzt.
  4. Execution: Die CPython-Ausführungsengine verarbeitet den Bytecode.

Diese Schritte laufen bei kleinen Programmen so schnell ab, dass Du davon normalerweise nichts bemerkst.

Schritt 1: Tokenizing

Betrachten wir eine einfache Python-Zeile:

ergebnis = 2 + 3

Für uns besteht sie aus einer Variablen, einem Gleichheitszeichen und einer Rechnung. Der Tokenizer zerlegt sie in einzelne bedeutungstragende Bestandteile, unter anderem:

NAME        ergebnis
EQUAL       =
NUMBER      2
PLUS        +
NUMBER      3
NEWLINE

Solche Bestandteile werden Tokens genannt. Dazu gehören beispielsweise:

  • Namen wie ergebnis
  • Zahlen und Strings
  • Keywords wie if, while oder def
  • Operatoren wie +, - oder ==
  • Einrückungen und Zeilenenden

Der Tokenizer versteht noch nicht die vollständige Bedeutung des Programms. Er bereitet den Quelltext zunächst in einer Form auf, mit der der Parser weiterarbeiten kann.

Du kannst Dir die Tokens eines Programms mit dem Standardmodul tokenize anzeigen lassen:

python -m tokenize skript.py

Die Ausgabe ist für den normalen Programmieralltag selten notwendig, zeigt aber gut, dass CPython den Quelltext nicht einfach Zeichen für Zeichen ausführt.

Schritt 2: Parsing und AST

Der Parser untersucht, ob die Tokens nach den grammatischen Regeln von Python zusammenpassen.

Aus

ergebnis = 2 + 3

entsteht dabei eine Baumstruktur. Sie beschreibt sinngemäß:

Zuweisung
├── Ziel: ergebnis
└── Wert: Addition
    ├── 2
    └── 3

Diese Struktur heißt Abstract Syntax Tree, kurz AST.

Der AST enthält die Bedeutung und Struktur des Codes, aber nicht mehr jedes Detail seiner Schreibweise. Ob Du beispielsweise

2+3

oder

2 + 3

schreibst, führt zum gleichen grundlegenden Syntaxbaum.

Mit dem Modul ast kannst Du Dir diesen Baum ansehen:

import ast

code = "ergebnis = 2 + 3"
baum = ast.parse(code)

print(ast.dump(baum, indent=4))

Die Ausgabe sieht gekürzt etwa so aus:

Module(
    body=[
        Assign(
            targets=[
                Name(id='ergebnis')
            ],
            value=BinOp(
                left=Constant(value=2),
                op=Add(),
                right=Constant(value=3)
            )
        )
    ]
)

Hier ist zu erkennen, dass der Code aus einer Zuweisung besteht, deren Wert eine Addition zweier Konstanten ist.

Syntaxfehler werden bereits in dieser Phase erkannt. Fehlt beispielsweise der Doppelpunkt hinter einer if-Bedingung, kann Python keinen gültigen Syntaxbaum erzeugen:

if hp > 0
    print("Du lebst.")

Das Programm endet dann mit einem SyntaxError, bevor irgendeine seiner Anweisungen ausgeführt wird.

Schritt 3: Compiling zu einem Code-Objekt

Aus dem AST erzeugt der CPython-Compiler ein Code-Objekt.

Ein Code-Objekt enthält unter anderem:

  • den Bytecode
  • verwendete Konstanten
  • Namen von Variablen und Funktionen
  • Informationen über lokale Variablen
  • Verweise auf Quelldatei und Zeilennummern

Du kannst Quelltext mit der eingebauten Funktion compile(...) selbst in ein Code-Objekt übersetzen:

code = compile(
    "ergebnis = 2 + 3",
    filename="<beispiel>",
    mode="exec",
)

print(code)

Die Ausgabe ähnelt:

<code object <module> at 0x..., file "<beispiel>", line 1>

Der Quelltext wurde damit kompiliert, aber noch nicht ausgeführt.

Das könntest Du anschließend mit exec(...) erledigen:

exec(code)

Wenn Du ein normales Skript startest, übernimmt CPython diese Schritte automatisch.

Bytecode: Instruktionen für CPython

Das Code-Objekt enthält Bytecode. Dabei handelt es sich um kompakte Instruktionen für die CPython-Ausführungsengine.

Bytecode ist kein Maschinencode für Deine konkrete CPU. Ein Prozessor kann Instruktionen wie LOAD_FAST oder BINARY_OP nicht direkt ausführen. Sie werden zunächst von CPython verarbeitet.

Ein einfaches Beispiel:

def addiere(a, b):
    return a + b

Den zugehörigen Bytecode kannst Du mit dem Standardmodul dis untersuchen:

import dis


def addiere(a, b):
    return a + b


dis.dis(addiere)

Je nach Python-Version kann die Ausgabe ungefähr so aussehen:

RESUME
LOAD_FAST       a
LOAD_FAST       b
BINARY_OP       +
RETURN_VALUE

Vereinfacht bedeuten die Instruktionen:

  • RESUME markiert den Einstieg in den ausführbaren Code.
  • LOAD_FAST lädt die lokalen Variablen a und b.
  • BINARY_OP führt die Addition aus.
  • RETURN_VALUE gibt das Ergebnis zurück.

Die konkreten Namen und Anordnungen der Opcodes können sich zwischen Python-Versionen ändern. CPython garantiert nicht, dass sein Bytecode über verschiedene Releases hinweg stabil bleibt.

Deshalb solltest Du Bytecode als Implementierungsdetail betrachten und normalerweise keinen Anwendungscode schreiben, der von bestimmten Opcodes abhängt.

Du kannst auch eine komplette Datei disassemblieren:

python -m dis skript.py

Schritt 4: Den Bytecode ausführen

Nachdem das Code-Objekt erstellt wurde, beginnt die eigentliche Ausführung.

Die CPython-Ausführungsengine liest eine Bytecode-Instruktion, führt die zugehörige Operation aus und fährt anschließend mit der nächsten Instruktion fort.

Dabei arbeitet CPython unter anderem mit:

  • einem Evaluation Stack für Zwischenwerte
  • lokalen und globalen Namespaces
  • Python-Objekten wie int, str oder list
  • Stack Frames für Funktionsaufrufe
  • Exception Handling
  • Referenzzählung und Garbage Collection

Bei einem Ausdruck wie

2 + 3

werden die beiden Werte geladen, die passende Additionsoperation ausgeführt und das Ergebnis als neues Python-Objekt bereitgestellt.

Der Begriff virtuelle Maschine wird häufig verwendet, weil CPython eine eigene abstrakte Ausführungsumgebung für seinen Bytecode bereitstellt. Anders als eine virtuelle Maschine wie VirtualBox simuliert sie aber keinen kompletten Computer.

Das Skript wird zu __main__

Eine Python-Datei wird nicht einfach nur zeilenweise in einem leeren Raum ausgeführt. CPython legt für das gestartete Skript ein Modul an.

Dieses Modul trägt den besonderen Namen:

__main__

Deshalb ergibt die folgende Datei:

print(__name__)

bei einem direkten Start:

python skript.py

die Ausgabe:

__main__

Wird dieselbe Datei dagegen als Modul importiert, enthält __name__ ihren Modulnamen.

Darauf beruht das bekannte Muster:

def main():
    print("Das Programm startet.")


if __name__ == "__main__":
    main()

Der Aufruf von main() erfolgt nur, wenn die Datei direkt als Programm gestartet wird. Beim Import können ihre Funktionen verwendet werden, ohne automatisch das eigentliche Programm auszuführen.

Darauf gehen wir im späteren Artikel über Module und Projektstruktur genauer ein.

Was geschieht mit dem Importpfad?

Beim Start von

python skript.py

wird das Verzeichnis, in dem sich das Skript befindet, an den Anfang des Modul-Suchpfads gesetzt.

Diesen Suchpfad findest Du in:

import sys

print(sys.path)

Wenn Dein Skript anschließend

import monster

ausführt, sucht Python deshalb zunächst im Verzeichnis des gestarteten Skripts nach einem passenden Modul.

Das erklärt auch eine häufige Stolperfalle: Nennst Du eine eigene Datei beispielsweise json.py, kann sie das gleichnamige Modul aus der Standard Library überschatten.

Statt des erwarteten Standardmoduls importiert Python dann möglicherweise Deine lokale Datei.

__pycache__: der Bytecode-Cache

Beim Import eines Moduls speichert CPython den kompilierten Bytecode normalerweise in einer .pyc-Datei innerhalb eines __pycache__-Verzeichnisses.

Eine mögliche Struktur sieht so aus:

projekt/
├── dungeon.py
├── monster.py
└── __pycache__/
    └── monster.cpython-314.pyc

Der Teil cpython-314 zeigt, dass die Datei für das Bytecode-Format von CPython 3.14 erzeugt wurde.

Beim nächsten Import prüft Python, ob der Cache noch zur Quelldatei passt. Ist das der Fall, kann es das vorhandene Code-Objekt laden und muss das Modul nicht erneut aus dem Quelltext kompilieren.

Dadurch startet der Import schneller.

Wichtig ist jedoch:

Eine .pyc-Datei beschleunigt das Laden eines Moduls, nicht die eigentliche Ausführung seines Codes.

Nach dem Laden wird grundsätzlich derselbe Bytecode ausgeführt. Eine .pyc-Datei macht Schleifen, Berechnungen oder Funktionen nicht automatisch schneller.

Das direkt gestartete Skript wird nicht gecacht

Bei

python dungeon.py

wird für dungeon.py normalerweise keine .pyc-Datei erzeugt, weil die Datei als Top-Level-Skript und nicht über einen Import geladen wird.

Importiert dungeon.py jedoch ein Modul namens monster.py, kann für monster.py eine Cache-Datei entstehen:

__pycache__/monster.cpython-314.pyc

Gehört __pycache__ ins Git?

Normalerweise nicht. Die Dateien werden automatisch aus dem Quelltext erzeugt und hängen von der verwendeten Python-Implementierung und Version ab.

Typische Einträge in einer .gitignore sind:

__pycache__/
*.py[cod]

Löschst Du einen __pycache__-Ordner, ist das in der Regel unproblematisch. Python erzeugt ihn bei Bedarf erneut.

Du kannst das Schreiben von Bytecode-Dateien vorübergehend verhindern:

python -B skript.py

Oder über die Umgebungsvariable:

PYTHONDONTWRITEBYTECODE=1 python skript.py

Das ändert nicht, dass Python den Code intern kompiliert. Es verhindert nur das Speichern der .pyc-Dateien.

Der adaptive, spezialisierende Interpreter

Seit Python 3.11 verwendet CPython einen specializing adaptive interpreter.

Die Grundidee: Der Interpreter beobachtet während der Ausführung, welche Werte an bestimmten Stellen besonders häufig vorkommen.

Betrachten wir:

def addiere(a, b):
    return a + b

Die allgemeine Operation a + b muss zunächst mit vielen möglichen Typen umgehen können:

  • zwei int
  • zwei float
  • zwei Strings
  • eigene Klassen mit einer passenden Methode
  • weitere Objekte

Wird die Funktion wiederholt mit zwei Ganzzahlen aufgerufen, kann CPython die allgemeine Operation durch eine spezialisierte Variante ersetzen, die auf diesen häufigen Fall optimiert ist.

Das Programm verhält sich dadurch nicht anders. CPython versucht lediglich, den gleichen Code effizienter auszuführen.

Mit dis kannst Du Dir spezialisierte Instruktionen und ihre internen Caches anzeigen lassen:

import dis


def addiere(a, b):
    return a + b


for _ in range(100_000):
    addiere(1, 2)

dis.dis(addiere, adaptive=True, show_caches=True)

Welche spezialisierten Instruktionen erscheinen, hängt von der CPython-Version, dem Build und dem tatsächlich ausgeführten Code ab.

Der adaptive Interpreter ist auch der Grund, warum der angezeigte Bytecode nach mehreren Aufrufen anders aussehen kann als unmittelbar nach dem Kompilieren.

Free-Threading ohne GIL

Der reguläre CPython-Build verwendet weiterhin den Global Interpreter Lock, kurz GIL.

Vereinfacht sorgt der GIL dafür, dass innerhalb eines Python-Prozesses normalerweise nur ein Thread gleichzeitig Python-Bytecode ausführt. Threads bleiben trotzdem nützlich, insbesondere bei I/O-Aufgaben, können CPU-intensive Python-Berechnungen aber nicht ohne Weiteres parallel auf mehreren CPU-Kernen ausführen.

Seit Python 3.13 gibt es zusätzlich einen free-threaded Build, bei dem der GIL deaktiviert werden kann.

In Python 3.14 ist diese Build-Variante offiziell unterstützt, aber weiterhin optional. Der normale Python-Download verwendet also nicht automatisch Free-Threading.

Ein free-threaded Build ermöglicht es mehreren Threads, Python-Code parallel auf mehreren CPU-Kernen auszuführen. Das bedeutet jedoch nicht, dass jedes Programm automatisch schneller wird:

  • Der Code muss sinnvoll auf mehrere Threads verteilt sein.
  • Gemeinsam verwendeter State muss weiterhin synchronisiert werden.
  • Nicht jede C-Extension unterstützt Free-Threading.
  • Manche Extensions können den GIL beim Import wieder aktivieren.
  • Free-threaded Builds besitzen zusätzlichen Verwaltungsaufwand.

Ob der laufende Interpreter den GIL verwendet, lässt sich in einem passenden Build unter anderem über sys._is_gil_enabled() prüfen.

Der experimentelle JIT

CPython besitzt außerdem einen experimentellen Just-in-Time-Compiler, kurz JIT.

Ein JIT kann häufig ausgeführte Teile eines Programms während der Laufzeit in nativen Maschinencode übersetzen. Dieser Code kann anschließend direkt von der CPU ausgeführt werden, statt jede Operation erneut über die normale Bytecode-Ausführung zu verarbeiten.

CPythons JIT konnte bereits seit Python 3.13 in speziell konfigurierten Builds aktiviert werden. Seit Python 3.14 enthalten die offiziellen Windows- und macOS-Binaries eine experimentelle JIT-Unterstützung, die standardmäßig deaktiviert ist.

Sie kann in einem unterstützten Build über die Umgebungsvariable aktiviert werden:

PYTHON_JIT=1 python skript.py

In der PowerShell:

$env:PYTHON_JIT = "1"
python skript.py

Ob der verwendete Interpreter den JIT unterstützt, kannst Du prüfen:

import sys

print(sys._jit.is_available())
print(sys._jit.is_enabled())

Dabei ist wichtig:

  • Der JIT ist in Python 3.14 weiterhin experimentell.
  • Er ist nicht für den Production-Einsatz empfohlen.
  • Er macht nicht automatisch jedes Programm schneller.
  • Je nach Workload kann die Ausführung sogar langsamer werden.
  • Free-threaded Builds unterstützen den JIT in Python 3.14 nicht.

Der JIT ändert nichts an der Python-Syntax. Er ist eine alternative Ausführungsstufe innerhalb von CPython.

Wird Python nun kompiliert oder interpretiert?

Die sinnvollste Antwort lautet:

CPython kompiliert Python-Quelltext zu Bytecode und führt diesen anschließend aus.

Damit ist Python weder ausschließlich kompiliert noch ausschließlich interpretiert.

Der Unterschied zu Sprachen wie C liegt vor allem im Ziel der Kompilierung:

C-Quelltext
    ↓ Compiler
nativer Maschinencode
    ↓
CPU

Bei klassischem CPython sieht der Ablauf vereinfacht so aus:

Python-Quelltext
    ↓ CPython-Compiler
Bytecode
    ↓ CPython-Ausführungsengine
Operationen auf Python-Objekten

Mit aktiviertem JIT kann für häufig ausgeführte Pfade eine weitere Stufe hinzukommen:

Python-Quelltext
    ↓
Bytecode
    ↓ JIT
nativer Maschinencode
    ↓
CPU

Die Grenzen zwischen „kompiliert“ und „interpretiert“ sind in modernen Laufzeitumgebungen deshalb weniger eindeutig, als diese beiden Begriffe zunächst vermuten lassen.

Nützliche Kommandozeilenoptionen

CPython bietet mehrere Optionen, mit denen Du verschiedene Teile dieses Ablaufs untersuchen oder beeinflussen kannst.

Code direkt ausführen

Mit -c übergibst Du Python-Code direkt auf der Kommandozeile:

python -c "print(2 ** 10)"

Ausgabe:

1024

Ein Modul als Programm starten

Mit -m startest Du ein Modul über seinen Modulnamen:

python -m json.tool datei.json

Ein besonders häufiges Beispiel ist das Erstellen einer Virtual Environment:

python -m venv .venv

Auch Tools werden häufig auf diese Weise gestartet:

python -m pytest

Bei einem Modul innerhalb eines Packages bleibt der Package-Kontext erhalten. Das ist insbesondere für relative Imports wichtig:

python -m dungeon.main

Bytecode anzeigen

Das Modul dis kann eine Datei direkt disassemblieren:

python -m dis skript.py

Importzeiten messen

Mit -X importtime zeigt CPython an, wie viel Zeit einzelne Imports benötigen:

python -X importtime skript.py

Das ist hilfreich, wenn ein Programm bereits beim Start auffällig lange benötigt.

Module vorkompilieren

Mit compileall kannst Du .pyc-Dateien für alle Module eines Verzeichnisses erzeugen:

python -m compileall .

Das kann bei Deployments sinnvoll sein, ist bei der normalen lokalen Entwicklung aber selten notwendig.

Keine Bytecode-Dateien schreiben

Mit -B verhinderst Du das Speichern von .pyc-Dateien:

python -B skript.py

Die interne Kompilierung zu einem Code-Objekt findet trotzdem statt.

Der gesamte Ablauf im Überblick

Wenn Du

python skript.py

startest, geschieht vereinfacht Folgendes:

  1. Das Betriebssystem startet den Python-Prozess.
  2. CPython initialisiert den Interpreter und die grundlegenden Laufzeitstrukturen.
  3. Das Verzeichnis des Skripts wird in den Modul-Suchpfad aufgenommen.
  4. Die Datei wird eingelesen und in Tokens zerlegt.
  5. Der Parser erzeugt einen AST.
  6. Der Compiler übersetzt den AST in ein Code-Objekt mit Bytecode.
  7. Das Skript wird als Modul __main__ eingerichtet.
  8. Die CPython-Ausführungsengine verarbeitet den Bytecode.
  9. Imports können weitere Module laden und deren Bytecode in __pycache__ speichern.
  10. Der adaptive Interpreter kann häufig ausgeführte Instruktionen während der Laufzeit spezialisieren.
  11. In einem unterstützten Build kann zusätzlich ein experimenteller JIT verwendet werden.
  12. Nach dem Ende des Top-Level-Codes beendet CPython den Prozess und räumt seine Laufzeitstrukturen auf.

Für ein einfaches print("Hallo") ist das erstaunlich viel Arbeit. Die meisten Schritte laufen jedoch in wenigen Millisekunden ab.

Fazit

Wenn Du ein Python-Skript startest, wird der Quelltext nicht einfach Zeile für Zeile direkt von der CPU ausgeführt.

CPython zerlegt den Code zunächst in Tokens, erstellt daraus einen AST und kompiliert diesen in ein Code-Objekt mit Bytecode. Erst danach beginnt die eigentliche Ausführung.

Importierte Module können ihren Bytecode in __pycache__ speichern, damit die Parsing- und Compiling-Schritte beim nächsten Start übersprungen werden können. Der Cache beschleunigt jedoch nur das Laden, nicht den laufenden Code.

Der adaptive Interpreter, optionale Free-Threading-Builds und der experimentelle JIT zeigen außerdem, dass CPythons Ausführungssystem ständig weiterentwickelt wird. Die grundlegende Python-Syntax bleibt dabei gleich – was sich verändert, ist die Technik darunter.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!