Hilos, procesos y asyncio en Python: cuál usar

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.

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
Fíjate en la última línea: cuatro hilos son más lentos que hacerlo de uno en uno. El GIL los serializa y encima añade el coste de ir cambiando entre ellos. Con procesos sí hay paralelismo de verdad: de 451 a 121 ms. Arrancarlos cuesta unos 45 ms, así que para tareas cortas conviene reutilizar el grupo en vez de crear uno por lote.

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.

Lo contagioso de 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.

Eso no significa que sea seguro, y conviene entender por qué. La operación no es atómica: son once instrucciones de la máquina virtual.

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.