Cuando tu programa espera red, disco o calcula demasiado, la concurrencia le deja hacer otras cosas mientras tanto. Python ofrece dos caminos: threading (hilos) y multiprocessing (procesos) — y elegir mal es el error clásico.
El GIL: la regla del juego
El GIL (Global Interpreter Lock) es un candado del intérprete estándar de Python: solo un hilo ejecuta bytecode de Python a la vez. Dos hilos no calculan en paralelo real.
Entonces, ¿para qué sirven los hilos? El GIL se libera durante esperas de I/O (red, disco, sleep). Mientras un hilo espera, otro puede correr. Ahí está el truco:
- I/O-bound (esperar red/disco): hilos y asyncio ayudan muchísimo
- CPU-bound (calcular): necesitas procesos, cada uno con su GIL propio
threading: hilos para esperar mejor
import threading
import time
def descargar(nombre):
print(f"descargando {nombre}...")
time.sleep(1) # simula espera de red
print(f"{nombre} lista")
inicio = time.perf_counter()
hilos = [
threading.Thread(target=descargar, args=("archivo1",)),
threading.Thread(target=descargar, args=("archivo2",)),
]
for hilo in hilos:
hilo.start() # arrancar
for hilo in hilos:
hilo.join() # esperar a que terminen
print(f"total: {time.perf_counter() - inicio:.1f}s") # ~1s, no 2s
Dos descargas de 1 segundo terminan en ~1 segundo: mientras una espera, la otra corre. Sin hilos serían 2 segundos.
Compartir datos: la zona peligrosa
Los hilos comparten memoria. Si dos hilos modifican lo mismo a la vez, aparecen condiciones de carrera:
import threading
contador = 0
lock = threading.Lock()
def incrementar():
global contador
for _ in range(100_000):
with lock: # solo un hilo a la vez en esta sección
contador += 1 # leer + sumar + escribir NO es atómico
hilos = [threading.Thread(target=incrementar) for _ in range(2)]
for h in hilos: h.start()
for h in hilos: h.join()
print(contador) # 200000 con lock; SIN lock, un número impredecible
Regla: datos compartidos entre hilos → protégelos con Lock (o evita compartir).
multiprocessing: paralelismo real
from multiprocessing import Pool
def es_primo(n):
if n < 2:
return False
for divisor in range(2, int(n ** 0.5) + 1):
if n % divisor == 0:
return False
return True
if __name__ == "__main__": # ¡obligatorio en Windows/Mac!
with Pool(processes=4) as pool:
resultados = pool.map(es_primo, range(2, 100))
print(resultados.count(True))
Pool.map reparte la lista entre procesos que corren en núcleos distintos, en paralelo real. Cada proceso tiene su intérprete y su GIL — ideal para CPU-bound.
Ojo: los procesos NO comparten memoria (se comunican serializando datos). Y el bloque if __name__ == "__main__": es obligatorio para el arranque de Pool en la mayoría de plataformas.
concurrent.futures: la API moderna
Una interfaz unificada para ambos mundos:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
with ThreadPoolExecutor(max_workers=5) as executor:
resultados = list(executor.map(descargar, ["a", "b", "c"]))
# mismo código con ProcessPoolExecutor para CPU-bound
En código nuevo, usa concurrent.futures: cambia de hilos a procesos cambiando una clase.
La tabla de decisión
| Tu tarea | Herramienta |
|---|---|
| Espera red/disco (muchas tareas) | ThreadPoolExecutor o asyncio |
| Cálculo pesado en CPU | ProcessPoolExecutor |
| Pocas tareas rápidas | Secuencial — no compliques |
Resumen
- El GIL permite un solo hilo ejecutando Python a la vez; se libera en esperas de I/O
- Hilos para I/O-bound; procesos para CPU-bound
- Datos compartidos entre hilos →
threading.Lock, o no los compartas concurrent.futureses la API moderna unificada- La concurrencia agrega complejidad: úsala solo cuando la espera sea medible