El GIL de CPython: qué protege y qué cuesta

El Global Interpreter Lock (GIL) es un mutex a nivel de intérprete que garantiza que solo un thread pueda ejecutar bytecode Python en cualquier momento dado. No es un detalle de implementación menor: es una decisión de diseño fundamental de CPython que tiene consecuencias directas en cómo piensas la concurrencia.

Por qué existe: reference counting y el infierno sin GIL

CPython gestiona la memoria mediante reference counting. Cada objeto Python tiene un campo ob_refcnt que se incrementa cuando algo apunta al objeto y se decrementa cuando esa referencia desaparece. Cuando llega a cero, el objeto se libera.

El problema es que incrementar y decrementar un entero no es atómico a nivel de CPU. Sin sincronización, dos threads pueden leer ob_refcnt = 1 simultáneamente, ambos decrementarlo a 0, y ambos intentar liberar el mismo objeto. El resultado es corrupción de memoria o un segfault. Podrías proteger cada objeto con su propio lock, pero eso introduce deadlocks potenciales, overhead masivo y complejidad brutal en la implementación del intérprete. La decisión de CPython fue un único lock global: simple, predecible, y suficientemente bueno para la mayoría de casos de uso de 1992.

Usas threads cuando el trabajo es I/O-bound: llamadas de red, lectura de disco, consultas a bases de datos. Para trabajo CPU-bound en CPython, los threads no te dan paralelismo real — te dan concurrencia ilusoria con overhead de cambio de contexto encima.

Si lanzas cuatro threads para calcular números primos esperando usar cuatro núcleos, vas a terminar más lento que con un solo thread. Eso sí rompe si no lo tienes claro.

import threading
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor

# ── Caso 1: CPU-bound — el GIL no ayuda ──────────────────────────────────────

def count_up(n: int) -> int:
    """Trabajo puro de CPU: incrementar un contador N veces."""
    total = 0
    for _ in range(n):
        total += 1
    return total

def benchmark_cpu() -> None:
    N = 50_000_000

    start = time.perf_counter()
    count_up(N)
    single_elapsed = time.perf_counter() - start

    start = time.perf_counter()
    t1 = threading.Thread(target=count_up, args=(N // 2,))
    t2 = threading.Thread(target=count_up, args=(N // 2,))
    t1.start(); t2.start()
    t1.join(); t2.join()
    threaded_elapsed = time.perf_counter() - start

    print(f"CPU-bound  | single: {single_elapsed:.2f}s | 2 threads: {threaded_elapsed:.2f}s")
    # threaded_elapsed >= single_elapsed: el GIL serializa la ejecución
    # y además añade overhead de switching entre threads


# ── Caso 2: I/O-bound — el GIL se libera mientras esperamos ──────────────────

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

def fetch(url: str) -> int:
    """Descarga la URL y devuelve el código HTTP."""
    with urllib.request.urlopen(url, timeout=10) as response:
        # Durante urlopen, CPython llama al socket de C subyacente.
        # La extensión C libera el GIL con Py_BEGIN_ALLOW_THREADS
        # antes de bloquearse en el syscall de red.
        return response.status

def benchmark_io() -> None:
    start = time.perf_counter()
    results_seq = [fetch(url) for url in URLS]
    seq_elapsed = time.perf_counter() - start

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        results_par = list(pool.map(fetch, URLS))
    par_elapsed = time.perf_counter() - start

    print(f"I/O-bound  | secuencial: {seq_elapsed:.1f}s | 4 threads: {par_elapsed:.1f}s")
    # par_elapsed ≈ 1s, seq_elapsed ≈ 4s
    # Los threads se solapan porque el GIL está libre mientras el kernel espera


# ── Inspección directa del estado del GIL ────────────────────────────────────

def show_gil_switches() -> None:
    """sys.getswitchinterval devuelve cada cuántos segundos CPython
    fuerza un cambio de thread (check interval). Por defecto: 5ms."""
    import sys
    print(f"Switch interval: {sys.getswitchinterval() * 1000:.1f} ms")

    # Reducirlo puede ayudar en workloads I/O-bound con muchos threads cortos.
    # Aumentarlo reduce overhead en workloads que mezclan CPU e I/O.
    sys.setswitchinterval(0.010)  # 10ms: menos switching, más throughput por ráfaga
    print(f"Switch interval ajustado: {sys.getswitchinterval() * 1000:.1f} ms")


if __name__ == "__main__":
    show_gil_switches()
    benchmark_cpu()
    # benchmark_io()  # descomenta si tienes red disponible

Lo que el código revela sobre el GIL

benchmark_cpu demuestra el punto más contraintuitivo: dividir trabajo entre dos threads no lo hace más rápido. CPython alterna entre threads cada sys.getswitchinterval() segundos (5ms por defecto), pero en código CPU-bound el thread que espera no tiene nada útil que hacer mientras el otro posee el GIL. El resultado es el mismo trabajo más overhead de scheduling del sistema operativo.

benchmark_io muestra la otra cara. Cuando urlopen llega al socket subyacente escrito en C, esa extensión llama a Py_BEGIN_ALLOW_THREADS antes de entrar al syscall bloqueante. Eso libera el GIL explícitamente, permitiendo que otro thread Python empiece a ejecutar bytecode mientras el kernel espera datos de red. Cuando el syscall retorna, la extensión llama Py_END_ALLOW_THREADS para re-adquirir el GIL y devolver el control a Python. Los cuatro fetches corren en paralelo real porque cada uno pasa la mayor parte de su tiempo fuera del GIL.

show_gil_switches expone sys.getswitchinterval, que controla con qué frecuencia CPython fuerza el cambio de thread. En Python 3.2+ este mecanismo reemplazó el viejo “check each 100 opcodes” por uno basado en tiempo, lo que eliminó la inestabilidad que tenía el modelo anterior con instrucciones de duración variable.

El punto sobre extensiones C es importante en producción: NumPy, Pillow, y la mayoría de librerías científicas liberan el GIL durante sus operaciones pesadas. Por eso puedes ver paralelismo real con threads cuando el cuello de botella está en código C optimizado, aunque el pegamento Python sea single-threaded.

Errores que debes conocer

Error: asumir que más threads acelera trabajo CPU-bound y lanzar un pool grande para procesar datos.

# ❌ Wrong
from concurrent.futures import ThreadPoolExecutor
import math

def heavy_compute(n):
    return sum(math.sqrt(i) for i in range(n))

with ThreadPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(heavy_compute, [1_000_000] * 8))
# Más lento que hacerlo secuencial. El GIL serializa todo
# y el switching entre 8 threads añade overhead.

# ✅ Right
from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(heavy_compute, [1_000_000] * 8))
# Cada worker es un proceso independiente con su propio GIL.
# Paralelismo real en todos los núcleos disponibles.

ProcessPoolExecutor evita el GIL porque cada proceso tiene su propia instancia de CPython con su propio lock; el coste es la serialización de datos entre procesos vía pickle.

Error: creer que el GIL elimina la necesidad de locks propios cuando varios threads modifican estructuras de datos Python.

# ❌ Wrong
counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1  # read-modify-write: NO es atómico aunque parezca una línea

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
# counter probablemente != 400_000

# ✅ Right
counter = 0
lock = threading.Lock()

def increment_safe():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

counter += 1 se compila a cuatro bytecodes (LOAD_GLOBAL, LOAD_CONST, BINARY_OP, STORE_GLOBAL). El GIL puede cambiar de thread entre cualquiera de ellos, así que la operación completa no es atómica desde la perspectiva de tu lógica de negocio, aunque cada bytecode individual sí lo sea.

136

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio