Paralelismo y concurrencia
Al completar este módulo serás capaz de:
- Entender la diferencia entre paralelismo y concurrencia
- Usar threading para tareas I/O-bound
- Usar multiprocessing para tareas CPU-bound
- Implementar código asíncrono con asyncio
¿Por qué hacer varias cosas a la vez?
Tu programa funciona. Hace lo que tiene que hacer. Pero… ¿podría ir más rápido si hiciera varias cosas a la vez?
Imagina un restaurante. Un solo camarero puede atender 10 mesas: toma un pedido, lo lleva a cocina, sirve otra mesa, cobra a otra… Funciona, pero si el restaurante se llena con 100 clientes, ese camarero no da abasto. Necesitas más personal o un sistema más eficiente.
En programación ocurre lo mismo:
- Descargar 100 archivos uno por uno puede tardar horas. Descargarlos en paralelo, minutos
- Procesar miles de imágenes secuencialmente es lento. Con varios núcleos trabajando, mucho más rápido
- Una app que espera a que el servidor responda se queda “congelada”. Si hace otras cosas mientras espera, sigue respondiendo al usuario
La buena noticia es que Python te da tres herramientas para esto: threading, multiprocessing y asyncio. La clave está en saber cuál usar en cada situación.
Concurrencia vs Paralelismo
Antes de ver las herramientas, necesitas entender la diferencia entre estos dos conceptos. Son parecidos pero no son lo mismo.
La metáfora del cocinero
Concurrencia es como un cocinero que prepara varios platos a la vez, pero solo tiene dos manos. Pone el arroz a hervir, y mientras espera, corta las verduras. Luego vuelve al arroz, remueve, y aprovecha para poner la sartén a calentar. Está alternando entre tareas, pero en cada momento solo hace una cosa.
Paralelismo es tener varios cocineros en la cocina, cada uno trabajando en su plato al mismo tiempo. Uno hace el arroz, otro la ensalada, otro el postre. Trabajo simultáneo de verdad.
graph TD
subgraph Concurrencia["Concurrencia: 1 cocinero, varios platos"]
C1["🍳 Empieza arroz"]
C2["🥗 Corta verduras"]
C3["🍳 Remueve arroz"]
C4["🥗 Aliña ensalada"]
C1 --> C2 --> C3 --> C4
end
subgraph Paralelismo["Paralelismo: varios cocineros"]
P1["👨🍳 Cocinero 1: Arroz"]
P2["👨🍳 Cocinero 2: Ensalada"]
P3["👨🍳 Cocinero 3: Postre"]
endEl GIL: la cocina de Python
Aquí viene lo importante. Python tiene algo llamado Global Interpreter Lock (GIL), que es como decir que la cocina de Python solo tiene una hornilla potente.
Con threading (hilos), puedes tener varios cocineros, pero solo uno puede usar la hornilla a la vez. Los demás tienen que esperar su turno. Esto significa que threading no acelera los cálculos (todos necesitan la hornilla), pero sí funciona bien cuando los cocineros pasan mucho tiempo esperando (a que hierva el agua, a que llegue un pedido…).
Con multiprocessing (procesos), cada cocinero tiene su propia cocina completa. Pueden trabajar realmente en paralelo, pero montar cocinas separadas tiene un coste.
- ¿Tu código espera mucho? (descargas, archivos, APIs) → Usa threading o asyncio
- ¿Tu código calcula mucho? (matemáticas, procesamiento) → Usa multiprocessing
Threading: El malabarista
Threading es como un malabarista que mantiene varias pelotas en el aire. No puede agarrar todas a la vez, pero alterna tan rápido entre ellas que parece que lo hace todo simultáneamente.
¿Cuándo usar threading? Cuando tu programa pasa mucho tiempo esperando:
- Esperando a que un servidor responda
- Esperando a que un archivo se descargue
- Esperando a que una base de datos devuelva datos
Mientras esperas una cosa, puedes hacer otra. Es como cuando cocinas: mientras el agua hierve (espera), cortas las verduras (trabajo útil).
Crear hilos básicos
Un “hilo” (thread) es como un ayudante que puede hacer una tarea mientras tú haces otra. Así se crean:
1import threading
2import time
3
4def tarea(nombre, duracion):
5 print(f"[{nombre}] Iniciando...")
6 time.sleep(duracion) # Simula espera (descarga, API, etc.)
7 print(f"[{nombre}] Completado!")
8
9# Crear dos hilos (dos ayudantes)
10hilo1 = threading.Thread(target=tarea, args=("Tarea1", 2))
11hilo2 = threading.Thread(target=tarea, args=("Tarea2", 1))
12
13# Iniciar ambos (empiezan a trabajar en paralelo)
14hilo1.start()
15hilo2.start()
16
17# Esperar a que ambos terminen
18hilo1.join()
19hilo2.join()
20
21print("Todas las tareas completadas")Sin threading, esto tardaría 3 segundos (2 + 1). Con threading, tarda solo 2 segundos porque ambas tareas “esperan” al mismo tiempo.
ThreadPoolExecutor: El equipo de descarga
Cuando tienes muchas tareas similares (descargar 100 archivos, por ejemplo), no quieres crear 100 hilos manualmente. ThreadPoolExecutor es como tener un equipo de descarga listo para trabajar:
1from concurrent.futures import ThreadPoolExecutor
2import time
3
4def descargar(url):
5 print(f"Descargando {url}...")
6 time.sleep(1) # Simular descarga
7 return f"Contenido de {url}"
8
9urls = ["url1.com", "url2.com", "url3.com", "url4.com"]
10
11# Crear un equipo de 4 trabajadores
12with ThreadPoolExecutor(max_workers=4) as executor:
13 resultados = list(executor.map(descargar, urls))
14
15for resultado in resultados:
16 print(resultado)Las 4 descargas ocurren “a la vez” (mientras una espera, otra avanza), así que tarda ~1 segundo en lugar de ~4.
Sincronización con Lock: El baño con pestillo
Aquí viene un problema. Si dos hilos intentan modificar la misma variable al mismo tiempo, pueden pisarse el uno al otro. Es como si dos personas intentaran escribir en el mismo papel a la vez: el resultado sería un desastre.
La solución es un Lock (cerrojo). Funciona como el pestillo de un baño: solo una persona puede entrar a la vez. Los demás esperan su turno.
1import threading
2
3contador = 0
4lock = threading.Lock()
5
6def incrementar():
7 global contador
8 for _ in range(100000):
9 with lock: # "Cierro el pestillo" - solo yo puedo tocar el contador
10 contador += 1
11 # Aquí "abro el pestillo" - el siguiente puede entrar
12
13hilos = [threading.Thread(target=incrementar) for _ in range(4)]
14for h in hilos:
15 h.start()
16for h in hilos:
17 h.join()
18
19print(f"Contador final: {contador}") # 400000 (correcto!)Sin el Lock, el contador podría dar un número incorrecto (por ejemplo, 387432 en lugar de 400000). Esto se llama race condition y es uno de los bugs más difíciles de detectar.
Multiprocessing: Contratar más trabajadores
Si threading es un malabarista, multiprocessing es contratar más empleados. Cada proceso es un trabajador independiente con su propia “cocina” (memoria, recursos). Pueden trabajar realmente en paralelo, sin compartir la hornilla.
¿Cuándo usar multiprocessing? Cuando tu programa hace cálculos pesados:
- Procesar miles de imágenes
- Calcular números primos grandes
- Entrenar modelos de machine learning
- Comprimir/descomprimir archivos
graph LR
subgraph Secuencial["Secuencial: 1 trabajador"]
S1["Tarea 1"] --> S2["Tarea 2"] --> S3["Tarea 3"] --> S4["Tarea 4"]
end
subgraph Paralelo["Paralelo: 4 trabajadores"]
P1["👷 Tarea 1"]
P2["👷 Tarea 2"]
P3["👷 Tarea 3"]
P4["👷 Tarea 4"]
endProcesos básicos
Cada proceso tiene su propio ID (PID), como cada empleado tiene su número de identificación:
1from multiprocessing import Process
2import os
3
4def tarea(nombre):
5 print(f"[{nombre}] PID: {os.getpid()}")
6 # Aquí va trabajo CPU-intensivo
7
8if __name__ == "__main__":
9 procesos = []
10 for i in range(4):
11 p = Process(target=tarea, args=(f"Proceso-{i}",))
12 procesos.append(p)
13 p.start()
14
15 for p in procesos:
16 p.join()El if __name__ == "__main__": es obligatorio en multiprocessing. Sin él, cada proceso intentaría crear más procesos, creando un bucle infinito. Es como decirle a tus empleados: “Solo el jefe puede contratar gente nueva”.
Pool de procesos: El equipo de cálculo
Igual que con threading, no quieres crear procesos manualmente para cientos de tareas. Pool te da un equipo de trabajadores listos:
1from multiprocessing import Pool
2import time
3
4def calcular_cuadrado(n):
5 time.sleep(0.1) # Simular cálculo pesado
6 return n ** 2
7
8if __name__ == "__main__":
9 numeros = list(range(20))
10
11 # Secuencial: un trabajador hace todo
12 inicio = time.time()
13 resultados_seq = [calcular_cuadrado(n) for n in numeros]
14 print(f"Secuencial: {time.time() - inicio:.2f}s") # ~2 segundos
15
16 # Paralelo: 4 trabajadores se reparten la tarea
17 inicio = time.time()
18 with Pool(4) as pool:
19 resultados_par = pool.map(calcular_cuadrado, numeros)
20 print(f"Paralelo: {time.time() - inicio:.2f}s") # ~0.5 segundosProcessPoolExecutor: La interfaz moderna
ProcessPoolExecutor funciona igual que ThreadPoolExecutor, lo que hace fácil cambiar entre hilos y procesos:
1from concurrent.futures import ProcessPoolExecutor
2import math
3
4def es_primo(n):
5 if n < 2:
6 return False
7 for i in range(2, int(math.sqrt(n)) + 1):
8 if n % i == 0:
9 return False
10 return True
11
12if __name__ == "__main__":
13 numeros = range(100000, 100020)
14
15 with ProcessPoolExecutor(max_workers=4) as executor:
16 resultados = list(executor.map(es_primo, numeros))
17
18 primos = [n for n, es in zip(numeros, resultados) if es]
19 print(f"Primos encontrados: {primos}")Una buena regla es usar tantos workers como núcleos tenga tu CPU. Puedes obtener este número con os.cpu_count(). Usar más workers de los que tienes núcleos no acelera nada, solo añade sobrecarga.
Asyncio: El camarero eficiente
Asyncio es como un camarero super eficiente en un restaurante. No se queda parado esperando a que cocina prepare el plato. En cuanto hace un pedido, va a otra mesa a tomar nota. Luego sirve bebidas, cobra una cuenta, y cuando el plato está listo, lo recoge y lo sirve. Un solo camarero puede atender muchas mesas porque nunca espera sin hacer nada.
graph LR
A["Toma pedido Mesa 1"] --> B["Toma pedido Mesa 2"]
B --> C["Sirve bebida Mesa 3"]
C --> D["Recoge plato Mesa 1"]
D --> E["Sirve plato Mesa 1"]¿Cuándo usar asyncio? Cuando tienes muchas operaciones de red:
- Descargar cientos de páginas web
- Hacer peticiones a múltiples APIs
- Manejar muchas conexiones simultáneas (chat, websockets)
Las palabras mágicas: async y await
async: “Esta función puede pausarse y continuar después”await: “Esto va a tardar, así que mientras espero, haz otra cosa”
1import asyncio
2
3async def tarea(nombre, duracion):
4 print(f"[{nombre}] Iniciando...")
5 await asyncio.sleep(duracion) # "Espero, pero no bloqueo"
6 print(f"[{nombre}] Completado!")
7 return nombre
8
9async def main():
10 # gather() ejecuta todas las tareas "a la vez"
11 resultados = await asyncio.gather(
12 tarea("A", 2),
13 tarea("B", 1),
14 tarea("C", 3)
15 )
16 print(f"Resultados: {resultados}")
17
18# Punto de entrada para código async
19asyncio.run(main())Las tres tareas tardan 2, 1 y 3 segundos. ¿Cuánto tarda el programa? Solo 3 segundos (no 6), porque mientras una espera, las otras avanzan.
Descargas asíncronas con aiohttp
Para descargas reales necesitas aiohttp (la versión async de requests):
1import asyncio
2import aiohttp # pip install aiohttp
3
4async def descargar_url(session, url):
5 async with session.get(url) as response:
6 return await response.text()
7
8async def descargar_todas(urls):
9 async with aiohttp.ClientSession() as session:
10 tareas = [descargar_url(session, url) for url in urls]
11 resultados = await asyncio.gather(*tareas)
12 return resultados
13
14urls = [
15 "https://httpbin.org/delay/1",
16 "https://httpbin.org/delay/2",
17 "https://httpbin.org/delay/1"
18]
19
20# Tarda ~2 segundos en lugar de ~4
21resultados = asyncio.run(descargar_todas(urls))Semáforos: No colapses el servidor
Si descargas 1000 URLs a la vez, podrías colapsar el servidor (o que te bloqueen). Un semáforo es como un cartel de aforo máximo: solo permite N tareas simultáneas.
1import asyncio
2
3async def tarea_limitada(semaforo, nombre):
4 async with semaforo: # "Espero mi turno si hay mucha gente"
5 print(f"[{nombre}] Ejecutando...")
6 await asyncio.sleep(1)
7 print(f"[{nombre}] Completado")
8
9async def main():
10 semaforo = asyncio.Semaphore(3) # Máximo 3 a la vez
11
12 tareas = [
13 tarea_limitada(semaforo, f"Tarea-{i}")
14 for i in range(10)
15 ]
16
17 await asyncio.gather(*tareas)
18
19asyncio.run(main())Las 10 tareas se ejecutan, pero nunca más de 3 simultáneas. Es como un probador de ropa: aunque haya cola, solo 3 personas pueden estar probándose a la vez.
Cuándo usar cada enfoque
| Situación | Solución | Ejemplo real |
|---|---|---|
| Descargar archivos | threading o asyncio | Bajar 50 imágenes de internet |
| Llamar a APIs | asyncio + aiohttp | Consultar precios en 10 tiendas |
| Leer/escribir archivos | threading | Procesar 100 logs simultáneamente |
| Cálculos matemáticos | multiprocessing | Calcular primos hasta 10 millones |
| Procesar imágenes | multiprocessing.Pool | Redimensionar 1000 fotos |
| Servidor web | asyncio | Manejar miles de conexiones |
Comparativa rápida
| Aspecto | Threading | Multiprocessing | Asyncio |
|---|---|---|---|
| Memoria | Compartida | Separada | Compartida |
| Complejidad | Media | Alta | Alta |
| Mejor para | I/O, pocas tareas | CPU, cálculos | I/O, muchas tareas |
| Limitación | GIL | Overhead de procesos | Solo código async |
Errores comunes
-
Olvidar
if __name__ == "__main__":en multiprocessing- Tu programa entrará en un bucle infinito creando procesos
-
No usar Lock cuando compartes datos entre hilos
- Race conditions: resultados incorrectos e impredecibles
-
Usar threading para cálculos pesados
- El GIL impide que sea más rápido. Usa multiprocessing
-
Mezclar código síncrono en asyncio
time.sleep()bloquea todo. Usaawait asyncio.sleep()requests.get()bloquea. Usaaiohttp
-
Crear demasiados procesos/hilos
- Más procesos que núcleos = overhead sin beneficio
- Miles de hilos = consumo excesivo de memoria
Guía rápida de decisión
¿No sabes qué usar? Sigue este diagrama:
graph TD
A["¿Qué hace tu código?"] --> B{"¿Espera mucho?<br/>(red, archivos, APIs)"}
B -->|Sí| C{"¿Cuántas tareas?"}
B -->|No, calcula| D["multiprocessing"]
C -->|Pocas| E["threading"]
C -->|Muchas| F["asyncio"]
El 80% de los casos se resuelven así:
- Descargas/APIs:
asyncioconaiohttp - Procesamiento de datos:
multiprocessing.Pool - Tareas simples en paralelo:
ThreadPoolExecutor
Ejercicios prácticos
Crea una función que descargue múltiples URLs en paralelo usando ThreadPoolExecutor.
Usa multiprocessing para encontrar todos los primos hasta N de forma paralela.