Fundamentos

Asyncio en Python: async/await, TaskGroup, hilos, multiprocessing y GIL

Actualizado: · Skillaria.Top

Asyncio es la biblioteca integrada de Python para el código asíncrono: las funciones declaradas con async def (corrutinas) se pausan en await, y mientras tanto el bucle de eventos ejecuta otras tareas, de modo que cientos de peticiones de red avanzan a la vez en un solo hilo. Para esperar la red o el disco sirven asyncio o los hilos (threading); para cálculos pesados, los procesos (multiprocessing), porque el GIL impide que los hilos del CPython habitual calculen en paralelo.

Asyncio es la biblioteca integrada de Python para el código asíncrono. Las funciones declaradas con async def pueden pausarse en await, y mientras una espera la respuesta de la red, el bucle de eventos ejecuta otras. Así, cientos de peticiones avanzan a la vez dentro de un mismo hilo. Pero asyncio es solo una de tres herramientas: también están los hilos (threading) y los procesos (multiprocessing). Veamos cómo funciona asyncio en Python, qué elegir para cada tarea y qué papel tiene el GIL.

Esperar y calcular son cosas distintas

Sin entender esta diferencia no se puede seguir. Un programa va lento por una de dos causas:

  • I/O-bound — espera entrada o salida: la respuesta de una API, de una base de datos o la lectura de un disco. El procesador está parado.
  • CPU-bound — calcula: procesa imágenes, recorre millones de registros en bucles. El procesador está cargado.

Para esperar sirven asyncio o los hilos: mientras una operación espera, se ejecuta otra. Para calcular hacen falta cálculos realmente paralelos en varios núcleos, y eso son los procesos. Confundirlos es el error principal: los hilos no aceleran los cálculos en Python puro, y usar procesos para tres peticiones HTTP es matar moscas a cañonazos.

Qué es asyncio: corrutinas y bucle de eventos

El código asíncrono se apoya en dos palabras. async def declara una corrutina, una función que puede pausarse. Su llamada no ejecuta el cuerpo, sino que crea un objeto de corrutina. await dentro de ella dice: «aquí espero, y mientras tanto ocúpate de otras tareas». Todo lo gobierna el bucle de eventos (event loop), que se arranca con asyncio.run().

import asyncio

async def obtener_tipo(moneda):
    await asyncio.sleep(1)          # simulación de una petición a una API
    return {"USD": 92.5, "EUR": 100.1, "CNY": 12.7}[moneda]

async def main():
    tipo = await obtener_tipo("USD")
    print(tipo)

asyncio.run(main())  # 92.5

asyncio.sleep() es una pausa asíncrona: cede el control al bucle de eventos. En cambio, un time.sleep() normal dentro de una corrutina bloquea todo el bucle, y las demás tareas se quedan paradas.

Uno tras otro o a la vez

Si se escribe await dentro de un bucle, las peticiones se hacen una tras otra: tres monedas tardan tres segundos. Para que avancen a la vez, hay que lanzarlas como tareas.

import asyncio, time

async def main():
    inicio = time.perf_counter()
    for moneda in ("USD", "EUR", "CNY"):
        await obtener_tipo(moneda)                    # una tras otra
    print(f"{time.perf_counter() - inicio:.1f} s")    # 3.0 s

asyncio.run(main())

TaskGroup: lanzar juntas y esperar a todas

La forma moderna (Python 3.11 o superior) es el grupo de tareas. Dentro del bloque async with, cada tarea arranca al momento mediante tg.create_task(), y la salida del bloque se produce cuando han terminado todas.

async def main():
    inicio = time.perf_counter()
    async with asyncio.TaskGroup() as tg:
        tareas = {m: tg.create_task(obtener_tipo(m)) for m in ("USD", "EUR", "CNY")}
    tipos = {m: t.result() for m, t in tareas.items()}
    print(tipos, f"{time.perf_counter() - inicio:.1f} s")   # ... 1.0 s

asyncio.run(main())

TaskGroup es estricto: si una tarea falla, las demás se cancelan, y todos los errores se reúnen en un ExceptionGroup. Se capturan con except*:

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(obtener_tipo("USD"))
            tg.create_task(obtener_tipo("XXX"))   # KeyError
    except* KeyError as eg:
        print("moneda desconocida:", eg.exceptions)

asyncio.gather y create_task

Antes de TaskGroup, el estándar era asyncio.gather(): lanza las corrutinas a la vez y devuelve los resultados en el mismo orden. Todavía aparece mucho en el código existente.

async def main():
    usd, eur, cny = await asyncio.gather(
        obtener_tipo("USD"), obtener_tipo("EUR"), obtener_tipo("CNY")
    )
    # con return_exceptions=True los errores vuelven en la lista, sin cortar todo

asyncio.create_task() lanza una corrutina en segundo plano sin grupo. Hay que guardar la referencia a la tarea; si no, el recolector de basura puede eliminarla y nadie verá sus errores. Por eso, en proyectos nuevos, TaskGroup es más seguro.

Cancelar tareas y tiempos límite

async def main():
    tarea = asyncio.create_task(obtener_tipo("USD"))
    tarea.cancel()                    # dentro de la tarea se produce CancelledError
    try:
        await tarea
    except asyncio.CancelledError:
        print("cancelada")

    try:
        async with asyncio.timeout(0.5):   # Python 3.11 o superior
            await obtener_tipo("EUR")
    except TimeoutError:
        print("demasiado tiempo")

En versiones antiguas, en lugar de asyncio.timeout() se usa asyncio.wait_for(coro, timeout=0.5). También existe asyncio.wait(), que devuelve los conjuntos de tareas terminadas y pendientes, y puede esperar a la primera (return_when=asyncio.FIRST_COMPLETED).

En Python 3.15, tal como se muestra en el curso, TaskGroup tiene el método tg.cancel(), que cancela todas las tareas del grupo sin excepción. Esto resuelve el caso de «quién responde primero»: preguntar a tres servicios espejo, quedarse con la primera respuesta y cancelar las demás. Antes había que lanzar una excepción propia como señal o cancelar las tareas a mano.

Sincronización: Semaphore, Lock, Event, Queue

Asyncio tiene sus propias primitivas, que funcionan con await. La más útil es Semaphore: limita cuántas operaciones se ejecutan a la vez, para no saturar una API ajena con mil peticiones.

semaforo = asyncio.Semaphore(10)

async def limitado(url):
    async with semaforo:             # no más de 10 a la vez
        await asyncio.sleep(0.1)     # aquí iría la petición
        return url
  • asyncio.Lock — solo una corrutina en la sección crítica.
  • asyncio.Event — una corrutina espera con await event.wait(), y otra avisa con event.set().
  • asyncio.Queue — cola «productor — consumidor»: await q.put(), await q.get(), q.task_done().

Trampa: olvidar await

El error más frecuente es llamar a una corrutina sin await:

async def main():
    tipo = obtener_tipo("USD")    # falta await
    print(tipo * 2)               # TypeError
# RuntimeWarning: coroutine 'obtener_tipo' was never awaited

Python no ejecutó la función, solo creó el objeto de corrutina. Si en los registros aparece «was never awaited», busque el await que falta. El segundo error habitual es una llamada bloqueante (time.sleep, requests.get) dentro de una corrutina. Para HTTP conviene usar bibliotecas asíncronas (httpx, aiohttp), y el código síncrono inevitable se lleva a un hilo con await asyncio.to_thread(funcion, argumento).

Multihilo en Python: el módulo threading

Un hilo (thread) es una línea de ejecución independiente dentro de un mismo proceso; los hilos comparten la memoria. Para esperar la red funcionan muy bien y no obligan a reescribir el código con async.

import threading, time

def descargar(nombre):
    time.sleep(1)               # espera de la red
    print(nombre, "listo")

hilos = [threading.Thread(target=descargar, args=(n,)) for n in ("a", "b", "c")]
for h in hilos:
    h.start()
for h in hilos:
    h.join()                    # todo en unos 1 segundo

La memoria compartida es a la vez una ventaja y una fuente de errores: si dos hilos modifican la misma variable, hace falta un threading.Lock. Normalmente es más sencillo no manejar los hilos a mano, sino usar un pool.

ThreadPoolExecutor

concurrent.futures.ThreadPoolExecutor crea un pool de hilos y les reparte las tareas. map devuelve los resultados en el orden original.

from concurrent.futures import ThreadPoolExecutor
import time

def descargar(url):
    time.sleep(1)
    return f"{url}: 200"

urls = [f"https://example.com/{i}" for i in range(5)]
with ThreadPoolExecutor(max_workers=5) as pool:
    for resultado in pool.map(descargar, urls):
        print(resultado)        # todo en unos 1 segundo

Qué es el GIL y por qué los hilos no aceleran los cálculos

El GIL (Global Interpreter Lock) es el cerrojo global del intérprete CPython. En cada momento, solo un hilo ejecuta bytecode de Python. Mientras un hilo espera la entrada o salida, libera el GIL, por eso los hilos sirven para la red. Pero en cálculos puros, cuatro hilos no van más rápido que uno: se pasan el cerrojo por turnos.

from concurrent.futures import ThreadPoolExecutor

def pesado(n):
    return sum(i * i for i in range(n))

with ThreadPoolExecutor(4) as pool:
    list(pool.map(pesado, [5_000_000] * 4))   # casi lo mismo que en secuencia

Las bibliotecas escritas en C (NumPy y similares) suelen liberar el GIL durante las operaciones pesadas, así que con ellas los hilos sí ayudan a acelerar.

multiprocessing: paralelismo real

Cada proceso tiene su propio intérprete y su propio GIL, así que los procesos calculan a la vez en distintos núcleos. El precio es la memoria separada: los datos viajan entre procesos mediante serialización (pickle), y arrancar un proceso cuesta más que un hilo.

from concurrent.futures import ProcessPoolExecutor

def pesado(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":      # obligatorio en Windows y macOS
    with ProcessPoolExecutor() as pool:
        print(list(pool.map(pesado, [5_000_000] * 4)))

Sin la comprobación if __name__ == "__main__", los procesos hijos, al importar el módulo, empezarían a crear nuevos procesos. Existe también el módulo de bajo nivel multiprocessing, con Process, Pool y Queue, pero ProcessPoolExecutor es más sencillo y tiene la misma interfaz que el pool de hilos.

Qué elegir: asyncio, threading o multiprocessing

TareaHerramientaPor qué
Descargar 200 páginas, muchas peticiones a una APIasyncio (httpx/aiohttp) o ThreadPoolExecutorse espera a la red, el procesador queda libre
Varias peticiones dentro de un programa ya asíncronoTaskGroupel código ya está en async
Biblioteca síncrona sin versión asynchilos, asyncio.to_threadno hace falta reescribir el código
Cálculo pesado en Python puroProcessPoolExecutorevita el GIL, usa todos los núcleos

Python sin GIL y subintérpretes: novedades

Desde Python 3.13 existe una compilación sin GIL (free-threading, PEP 703), que se identifica con la letra t en el nombre, por ejemplo python3.15t. En la 3.13 era experimental; desde la 3.14 (PEP 779) tiene soporte oficial. En ella, los hilos calculan en paralelo en distintos núcleos. Pero por defecto sigue usándose el Python habitual con GIL: el código de un solo hilo en la compilación free-threaded va algo más lento, y no todas las bibliotecas están preparadas para ella.

import sys
print(sys._is_gil_enabled())   # False en la compilación free-threaded

Un segundo camino hacia los cálculos en paralelo llegó en la 3.14 (PEP 734): los subintérpretes, en el módulo concurrent.interpreters. Son varios intérpretes aislados dentro de un mismo proceso, cada uno con su GIL. Se usan cómodamente a través de InterpreterPoolExecutor, que tiene el mismo aspecto que un pool de hilos. En la medición del curso, cuatro tareas pesadas tardaron 0.39 s frente a 1.03 s en un solo hilo.

Resumen

Asyncio en Python sirve para tareas de espera: corrutinas con async def, pausas con await y lanzamiento simultáneo con TaskGroup o gather. Los hilos resuelven el mismo problema sin reescribir el código en async, y para los cálculos pesados hacen falta procesos, porque el GIL impide que los hilos de CPython habitual calculen en paralelo. La compilación free-threading y los subintérpretes están cambiando este panorama, pero la regla básica sigue vigente: si se espera, asyncio o hilos; si se calcula, procesos. En el curso, todo esto se practica con el registro de gastos, que obtiene los tipos de cambio en paralelo en un segundo en lugar de tres.

Preguntas frecuentes

¿Se puede llamar a una función async desde código normal?

Sí, con asyncio.run(corrutina()): crea el bucle de eventos, ejecuta la corrutina y cierra el bucle. Dentro de un bucle que ya está en marcha no se puede llamar a asyncio.run; allí se usa await.

¿Asyncio funciona con varios hilos?

No, el bucle de eventos ejecuta todas las corrutinas en un solo hilo y cambia de una a otra solo en los puntos await. Por eso hay menos condiciones de carrera que con hilos, pero una sola operación bloqueante detiene todo.

¿Hay que instalar asyncio con pip?

No, asyncio forma parte de la biblioteca estándar de Python 3; basta con escribir import asyncio. El paquete asyncio de PyPI está obsoleto y no hace falta instalarlo.

¿Cómo ejecutar una función pesada desde asyncio sin bloquear el bucle?

Use loop.run_in_executor con ProcessPoolExecutor para cálculos, o asyncio.to_thread para entrada y salida bloqueante; el bucle de eventos seguirá atendiendo las demás tareas.

¿En qué se diferencia una corrutina de una tarea (Task)?

La corrutina es un objeto que no hace nada hasta que alguien la espera con await. La tarea envuelve la corrutina y la programa de inmediato en el bucle de eventos, de modo que se ejecuta en paralelo con el resto del código.

Aprende este tema en vídeo

Este tema se explica a fondo y con práctica en el curso «Python 3.15: от новичка до профи».

Ir al curso