Python 3.14 trennt den regulären CPython-Build vom optionalen Free-Threaded-Build. Nur die zweite Variante kann Python-Bytecode in mehreren Threads gleichzeitig ausführen. Ein Vergleich braucht deshalb getrennte Interpreter, dieselben Eingabedaten und eine dokumentierte Paketkette.
Ich halte einen Wechsel zu Python without GIL nur dann für vertretbar, wenn der Thread-Benchmark den vorab festgelegten Durchsatz- oder Latenzzielwert erreicht und sich gegen den Standard-Build sowie den ProcessPoolExecutor behauptet. Free-Threading kann einzelne Threads verlangsamen, zusätzlichen Speicher beanspruchen und bisher verborgene Race Conditions sichtbar machen.
Was änderte sich beim GIL in Python 3.14?
Free-Threading gilt in Python 3.14 als offiziell unterstützte Build-Konfiguration. Der reguläre CPython-Interpreter startet weiterhin mit aktiviertem Global Interpreter Lock (GIL). Für parallelen Python-Bytecode brauchst du deshalb den separaten Free-Threaded-Build und einen Laufzeitcheck, der den tatsächlichen GIL-Status erfasst.
Der unterstützte Status bedeutet weder, dass jedes Python-Paket kompatibel ist, noch dass sich bestehender Code automatisch parallelisiert. Mehrere Threads lasten mehrere Kerne nur dann aus, wenn sie gleichzeitig genügend CPU-Zeit mit Python-Bytecode verbringen.
Der Free-Threaded-Build verwendet unter anderem eine angepasste Referenzzählung und interne Sperren. Dieser Verwaltungsaufwand kann die Laufzeit eines dominierenden Hauptthreads erhöhen. Eine Ein-Thread-Baseline zeigt, ob dieser Effekt für den untersuchten Workload relevant ist. Vor der ersten Messung muss daher feststehen, welcher Interpreter tatsächlich läuft.
Standard-Python und Python 3.14t parallel installieren
Die offiziellen Installer für Windows und macOS bieten Free-Threaded-Binärdateien als zusätzliche Komponente an. Unter Windows lassen sich die getrennten Interpreter gewöhnlich mit py -3.14 und py -3.14t starten. Auf macOS und bei einem selbst kompilierten Linux-Build heißen die ausführbaren Dateien üblicherweise python3.14 und python3.14t.
Unter Linux lässt sich CPython mit --disable-gil aus dem Quellcode bauen. Ein eigenes Präfix verhindert, dass der Test-Build den System-Interpreter ersetzt:
tar xf Python-3.14.x.tar.xz
cd Python-3.14.x
./configure --prefix="$HOME/opt/python-3.14t" --disable-gil
make -j"$(nproc)"
make altinstall
python3.14 -m venv .venv314
$HOME/opt/python-3.14t/bin/python3.14t -m venv .venv314t
Zwei Abfragen unterscheiden die Build-Konfiguration vom aktuellen Laufzeitstatus. Py_GIL_DISABLED zeigt, ob der Interpreter Free-Threading unterstützt. sys._is_gil_enabled() meldet, ob der GIL im laufenden Prozess aktiv ist.
import sys
import sysconfig
print("free_threaded_build:", bool(
sysconfig.get_config_var("Py_GIL_DISABLED")
))
print("gil_enabled:", sys._is_gil_enabled())
Ein Free-Threaded-Build kann trotzdem mit aktiviertem GIL laufen, etwa durch -X gil=1, die Umgebungsvariable PYTHON_GIL=1 oder den Import einer nicht kompatibel markierten C-Erweiterung. Erst die Kombination aus Build-Merkmal und Laufzeitstatus schafft eine vergleichbare Ausgangslage für den Benchmark.
Benchmark: Standard-Python gegen Python 3.14t
Vergleichbare Messungen halten die Eingabedaten, die Zahl der Aufgaben, die Worker-Zahl und die Aufwärmphase konstant. Der folgende Inhalt für benchmark.py führt die rechenintensive Schleife in Python-Bytecode aus. Zusammen mit einer leeren requirements.txt entsteht ein isoliert ausführbares Beispielprojekt.
import argparse
import json
import multiprocessing
import os
import platform
import sys
import sysconfig
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
MODULUS = 1_000_000_007
def cpu_task(size):
value = 0
for number in range(size):
value = (value + number * number) % MODULUS
return value
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--executor",
choices=("thread", "process"),
required=True,
)
parser.add_argument("--workers", type=int, required=True)
parser.add_argument("--jobs", type=int, default=16)
parser.add_argument("--size", type=int, default=2_000_000)
args = parser.parse_args()
cpu_task(args.size // 20) # Aufwärmphase im Hauptprozess
executor_type = (
ThreadPoolExecutor
if args.executor == "thread"
else ProcessPoolExecutor
)
started = time.perf_counter()
with executor_type(max_workers=args.workers) as executor:
results = list(executor.map(
cpu_task,
[args.size] * args.jobs,
))
elapsed = time.perf_counter() - started
print(json.dumps({
"python": sys.version.split()[0],
"executable": sys.executable,
"platform": platform.platform(),
"free_threaded_build": bool(
sysconfig.get_config_var("Py_GIL_DISABLED")
),
"gil_enabled": sys._is_gil_enabled(),
"executor": args.executor,
"process_start_method": multiprocessing.get_start_method(),
"workers": args.workers,
"jobs": args.jobs,
"size": args.size,
"elapsed_seconds": round(elapsed, 3),
"checksum": sum(results),
"logical_cpus": os.cpu_count(),
}))
if __name__ == "__main__":
main()
Eine Messreihe umfasst pro Interpreter und Executor die Worker-Stufen 1, 2, 4 und weitere Zweierpotenzen bis zur Zahl der logischen CPUs. Jede Kombination läuft mit einer vorab festgelegten Wiederholungszahl unter vergleichbaren Bedingungen. Median und Interquartilsabstand zeigen die Verteilung besser als der schnellste Einzelwert.
Das Skript misst die vollständige Executor-Laufzeit einschließlich Start und Shutdown. Für lang laufende Dienste braucht es zusätzlich einen Test, der den Pool einmal startet und mehrere Aufgabenblöcke verarbeitet. Sonst begünstigen die Startkosten des ProcessPoolExecutor den ThreadPoolExecutor.
Unter Linux erfasst /usr/bin/time -v python3.14t benchmark.py --executor thread --workers 4 unter anderem die Laufzeit, die CPU-Auslastung und den maximalen Resident Set Size. Dieser Einzelwert bildet die gleichzeitige Gesamtspeichernutzung eines Process-Pools nicht ab. Dafür muss ein Prozessmonitor die RSS-Werte des Hauptprozesses und aller Worker zum selben Zeitpunkt erfassen.
Die Prüfsumme deckt abweichende Ergebnisse auf. Der Python-Aufbaukurs greift Executor-Muster, Bibliotheksauswahl und Projektstrukturen auf. Vor der Wahl eines Executors muss der Benchmark zwischen CPU-Zeit in Python-Code und Wartezeit auf Ein- oder Ausgabe unterscheiden.
CPU-bound und I/O-bound Workloads unterscheiden
Pure-Python-Berechnungen mit unabhängigen Aufgaben sind der aussichtsreichste Einsatzfall für Python 3.14t. Dazu gehören Parser, kombinatorische Suchen und Transformationen, deren Laufzeit überwiegend in Python-Bytecode entsteht. Threads teilen denselben Adressraum und müssen die Eingabedaten nicht für jeden Worker serialisieren.
I/O-lastige Anwendungen profitieren bereits mit aktiviertem GIL von Threads, weil CPython den GIL bei vielen blockierenden Datei-, Socket- und Datenbankoperationen freigibt. Ein beschleunigter Download-Test belegt deshalb keinen Vorteil durch Free-Threading. Mehrere Threads können jeweils einen eigenen asyncio-Event-Loop betreiben. Zugriffe zwischen den Loops müssen jedoch über thread-sichere APIs wie call_soon_threadsafe() laufen.
Native Bibliotheken verändern das Ergebnis erneut. Manche Erweiterungen geben den GIL während rechenintensiver Operationen schon im Standard-Build frei. Ein Mikrobenchmark lässt sich daher nicht pauschal auf Webserver, Datenverarbeitung oder Kommandozeilenprogramme übertragen. Erst das CPU-Profil des untersuchten Workloads zeigt, welcher Executor in den Vergleich gehört.
ThreadPoolExecutor oder ProcessPoolExecutor?
Beide Executor-Klassen besitzen eine ähnliche Schnittstelle, unterscheiden sich aber bei Isolation und Datentransfer. Threads starten mit weniger Verwaltungsaufwand und greifen direkt auf gemeinsame Objekte zu. Prozesse verwenden einen eigenen Interpreter und übertragen Argumente sowie Ergebnisse in der Regel über pickle.
| Kriterium | ThreadPoolExecutor mit 3.14t | ProcessPoolExecutor |
|---|---|---|
| Startkosten | Threads starten innerhalb eines Interpreters | Jeder Worker startet einen eigenen Prozess |
| Datentransfer | Objekte bleiben im gemeinsamen Speicher | Argumente und Ergebnisse werden serialisiert |
| Speicher | Threads teilen den Prozessspeicher | Interpreter und veränderte Speicherseiten fallen pro Prozess an |
| Fehlerisolation | Ein nativer Absturz beendet den gesamten Prozess | Ein nativer Absturz bleibt im Kindprozess, der Pool muss danach neu aufgebaut werden |
| Geeignete Aufgaben | Viele Aufgaben lesen dieselben unveränderlichen Datenstrukturen | Aufgaben benötigen Prozessisolation oder inkompatible Erweiterungen |
Prozesse passen zu Erweiterungen ohne Free-Threading-Unterstützung, zu bewusst isoliertem globalem Zustand und zu Aufgaben, bei denen ein nativer Absturz den Hauptprozess nicht beenden darf. Threads kommen infrage, wenn viele CPU-lastige Aufgaben große, unveränderliche Eingangsdaten gemeinsam lesen. Vor einem Pilotbetrieb folgt deshalb die Prüfung der gesamten Paketkette.
C-Erweiterungen und cp314t-Wheels prüfen
Ein Interpreter mit Py_GIL_DISABLED=1 garantiert keinen Prozess mit deaktiviertem GIL. Eine C-Erweiterung, die ihre Free-Threading-Kompatibilität nicht deklariert, kann den GIL beim Import aktivieren. Ein separater Prozess sollte den Status vor und nach dem Import jedes nativen Moduls protokollieren.
# check_import.py
import importlib
import sys
module_name = sys.argv[1]
before = sys._is_gil_enabled()
importlib.import_module(module_name)
after = sys._is_gil_enabled()
print({"module": module_name, "before": before, "after": after})
Dieser Check erfasst nur Module, die beim Start importiert werden. Lazy Imports und Plug-ins müssen zusätzlich durch einen repräsentativen Anwendungstest ausgelöst werden.
Free-Threaded-Erweiterungen verwenden eigene Wheel-Tags wie cp314t. python3.14t -m pip debug --verbose zeigt die akzeptierten Tags. Der Befehl python3.14t -m pip install --only-binary=:all: -r requirements.txt meldet Pakete ohne kompatibles Wheel, statt unbemerkt einen lokalen Compiler-Build zu erzeugen.
Die Limited C API und die Stable ABI decken Free-Threaded-Builds noch nicht vollständig ab. Ein vorhandenes abi3-Wheel beweist deshalb keine Kompatibilität mit Python 3.14t. Getrennte virtuelle Umgebungen und eine Lock-Datei mit festgelegten Versionen machen den Test wiederholbar. Die Schulung Modernes Python-Packaging mit uv, Poetry und pyproject.toml vertieft Wheel-Tags, native Erweiterungen und reproduzierbare Umgebungen. Nach der Paketprüfung muss der Anwendungscode konkurrierende Zugriffe korrekt synchronisieren.
Race Conditions mit pytest sichtbar machen
CPython schützt die internen Strukturen von Listen, Dictionaries und Sets im Free-Threaded-Build mit Sperren. Diese Schutzmechanismen machen eine Folge aus Lesen, Berechnen und Schreiben jedoch nicht atomar. Zwei Threads können denselben alten Wert lesen und anschließend jeweils ihr eigenes Ergebnis speichern.
import threading
import time
from concurrent.futures import ThreadPoolExecutor
class Counter:
def __init__(self):
self.value = 0
self.lock = threading.Lock()
def add_racy(self):
current = self.value
time.sleep(0) # erhöht die Chance auf einen Thread-Wechsel
self.value = current + 1
def add_locked(self):
with self.lock:
self.value += 1
def run(method_name):
counter = Counter()
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(
lambda _: getattr(counter, method_name)(),
range(800),
))
return counter.value
Ein pytest-Test kann den fehlerhaften Ablauf wiederholen und die gesperrte Variante als Regressionstest festhalten:
import pytest
from counter import run
@pytest.mark.parametrize("_attempt", range(50))
def test_racy_counter_exposes_lost_updates(_attempt):
assert run("add_racy") == 800
@pytest.mark.parametrize("_attempt", range(20))
def test_locked_counter_keeps_all_updates(_attempt):
assert run("add_locked") == 800
Der erste Test ist eine absichtlich fehlschlagende Diagnose und gehört nicht in die reguläre Testsuite. time.sleep(0) gibt auch im Standard-Build anderen Threads Rechenzeit. Der Test demonstriert daher eine allgemeine Race Condition und beweist nicht allein ein Free-Threading-Problem. Der zweite Test sichert die Sperre ab.
Wiederholte Testläufe in separaten Prozessen variieren Scheduling und Speicherlayout, können die Abwesenheit von Race Conditions aber nicht beweisen. Der pytest-Einstieg mit testgetriebener Python-Entwicklung behandelt den Aufbau von Regressionstests. Nach den Belastungstests folgt die Entscheidung anhand vorher festgelegter Grenzwerte.
Entscheidungsmatrix für reale Projekte
Vier Stufen trennen die technische Erkundung von einer Produktionsfreigabe. Jede Stufe verwendet überprüfbare Kriterien, damit ein schneller Einzeltest keine Architekturentscheidung bestimmt.
| Entscheidung | Kriterien |
|---|---|
| Nur Entwicklung | Mindestens ein benötigtes Paket hat kein kompatibles Wheel oder aktiviert beim Import den GIL |
| Pilot | Das CPU-Profil zeigt einen hohen Anteil an Python-Bytecode, der Thread-Benchmark erreicht den vorab festgelegten Zielwert und alle benötigten Erweiterungen lassen den GIL deaktiviert |
| Produktion | Lasttests und Regressionstests bestehen, das Monitoring erfasst CPU-Auslastung, RSS, Latenz und Fehlerrate, ein Rollback auf den Standard-Build wurde getestet |
| Verschieben | Die Ein-Thread-Laufzeit, der zusätzliche Speicherbedarf, inkompatible Erweiterungen oder Betriebsrisiken überwiegen den gemessenen Durchsatzgewinn |
Python 3.14t ersetzt Process-Pools nicht. Die Variante lohnt sich für Projekte, bei denen Thread-Benchmarks den vorab festgelegten Zielwert erreichen und alle Abhängigkeiten den GIL deaktiviert lassen. Starte mit einem eingegrenzten Produktions-Workload und führe den Standard-Build, den Free-Threaded-Build sowie den ProcessPoolExecutor in derselben Messreihe aus.