23 puntos por darjeeling 2025-11-16 | 12 comentarios | Compartir por WhatsApp

Causas de la lentitud en código async y cómo resolverlas (resumen técnico)

Este video trata las causas más comunes por las que el código asyncio de Python puede volverse más lento que el código síncrono, y las metodologías técnicas para solucionarlo.

1. Conceptos clave de Asyncio

  • Bucle de eventos (Event Loop): es el núcleo de toda aplicación asíncrona. Se inicia con asyncio.run() y administra y planifica la ejecución de tareas en un solo hilo.
  • Corrutinas (Coroutines): son funciones asíncronas declaradas con async def. Cuando encuentran la palabra clave await, pueden pausar su ejecución y devolver el control al bucle de eventos.
  • Tareas (Tasks): envuelven corrutinas y las programan para ejecutarse de forma concurrente en el bucle de eventos. Se crean mediante asyncio.create_task().
  • Futuros (Futures): son objetos de bajo nivel que representan el resultado final de una operación asíncrona.

2. Ejemplo de conversión de código síncrono a asíncrono

Se reemplaza el time.sleep() síncrono existente por await asyncio.sleep(), se declara la función con async def y se ejecuta la corrutina principal con asyncio.run().


Errores comunes que degradan el rendimiento y sus soluciones

Error 1: ejecución secuencial (Sequential Execution)

Si se hace await de tareas independientes de forma secuencial en lugar de ejecutarlas en paralelo, el tiempo total de ejecución será la suma del tiempo de todas las tareas.

  • Ejemplo incorrecto (secuencial):

    # Cada await espera a que termine el trabajo anterior  
    await get_user_notifications()  
    await get_recent_activity()  
    await get_unread_messages()  
    
  • Solución (paralelo): usar asyncio.gather o asyncio.TaskGroup para ejecutar tareas independientes al mismo tiempo. El tiempo total de ejecución se reduce al de la tarea más lenta.

    # Las tres tareas se inician al mismo tiempo  
    await asyncio.gather(  
        get_user_notifications(),  
        get_recent_activity(),  
        get_unread_messages()  
    )  
    

Comparación de herramientas de ejecución en paralelo

  • asyncio.gather:
    • Ejecuta varias corrutinas al mismo tiempo.
    • Desventaja: el manejo de errores es deficiente. Si ocurre una excepción en una tarea, las demás tareas en ejecución se cancelan.
  • asyncio.create_task:
    • Permite control y manejo de errores por tarea.
    • Es útil para ejecución en segundo plano, pero tiene la incomodidad de que hay que hacer await de varias tareas por separado.
  • asyncio.TaskGroup (Python 3.11+):
    • Es la alternativa moderna para la “concurrencia estructurada”.
    • Administra grupos de tareas con sintaxis async with, y al salir del contexto garantiza que todas las tareas hayan terminado o que las excepciones se hayan manejado.
    async with asyncio.TaskGroup() as tg:  
        tg.create_task(some_coro_1())  
        tg.create_task(some_coro_2())  
    # Cuando termina el bloque 'async with', se hace await de todas las tareas  
    

Error 2: uso de librerías síncronas

Si dentro de código asyncio se usan librerías síncronas (blocking) como requests o pathlib, se bloquea todo el bucle de eventos. Incluso si se usan dentro de asyncio.gather, en la práctica funcionarán de forma secuencial.

  • Solución: se deben usar librerías dedicadas con soporte asíncrono (non-blocking), como aiohttp (en lugar de requests) y aiofiles (en lugar de files/pathlib).

Error 3: bloquear el bucle de eventos con trabajo CPU-bound

Como asyncio se ejecuta en un solo hilo, las operaciones de cálculo pesado (CPU-bound) detienen el bucle de eventos y retrasan otras tareas de I/O.

  • Solución: usar loop.run_in_executor() para descargar el trabajo CPU-bound a un pool de hilos separado (valor predeterminado) o a un pool de procesos.
    loop = asyncio.get_running_loop()  
    # Ejecutar una función intensiva en CPU en un hilo separado  
    await loop.run_in_executor(  
        None,  # Usa el pool de hilos predeterminado  
        cpu_bound_function,  
        arg1  
    )  
    

Error 4: bloqueo por tareas no importantes

Si se hace await de tareas no esenciales, como logging que no está relacionado con la respuesta al usuario, el tiempo de respuesta se retrasa innecesariamente.

  • Solución: usar asyncio.create_task() para separar ese trabajo como tarea en segundo plano y no hacerle await.
    user_profile = await get_user_profile()  
    # Ejecutar el logging en segundo plano sin await  
    asyncio.create_task(send_logs_to_external_service())  
    return user_profile  
    

Error 5: crear demasiadas tareas

Si se convierten en tareas demasiados trabajos muy pequeños, el overhead de cambio de contexto puede degradar el rendimiento.

  • Solución 1: agrupar trabajos pequeños (batching) para formar unas pocas tareas más grandes.
  • Solución 2: usar asyncio.Semaphore para limitar la cantidad máxima de tareas que se ejecutan al mismo tiempo.
    # Permitir como máximo 10 tareas simultáneas  
    semaphore = asyncio.Semaphore(10)  
    
    async with semaphore:  
        await fetch_data()  
    

Otros errores

  • Corrutinas “Never Awaited”: se llama a una corrutina y no se le hace await, por lo que el trabajo ni siquiera llega a ejecutarse y falla silenciosamente. Se puede detectar con linters como flake8-async.
  • Gestión inadecuada de recursos: si se usan archivos, conexiones de DB, etc. sin try...finally, pueden producirse fugas de recursos. Se resuelve con administradores de contexto asíncronos usando async with.

Depuración y elección del modelo de concurrencia

Modo debug de Asyncio

Si se activa el modo debug, que está deshabilitado por defecto (asyncio.run(debug=True)), ayuda a detectar problemas como los siguientes.

  • Corrutinas sin await (RuntimeWarning).
  • APIs asíncronas llamadas desde el hilo incorrecto.
  • Callbacks con tiempo de ejecución superior a 100 ms.
  • Operaciones lentas del selector de I/O.

Otras herramientas de depuración

  • Scalene: perfilador de CPU y memoria.
  • aio-monitor: monitoreo y CLI para aplicaciones asyncio.
  • pdb: depurador básico de Python.
  • py-stack: imprime stack traces de procesos Python en ejecución para detectar puntos de bloqueo.

Guía para elegir el modelo de concurrencia

  • Asyncio (un solo hilo): es ideal para una gran cantidad de tareas I/O-bound con alta latencia de espera (por ejemplo, solicitudes de red, I/O de archivos).
  • Threads (multihilo): se usan para tareas I/O-bound que requieren acceso a datos compartidos. Por el GIL (Global Interpreter Lock) no hay paralelismo real, pero mientras un hilo espera I/O, otro puede ejecutarse.
  • Processes (multiproceso): se usan para tareas CPU-bound (por ejemplo, procesamiento de imágenes, cálculos pesados). Aprovechan varios núcleos de CPU para lograr paralelismo real, pero tienen alto overhead de memoria y comunicación.

https://youtu.be/wGDOwNW6lVk

12 comentarios

 
savvykang 2025-11-18

Python sin duda es un gran lenguaje, pero su interfaz asíncrona parece una funcionalidad mal diseñada.

 
ceruns 2025-11-17

Faltó eager_start=True en el punto 4. Como create_task crea una weakref, el código podría terminar siendo una tarea que nunca se ejecute....

 
tested 2025-11-17

> https://rosettalens.com/s/ko/python-to-node

Dicen que esta persona también se cambió a Node.js por culpa de async de Python

 
kandk 2025-11-17

Conclusión: la interfaz asíncrona de Python todavía no es intuitiva.

 
bungker 2025-11-17

De hecho, si es un proyecto lo bastante grande como para optimizar la asincronía en Python, escribirlo en otro lenguaje suele ser mucho mejor tanto en rendimiento como en estabilidad.

 
euphcat 2025-11-17

Si no se va a usar un lenguaje compilado, ¿hay realmente una gran diferencia de rendimiento? En multithreading sí la habría por la existencia del GIL, pero si al final se trata de una estructura asíncrona donde funciona un event loop, me da curiosidad qué tipo de diferencias aparecen según el lenguaje.

 
vwjdalsgkv 2025-11-17

La presencia o ausencia de compilación JIT influye más de lo que uno piensa. V8 está muy bien optimizado.

 
euphcat 2025-11-16

No he revisado el video fuente, pero el código de la solución para el error 4 está mal.

La instancia de tarea que devuelve create_task() debe asignarse al menos a una variable, y esa variable debe seguir viva hasta que la tarea termine. De lo contrario, existe el riesgo de que la instancia de la tarea sea recolectada por el garbage collector mientras la corrutina todavía se está ejecutando.

Si la función que crea la tarea termina poco después, como en el caso anterior, hay que usar métodos como devolver la instancia de la tarea, asignarla a una variable global o asignarla a una variable de instancia.

P.D.)
Aunque realmente no haga falta el valor de retorno y tengas la certeza de que la corrutina va a terminar en poco tiempo, igual conviene programarlo de forma que en algún momento se le haga await a la instancia de la tarea. Si no quieres hacerlo, entonces al menos hay que poner un manejo de excepciones muy estricto en cada corrutina que vaya a funcionar como tarea, y preparar una estructura que emita mensajes de log sin dejar huecos. Si no, puede pasar que por más grave que sea el problema que cause la tarea, la Exception no se procese y falle silenciosamente.

En un proyecto que desarrollo y administro como trabajo, llegué a diseñar un patrón donde decenas de módulos crean cada uno una tarea del tipo while self.ok(): cmd = await self.cmd_queue.get(); await self.process(cmd); y la dejan corriendo continuamente. Hasta que definimos un patrón claro de manejo de excepciones, cada vez que aparecía un problema, yo también terminaba explotando mentalmente con él, una experiencia bastante singular jaja

 
kunggom 2025-11-16

Incluso desde la perspectiva de alguien que trabaja en una empresa que usa C#, que podría considerarse el origen(?) del patrón Async/Await, veo con bastante frecuencia código incorrecto como el del error 1, donde simplemente encadenan await uno tras otro en orden.

Cuando veo ese tipo de código, siento que en muchos casos lo único que saben en común es que hay que usar la palabra clave await delante de una llamada a un método async, pero no piensan mucho más allá en el orden de ejecución asíncrona, y por eso termina saliendo código así.
Cuando aparecen varios await, algunos resultados se usan justo en la línea siguiente, así que conviene recibir antes el valor resultante del await del objeto Task<T>; en cambio, otros se usarán bastante más adelante, así que se puede recibir solo el Task<T> y hacer await después. Escribir código considerando este flujo asíncrono implica, justamente, pensar más las cosas.

Al menos yo, en métodos declarados como asíncronos, sí escribo el código teniendo en cuenta este flujo de procesamiento. Pero a veces, cuando veo código heredado de alguien que ya dejó la empresa y que estoy manteniendo, también me da la impresión de que pensó algo como: “yo solo quiero escribir código síncrono de forma simple, pero como el método que tengo que usar en medio solo existe en versión asíncrona, entonces lo escribo así nomás”.

 
skageektp 2025-11-17

Si el punto 1 siempre es independiente, hacerlo así sí parece conveniente,
pero si al modificar el código deja de ser independiente, también da la impresión de que existe la incomodidad de tener que revisar y corregir todos los lugares donde se usa esa función.
Si no es una tarea que tome muchísimo tiempo, quizá sea mejor hacer await en serie desde el punto de vista del mantenimiento del código.

 
euphcat 2025-11-17

Creo que conviene abordarlo desde la idea de que, como el overhead del multithreading puede ser una carga, la alternativa es dividir un solo hilo para resolver el procesamiento en paralelo. Por eso, en principio, parece correcto que en algunos casos haya que prestarle incluso más atención que al multithreading.

 
kunggom 2025-11-17

Así es.
Parece que el código asíncrono bien hecho, por su propia naturaleza, es algo a lo que hay que prestarle mucha atención.