En Python hay tres formas de hacer varias cosas a la vez y elegir mal cuesta rendimiento o corrección. La regla cabe en una frase, pero conviene verla medida.
También te puede interesar
La regla
| Si el trabajo… | Usa | Por qué |
|---|---|---|
| Espera (red, disco, base de datos) | hilos o asyncio |
al esperar se suelta el GIL |
| Calcula (números, imágenes, texto) | procesos | el GIL impide el paralelismo real |
| Son miles de esperas a la vez | asyncio |
no cuesta un hilo por tarea |
Esperar: los hilos sí sirven
from concurrent.futures import ThreadPoolExecutor
import time
def lento(n):
time.sleep(0.1) # simula red o disco
return n * n
with ThreadPoolExecutor(max_workers=9) as ex:
r = list(ex.map(lento, range(9)))
salidasecuencial 904 ms 9 hilos 105 ms [0, 1, 4, 9, 16, 25, 36, 49, 64]
Casi nueve veces más rápido. Mientras un hilo espera, suelta el GIL y otro avanza.
Calcular: los hilos no sirven
def calculo(n):
return sum(i*i for i in range(n)) # cálculo puro: no suelta el GIL
salidasecuencial 451 ms 4 procesos (ya arrancados) 121 ms 4 procesos (incl. arranque) 166 ms 4 hilos 549 ms
Esa medición es de una máquina con 16 núcleos; la mejora depende de cuántos tengas.
asyncio: miles de esperas en un hilo
import asyncio
async def lento_a(n):
await asyncio.sleep(0.1)
return n * n
async def main():
r = await asyncio.gather(*(lento_a(i) for i in range(9)))
async with asyncio.TaskGroup() as tg: # 3.11+: si una falla, cancela el resto
for i in range(5):
tg.create_task(lento_a(i))
asyncio.run(main())
salida9 corrutinas 101 ms [0, 1, 4, 9, 16, 25, 36, 49, 64] 10 000 esperas 156 ms TaskGroup 102 ms
Diez mil esperas de 100 ms en 156 ms, y todo en un solo hilo. Esa es la razón de que asyncio exista: con hilos serían diez mil hilos del sistema.
TaskGroup es lo que conviene usar hoy en vez de gather: espera a todas, y si una falla cancela las demás y propaga el error, en lugar de dejar tareas sueltas corriendo.
asyncio. Para usar await hay que estar en una función async, y para llamarla hay que estar en otra, hasta arriba del todo. Y una llamada bloqueante normal dentro de una corrutina para el bucle entero: ahí hay que usar asyncio.to_thread(...). Si el programa no es de red, los hilos suelen dar menos problemas.El GIL, y lo que viene
import sys, sysconfig
sysconfig.get_config_var("Py_GIL_DISABLED")
sys._is_gil_enabled()
salidaversión : 3.14.8 Py_GIL_DISABLED : 0 sys._is_gil_enabled: True
Desde Python 3.13 existe una compilación sin GIL, y en la 3.14 dejó de ser experimental. No es la misma que te instalas por omisión: es un binario aparte, y esas dos llamadas son la forma de saber en cuál estás. No he podido medirla aquí porque no hay imagen oficial de contenedor con esa variante, así que no digo nada de su rendimiento.
La condición de carrera, y una sorpresa
El ejemplo de manual: cuatro hilos sumando uno a un contador compartido, doscientas mil veces cada uno.
salidasin cerrojo: 800000 (deberían ser 800000) con cerrojo: 800000 (deberían ser 800000)
No falló. Lo intenté quince veces, incluso bajando el intervalo de conmutación de hilos de 5 milisegundos a un microsegundo, y no perdí un solo incremento.
dis de caja[0] += 1LOAD_FAST_BORROW LOAD_SMALL_INT COPY COPY BINARY_OP LOAD_SMALL_INT BINARY_OP SWAP SWAP STORE_SUBSCR LOAD_CONST
Que no se parta por la mitad en una prueba concreta, en una versión concreta, es suerte del planificador, no una garantía. En cuanto cambie el intérprete, la carga de la máquina o la forma exacta del código, puede dejar de ser verdad. Pon el Lock.
Y la alternativa que lo evita de raíz: no compartir estado. Que cada hilo o proceso devuelva su resultado y se junten al final, como hacen ex.map y asyncio.gather. Un cerrojo que no existe no se puede olvidar.
Todo el código se ejecutó con Python 3.14 en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.