Asyncio en Python: Cómo ejecutar tareas simultáneas sin bloqueos

¿Alguna vez te ha pasado que mientras Python está esperando algo lento, como leer de la red o del disco, parece que todo se detiene? Con asyncio, tu código puede seguir avanzando sin quedarse atascado.

Pero antes de entrar en detalles y ver ejemplos prácticos, vamos a repasar algunos conceptos clave.

Entendiendo Asyncio

  • Bucle de eventos: El “cerebro” detrás de todo, que coordina las tareas. Cuando una función asíncrona está esperando por algo, el bucle sigue avanzando con otras tareas.
  • Corutinas: Son funciones que se definen con async def. A diferencia de las funciones normales, pueden detenerse y retomarse más tarde. Ideal para cuando tienes que esperar por operaciones lentas (como descargar una página web).
  • Await: Es la palabra clave que usamos para decir “pausa aquí mientras esperamos por algo”. Mientras el programa espera, puede seguir con otras tareas.
  • Tareas y futuros: Son las tareas ya en ejecución. Un futuro es simplemente una tarea que aún no ha terminado, como ese paquete que pediste online y sigue “en camino”.

Primer paso: await y async def

Un ejemplo clásico de asyncio para que te hagas una idea de cómo funciona.

import asyncio

async def di_hola_async():
    await asyncio.sleep(2)
    print("Hola, mundo asíncrono")

asyncio.run(di_hola_async())

Estamos usando await asyncio.sleep(2) para simular una espera de 2 segundos. Mientras Python “espera”, podría estar haciendo otras cosas. Así que cuando el tiempo pasa, imprime “Hola, mundo asíncrono”.

Haciendo múltiples tareas a la vez

¿Quieres hacer varias cosas al mismo tiempo sin bloquear tu programa? asyncio.gather es tu mejor aliado.

import asyncio

async def di_hola_async():
    await asyncio.sleep(2)
    print("Hola, mundo asíncrono")

async def haz_algo_mas():
    print("Empezando otra tarea...")
    await asyncio.sleep(1)
    print("Terminó la otra tarea")

async def main():
    await asyncio.gather(
        di_hola_async(),
        haz_algo_mas(),
    )

asyncio.run(main())

Mientras di_hola_async() espera, la función haz_algo_mas() sigue su curso. Así, ambas tareas avanzan en paralelo, optimizando el uso del tiempo.

Caso práctico: Peticiones a páginas web con asyncio

Aquí es donde asyncio brilla de verdad. Las tareas de entrada/salida, como realizar peticiones a páginas web, suelen ser lentas. Si las hacemos secuencialmente, perderíamos un montón de tiempo esperando. Vamos a ver cómo hacerlo de manera eficiente usando aiohttp para manejar múltiples descargas simultáneamente.

El enfoque clásico (bloqueante):

import requests
import time

def peticion(url):
    return requests.get(url).text

start_time = time.time()
pagina1 = peticion('http://example.com')
pagina2 = peticion('http://example.org')
print(f"Todo listo en {time.time() - start_time} segundos")

Y ahora el enfoque asíncrono con asyncio:

import aiohttp
import asyncio
import time

async def peticion_async(url, session):
    async with session.get(url) as respuesta:
        return await respuesta.text()

async def main():
    async with aiohttp.ClientSession() as session:
        pagina1 = asyncio.create_task(peticion_async('http://example.com', session))
        pagina2 = asyncio.create_task(peticion_async('http://example.org', session))
        await asyncio.gather(pagina1, pagina2)

start_time = time.time()
asyncio.run(main())
print(f"Todo listo en {time.time() - start_time} segundos")

En este caso, ambas peticiones ocurren simultáneamente, lo que reduce el tiempo total de espera. Esto es ideal cuando tienes que hacer muchas peticiones a la vez, como en web scraping o aplicaciones que manejan APIs.

¿Y si necesitas mezclar código síncrono y asíncrono?

A veces, tenemos partes del código que no pueden ser asíncronas, como una función que hace cálculos pesados o una API que solo tiene métodos síncronos. En ese caso, no te preocupes, hay una forma de combinar ambas cosas.

import asyncio
import time

def tarea_sincrona():
    print("Empezando una tarea síncrona...")
    time.sleep(5)
    print("Tarea síncrona terminada")

async def async_wrapper():
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, tarea_sincrona)

async def main():
    await asyncio.gather(
        async_wrapper(),
        # Aquí puedes agregar más tareas asíncronas
    )

asyncio.run(main())

Aquí usamos loop.run_in_executor() para ejecutar la tarea síncrona en un hilo separado, sin bloquear el bucle de eventos de asyncio. Así puedes seguir usando código síncrono sin perder la eficiencia que te da asyncio.

Otros usos prácticos: Leer archivos con asyncio

Una tarea común es leer varios archivos de forma eficiente, especialmente si son grandes o tienes muchos. Para eso podemos usar aiofiles y aprovechar la programación asíncrona también en la lectura de archivos.

import asyncio
import aiofiles

async def lee_archivo_async(ruta):
    async with aiofiles.open(ruta, 'r') as archivo:
        return await archivo.read()

async def lee_todos_async(rutas):
    tareas = [lee_archivo_async(ruta) para ruta en rutas]
    return await asyncio.gather(*tareas)

async def main():
    rutas = ['archivo1.txt', 'archivo2.txt']
    datos = await lee_todos_async(rutas)
    print(datos)

asyncio.run(main())

Con aiofiles podemos leer varios archivos simultáneamente sin bloquear el programa, haciéndolo mucho más eficiente cuando tienes muchas operaciones de I/O.

Conclusión: Asyncio es más sencillo de lo que parece

Al principio, puede que asyncio parezca complicado con sus bucles de eventos, corutinas y awaits. Pero una vez que lo pruebes con casos reales, descubrirás lo útil que es para escribir código eficiente y moderno. Ya sea manejando redes, archivos o cualquier operación de I/O, asyncio te permitirá aprovechar al máximo el tiempo de espera y hacer que tu código sea mucho más rápido.

¡Anímate a usar asyncio en tus proyectos! Cuando veas lo que puedes hacer con esto, no querrás volver atrás.