Free-threaded Python: CPython sin GIL

Durante décadas, el Global Interpreter Lock (GIL) fue la razón por la que threading en CPython era útil para I/O pero inútil para paralelismo real en CPU. Un único lock global garantizaba que solo un hilo ejecutara bytecode Python en cada instante. Elegante para la implementación, fatal para el hardware moderno.

Python 3.13 cambia eso: por primera vez puedes ejecutar CPython con el GIL desactivado de forma estable enough para producir código de prueba real. No es un parche experimental de terceros ni una bifurcación del intérprete —es python3.13t, un build oficial que convive con el intérprete estándar.

El porqué de este diseño dual es pragmático: eliminar el GIL no es gratis. El GIL actuaba como sincronización implícita para operaciones que parecían atómicas desde Python (incremento de refcounts, modificaciones a dict, operaciones en list). Sin él, el intérprete necesita reemplazar ese único lock con reference counting atómico por objeto y locks más finos (per-object locks) en las estructuras internas. El resultado es que el intérprete en modo free-threaded tiene overhead incluso en código single-threaded —los benchmarks actuales muestran entre 5% y 40% de regresión dependiendo del workload— a cambio de paralelismo real cuando tienes múltiples hilos.

El momento correcto para explorar esto es ahora, precisamente porque el ecosistema de extensiones C aún no se ha adaptado. Si tu código depende de NumPy, Cython o cualquier extensión que asuma que el GIL le protege su estado interno, ese código puede romperse silenciosamente o crashear. Las extensiones declaran compatibilidad mediante el flag Py_GIL_DISABLED en sus metadatos, y en ausencia de esa declaración, python3.13t re-activa el GIL automáticamente al cargarlas —lo cual te deja de vuelta en terreno conocido, pero sin el beneficio.

Lo que puede romperse si ignoras esto: cualquier código C que llame a la API de CPython sin adquirir explícitamente el lock, patrones donde contabas con que ciertos refcount drops fueran atómicos, o librerías que mantienen estado mutable compartido entre hilos asumiendo que el GIL los serializa.

# free_threaded_demo.py
# Ejecutar con: python3.13t free_threaded_demo.py
# O con: python3.13 -X gil=0 free_threaded_demo.py  (misma build, flag explícito)

import sys
import threading
import time
from collections import defaultdict

def check_gil_status() -> None:
    status = sys._is_gil_enabled()  # nueva en 3.13
    label = "ACTIVO" if status else "DESACTIVADO"
    print(f"GIL: {label}")

# ── Demostración de paralelismo real en CPU ────────────────────────────

def count_primes(start: int, end: int, results: dict, key: str) -> None:
    """Trabajo CPU-bound puro para medir paralelismo efectivo."""
    def is_prime(n: int) -> bool:
        if n < 2:
            return False
        for i in range(2, int(n**0.5) + 1):
            if n % i == 0:
                return False
        return True

    results[key] = sum(1 for n in range(start, end) if is_prime(n))

def benchmark(n_threads: int) -> float:
    results: dict[str, int] = {}
    chunk = 100_000
    threads = [
        threading.Thread(
            target=count_primes,
            args=(i * chunk, (i + 1) * chunk, results, str(i))
        )
        for i in range(n_threads)
    ]
    t0 = time.perf_counter()
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    elapsed = time.perf_counter() - t0
    total_primes = sum(results.values())
    return elapsed, total_primes

# ── Race condition real sin GIL ────────────────────────────────────────

# Con GIL: este código "funciona" porque el GIL serializa los incrementos.
# Sin GIL: expone la race condition que siempre estuvo latente.
counter = 0

def unsafe_increment(n: int) -> None:
    global counter
    for _ in range(n):
        # LOAD_GLOBAL + BINARY_ADD + STORE_GLOBAL no es atómico.
        # Con GIL había suerte de que el switch no ocurriera aquí.
        # Sin GIL, esto pierde actualizaciones de forma consistente.
        counter += 1

def safe_increment(n: int, lock: threading.Lock) -> None:
    global counter
    for _ in range(n):
        with lock:
            counter += 1

def demonstrate_race() -> None:
    global counter
    iterations = 50_000
    n_threads = 4
    expected = iterations * n_threads

    # Versión sin protección
    counter = 0
    threads = [threading.Thread(target=unsafe_increment, args=(iterations,))
               for _ in range(n_threads)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print(f"Sin lock  — esperado: {expected:>8}, obtenido: {counter:>8}, "
          f"pérdida: {expected - counter:>6}")

    # Versión protegida
    counter = 0
    lock = threading.Lock()
    threads = [threading.Thread(target=safe_increment, args=(iterations, lock))
               for _ in range(n_threads)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print(f"Con lock  — esperado: {expected:>8}, obtenido: {counter:>8}, "
          f"pérdida: {expected - counter:>6}")

# ── Main ───────────────────────────────────────────────────────────────

if __name__ == "__main__":
    check_gil_status()
    print(f"Python {sys.version}\n")

    print("── Benchmark CPU-bound ──────────────────────────────")
    for n in (1, 2, 4):
        elapsed, total = benchmark(n)
        print(f"  {n} hilo(s): {elapsed:.3f}s  ({total} primos encontrados)")

    print("\n── Race condition ───────────────────────────────────")
    demonstrate_race()

Qué está ocurriendo aquí

sys._is_gil_enabled() es la forma canónica de verificar en runtime si el GIL está activo. El prefijo _ insinúa que es API semipública todavía —puede moverse en versiones futuras. Puedes también inspeccionar sys.flags.gil (valor 0 si lo desactivaste vía -X gil=0).

El benchmark de count_primes es deliberadamente CPU-bound puro, sin I/O ni llamadas C que liberen el GIL. Con el GIL estándar (python3.13), ejecutar 4 hilos tarda más que uno porque la contención del lock supera cualquier ganancia. Con python3.13t verás que 4 hilos se acercan a 4× de speedup, lo cual confirma que el paralelismo es real.

La sección de unsafe_increment es el punto crítico del artículo. Ese patrón counter += 1 se descompone en tres instrucciones bytecode. Con el GIL clásico, el switch de hilos ocurre entre instrucciones pero de forma infrecuente, así que en la práctica casi nunca perdías actualizaciones —te protegía la suerte estructural del scheduler. Sin GIL, los hilos corren verdaderamente en paralelo y la ventana de la race está siempre abierta. El resultado son valores menores al esperado de forma reproducible.

La corrección no cambia: threading.Lock funciona igual en ambos modos. Lo que cambia es que ahora el error es inevitable, no probabilístico.

Sobre las extensiones C: el mecanismo de opt-in es el slot Py_mod_gil en la estructura de módulo. Una extensión que declare Py_MOD_GIL_NOT_USED le dice al intérprete “soy thread-safe sin GIL”. Si no declara nada, python3.13t imprime una advertencia y re-activa el GIL para ese proceso. Esto significa que importar una sola extensión no compatible puede silenciosamente devolverte al comportamiento antiguo, lo cual hace que tus benchmarks parezcan idénticos entre builds y te deje rascándote la cabeza.

El roadmap oficial (PEP 703) plantea que el modo free-threaded se mantenga experimental en 3.13 y 3.14, con la posibilidad de convertirse en modo por defecto —o de eliminar el modo GIL del todo— en algún punto de la serie 3.1x dependiendo del estado del ecosistema de extensiones. La decisión no está tomada: depende de cuánto tarda NumPy, SciPy y el resto del stack científico en declarar compatibilidad.

Errores que debes conocer

Error: asumir que operaciones sobre tipos built-in siguen siendo atómicas sin GIL porque “siempre lo fueron”.

# ❌ Código que dependía del GIL como sincronización implícita
shared_list: list[int] = []

def producer():
    for i in range(10_000):
        shared_list.append(i)  # append no es atómica sin GIL

def consumer():
    while len(shared_list) > 0:
        shared_list.pop()  # concurrent pop+len puede lanzar IndexError

# ✅ Con sincronización explícita
import threading
lock = threading.Lock()

def producer_safe():
    for i in range(10_000):
        with lock:
            shared_list.append(i)

def consumer_safe():
    with lock:
        if shared_list:
            shared_list.pop()

list.append y list.pop tienen su propio lock interno en python3.13t (parte del trabajo de per-object locking), pero las operaciones compuestas como len + pop en secuencia siguen siendo races. El lock explícito protege la invariante de negocio, no solo la instrucción individual.

Error: ejecutar python3.13 -X gil=0 esperando el mismo resultado que python3.13t.

# ❌ python3.13 estándar — la flag se ignora si el build no lo soporta
# $ python3.13 -X gil=0 script.py
# RuntimeWarning: The GIL cannot be disabled because this Python was built
# without Py_GIL_DISABLED support; ignoring -X gil option

# ✅ Verificar antes de correr
import sys

if not hasattr(sys, '_is_gil_enabled'):
    raise RuntimeError("Este intérprete no soporta free-threaded mode. "
                       "Instala python3.13t via pyenv o tu gestor de paquetes.")

-X gil=0 solo tiene efecto en builds compilados con --disable-gil. El build estándar ignora la flag con un warning no fatal, lo que puede llevar a creer que estás probando sin GIL cuando no es así.

141

Dejar un comentario

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

Scroll al inicio