GIL in Python – was es ist und wie es die Leistung beeinflusst

1. Übersicht

Viele Entwickler wechseln von Java oder Go zu Python und stoßen auf das gleiche Problem. Sie führen 4 Threads auf einem 8-Core-Rechner aus, aber das Programm wird nicht schneller, manchmal sogar langsamer. Der Schuldige ist normalerweise das GIL (Global Interpreter Lock).

Dieser Artikel erklärt, was GIL ist und warum es existiert. Sie werden den Einfluss von GIL auf CPU-bound und I/O-bound Code messen. Am Ende des Artikels wissen Sie, wann Sie threading, wann Sie multiprocessing verwenden sollten, und was sich mit dem free-threaded Build von Python 3.13+ ändert.

2. Was ist GIL?

Das GIL ist ein Mutex in CPython, der am weitesten verbreiteten Python-Implementierung. Zu jedem Zeitpunkt in einem Prozess kann nur ein Thread Python-Bytecode ausführen.

Sie können immer noch viele Threads erstellen. Die Threads sind echte Betriebssystem-Threads. Aber um Bytecode auszuführen, muss ein Thread das GIL halten. Andere Threads müssen warten.

2.1. Warum braucht CPython das GIL?

Der Hauptgrund ist der Memory-Management-Mechanismus durch Referenzzählung. Jedes Objekt hat einen Zähler für die Anzahl der Verweise darauf:

import sys

order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))

alias = order
print(sys.getrefcount(order))
2
3

sys.getrefcount gibt die Anzahl der Verweise zurück. Das Ergebnis ist immer mindestens 1, da der Funktionsaufruf selbst eine temporäre Referenz erzeugt. Wenn Sie alias = order zuweisen, erhöht sich der Zähler um 1.

Wenn zwei Threads diesen Zähler gleichzeitig erhöhen oder verringern, ohne ein Lock zu verwenden, kann der Zähler falsch werden. Die Folge ist ein Memory Leak oder schlimmer: Das Objekt wird freigegeben, während noch jemand es benutzt. Ein globales Lock ist die einfachste Lösung zum Schutz aller Objekte. Es macht auch das Schreiben von C-Erweiterungen einfacher und macht Single-Thread-Code schneller.

2.2. Wann gibt ein Thread das GIL frei?

Ein Thread, der das GIL hält, gibt es in zwei Fällen frei:

  1. Wenn es auf I/O wartet: Datei lesen, Netzwerkaufruf, time.sleep… Diese Funktionen geben das GIL frei, bevor sie blockieren.
  2. Wenn seine Zeitscheibe vorbei ist: Der Interpreter fordert den laufenden Thread regelmäßig auf, das GIL freizugeben, damit andere Threads eine Chance haben.

Sie können die Länge einer Zeitscheibe überprüfen:

import sys

print(sys.getswitchinterval())
0.005

Der Standard beträgt 5 ms. Sie können dies mit sys.setswitchinterval() ändern, aber es ist fast nie nötig.

3. Auswirkungen auf CPU-gebundene Code

Lassen Sie uns das messen. Die folgende Funktion zählt Primzahlen, eine reine Rechnung:

import time
from concurrent.futures import ThreadPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


def run_sequential(jobs: list[int]) -> float:
    start = time.perf_counter()
    for job in jobs:
        count_primes(job)
    return time.perf_counter() - start


def run_threads(jobs: list[int]) -> float:
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(count_primes, jobs))
    return time.perf_counter() - start


if __name__ == "__main__":
    jobs = [200_000] * 4
    print(f"Sequenziell : {run_sequential(jobs):.2f}s")
    print(f"4 Threads: {run_threads(jobs):.2f}s")

run_sequential führt 4 Jobs nacheinander aus. run_threads verteilt 4 Jobs auf 4 Threads. Das Ergebnis auf einem 8-Core-Rechner (nur als Referenz):

Sequenziell : 2.31s
4 Threads: 2.40s

Vier Threads sind nicht schneller. Sogar etwas langsamer. Wegen des GIL wechseln sich die Threads auf einem Core ab. Der zusätzliche Overhead kommt vom GIL-Contention und Context Switching.

Hinweis: Das ist der häufigste Fehler. Das Hinzufügen von Threads zu reinem Python-Rechencode auf CPython mit GIL macht ihn nicht schneller.

4. Auswirkungen auf I/O-gebundene Code

Bei Code, der hauptsächlich auf I/O wartet, ist die Situation ganz anders. Wir simulieren einen Aufruf an einen Remote PaymentService, der 0,5 Sekunden dauert:

import time
from concurrent.futures import ThreadPoolExecutor


def charge_payment(order_id: int) -> str:
    time.sleep(0.5)  # HTTP-Aufruf zum Payment-Gateway simulieren
    return f"order-{order_id}: OK"


if __name__ == "__main__":
    order_ids = list(range(10))

    start = time.perf_counter()
    for oid in order_ids:
        charge_payment(oid)
    print(f"Sequenziell  : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=10) as pool:
        list(pool.map(charge_payment, order_ids))
    print(f"10 Threads: {time.perf_counter() - start:.2f}s")
Sequenziell  : 5.01s
10 Threads: 0.50s

10-mal schneller. time.sleep sowie Socket oder Dateioperationen geben das GIL während des Wartens frei. Zehn Threads können parallel warten. Sie benötigen das GIL nur für kurze Momente, um die Ergebnisse zu verarbeiten.

Fazit: Threads in Python sind immer noch sehr nützlich für I/O-gebundene Aufgaben, z. B. API-Aufrufe, Datenbankabfragen oder Datei-Downloads. Bei sehr vielen I/O-Operationen ist auch asyncio eine gute Wahl.

5. Das GIL mit Multiprocessing umgehen

Um mehrere Cores für Berechnungen zu nutzen, ist der Standard-Weg, mehrere Prozesse zu verwenden. Jeder Prozess hat seinen eigenen Interpreter und sein eigenes GIL.

import time
from concurrent.futures import ProcessPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


if __name__ == "__main__":
    jobs = [200_000] * 4
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(count_primes, jobs))
    print(results)
    print(f"4 Prozesse: {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4 Prozesse: 0.68s

Im Vergleich zu 2,31 Sekunden bei sequenzieller Ausführung sind wir etwa 3,4 Mal schneller. Nicht die vollen 4x, weil der Prozess-Startaufwand zählt.

Der Block if __name__ == "__main__": ist erforderlich. Unter Windows und macOS werden Child-Prozesse durch Reimport des Moduls erstellt. Ohne diesen Block erstellt der Child-Prozess wieder einen neuen Pool und das Programm fehlschlägt.

Hinweis: Multiprocessing hat seinen Preis. Parameter und Ergebnisse müssen mit pickle serialisiert werden, um zwischen Prozessen übertragen zu werden. Wenn Sie große Objekte, wie ein DataFrame mit Hunderten von MB, für eine kleine Aufgabe senden, können die Kopierkosten den Nutzen übersteigen. Lambda und verschachtelte Funktionen können auch nicht gepickelt werden.

6. C-Erweiterungen können das GIL freigeben

Viele in C geschriebene Bibliotheken geben das GIL aktiv frei, wenn sie schwere Berechnungen ausführen, z. B. NumPy, hashlib oder zlib. In diesem Fall laufen Threads wirklich parallel:

import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor

payloads = [os.urandom(50_000_000) for _ in range(4)]


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


if __name__ == "__main__":
    start = time.perf_counter()
    for p in payloads:
        digest(p)
    print(f"Sequenziell : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(digest, payloads))
    print(f"4 Threads: {time.perf_counter() - start:.2f}s")
Sequenziell : 0.98s
4 Threads: 0.27s

hashlib gibt das GIL frei, wenn es große Daten hasht (ab etwa 2 KB). So laufen 4 Threads auf 4 Cores. In Wirklichkeit blockiert das GIL nur Python-Bytecode, nicht nativen Code von Bibliotheken.

7. Das GIL macht deinen Code nicht thread-safe

Viele Menschen denken „mit GIL braucht man keine Locks”. Das ist falsch. Das GIL schützt den internen Zustand des Interpreters, nicht deine Logik. Ein Thread kann zwischen zwei beliebigen Anweisungen unterbrochen werden.

import threading
import time


class BankAccount:
    def __init__(self) -> None:
        self.balance = 0
        self._lock = threading.Lock()

    def deposit_unsafe(self, amount: int) -> None:
        current = self.balance
        time.sleep(0)  # GIL freigeben, simuliert eine I/O-Operation dazwischen
        self.balance = current + amount

    def deposit_safe(self, amount: int) -> None:
        with self._lock:
            current = self.balance
            time.sleep(0)
            self.balance = current + amount


def run(method_name: str, threads: int = 10, times: int = 100) -> int:
    account = BankAccount()
    method = getattr(account, method_name)

    def worker() -> None:
        for _ in range(times):
            method(1)

    pool = [threading.Thread(target=worker) for _ in range(threads)]
    for t in pool:
        t.start()
    for t in pool:
        t.join()
    return account.balance


if __name__ == "__main__":
    print("unsafe:", run("deposit_unsafe"))
    print("safe  :", run("deposit_safe"))
unsafe: 112
safe  : 1000

Wir erwarten 10 × 100 = 1000. Bei deposit_unsafe lesen mehrere Threads denselben alten current-Wert und überschreiben sich gegenseitig, wodurch die meisten Einzahlungen verloren gehen. time.sleep(0) hilft, den Fehler zuverlässig zu zeigen. Im echten Code könnte die GIL-Freigabe bei einem Log-Statement oder einem Datenbankaufruf erfolgen. deposit_safe verwendet threading.Lock, um die Lese-Änder-Schreib-Operation atomar zu machen.

Wir schreiben einen Test mit pytest, um dieses Verhalten zu dokumentieren. Installation:

pip install pytest
# test_bank_account.py
from bank_account import run


def test_safe_deposit_keeps_correct_balance():
    assert run("deposit_safe", threads=8, times=200) == 1600


def test_unsafe_deposit_loses_updates():
    assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
..                                                       [100%]
2 passed in 0.41s

Der zweite Test beweist, dass Race Conditions auch in CPython mit GIL real sind.

8. Python free-threaded: Die Zukunft ohne GIL

Ab Python 3.13 hat CPython einen free-threaded Build (PEP 703), der das GIL deaktivieren lässt. Normalerweise führen Sie diese Version mit dem Befehl python3.13t oder python3.14t aus. In Python 3.14 ist diese Version offiziell unterstützt (PEP 779), aber noch nicht der Standard.

Überprüfen Sie den GIL-Status zur Laufzeit:

import sys
import sysconfig

print("Build unterstützt free-threading:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL ist aktiviert:", sys._is_gil_enabled())

Mit dem free-threaded Build ausführen:

python3.14t check_gil.py
Build unterstützt free-threading: True
GIL ist aktiviert: False

Mit dieser Version kann das count_primes-Beispiel mit ThreadPoolExecutor aus Abschnitt 3 wirklich mehrere Cores nutzen.

Allerdings müssen Sie mehrere Punkte berücksichtigen, bevor Sie wechseln:

  • Single-Thread-Code ist etwas langsamer, da der Interpreter raffiniertere Synchronisationsmechanismen verwenden muss.
  • C-Erweiterungen müssen kompatibel sein. Wenn Sie eine Extension importieren, die keine Unterstützung für free-threading erklärt, aktiviert CPython das GIL automatisch wieder und gibt eine Warnung aus. Sie können es mit PYTHON_GIL=0 erzwingen, aber das ist auf eigenes Risiko.
  • Race Conditions können leichter auftreten. Das Beispiel in Abschnitt 7 ist nicht mehr durch den Fakt „nur ein Thread läuft zu einem Zeitpunkt” geschützt.

9. Welches Tool sollte man wählen?

Art der Arbeit Zu verwenden
I/O-bound (HTTP, DB, Datei) threading, ThreadPoolExecutor, asyncio
CPU-bound reines Python ProcessPoolExecutor / multiprocessing
CPU-bound mit NumPy, hashlib… Threads funktionieren immer noch, da die Bibliothek das GIL freigibt
CPU-bound, braucht geteilten Speicher, Bibliothek ist bereits kompatibel Erwägen Sie Python free-threaded (3.13t/3.14t)

Hinweis: Optimieren Sie nicht nach Gefühl. Messen Sie zuerst mit time.perf_counter() oder cProfile. Viele Programme, die „langsam wegen GIL” sind, sind eigentlich langsam wegen des Algorithmus oder von Datenbankabfragen.

10. Fazit

Das GIL ist ein globales Lock von CPython. Es stellt sicher, dass zu jedem Zeitpunkt nur ein Thread Python-Bytecode ausführt, hauptsächlich zum Schutz der Referenzzählung.

Die praktischen Folgen sind vier Punkte:

  • Threads beschleunigen reinen Python-CPU-bound-Code nicht.
  • Threads sind immer noch sehr effektiv für I/O-bound und für native Bibliotheken, die das GIL freigeben.
  • Um mehrere Cores für Berechnungen zu nutzen, verwenden Sie Multiprocessing oder probieren Sie den free-threaded Build von Python 3.13+.
  • Das GIL ersetzt Locks nicht. Gemeinsame Daten zwischen Threads müssen immer noch korrekt synchronisiert werden.
pythongilconcurrencymultithreadingperformance