Volver al inicio

GIL CPython: cómo funciona y cómo eludirlo senior

El artículo desglosa GIL en CPython: protección refcount, mecanismo de conmutación, diferencias CPU/I/O-bound. Discute Lock/RLock, multiprocessing, liberación de GIL en extensiones C y asyncio.

GIL en CPython: desglose completo para entrevistas senior
Advertisement 728x90

El GIL de CPython: Mecanismo, Limitaciones y Soluciones para Desarrolladores Senior

En CPython, el GIL protege el conteo de referencias de condiciones de carrera. Sin él, dos hilos podrían decrementar simultáneamente el ob_refcnt del mismo objeto: ambos verían un valor de 1, ambos liberarían la memoria, lo que resultaría en un segfault.

Términos clave:

  • Segfault: Acceder a memoria liberada, desencadenando SIGSEGV.
  • Opcode: Una instrucción de bytecode atómica (usa dis para ver).
  • Conteo de referencias: Cuando ob_refcnt cae a 0, el objeto se destruye.
  • Mutex: Solo un hilo puede entrar en una sección crítica a la vez.
  • Condición de carrera: El resultado depende del orden de ejecución de los hilos.
  • Bytecode: .py compilado en .pyc, ejecutado por la VM.
  • Cambio de contexto: Sobrecarga de ~microsegundos, interrumpe la caché de la CPU.

Ejemplo de una condición de carrera sin el GIL:

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

Propósito del GIL en CPython

El GIL es un mutex global que permite que solo un hilo ejecute bytecode a la vez. Los hilos pueden trabajar en paralelo durante las llamadas al sistema, pero no en código Python.

Por qué es necesario:

  • Protege ob_refcnt de modificaciones simultáneas.
  • Simplifica las extensiones en C sin preocupaciones de seguridad de hilos.
  • Proporciona seguridad de hilos económica sin sincronización compleja.

El GIL es específico de CPython. Jython e IronPython no lo tienen.

Google AdInline article slot

Mecanismo Interno del GIL (desde 3.2+)

Nueva implementación en Python/ceval_gil.c: la estructura _gil_runtime_state.

Componentes clave:

  • locked: Bandera atómica (0/1).
  • mutex + cond_var: Primitivas POSIX para esperar.
  • eval_breaker: Verificado después de cada opcode.
  • gil_drop_request: Solicitud de un hilo en espera.

Algoritmo:

Google AdInline article slot
  • Un hilo mantiene el GIL y ejecuta bytecode.
  • Después de 5 ms (sys.getswitchinterval()), otro hilo envía una señal.
  • El hilo actual ve la bandera a través de eval_breaker y libera el GIL.
  • El hilo en espera lo adquiere; el hilo actual espera en cond_var.

sys.setswitchinterval(): Por defecto es 0.005 segundos. Aumenta a 0.05–0.1 segundos para tareas intensivas en CPU con NumPy (menos trashing). No ajustes para E/S.

Por qué se Necesitan Lock/RLock a Pesar del GIL

El GIL es atómico por opcode, pero counter += 1 implica LOAD_GLOBAL + BINARY_ADD + STORE_GLOBAL.

counter += 1
# Puede ser interrumpido después de LOAD_GLOBAL
# Dos hilos: +1 en lugar de +2

Solución:

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

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

RLock para reentrancia:

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

    def insert(self, value):
        with self._lock:
            self._rebalance()  # sin deadlock

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

Regla: El GIL protege el intérprete; Lock protege la lógica de negocio.

Tareas Intensivas en CPU vs Intensivas en E/S

Intensivas en E/S (red/disco): El GIL se libera durante las llamadas al sistema (lectura/escritura). threading/asyncio proporcionan concurrencia.

import threading, requests

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

Intensivas en CPU (cálculos): El GIL bloquea el paralelismo. Usa 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 GILs

Liberar el GIL en Extensiones en C

NumPy/pandas liberan el GIL durante operaciones pesadas:

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

threading + NumPy = paralelismo real. Python puro en hilos no.

asyncio e Independencia del GIL

Multitarea cooperativa en un solo hilo. El cambio ocurre en 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 para E/S:

  • threading: Hilos del SO, sobrecarga, condiciones de carrera.
  • asyncio: 1 hilo, sin sobrecarga, control explícito.

Niveles de Seguridad de Hilo/Proceso/Async

| Tipo | Problema | Memoria Compartida | Herramientas | Caso de Uso |

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

| Hilo | Carrera en hilos | Sí | Lock, Queue, local() | ThreadPoolExecutor |

| Proceso | Carrera de procesos | Explícitamente | multiprocessing.Lock, Manager | Celery, Gunicorn |

| Async | Carrera de corrutinas | Un solo hilo | asyncio.Lock, evitar mutabilidad | FastAPI |

Ejemplo de carrera en Async:

# NO seguro
async def transfer(from_acc, to_acc, amount):
    balance = await db.get_balance(from_acc)
    if balance >= amount:  # yield aquí
        await db.deduct(from_acc, amount)

Usa asyncio.Lock o transacciones de BD.

Conclusiones Clave

  • El GIL protege el conteo de referencias, no la lógica de negocio—siempre usa Lock.
  • Para tareas intensivas en CPU: multiprocessing, no threading.
  • Las extensiones en C (NumPy) permiten paralelismo en hilos.
  • asyncio ignora el GIL: multitarea cooperativa en un solo hilo.
  • sys.setswitchinterval() rara vez se necesita, solo para tareas con mucho NumPy.

— Editorial Team

Advertisement 728x90

Leer después