GIL w CPython: mechanizm, ograniczenia i obejścia dla senior developerów
W CPython GIL chroni reference counting przed race condition. Bez niego dwa wątki jednocześnie dekrementują ob_refcnt jednego obiektu: oba widzą wartość 1, oba zwalniają pamięć — segfault.
Kluczowe terminy:
- Segfault: odwołanie do zwolnionej pamięci, sygnał SIGSEGV.
- Opcode: atomowa instrukcja bajtkodu (dis do przeglądania).
- Reference counting: ob_refcnt spada do 0 — obiekt jest niszczony.
- Mutex: tylko jeden wątek w sekcji krytycznej.
- Race condition: wynik zależy od kolejności wykonania wątków.
- Bytecode: skompilowany .py w .pyc, wykonywany przez VM.
- Context switch: narzut ~mikrosekundy, psuje cache CPU.
Przykład race bez GIL:
# wątek1: refcount=1 -> free(obj)
# wątek2: refcount=1 -> free(obj) -> segfault
Przeznaczenie GIL w CPython
GIL to globalny mutex, który pozwala na wykonanie bajtkodu tylko jednemu wątkowi naraz. Wątki mogą działać równolegle w wywołaniach systemowych, ale nie w kodzie Python.
Po co jest potrzebny:
- Ochrona ob_refcnt przed jednoczesnymi zmianami.
- Uproszczenie rozszerzeń C bez thread-safety.
- Tania thread-safety bez skomplikowanej synchronizacji.
GIL dotyczy tylko CPython. Jython/IronPython go nie mają.
Wewnętrzny mechanizm GIL (od 3.2+)
Nowa implementacja w Python/ceval_gil.c: struktura _gil_runtime_state.
Kluczowe komponenty:
locked: atomowa flaga (0/1).mutex+cond_var: prymitywy POSIX do oczekiwania.eval_breaker: sprawdzenie po każdym opcode.gil_drop_request: żądanie od oczekującego wątku.
Algorytm:
- Wątek trzyma GIL, wykonuje bajtkod.
- Po 5 ms (sys.getswitchinterval()) inny wątek sygnalizuje.
- Bieżący widzi flagę przez eval_breaker, zwalnia GIL.
- Oczekujący przejmuje, bieżący czeka na cond_var.
sys.setswitchinterval(): domyślnie 0.005 s. Zwiększyć do 0.05–0.1 s dla CPU-bound z NumPy (mniej thrashing). Nie zmieniać dla I/O.
Dlaczego Lock/RLock są potrzebne przy GIL
GIL jest atomowy dla jednego opcode, ale counter += 1 to LOAD_GLOBAL + BINARY_ADD + STORE_GLOBAL.
counter += 1
# Może przerwać po LOAD_GLOBAL
# Dwa wątki: +1 zamiast +2
Rozwiązanie:
import threading
counter = 0
lock = threading.Lock()
def safe_increment():
global counter
with lock:
counter += 1
RLock dla reentrant:
class SafeTree:
def __init__(self):
self._lock = threading.RLock()
def insert(self, value):
with self._lock:
self._rebalance() # nie deadlock
def _rebalance(self):
with self._lock:
pass
Zasada: GIL chroni interpreter, Lock — logikę biznesową.
Zadania CPU-bound vs I/O-bound
I/O-bound (sieć/dysk): GIL jest oddawany w wywołaniach systemowych (read/write). threading/asyncio dają równoległość.
import threading, requests
def fetch(url):
r = requests.get(url) # GIL free
return r.status_code
CPU-bound (obliczenia): GIL blokuje równoległość. Użyj multiprocessing.
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 GIL
Zwolnienie GIL w rozszerzeniach C
NumPy/pandas zwalniają GIL w ciężkich operacjach:
Py_BEGIN_ALLOW_THREADS
matrix_multiply(a, b, result, n);
Py_END_ALLOW_THREADS
threading + NumPy = prawdziwa równoległość. Czysty Python w wątkach — nie.
asyncio i brak zależności od GIL
Kooperacyjna wielozadaniowość w jednym wątku. Przełączenie na 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 dla I/O:
- threading: wątki OS, narzut, race conditions.
- asyncio: 1 wątek, brak narzutu, jawne zarządzanie.
Poziomy thread/process/async safety
| Typ | Problem | Pamięć współdzielona | Narzędzia | Zastosowanie |
|-----|----------|--------------|-------------|------------|
| Thread | race w wątkach | tak | Lock, Queue, local() | ThreadPoolExecutor |
| Process | wyścigi procesów | jawnie | multiprocessing.Lock, Manager | Celery, Gunicorn |
| Async | wyścigi korutyn | jeden wątek | asyncio.Lock, bez mutowalności | FastAPI |
Przykład Async race:
# NIEbezpiecznie
async def transfer(from_acc, to_acc, amount):
balance = await db.get_balance(from_acc)
if balance >= amount: # tutaj yield
await db.deduct(from_acc, amount)
Użyj asyncio.Lock lub transakcji DB.
Co jest ważne
- GIL chroni refcount, ale nie logikę biznesową — zawsze używaj Lock.
- Dla CPU-bound: multiprocessing, nie threading.
- Rozszerzenia C (NumPy) dają równoległość w wątkach.
- asyncio ignoruje GIL: kooperacyjność w jednym wątku.
- sys.setswitchinterval() rzadko potrzebny, tylko dla zadań z NumPy-heavy.
— Editorial Team
Brak komentarzy.