Zpět na domů

GIL CPython: jak funguje a jak obejít senior

Článek rozebírá GIL v CPython: ochrana refcount, mechanismus přepínání, rozdíly CPU/I/O-bound. Jsou diskutovány Lock/RLock, multiprocessing, uvolnění GIL v C-rozšířeních a asyncio.

GIL v CPython: úplný rozbor pro senior pohovory
Advertisement 728x90

GIL v CPythonu: mechanismus, omezení a způsoby obejití pro seniorní vývojáře

V CPythonu GIL chrání reference counting před race condition. Bez něj by dva vlákna současně dekrementovala ob_refcnt jednoho objektu: obě by viděla hodnotu 1, obě by uvolnila paměť – segfault.

Klíčové termíny:

  • Segfault: přístup k uvolněné paměti, signál SIGSEGV.
  • Opcode: atomární instrukce bajtkódu (dis pro zobrazení).
  • Reference counting: ob_refcnt klesne na 0 – objekt je zničen.
  • Mutex: pouze jedno vlákno v kritické sekci.
  • Race condition: výsledek závisí na pořadí provádění vláken.
  • Bytecode: zkompilovaný .py do .pyc, provádí se VM.
  • Context switch: režie ~mikrosekundy, kazí CPU-cache.

Příklad race bez GIL:

Google AdInline article slot
# thread1: refcount=1 -> free(obj)
# thread2: refcount=1 -> free(obj) -> segfault

Účel GIL v CPythonu

GIL je globální mutex, který umožňuje provádění bajtkódu pouze jednomu vláknu současně. Vlákna mohou pracovat paralelně v systémových voláních, ale ne v Python kódu.

Proč je potřeba:

  • Ochrana ob_refcnt před současnými změnami.
  • Zjednodušení C-rozšíření bez thread-safety.
  • Levná thread-safety bez složité synchronizace.

GIL je relevantní pouze pro CPython. Jython/IronPython ho nemají.

Google AdInline article slot

Vnitřní mechanismus GIL (od 3.2+)

Nová implementace v Python/ceval_gil.c: struktura _gil_runtime_state.

Klíčové komponenty:

  • locked: atomární příznak (0/1).
  • mutex + cond_var: POSIX-primitivy pro čekání.
  • eval_breaker: kontrola po každém opcode.
  • gil_drop_request: požadavek od čekajícího vlákna.

Algoritmus:

Google AdInline article slot
  • Vlákno drží GIL, provádí bajtkód.
  • Po 5 ms (sys.getswitchinterval()) signalizuje jiné vlákno.
  • Současné vidí příznak přes eval_breaker, uvolní GIL.
  • Čekající zachytí, současné čeká na cond_var.

sys.setswitchinterval(): výchozí 0.005 s. Zvýšit na 0.05–0.1 s pro CPU-bound s NumPy (méně thrashing). Neměnit pro I/O.

Proč Lock/RLock jsou potřeba i s GIL

GIL je atomární pro jeden opcode, ale counter += 1 – to je LOAD_GLOBAL + BINARY_ADD + STORE_GLOBAL.

counter += 1
# Může být přerušeno po LOAD_GLOBAL
# Dvě vlákna: +1 místo +2

Řešení:

import threading
counter = 0
lock = threading.Lock()

def safe_increment():
    global counter
    with lock:
        counter += 1

RLock pro reentrantnost:

class SafeTree:
    def __init__(self):
        self._lock = threading.RLock()

    def insert(self, value):
        with self._lock:
            self._rebalance()  # žádný deadlock

    def _rebalance(self):
        with self._lock:
            pass

Pravidlo: GIL chrání interpret, Lock – obchodní logiku.

CPU-bound vs I/O-bound úlohy

I/O-bound (síť/disk): GIL se uvolňuje v systémových voláních (read/write). threading/asyncio poskytují paralelismus.

import threading, requests

def fetch(url):
    r = requests.get(url)  # GIL free
    return r.status_code

CPU-bound (výpočty): GIL blokuje paralelismus. Použijte 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

Uvolnění GIL v C-rozšířeních

NumPy/pandas uvolňují GIL v těžkých operacích:

Py_BEGIN_ALLOW_THREADS
matrix_multiply(a, b, result, n);
Py_END_ALLOW_THREADS

threading + NumPy = skutečný paralelismus. Čistý Python ve vláknech – ne.

asyncio a nezávislost na GIL

Kooperativní multitasking v jednom vlákně. Přepnutí 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 pro I/O:

  • threading: OS-vlákna, režie, race conditions.
  • asyncio: 1 vlákno, žádná režie, explicitní řízení.

Úrovně thread/process/async safety

| Typ | Problém | Sdílená paměť | Nástroje | Použití |

|-----|----------|--------------|-------------|------------|

| Thread | race ve vláknech | ano | Lock, Queue, local() | ThreadPoolExecutor |

| Process | závody procesů | explicitně | multiprocessing.Lock, Manager | Celery, Gunicorn |

| Async | závody korutin | jedno vlákno | asyncio.Lock, bez mutability | FastAPI |

Příklad Async race:

# Nebezpečné
async def transfer(from_acc, to_acc, amount):
    balance = await db.get_balance(from_acc)
    if balance >= amount:  # zde yield
        await db.deduct(from_acc, amount)

Použijte asyncio.Lock nebo DB-transakce.

Co je důležité

  • GIL chrání refcount, ale ne obchodní logiku – vždy používejte Lock.
  • Pro CPU-bound: multiprocessing, ne threading.
  • C-rozšíření (NumPy) poskytují paralelismus ve vláknech.
  • asyncio ignoruje GIL: kooperativnost v jednom vlákně.
  • sys.setswitchinterval() je zřídka potřeba, jen pro NumPy-heavy úlohy.

— Editorial Team

Advertisement 728x90

Číst dál