Qué es GIL en Python y cómo afecta al rendimiento
1. Introducción
Muchos desarrolladores que vienen de Java o Go a Python se encuentran con la misma historia. Ejecutan 4 threads en una máquina con 8 cores, pero el programa no es más rápido, a veces es más lento. El culpable suele ser el GIL (Global Interpreter Lock).
Este artículo explica qué es GIL y por qué existe. Medirás el impacto del GIL en código CPU-bound e I/O-bound. Al final, sabrás cuándo usar threading, cuándo usar multiprocessing, y cómo la compilación free-threaded de Python 3.13+ cambia las cosas.
2. ¿Qué es GIL?
GIL es un mutex en CPython, la implementación de Python más común. En cada momento, dentro de un proceso, solo un thread ejecuta bytecode Python.
Aún puedes crear múltiples threads. Los threads siguen siendo threads reales del sistema operativo. Pero para ejecutar bytecode, un thread debe mantener el GIL. Los otros threads deben esperar.
2.1. ¿Por qué CPython necesita GIL?
La razón principal es el mecanismo de gestión de memoria mediante reference counting. Cada objeto tiene un contador de referencias que apuntan a él:
import sys
order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))
alias = order
print(sys.getrefcount(order))
2
3
sys.getrefcount devuelve el número de referencias. El resultado siempre es mayor que 1 porque la llamada a la función en sí crea una referencia temporal. Cuando asignas alias = order, el contador aumenta en 1.
Si dos threads incrementan o decrementan este contador sin un candado, el contador puede ser incorrecto. Las consecuencias son memory leaks, o peor aún, que un objeto sea liberado mientras todavía lo está usando alguien. Poner un candado global es la forma más simple de proteger todos los objetos. También facilita escribir extensiones C y hace que el código de un solo thread sea más rápido.
2.2. ¿Cuándo libera un thread el GIL?
Un thread que mantiene el GIL lo libera en dos casos:
- Cuando espera I/O: leer un archivo, hacer una llamada de red,
time.sleep… Estas funciones liberan el GIL antes de bloquear. - Cuando se acaba el “turno”: el intérprete periódicamente pide al thread que se está ejecutando que libere el GIL para que otro thread tenga la oportunidad de ejecutarse.
Puedes ver cuánto tiempo dura un turno:
import sys
print(sys.getswitchinterval())
0.005
El valor por defecto es 5 ms. Puedes cambiarlo con sys.setswitchinterval(), pero casi nunca necesitarás hacerlo.
3. Impacto en código CPU-bound
Hagamos una prueba. Esta función cuenta números primos, que es cálculo puro:
import time
from concurrent.futures import ThreadPoolExecutor
def count_primes(limit: int) -> int:
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
count += 1
return count
def run_sequential(jobs: list[int]) -> float:
start = time.perf_counter()
for job in jobs:
count_primes(job)
return time.perf_counter() - start
def run_threads(jobs: list[int]) -> float:
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(count_primes, jobs))
return time.perf_counter() - start
if __name__ == "__main__":
jobs = [200_000] * 4
print(f"Secuencial: {run_sequential(jobs):.2f}s")
print(f"4 threads : {run_threads(jobs):.2f}s")
run_sequential ejecuta 4 trabajos secuencialmente. run_threads asigna 4 trabajos a 4 threads. Resultado en una máquina de 8 cores (solo para referencia):
Secuencial: 2.31s
4 threads : 2.40s
Cuatro threads no son más rápidos. De hecho, son un poco más lentos. Debido al GIL, los threads se turnan ejecutándose en un solo core. La parte más lenta es el costo de contender por el GIL y cambiar de contexto.
Nota: Este es el error más común. Agregar threads a código de cálculo puro en Python en CPython con GIL no lo hace más rápido.
4. Impacto en código I/O-bound
Con código que principalmente espera I/O, la situación es muy diferente. Simulemos una llamada remota a un PaymentService que tarda 0,5 segundos:
import time
from concurrent.futures import ThreadPoolExecutor
def charge_payment(order_id: int) -> str:
time.sleep(0.5) # simula una llamada HTTP al procesador de pagos
return f"order-{order_id}: OK"
if __name__ == "__main__":
order_ids = list(range(10))
start = time.perf_counter()
for oid in order_ids:
charge_payment(oid)
print(f"Secuencial: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
list(pool.map(charge_payment, order_ids))
print(f"10 threads: {time.perf_counter() - start:.2f}s")
Secuencial: 5.01s
10 threads: 0.50s
10 veces más rápido. time.sleep, así como las operaciones de socket y lectura de archivos, liberan el GIL mientras esperan. Diez threads esperan en paralelo. Solo necesitan el GIL por momentos para procesar el resultado.
Conclusión: los threads en Python siguen siendo muy útiles para I/O-bound, por ejemplo llamadas a API, consultas de base de datos o descargas de archivos. Para I/O muy voluminoso, asyncio también es una buena opción.
5. Superar GIL con multiprocessing
Si quieres aprovechar múltiples cores para computación, la forma estándar es usar múltiples procesos. Cada proceso tiene su propio intérprete y su propio GIL.
import time
from concurrent.futures import ProcessPoolExecutor
def count_primes(limit: int) -> int:
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
count += 1
return count
if __name__ == "__main__":
jobs = [200_000] * 4
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(count_primes, jobs))
print(results)
print(f"4 procesos: {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4 procesos: 0.68s
Comparado con 2.31 segundos en ejecución secuencial, es aproximadamente 3.4 veces más rápido. No es 4 veces porque hay costo en la inicialización del proceso.
El bloque if __name__ == "__main__": es obligatorio. En Windows y macOS, los procesos secundarios se crean reimportando el módulo. Sin este bloque, el proceso hijo creará un nuevo pool y el programa fallará.
Nota: Multiprocessing tiene su costo. Los parámetros y resultados deben serializarse con
picklepara pasar entre procesos. Si envías objetos grandes, como un DataFrame de cientos de MB, para una tarea pequeña, el costo de copia puede ser mayor que el beneficio. Las lambdas y funciones anidadas tampoco se pueden serializar con pickle.
6. Las extensiones C pueden liberar el GIL
Muchas bibliotecas escritas en C liberan activamente el GIL cuando ejecutan la parte pesada, por ejemplo NumPy, hashlib o zlib. En ese caso, los threads se ejecutan realmente en paralelo:
import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor
payloads = [os.urandom(50_000_000) for _ in range(4)]
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
if __name__ == "__main__":
start = time.perf_counter()
for p in payloads:
digest(p)
print(f"Secuencial: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(digest, payloads))
print(f"4 threads : {time.perf_counter() - start:.2f}s")
Secuencial: 0.98s
4 threads : 0.27s
hashlib libera el GIL cuando calcula hash de datos grandes (a partir de unos 2 KB). Por eso 4 threads se ejecutan en 4 cores. De hecho, el GIL solo bloquea bytecode Python, no el código máquina de las bibliotecas nativas.
7. El GIL no hace que tu código sea thread-safe
Mucha gente piensa “con GIL no necesito candados”. Esto es falso. El GIL protege el estado interno del intérprete, no protege tu lógica. Un thread puede ser desplazado entre cualquier par de sentencias.
import threading
import time
class BankAccount:
def __init__(self) -> None:
self.balance = 0
self._lock = threading.Lock()
def deposit_unsafe(self, amount: int) -> None:
current = self.balance
time.sleep(0) # libera el GIL, simula una operación de I/O intercalada
self.balance = current + amount
def deposit_safe(self, amount: int) -> None:
with self._lock:
current = self.balance
time.sleep(0)
self.balance = current + amount
def run(method_name: str, threads: int = 10, times: int = 100) -> int:
account = BankAccount()
method = getattr(account, method_name)
def worker() -> None:
for _ in range(times):
method(1)
pool = [threading.Thread(target=worker) for _ in range(threads)]
for t in pool:
t.start()
for t in pool:
t.join()
return account.balance
if __name__ == "__main__":
print("unsafe:", run("deposit_unsafe"))
print("safe :", run("deposit_safe"))
unsafe: 112
safe : 1000
Esperamos 10 × 100 = 1000. En deposit_unsafe, múltiples threads leen el mismo valor antiguo de current y se sobrescriben mutuamente, por lo que se pierden la mayoría de los depósitos. time.sleep(0) asegura que el error se manifieste. En código real, el punto donde se libera el GIL podría ser un log o una llamada a base de datos. deposit_safe usa threading.Lock para hacer que la operación de lectura-modificación-escritura sea atómica.
Escribimos tests con pytest para capturar este comportamiento. Instalación:
pip install pytest
# test_bank_account.py
from bank_account import run
def test_safe_deposit_keeps_correct_balance():
assert run("deposit_safe", threads=8, times=200) == 1600
def test_unsafe_deposit_loses_updates():
assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
.. [100%]
2 passed in 0.41s
La segunda prueba demuestra que la race condition es real incluso con CPython teniendo GIL.
8. Python free-threaded: el futuro sin GIL
Desde Python 3.13, CPython tiene una compilación free-threaded (PEP 703) que permite desactivar el GIL. Usualmente ejecutas esta versión con python3.13t o python3.14t. En Python 3.14, esta versión ya está soportada oficialmente (PEP 779), pero aún no es la por defecto.
Puedes verificar el estado del GIL en tiempo de ejecución:
import sys
import sysconfig
print("Build soporta free-threading:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL está activado:", sys._is_gil_enabled())
Ejecuta con la versión free-threaded:
python3.14t check_gil.py
Build soporta free-threading: True
GIL está activado: False
Con esta versión, el ejemplo count_primes usando ThreadPoolExecutor de la sección 3 realmente aprovechará múltiples cores.
Sin embargo, necesitas considerar algunos puntos antes de cambiar:
- El código de un solo thread es más lento un poco, porque el intérprete debe usar mecanismos de sincronización más sofisticados.
- Las extensiones C deben ser compatibles. Si importas una extensión que aún no declara soporte para free-threading, CPython reactivará automáticamente el GIL e imprimirá una advertencia. Puedes forzarla con
PYTHON_GIL=0, pero eso es asumir el riesgo. - Las race conditions son más fáciles de ocurrir. El ejemplo en la sección 7 ya no está “protegido” por la ejecución de un solo thread a la vez.
9. ¿Qué herramienta elegir?
| Tipo de trabajo | Usar |
|---|---|
| I/O-bound (HTTP, DB, archivo) | threading, ThreadPoolExecutor, asyncio |
| CPU-bound Python puro | ProcessPoolExecutor / multiprocessing |
| CPU-bound en NumPy, hashlib… | Threads siguen funcionando, la biblioteca libera el GIL |
| CPU-bound, necesita compartir memoria, biblioteca ya compatible | Considera Python free-threaded (3.13t/3.14t) |
Nota: No optimices por instinto. Mide primero con
time.perf_counter()ocProfile. Muchos programas que son “lentos por GIL” en realidad son lentos por el algoritmo o por consultas de base de datos.
10. Conclusión
El GIL es el candado global de CPython. Asegura que solo un thread ejecute bytecode Python a la vez, principalmente para proteger el reference counting.
Las consecuencias prácticas son cuatro puntos:
- Los threads no aceleran código CPU-bound Python puro.
- Los threads siguen siendo muy eficientes para I/O-bound y para bibliotecas nativas que liberan el GIL.
- Para aprovechar múltiples cores en computación, usa multiprocessing, o prueba la versión free-threaded de Python 3.13+.
- El GIL no reemplaza los candados. Los datos compartidos entre threads siguen necesitando sincronización adecuada.