Python: programación asíncrona en acción
Table of Contents
En vez de esperar que una tarea termine para seguir con la siguiente (modo síncrono y bloqueante), el código asíncrono permite:
- Iniciar una tarea.
- Mientras esa tarea "espera" se aprovecha el tiempo para hacer otras tareas.
- Se retoma la tarea inicial cuando está lista.
O, dicho de otro modo, la programación asíncrona permite a las tareas ejecutarse sin esperar a otras mejorando, así, el rendimiento de los programas.
Esto no es lo mismo que paralelismo (ejecución simultánea en varios núcleos), sino concurrencia.
En Python, la programación asíncrona se logra por medio del módulo asyncio que nos lleva a conceptos como son: el bucle de eventos (event loops), corrutinas (coroutines), tareas (tasks) y futuros (futures), en los que vamos a entrar en los siguientes apartados.
Bucle de ventos
El bucle de ventos del módulo asyncio es el elemento central de la programación asíncrona, donde se coordinan múltiples tareas y se decide qué tarea se ejecuta en cada momento. Esta decisión se hace teniendo en cuenta que si hay tareas que necesitan esperar se pausen y se pase a ejecutar otra tarea. Esto mantienen al programa activo y aumenta la eficiencia.
Una tarea necesita realizar estas esperas cuando se lanza un temporalizador, se tiene que hacer una llamada por red, se tiene que acceder a un fichero o se hace una acción en una base de datos.
Estas acciones son bloqueantes y, por tanto, ralentizan mucho los programas. Si mientras hacen esta espera se pausan para que otras tareas puedan ejecutarse se aumenta el rendimiento de los programas que es el objetivo del código asíncrono.
Aquí tienes un ejemplo básico de uso del módulo asyncio que se describe más abajo:
import asyncio async def task(): print("Empieza la tarea a ejecutarse...") # Simulación de una espera await asyncio.sleep(2) print("... finaliza la tarea") # Ejecuta el bucle de eventos lanzando la tarea asyncio.run(task())
asyncio.run()se usa para iniciar el bucle de eventos indicando, como argumento, la corrutina que se va a ejecutar para dar inicio al código asíncrono.async def task()define una corrutina que no es más que una función que se puede ejecutar de forma asíncrona. Estas se pueden pausar y remotar dentro del bucle de ventos.awaites la instrucción que permite pausar una corrutina. Se trata de una espera no bloqueante permitiendo, así, que otras corrutinas se puedan ejecutar. Estosawaitse especifican en llamadas a corrutinas, temporizadores y, en general, en instrucciones que puedan bloquear el hilo principal.
Corrutinas: bloques de código asíncronos
Como se ha comentado antes, las corrutinas son funciones especiales que permiten pausar y reanudar el código. Se definen con async def.
Cuando se llama a una corrutina se tiene que hacer con la instrucción await.
Te muestro un ejemplo en el que se simula una acceso a un recurso por red:
import asyncio async def fetch_data(url, delay): print(f"Accediendo al recurso {url}") #Simulamos que estamos accediendo al recurso por red await asyncio.sleep(delay) return f"Recurso {url} obtenido tras {delay} segundos" async def main(): task1 = asyncio.create_task(fetch_data("http://proferoman.com/recurso1", 3)) task2 = asyncio.create_task(fetch_data("http://proferoman.com/recurso2", 2)) result1 = await task1 result2 = await task2 print(f"Resultado tarea 1: {result1}") print(f"Resultado tarea 2: {result2}") if __name__ == "__main__": # Ejecuta el bucle de eventos asyncio.run(main())
fetch_dataes la corrutina que accede a un recurso.asyncio.create_taskcrea una corrutina por la que luego hay que esperar.- El programa tarda 3 segundos en terminar.
La versión síncrona tardaría 5 segundos en terminar lo que demuestra la ventaja de la programación asíncrona. Aquí, puedes ver la versión síncrona del programa anterior:
from time import sleep def fetch_data(url, delay): print(f"Accediendo al recurso {url}") #Simulamos que estamos accediendo al recurso por red sleep(delay) return f"Recurso {url} obtenido tras {delay} segundos" def main(): result1 = fetch_data("recurso1", 3) result2 = fetch_data("recurso2", 2) print(f"Resultado tarea 1: {result1}") print(f"Resultado tarea 2: {result2}") if __name__ == "__main__": main()
Futuros (o promesas)
Un futuro es, básicamente, la promesa de un valor que se obtendrá más tarde y es un concepto clave de la programación asíncrona con corrutinas.
De hecho, en el código asíncrono anterior, las variables task1 y task2 declaradas en main son objetos del tipo asyncio.Future (y asyncio.Task).
Puedes comprobar el tipo de los objetos task1 y task2 así:
print(f"¿task1 es Future? {isinstance(task1, asyncio.Future)}")
De todo esto se deriva que tengamos que esperar por esos valores con la instrucción await.
Últimos ejemplos con novedades
En estos ejemplos que te muestro hay dos novedades con respecto a los ejemplos anteriores:
- Usamos la función
gatherpara lanzar una lista de tareas o corrutinas, en vez de usar la funcióncreate_task. - Vemos cómo podemos lanzar subprocesos de forma asíncrona usando la función
create_subprocess_execdel móduloasyncio.
Ejemplo 1: uso de subprocesos lanzados de forma asíncrona
import subprocess import asyncio async def ping(ip): process = await asyncio.create_subprocess_exec("ping", ip, "-c", "3") await process.wait() async def main(): tasks = [ ping("10.184.102.1") ping("10.184.102.206") ] await asyncio.gather(*tasks) if __name__ == "__main__": asyncio.run(main())
Ejemplo 2 obtención de resultados con grather
import asyncio async def fetch_data(url, delay): print(f"Accediendo al recurso {url}") #Simulamos que estamos accediendo al recurso por red await asyncio.sleep(delay) return f"Recurso {url} obtenido tras {delay} segundos" async def main(): tasks = [ asyncio.create_task(fetch_data("http://proferoman.com/recurso1", 3)), asyncio.create_task(fetch_data("http://proferoman.com/recurso2", 2)) ] results = await asyncio.gather(*tasks) for i, r in enumerate(results): print(f"Resultado {i + 1}: {r}") if __name__ == "__main__": # Ejecuta el bucle de eventos asyncio.run(main())
Cancelación de corrutinas
Las corrutinas (tareas) que se lanzan pueden ser canceladas llamando el método cancel de la tara correspondiente.
En el siguiente ejemplo vemos cómo creamos varias corrutinas con asyncio.create_task que simulan la descarga de ficheros. Estas descargas serán canceladas si pasados tres segundos no han terminado:
import asyncio import random async def download_file(file_id): print(f"Iniciando descarga del archivo {file_id}...") try: download_time = random.randint(1, 10) await asyncio.sleep(download_time) print(f"Archivo {file_id} descargado en {download_time} segundos.") except asyncio.CancelledError: print(f"Descarga del archivo {file_id} cancelada.") async def main(): tasks = [] for i in range(5): task = asyncio.create_task(download_file(i)) tasks.append(task) # Permitimos que las descargas ocurran durante 3 segundos await asyncio.sleep(3) # Pasados los 3 segundos se cancelan las descargas restantes for task in tasks: task.cancel() # Manejo de excepciones para tareas canceladas for task in tasks: try: await task except asyncio.CancelledError: print(f"Tarea {task.get_name()} cancelada correctamente.") if __name__ == "__main__": asyncio.run(main())
Cuándo usar corrutinas, hilos o procesos
Aunque todavía no hemos visto programación multiproceso ni multihilo, resulta conveniente mencionar los escenarios en los que usar un paradigma de programación u otro: programación asíncrona o programación multiproceso o programación multihilo.
Usar programación asíncrona para tareas asociadas a E/S (I/O-bound Tasks)
El caso ideal en el que usar asyncio es aquel en el que se necesita manejar tareas que gasta mucho tiempo esperando a recursos externos tales como:
- Peticiones por red: enviar y recibir datos sobre Internet (HTTP, peticiones, llamadas a API…)
- Manejar ficheros: leer y escribir en ficheros, especialmente si son ficheros muy grandes y que están en discos de almacenamiento lentos.
- Operaciones de bases de datos: realizar queries o transacciones que involucren esperar respuestas de bases de datos.
Ventajas de la programación asíncrona:
- No hay bloqueso: mientras un corrutina espera, el bucle de eventos pued continuar con la ejecución de otra corrutina.
- Las corrutinas son ligeras: ocupan menos memoria comparado con hilos y procesos, permitiendo la ejecución de miles de corrutinas simultáneamente.
Hilos para tareas asociadas a E/S (I/O-bound Tasks) con datos compartidos
El caso ideal en el que usar hilos se da cuando:
- Tienes múltiples tareas asociadas a E/S que necesitan compartir datos.
- Necesitas realizar operaciones que se puedan beneficiar de la concurrencia, tales como descargar múltiples ficheros al mismo tiempo.
Ventajas de la programación multihilo:
- Memoria compartida: los hilos comparten el mismo espacio de memoria, haciendo que compartir datos entre ellos sea fácil. Esto simplifica el diseño de algunas aplicaciones.
- Buenos para operaciones simples de E/S: los hilos son fáciles de implementar para tareas que implican operaciones de E/S, aunque tienes que manejar la complejidad en la sincronización cuando se comparten datos.
Procesos para tareas intensivas de CPU (CPU-intensive Tasks)
El caso de uso ideal donde usar programación multiproceso se da cuando tienes que realizar tareas computacionales pesadas, tales como:
- Cálculos matemáticos complejos, procesamiento de imágenes o tareas que hacen un uso intensivo de la CPU.
- Tareas que pueden ejecutarse independientemente y no necesitan compartir datos frecuentemente.
Ventajas de la programación multiproceso:
- Paralelismo real: los procesos se ejecutan en espacios de memoria separados y pueden aprovechar los sistemas con múltiples CPU y núcleos. Lo cual, nos lleva a alcanzar mejoras significativas en las tareas que requieren mucho tiempo de CPU.
- Evitan las restricciones de GIL (Global Interpreter Lock): esta es una restricción particular de los programas en Python que limita y penaliza el uso de hilos en sistemas con múltiples núcleos. Esta restricción no se da para los procesos en Python.