Der CPython GIL: Mechanismus, Grenzen und Workarounds für erfahrene Entwickler
In CPython schützt der GIL die Referenzzählung vor Race Conditions. Ohne ihn könnten zwei Threads gleichzeitig den ob_refcnt desselben Objekts dekrementieren: beide sehen einen Wert von 1, beide geben den Speicher frei – was zu einem Segfault führt.
Schlüsselbegriffe:
- Segfault: Zugriff auf freigegebenen Speicher, löst SIGSEGV aus.
- Opcode: Eine atomare Bytecode-Anweisung (mit dis anzeigen).
- Referenzzählung: Wenn ob_refcnt auf 0 fällt, wird das Objekt zerstört.
- Mutex: Nur ein Thread kann gleichzeitig einen kritischen Abschnitt betreten.
- Race Condition: Das Ergebnis hängt von der Reihenfolge der Thread-Ausführung ab.
- Bytecode: Kompilierte .py in .pyc, ausgeführt von der VM.
- Kontextwechsel: Overhead von ~Mikrosekunden, stört CPU-Cache.
Beispiel einer Race Condition ohne GIL:
# thread1: refcount=1 -> free(obj)
# thread2: refcount=1 -> free(obj) -> segfault
Zweck des GIL in CPython
Der GIL ist ein globaler Mutex, der nur einem Thread erlaubt, gleichzeitig Bytecode auszuführen. Threads können während Systemaufrufen parallel arbeiten, aber nicht in Python-Code.
Warum er benötigt wird:
- Schützt ob_refcnt vor gleichzeitigen Änderungen.
- Vereinfacht C-Erweiterungen ohne Thread-Safety-Bedenken.
- Bietet günstige Thread-Safety ohne komplexe Synchronisation.
Der GIL ist spezifisch für CPython. Jython und IronPython haben ihn nicht.
Interner Mechanismus des GIL (seit 3.2+)
Neue Implementierung in Python/ceval_gil.c: die _gil_runtime_state-Struktur.
Schlüsselkomponenten:
locked: Atomares Flag (0/1).mutex+cond_var: POSIX-Primitive für Warteoperationen.eval_breaker: Nach jedem Opcode geprüft.gil_drop_request: Anforderung von einem wartenden Thread.
Algorithmus:
- Ein Thread hält den GIL und führt Bytecode aus.
- Nach 5 ms (sys.getswitchinterval()) signalisiert ein anderer Thread.
- Der aktuelle Thread sieht das Flag über eval_breaker und gibt den GIL frei.
- Der wartende Thread erhält ihn; der aktuelle Thread wartet auf cond_var.
sys.setswitchinterval(): Standard ist 0,005 Sekunden. Für CPU-intensive Aufgaben mit NumPy erhöhen (0,05–0,1 Sekunden, weniger Thrashing). Nicht für I/O anpassen.
Warum Lock/RLock trotz GIL benötigt werden
Der GIL ist atomar pro Opcode, aber counter += 1 umfasst LOAD_GLOBAL + BINARY_ADD + STORE_GLOBAL.
counter += 1
# Kann nach LOAD_GLOBAL unterbrochen werden
# Zwei Threads: +1 statt +2
Lösung:
import threading
counter = 0
lock = threading.Lock()
def safe_increment():
global counter
with lock:
counter += 1
RLock für Reentrancy:
class SafeTree:
def __init__(self):
self._lock = threading.RLock()
def insert(self, value):
with self._lock:
self._rebalance() # kein Deadlock
def _rebalance(self):
with self._lock:
pass
Regel: Der GIL schützt den Interpreter; Lock schützt die Geschäftslogik.
CPU-intensive vs. I/O-intensive Aufgaben
I/O-intensive (Netzwerk/Festplatte): Der GIL wird während Systemaufrufen (read/write) freigegeben. threading/asyncio bieten Nebenläufigkeit.
import threading, requests
def fetch(url):
r = requests.get(url) # GIL frei
return r.status_code
CPU-intensive (Berechnungen): Der GIL blockiert Parallelität. Multiprocessing verwenden.
from multiprocessing import Pool
def heavy_compute(n):
return sum(i * i for i in range(n))
with Pool(4) as p:
results = p.map(heavy_compute, [10**7]*4) # 4 GILs
Freigabe des GIL in C-Erweiterungen
NumPy/pandas geben den GIL während rechenintensiver Operationen frei:
Py_BEGIN_ALLOW_THREADS
matrix_multiply(a, b, result, n);
Py_END_ALLOW_THREADS
threading + NumPy = echte Parallelität. Reiner Python-Code in Threads nicht.
asyncio und Unabhängigkeit vom GIL
Kooperatives Multitasking in einem einzelnen Thread. Wechsel erfolgt bei await.
import asyncio
import aiohttp
async def fetch_data(session, url):
async with session.get(url) as resp:
return await resp.json()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch_data(session, url) for url in urls]
results = await asyncio.gather(*tasks)
threading vs. asyncio für I/O:
- threading: OS-Threads, Overhead, Race Conditions.
- asyncio: 1 Thread, kein Overhead, explizite Kontrolle.
Ebenen der Thread-/Prozess-/Async-Sicherheit
| Typ | Problem | Gemeinsamer Speicher | Tools | Anwendungsfall |
|------|---------|---------------|-------|----------|
| Thread | Race in Threads | Ja | Lock, Queue, local() | ThreadPoolExecutor |
| Prozess | Prozess-Races | Explizit | multiprocessing.Lock, Manager | Celery, Gunicorn |
| Async | Coroutine-Races | Einzelner Thread | asyncio.Lock, Mutabilität vermeiden | FastAPI |
Async-Race-Beispiel:
# NICHT sicher
async def transfer(from_acc, to_acc, amount):
balance = await db.get_balance(from_acc)
if balance >= amount: # yield hier
await db.deduct(from_acc, amount)
asyncio.Lock oder DB-Transaktionen verwenden.
Wichtige Erkenntnisse
- Der GIL schützt die Referenzzählung, nicht die Geschäftslogik – immer Lock verwenden.
- Für CPU-intensive Aufgaben: Multiprocessing, nicht Threading.
- C-Erweiterungen (NumPy) ermöglichen Parallelität in Threads.
- asyncio ignoriert den GIL: Kooperatives Multitasking in einem Thread.
- sys.setswitchinterval() wird selten benötigt, nur für NumPy-lastige Aufgaben.
— Editorial Team
Noch keine Kommentare.