Python ofrece tres modelos principales para escribir código concurrente o paralelo: el módulo threading, el módulo multiprocessing y el modelo asíncrono basado en asyncio. Cada uno resuelve un problema distinto y elegir el adecuado depende de si la tarea está limitada por E/S (I/O-bound) o por cómputo (CPU-bound).
El GIL: la pieza clave para entender Python
CPython, la implementación de referencia de Python, incluye un mecanismo llamado Global Interpreter Lock (GIL), un candado global que permite que solo un hilo ejecute bytecode de Python a la vez, incluso en máquinas con varios núcleos. Esto significa que el módulo threading no ofrece paralelismo real para tareas que consumen CPU, aunque sí es muy útil cuando los hilos pasan la mayor parte del tiempo esperando (por ejemplo, en operaciones de red o disco).
Desde Python 3.13 existe una compilación experimental "free-threaded" sin GIL, pero en la mayoría de entornos de producción el GIL sigue presente, por lo que la distinción entre tareas I/O-bound y CPU-bound continúa siendo fundamental.
1. threading: concurrencia para tareas de E/S
El módulo threading permite ejecutar varias tareas "al mismo tiempo" cediendo el control mientras se espera una operación externa (red, disco, bases de datos). Es ideal para tareas I/O-bound.
Ejemplo básico
import threading
import time
def descargar(nombre, segundos):
print(f"Iniciando descarga de {nombre}")
time.sleep(segundos) # simula espera de red
print(f"{nombre} descargado")
hilos = []
for nombre, segundos in [("archivo1", 2), ("archivo2", 1), ("archivo3", 3)]:
hilo = threading.Thread(target=descargar, args=(nombre, segundos))
hilos.append(hilo)
hilo.start()
for hilo in hilos:
hilo.join()
print("Todas las descargas completadas")Sincronización con Lock
Cuando varios hilos modifican un mismo recurso compartido, es necesario sincronizar el acceso con un Lock para evitar condiciones de carrera.
import threading
contador = 0
lock = threading.Lock()
def incrementar():
global contador
for _ in range(100_000):
with lock:
contador += 1
hilos = [threading.Thread(target=incrementar) for _ in range(4)]
for h in hilos:
h.start()
for h in hilos:
h.join()
print(contador) # 400000ThreadPoolExecutor
El módulo concurrent.futures simplifica la gestión de hilos mediante un pool de trabajadores:
from concurrent.futures import ThreadPoolExecutor
import requests
urls = ["https://example.com"] * 5
def obtener(url):
return requests.get(url).status_code
with ThreadPoolExecutor(max_workers=5) as executor:
resultados = list(executor.map(obtener, urls))
print(resultados)2. multiprocessing: paralelismo real para CPU
Para tareas CPU-bound (cálculos intensivos, procesamiento de imágenes, machine learning), el módulo multiprocessing es la opción correcta: crea procesos independientes, cada uno con su propio intérprete y memoria, evitando así las limitaciones del GIL y aprovechando varios núcleos del procesador.
Ejemplo básico
import multiprocessing
import time
def calcular_cuadrado(n):
return n * n
if __name__ == "__main__":
numeros = list(range(10))
inicio = time.time()
with multiprocessing.Pool(processes=4) as pool:
resultados = pool.map(calcular_cuadrado, numeros)
print(resultados)
print(f"Tiempo: {time.time() - inicio:.2f}s")El bloque if __name__ == "__main__": es obligatorio en Windows y recomendable en cualquier plataforma, ya que multiprocessing reimporta el módulo principal al crear cada nuevo proceso.
Comunicación entre procesos
Como los procesos no comparten memoria, se necesitan mecanismos específicos para intercambiar datos, como Queue o Pipe:
from multiprocessing import Process, Queue
def productor(q):
for i in range(5):
q.put(i)
q.put(None) # señal de fin
def consumidor(q):
while True:
item = q.get()
if item is None:
break
print(f"Procesado: {item}")
if __name__ == "__main__":
q = Queue()
p1 = Process(target=productor, args=(q,))
p2 = Process(target=consumidor, args=(q,))
p1.start(); p2.start()
p1.join(); p2.join()ProcessPoolExecutor
from concurrent.futures import ProcessPoolExecutor
def es_primo(n):
if n < 2:
return False
return all(n % i != 0 for i in range(2, int(n**0.5) + 1))
if __name__ == "__main__":
numeros = range(100_000, 100_020)
with ProcessPoolExecutor() as executor:
resultados = list(executor.map(es_primo, numeros))
print(resultados)3. asyncio: concurrencia cooperativa de un solo hilo
asyncio implementa concurrencia mediante un único hilo y un bucle de eventos (event loop), donde las tareas ceden el control voluntariamente con await en lugar de ser interrumpidas por el sistema operativo. Es la opción más eficiente para manejar miles de conexiones de red simultáneas, como en servidores web o clientes HTTP masivos.
Ejemplo básico
import asyncio
async def descargar(nombre, segundos):
print(f"Iniciando descarga de {nombre}")
await asyncio.sleep(segundos) # espera no bloqueante
print(f"{nombre} descargado")
return nombre
async def main():
resultados = await asyncio.gather(
descargar("archivo1", 2),
descargar("archivo2", 1),
descargar("archivo3", 3),
)
print("Completados:", resultados)
asyncio.run(main())Tareas concurrentes con create_task
import asyncio
async def tarea(nombre, segundos):
await asyncio.sleep(segundos)
print(f"{nombre} terminada")
async def main():
t1 = asyncio.create_task(tarea("A", 2))
t2 = asyncio.create_task(tarea("B", 1))
await t1
await t2
asyncio.run(main())Peticiones HTTP asíncronas con aiohttp
import asyncio
import aiohttp
async def obtener(session, url):
async with session.get(url) as response:
return response.status
async def main():
urls = ["https://example.com"] * 5
async with aiohttp.ClientSession() as session:
tareas = [obtener(session, url) for url in urls]
resultados = await asyncio.gather(*tareas)
print(resultados)
asyncio.run(main())Es importante recordar que asyncio requiere librerías compatibles (aiohttp, asyncpg, etc.); usar librerías bloqueantes como requests dentro de código asíncrono anula sus beneficios, ya que bloquea el único hilo del event loop.
Comparativa: cuándo usar cada uno
| Modelo | Tipo de tarea | Paralelismo real | Caso de uso típico |
|---|---|---|---|
| threading | I/O-bound | No (limitado por el GIL) | Peticiones HTTP simples, lectura de archivos, scraping moderado |
| multiprocessing | CPU-bound | Sí (procesos separados) | Cálculos numéricos, procesamiento de imágenes, machine learning |
| asyncio | I/O-bound de alta concurrencia | No (un solo hilo) | Servidores web, miles de conexiones simultáneas, websockets |
Combinando modelos
En aplicaciones reales es común combinar varios enfoques. Por ejemplo, un servidor asíncrono puede delegar tareas CPU-intensivas a un pool de procesos sin bloquear el event loop:
import asyncio
from concurrent.futures import ProcessPoolExecutor
def tarea_pesada(n):
return sum(i * i for i in range(n))
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
resultado = await loop.run_in_executor(pool, tarea_pesada, 10_000_000)
print(resultado)
asyncio.run(main())Buenas prácticas
- Usa
threadingoasynciopara tareas limitadas por E/S, nuncamultiprocessingsi no hay cómputo intensivo (el overhead de crear procesos no compensa). - Usa
multiprocessingcuando el cuello de botella sea CPU y se busque aprovechar varios núcleos. - Prefiere
asynciofrente athreadingcuando se necesiten miles de conexiones concurrentes, por su menor consumo de memoria por tarea. - Evita mezclar código bloqueante dentro de funciones
async; usarun_in_executorsi es inevitable. - Protege siempre los recursos compartidos con mecanismos de sincronización (
Lock,Semaphore, colas) para evitar condiciones de carrera.
Conclusión
No existe un único modelo de concurrencia "mejor" en Python: la elección depende de la naturaleza del problema. threading resuelve bien la espera de E/S con poca complejidad, multiprocessing es la vía para lograr paralelismo real en tareas intensivas en CPU, y asyncio ofrece la mayor escalabilidad para escenarios con miles de operaciones de E/S concurrentes. Entender el papel del GIL y las diferencias entre tareas I/O-bound y CPU-bound es el primer paso para elegir la herramienta adecuada en cada situación.
No hay comentarios todavía. Sé el primero en compartir tu opinión.