¿Cómo limita realmente la concurrencia el Global Interpreter Lock (GIL) de Python, y cómo se lo evita?
El GIL es la línea más malinterpretada en el perfil de rendimiento de un backend en Python: bloquea el paralelismo de CPU pero apenas afecta la concurrencia de E/S.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo limita realmente la concurrencia el Global Interpreter Lock (GIL) de Python, y cómo se lo evita?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Qué es realmente el GIL
El Global Interpreter Lock es un único mutex dentro de CPython —la implementación de referencia que casi todo el mundo tiene en mente cuando dice "Python"— que un hilo debe mantener para poder ejecutar bytecode de Python. Existe porque la gestión de memoria de CPython usa un conteo de referencias simple y no atómico en cada objeto. Sin un lock global, dos hilos incrementando y decrementando el contador de referencias del mismo objeto al mismo tiempo podrían competir, corromper el contador y liberar un objeto que aún está en uso, o dejarlo filtrado para siempre. El GIL evita toda esa categoría de errores asegurando que solo un hilo ejecute código a nivel de Python en cualquier instante, dentro de cada proceso.
Esto es un detalle de implementación de CPython, no una propiedad del lenguaje Python. Jython, IronPython y la variante STM de PyPy no tienen uno, y desde Python 3.13 existe una compilación experimental oficial "free-threaded" (PEP 703) que lo elimina por completo cambiando a conteo de referencias atómico por objeto y locking sesgado, con un costo real —aunque todavía en maduración— en rendimiento de un solo hilo. Para la entrevista, asume CPython estándar salvo que te digan lo contrario, porque es lo que corre la inmensa mayoría del Python en producción.
#Por qué threading "sí funciona, más o menos"
El GIL no serializa todo tu programa: serializa la ejecución de bytecode de Python. Cualquier hilo que entre en una operación bloqueante a nivel de C —una lectura de socket, una lectura de archivo, time.sleep, la mayoría de requests y psycopg2 por debajo— libera el GIL durante esa llamada. Mientras un hilo está bloqueado en el kernel esperando un socket, el GIL queda libre y otro hilo de Python puede tomarlo y ejecutar. Por eso threading sigue siendo una herramienta perfectamente válida para cargas de trabajo dominadas por E/S en Python: descargar diez URLs en diez hilos realmente solapa su tiempo de espera, aunque solo un hilo esté ejecutando bytecode de Python en cada instante.
El modo de fallo aparece con trabajo dominado por CPU: un bucle de Python ajustado haciendo aritmética, manipulación de strings o hashing nunca llama a una función en C que libere el GIL, así que mantiene el lock durante toda su porción de tiempo. CPython fuerza periódicamente una cesión (por defecto, cada 5 milisegundos de reloj, ajustable con sys.setswitchinterval), pero esa cesión es un round-robin entre los hilos que quieren el GIL: no crea paralelismo, solo intercala. Dos hilos ligados a CPU en dos núcleos igual se ejecutan uno a la vez, y encima pagas por la propia intercalación: cambios de contexto, invalidación de caché y —en un patrón de comportamiento bien conocido de CPython— un hilo dominado por CPU incluso puede llegar a inanir de GIL a un hilo de E/S bajo ciertas interacciones del planificador, haciendo que usar hilos sea peor que no usarlos para esa mezcla.
#Demostrándolo: threading no hace nada por trabajo ligado a CPU
import threadingimport timedef count_down(n: int) -> None:while n > 0:n -= 1N = 30_000_000# Referencia: un solo hilo, hace las 2N unidades de trabajo en serie.start = time.perf_counter()count_down(N)count_down(N)print(f"un solo hilo: {time.perf_counter() - start:.2f}s")# Dos hilos repartiendo el mismo trabajo total.start = time.perf_counter()t1 = threading.Thread(target=count_down, args=(N,))t2 = threading.Thread(target=count_down, args=(N,))t1.start(); t2.start()t1.join(); t2.join()print(f"dos hilos: {time.perf_counter() - start:.2f}s")
Ejecuta esto en cualquier máquina multinúcleo con CPython estándar y la línea de "dos hilos" no se acerca a la mitad del tiempo de un solo hilo: típicamente sale prácticamente igual, o incluso algo peor, porque el GIL solo deja que uno de esos hilos ejecute bytecode de Python a la vez. Ambos hilos compiten por el mismo lock, turnándose sobre la capacidad de un solo núcleo mientras suman encima el costo de la cesión.
#Solución 1: multiprocessing para trabajo ligado a CPU
Cada proceso creado por multiprocessing obtiene su propio intérprete de Python y, esto es lo clave, su propio GIL. No hay un lock compartido por el que competir porque no hay estado de intérprete compartido: el planificador del sistema operativo puede correr el intérprete de cada proceso en un núcleo distinto, logrando paralelismo real.
import multiprocessing as mpimport timedef count_down(n: int) -> None:while n > 0:n -= 1if __name__ == "__main__":N = 30_000_000start = time.perf_counter()p1 = mp.Process(target=count_down, args=(N,))p2 = mp.Process(target=count_down, args=(N,))p1.start(); p2.start()p1.join(); p2.join()print(f"dos procesos: {time.perf_counter() - start:.2f}s")
Esta versión escala casi linealmente con los núcleos disponibles, porque count_down no necesita compartir objetos mutables de Python entre las dos ejecuciones. El costo es real, eso sí: crear un proceso pesa más que crear un hilo, y pasar datos hacia dentro o hacia fuera implica serializarlos (pickling) a través de un pipe o un segmento de memoria compartida, por lo que multiprocessing brilla con trozos de trabajo de CPU gruesos y en gran medida independientes (redimensionar imágenes por lotes, parsear archivos grandes, cómputo científico) y sufre cuando las tareas necesitan compartir mucho estado o comunicarse con frecuencia.
#Solución 2: asyncio para trabajo ligado a E/S
Para cargas dominadas por E/S, threading ya funciona porque las llamadas bloqueantes liberan el GIL, pero asyncio lo logra con mucho menos overhead, porque usa un solo hilo y planificación cooperativa en lugar de hilos del sistema operativo con time-slicing preventivo. No hay ninguna cesión de GIL que pagar, porque nunca hay más de un hilo; la concurrencia viene de que una corrutina cede el control en cada punto await mientras el event loop consulta si los sockets subyacentes están listos.
import asyncioimport aiohttpimport timeasync def fetch(session: aiohttp.ClientSession, url: str) -> int:async with session.get(url) as resp:return resp.statusasync def main() -> None:urls = ["https://example.com"] * 20start = time.perf_counter()async with aiohttp.ClientSession() as session:results = await asyncio.gather(*(fetch(session, u) for u in urls))print(f"20 solicitudes concurrentes: {time.perf_counter() - start:.2f}s")print(results[:3])asyncio.run(main())
Veinte solicitudes lanzadas concurrentemente con asyncio.gather terminan aproximadamente en el tiempo de la más lenta, no en la suma de las veinte, porque el event loop las va atendiendo a todas mientras cada una espera por la red: ninguna compite por CPU, así que el modelo de un solo hilo atado al GIL no es un cuello de botella aquí en absoluto.
#Uniendo todo
La respuesta que vale la pena dar en la entrevista es una regla de decisión, no una charla sobre internals de CPython: primero perfilar para saber si la carga está ligada a CPU o a E/S, luego recurrir a threading/asyncio para lo ligado a E/S (asyncio si lo estás construyendo desde cero y quieres el menor overhead por unidad concurrente; threading si estás pegando librerías bloqueantes que nunca fueron escritas para ser async), y recurrir a multiprocessing (o delegar la parte pesada de CPU a una extensión nativa, o a un servicio aparte escrito en un lenguaje sin GIL) cuando el cuello de botella es cómputo genuino, no espera.
🧠 Check your understanding
0/2 · 0/2 answered1. Un servicio en Python pasa el 95% del tiempo de cada solicitud esperando a una base de datos y a una API HTTP externa. ¿Qué enfoque de concurrencia es más apropiado dado el GIL?
2. ¿Por qué lanzar dos hilos de Python para correr un bucle puramente de cómputo (sin E/S) no reduce el tiempo de reloj aproximadamente a la mitad en una máquina multinúcleo?