Conociendo asyncio: Los Fundamentos de la Programación Asíncrona en Python

Conociendo asyncio: Los Fundamentos de la Programación Asíncrona en Python

Tabla de Contenido

Si alguna vez construiste un scraper que recorre decenas de páginas, un microservicio que atiende cientos de peticiones o un bot que consume múltiples APIs, seguro sentiste la frustración de esperar. Una petición termina, la siguiente arranca, y tu programa queda ahí sin hacer nada útil.

La librería asyncio de Python fue diseñada para resolver exactamente este problema. En este artículo vamos a construir un modelo mental sólido sobre los conceptos clave: concurrencia, paralelismo, multitarea y la diferencia entre operaciones limitadas por CPU y por E/S.

¿Qué es la programación asíncrona?

La programación asíncrona es un modelo donde las tareas de larga duración se ejecutan en segundo plano, separadas del flujo principal de tu aplicación. En lugar de bloquear todo mientras esperas una respuesta de red o una lectura de archivo, el sistema queda libre para hacer otro trabajo. Cuando la tarea termina, tu código recibe una notificación y puede procesar el resultado.

Note

asyncio — abreviación de asynchronous I/O — es la librería estándar de Python que implementa este modelo. Fue introducida en Python 3.4 y hoy es la base de frameworks modernos como FastAPI , aiohttp y Starlette .

CPU-bound vs. I/O-bound

Antes de avanzar, necesitas entender una distinción fundamental: ¿en qué está gastando tiempo tu código?

Operaciones limitadas por E/S (I/O-bound)

Tu programa pasa la mayor parte del tiempo esperando un sistema externo: la red, el disco, una base de datos. La CPU está prácticamente ociosa. Ejemplos:

  • Hacer peticiones HTTP.
  • Leer o escribir archivos en disco.
  • Consultar una base de datos.

Operaciones limitadas por CPU (CPU-bound)

El procesador es el cuello de botella. La CPU está activamente calculando, sin esperas externas. Ejemplos:

  • Calcular dígitos de pi.
  • Aplicar lógica de negocio sobre estructuras de datos grandes.
  • Procesamiento de imágenes o entrenamiento de modelos.

Ejemplo práctico

Considera este fragmento de código:

import requests

response = requests.get('https://www.example.com')       # 1 - I/O-bound
items = response.headers.items()
headers = [f'{key}: {header}' for key, header in items]   # 2 - CPU-bound

formatted_headers = '\n'.join(headers)                     # 3 - CPU-bound

with open('headers.txt', 'w') as file:
    file.write(formatted_headers)                          # 4 - I/O-bound

El siguiente diagrama muestra el flujo de ejecución y clasifica cada paso:

flowchart LR A["1. requests.get()"] -->|respuesta| B["2. List comprehension"] B --> C["3. join() headers"] C --> D["4. file.write()"] style A fill:#3b82f6,stroke:#1e40af,color:#fff style B fill:#f59e0b,stroke:#b45309,color:#fff style C fill:#f59e0b,stroke:#b45309,color:#fff style D fill:#3b82f6,stroke:#1e40af,color:#fff

🔵 Azul = I/O-bound (esperando red o disco) · 🟡 Amarillo = CPU-bound (procesando en memoria)

Info

asyncio brilla con cargas I/O-bound. Si tu cuello de botella es la CPU, necesitarás multiprocessing o concurrent.futures .

Concurrencia vs. Paralelismo

Estos dos términos se confunden constantemente, pero describen cosas distintas.

Concurrencia: alternar entre tareas

Concurrencia significa gestionar múltiples tareas en períodos de tiempo superpuestos, sin necesidad de ejecutarlas en el mismo instante. Piensa en un chef preparando dos platos: pica verduras para el plato A, lo mete al horno y empieza a trabajar en el plato B mientras el A se cocina. Solo hace una cosa a la vez, pero ambos platos avanzan.

En un sistema concurrente, una sola CPU alterna entre tareas. Mientras la tarea #1 espera por E/S, la CPU toma la tarea #2.

gantt title Concurrencia — Una sola CPU alternando entre tareas dateFormat X axisFormat %s section CPU App #1 :a1, 0, 2 App #2 :a2, 2, 4 App #1 :a3, 4, 6 App #2 :a4, 6, 8 App #1 :a5, 8, 10

Paralelismo: ejecución simultánea real

Paralelismo significa ejecutar múltiples tareas al mismo tiempo, lo cual requiere múltiples CPUs o núcleos.

gantt title Paralelismo — Múltiples CPUs ejecutando simultáneamente dateFormat X axisFormat %s section CPU 1 App #1 :a1, 0, 10 section CPU 2 App #2 :b1, 0, 10

En palabras de Rob Pike (co-creador de Go): “La concurrencia se trata de lidiar con muchas cosas a la vez. El paralelismo se trata de hacer muchas cosas a la vez.”

Note

La concurrencia es un patrón de diseño; el paralelismo es un modelo de ejecución. asyncio logra concurrencia en un solo hilo, sin paralelismo.

Tipos de Multitarea

Existen dos enfoques principales para la multitarea, y entender la diferencia es clave para comprender dónde encaja asyncio.

Multitarea preventiva (Preemptive)

El sistema operativo decide cuándo cambiar entre tareas mediante un mecanismo llamado time slicing: divide el tiempo de CPU en intervalos pequeños y rota entre las tareas activas. Cuando el SO fuerza un cambio, se dice que está haciendo preemption.

En Python, este es el modelo que usan los módulos threading (hilos) y multiprocessing (procesos). La ventaja es que no tienes que pensar en cuándo ceder el control. La desventaja: los cambios pueden ocurrir en cualquier momento, lo que introduce condiciones de carrera y la necesidad de locks.

Multitarea cooperativa (Cooperative)

Las propias tareas deciden cuándo ceder el control. Cada tarea señala explícitamente: “Me pauso aquí, ejecuta otras tareas.” No hay intervención del SO.

Este es exactamente el modelo de asyncio. En Python, la palabra clave await es esa señal explícita:

import asyncio

async def obtener_datos():
    print("Obteniendo datos...")
    await asyncio.sleep(2)  # 👈 "Me pauso aquí, ejecuta otras tareas"
    print("Datos obtenidos!")

async def procesar_datos():
    print("Procesando datos...")
    await asyncio.sleep(1)  # 👈 Misma señal
    print("Datos procesados!")

async def main():
    await asyncio.gather(obtener_datos(), procesar_datos())

asyncio.run(main())

La ventaja es la predictibilidad: tú controlas exactamente dónde ocurren los cambios. El riesgo: si una tarea olvida hacer await o ejecuta una operación CPU-bound larga sin ceder control, bloquea todo el event loop.

El siguiente diagrama de secuencia ilustra cómo el event loop coordina las tareas cooperativamente:

sequenceDiagram participant EL as Event Loop participant T1 as obtener_datos() participant T2 as procesar_datos() EL->>T1: Ejecutar T1->>EL: await sleep(2) — cede control EL->>T2: Ejecutar T2->>EL: await sleep(1) — cede control EL->>T2: sleep completado — reanudar T2->>EL: Finalizada ✅ EL->>T1: sleep completado — reanudar T1->>EL: Finalizada ✅

Comparación rápida

  • Preventiva
  • Cooperativa

Multitarea Preventiva

  • ¿Quién cambia? El Sistema Operativo
  • Mecanismo: Time slicing
  • Herramientas en Python: threading, multiprocessing
  • Riesgo principal: Condiciones de carrera
  • Ideal para: Cargas CPU-bound o mixtas

Multitarea Cooperativa

  • ¿Quién cambia? La propia tarea
  • Mecanismo: await / yield
  • Herramientas en Python: asyncio
  • Riesgo principal: Bloquear el event loop
  • Ideal para: Cargas I/O-bound

¿Dónde encaja asyncio?

Con todos estos conceptos claros, el siguiente diagrama te ayuda a decidir qué herramienta usar según tu caso:

flowchart TD A["¿Qué tipo de operación\nnecesitas optimizar?"] --> B{"¿Es I/O-bound?"} B -->|Sí| C{"¿Necesitas manejar\nmuchas conexiones?"} C -->|Sí| D["✅ asyncio"] C -->|No| E["threading o\nasyncio"] B -->|No| F{"¿Es CPU-bound?"} F -->|Sí| G{"¿Necesitas compartir\nmemoria entre tareas?"} G -->|Sí| H["threading\n+ locks"] G -->|No| I["multiprocessing"] F -->|Mixto| J["asyncio +\nrun_in_executor()"] style D fill:#10b981,stroke:#065f46,color:#fff style E fill:#10b981,stroke:#065f46,color:#fff style H fill:#f59e0b,stroke:#b45309,color:#fff style I fill:#3b82f6,stroke:#1e40af,color:#fff style J fill:#8b5cf6,stroke:#5b21b6,color:#fff

En conclusión

  • asyncio ofrece concurrencia, no paralelismo — todo corre en un solo hilo.
  • Usa multitarea cooperativa — tu código cede control explícitamente con await.
  • Destaca en cargas I/O-bound — peticiones de red, operaciones de archivo, consultas a bases de datos.
  • No es la solución para trabajo CPU-bound — para eso, combínalo con multiprocessing o usa loop.run_in_executor() .

Referencias