Powrót do strony głównej

GIL CPython: jak działa i jak omijać senior

Artykuł omawia GIL w CPython: ochrona refcount, mechanizm przełączania, różnice CPU/I/O-bound. Omówiono Lock/RLock, multiprocessing, zwalnianie GIL w C-rozszerzeniach i asyncio.

GIL w CPython: pełny rozbiór dla senior-wywiadów
Advertisement 728x90

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:

Google AdInline article slot
# 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ą.

Google AdInline article slot

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:

Google AdInline article slot
  • 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

Advertisement 728x90

Czytaj dalej