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 claveawait, 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.gatheroasyncio.TaskGrouppara 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
awaitde 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) yaiofiles(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 hacerleawait.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.Semaphorepara 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 comoflake8-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 usandoasync 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.
12 comentarios
Python sin duda es un gran lenguaje, pero su interfaz asíncrona parece una funcionalidad mal diseñada.
Faltó
eager_start=Trueen el punto 4. Comocreate_taskcrea unaweakref, el código podría terminar siendo una tarea que nunca se ejecute....> https://rosettalens.com/s/ko/python-to-node
Dicen que esta persona también se cambió a Node.js por culpa de
asyncde PythonConclusión: la interfaz asíncrona de Python todavía no es intuitiva.
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.
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.
La presencia o ausencia de compilación JIT influye más de lo que uno piensa. V8 está muy bien optimizado.
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
awaita 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 jajaIncluso 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
awaituno 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
awaitdelante de una llamada a un métodoasync, 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 delawaitdel objetoTask<T>; en cambio, otros se usarán bastante más adelante, así que se puede recibir solo elTask<T>y hacerawaitdespué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”.
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
awaiten serie desde el punto de vista del mantenimiento del código.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.
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.