Resumen del libro "Using Asyncio in Python"

By raymundoortega Aug. 17, 2023, 8:32 p.m. Python Asincronismo asyncio

En este post se abarcara un breve resumen de los puntos mas importantes de cada capitulo del libro Using Asncio in Pyhon el cual tiene como objetivo brindar un conocimiento básico para comprender mejor la programación asicrona y poder escribir bloques de código lo suficientemente simples con asyncio.

Introducción a Asyncio (Introducing Asyncio)

¿Que es el asincronismo?

El asincronismo en términos de la programación se refiere a una técnica que te permite realizar múltiples tareas de larga duración en un programa sin necedad de esperar a que estas terminen para poder ejecutar otras tareas. En lugar de ejecutar pasos en orden como lo hace la programación sincrona, el asincronismo te permite que tu código continúe ejecutándose en otras tareas mientras espera que otras tareas se completen.

¿Que es ayncio?

Asyncio es un módulo en Python que proporciona un marco para escribir código asincrónico y concurrente el cual fue lanzado en la versión 3.5 de python

¿Cual es el problema que intenta resolver Asyncio?

Como sabrán el nombre de "asyncio" proviene de "asynchronous I/O", lo que significa E/S asincrónica (I/O bound), por lo que podemos decir que el problema a resolver con la librería asyncio son las tareas que están limitadas principalmente por la velocidad de entrada/salida del sistema como leer/escribir en disco, hacer solicitudes de red o interactuar con bases de datos externas.

La verdad acerca de los Hilos (The Truth About Threads)

¿Que son los Trheads?

Los trheads son una función proporcionada por el sistema operativo que permite a los desarrolladores indicar en qué partes de su programa pueden ejecutarse de forma paralela. El sistema operativo decide cómo compartir los recursos de la CPU entre estas partes, de manera similar a cómo lo hace con otros programas en ejecución al mismo tiempo.

Aunque este libro no va enfocado a los Threads este lo menciona por que antes de usar asyncio es importante saber porque ambos conceptos están relacionados con la programación concurrente y asincrónica, y entender los hilos proporciona una base sólida para comprender las características y ventajas de asyncio.

Algunos Beneficios de los Threads

  • Facilidad para leer el código: La programación con Threads en Python suele ser más sencilla y comprensible aunque el código se ejecute en paralelo su estructura es lo suficientemente clara y comprensible que parece que todo se está ejecutando secuencialmente.
  • Paralelismo con memoria compartida: Los hilos son eficientes en términos de recursos, ya que comparten memoria y recursos del sistema.
  • Concurrencia: Los hilos permiten que múltiples partes de un programa se ejecuten de manera concurrente, lo que puede mejorar la eficiencia y la capacidad de respuesta de la aplicación.

Algunos inconvenientes de los Threads

  • El Threading es dificil: La programación con threads puede aumentar la complejidad del código debido a la necesidad de manejar la concurrencia, las condiciones de carrera (race conditions) y la sincronización de manera adecuada.
  • Los Threads consumen muchos recursos: Cada thread en Python consume memoria adicional para el seguimiento de su estado, lo que puede resultar en un consumo total de memoria más alto cuando se utilizan muchos hilos.
  • Global Interpreter Lock (GIL): Python tiene una limitación conocida como el "Global Interpreter Lock" (GIL), que permite que solo un hilo ejecute código Python en un momento dado. Esto limita el paralelismo real y puede afectar negativamente el rendimiento en ciertos casos.

Recorrido por Asyncio (Asyncio Walk-Through)

Corrutinas (Coroutines)

Una corrutina es una función que puede pausar su ejecución en puntos específicos para permitir que otras corrutinas se ejecuten antes de continuar. A diferencia de las funciones regulares, que se ejecutan de principio a fin, las corrutinas pueden suspenderse y reanudarse en cualquier momento. Para definir una corrutina, debes utilizar la palabra clave async def antes de la declaración de la función. Por ejemplo:

import asyncio

async def mi_corutina():
  print("Inicio de la corutina")
  await asyncio.sleep(1)
  print("Fin de la corutina")

Se puede decir que toda función que tiene la palabra clave async antes de def se considera una corrutina.

Palabra Clave await:

Dentro de una corrutina, se puede usar la palabra clave await para indicar que la ejecución debe pausarse hasta que se complete la tarea asincrónica. Por ejemplo, en el código anterior, await asyncio.sleep(1) pausa la corrutina durante 1 segundo.

¿Como ejecuto una courutina?

Las corrutinas se ejecutan utilizando la palabra clave await en otra corrutina o utilizando la función asyncio.run() para ejecutar una corrutina independiente.

import asyncio

async def main():
  await mi_corutina()

asyncio.run(main())

Si una courutina no se manda a ejecutar esta no lo hará automáticamente, en lugar de eso, se convertirá en un objeto de courutina que puede ser programado para ejecutarse en un bucle de eventos de asyncio el cual se explicara mas adelante en los event loop. Cabe mencionar que una courutina tiene el comportamiento muy similar las funcion regulares, ya que puedes devolver valores utilizando la palabra clave return y puedes hacer uso de try y except para manejar excepciones.

Event loop (Bucle de eventos)

Los Event loops (Bucle de eventos) es considerado el nucleo de la programación asincrónica en Python y proporciona la infraestructura para la ejecución concurrente y asincrónica de tareas, Permite que las courutinas se suspendan, se reanuden y se ejecuten de manera eficiente, lo que facilita el desarrollo de aplicaciones de alto rendimiento que gestionan operaciones asincrónicas y eventos concurrentes.

En el siguiente bloque de codigo se muestre ejemplo sencillo sobre como usar un event loop:

import asyncio

async def tarea1():
    print("Tarea 1 iniciada")
    await asyncio.sleep(2)
    print("Tarea 1 completada")

async def tarea2():
    print("Tarea 2 iniciada")
    await asyncio.sleep(1)
    print("Tarea 2 completada")

async def main():
    loop = asyncio.get_event_loop()

    # Crear tareas y registrarlas en el event loop
    task1 = loop.create_task(tarea1())
    task2 = loop.create_task(tarea2())

    # Esperar a que todas las tareas finalicen
    await asyncio.gather(task1, task2)

# Crear y ejecutar el event loop manualmente
if __name__ == "__main__":
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()

En este ejemplo, creamos un objeto loop usando asyncio.get_event_loop() y luego utilizamos loop.run_until_complete(main()) para ejecutar el event loop hasta que la tarea principal (main()) y sus tareas secundarias (tarea1() y tarea2()) se completen para finalmente cerrar el event loop utilizando loop.close().

El ejemplo anterior se pudo haber hecho tambien con asyncio.run() y se lograria el mismo resultado, pero asyncio.run() proporciona una forma más simple y conveniente de ejecutar el event loop en la mayoría de los casos. Al realizarlo de forma manual con el metodo async.get_event_loop() puede ser útil en situaciones más complejas donde necesitas un mayor control sobre el ciclo de vida del event loop.


NOTA

En la version 3.7 de python fue introducido get_running_loop() el cual hace lo mismo que get_event_loop() ambos devuelven un bucle de eventos en ejecución pero con la diferencia que get_running_loop() es recomienda usar cuando estás dentro de una función asincrónica (Courutina) ya que garantiza que estás obteniendo el bucle de eventos correcto para el contexto en el que te encuentras. En cambio, get_event_loop() se puede utilizar en cualquier función, ya sea una corrutina o no.

El libro meciona que es recomendable usar get_running_loop() por le hecho que el bucle de eventos estara siempre bajo el contexto de una courutina.


Task (Tareas) y Futures (Futuros)

Hablaremos muy breve acerca de los tasks ya que se puede decir que ya los mencionamos anteriormente por que estan muy relacionados con las courutinas y los even loops, en pocas palabras un task es una abstracción de una corrutina el cual se ejecuta dentro de un event loop (bucle de eventos).

En el siguiente ejemplo se muestra como crear un task

import asyncio

async def my_coroutine():
    print("Iniciando la corutina")
    await asyncio.sleep(2)  # Simula una operación asincrónica
    print("Finalizando la corutina")

async def main():
    task = asyncio.create_task(my_coroutine())  # Crear un Task
    print("Tarea creada")

    await task  # Esperar a que la tarea termine

asyncio.run(main())

En este ejemplo, la función my_coroutine es una corrutina que simplemente espera durante 2 segundos. La función main crea un Task utilizando asyncio.create_task(), que inicia la ejecución de la corrutina en segundo plano. Luego, espera a que la tarea se complete usando await task.

Al usa los task podras hacer el uso de ciertas funciones las cuales te seran utiles:

  • Obtener el estado de un Task: Puedes usar el atributo Task.state para obtener el estado actual del Task.
  • Cancelar un Task: Puedes cancelar un Task utilizando el método Task.cancel()
  • Callbacks adicionales: Tambien podras adjuntar callbacks adicionales utilizando el método add_done_callback(callback).

Existe otra forma con la cual puedes crear un task y es con el metodo asyncio.ensure_future() y funciona muy similar a asyncio.create_task(), pero entonces si existen dos metodos para crear un task ¿Cual es la diferencia? y ¿Cual deberia usar?, bueno pues la principal diferencia es que asyncio.create_task() es la opción preferida y más moderna para crear una tarea asincrónica, mientras que asyncio.ensure_future() es una opción más antigua pero aún válida y es usado principalmente por los autores de frameworks como asyncio. Una vez explicado lo anterior lo ideal es usar asyncio.create_task() ya que ofrece una sintaxis más clara y explícita. Sin embargo, asyncio.ensure_future() todavía es válido y funciona, especialmente si estás trabajando con versiones de Python anteriores a 3.7.

Ahota hablemos de los Futures, un Future representa el resultado de una operación asincrónica que aún no se ha completado, se puede decir que un Future es como una promesa de un evento del del cual obtendrás un resultado en algún momento.

Los asyncio.Future son útiles para operaciones intensivas en CPU o cálculos largos que deseas ejecutar de manera asincrónica. No se recomiendan para tareas de I/O bound, para las cuales es mejor utilizar corrutinas y tareas.

Con asyncio hay dos maneras de crear los futuros una es con loop.create_future y la otra con asyncio.Future()

Ejemplo con loop.create_future():

import asyncio

async def main():
    loop = asyncio.get_event_loop()
    future = loop.create_future()
    await asyncio.sleep(2)
    future.set_result("¡Futuro completado!")

asyncio.run(main())

En este ejemplo, estamos usando loop.create_future() para crear un futuro dentro de un bucle de eventos. Luego, el futuro se completa después de esperar durante 2 segundos y se establece un resultado.

Ejemplo con asyncio.Future():

import asyncio

async def main():
    future = asyncio.Future()
    await asyncio.sleep(2)
    future.set_result("¡Futuro completado!")

asyncio.run(main())

Aquí estamos usando directamente asyncio.Future() para crear un futuro fuera de un bucle de eventos. Funciona de manera similar al ejemplo anterior, pero no está vinculado a ningún bucle específico.

La diferencia principal entre loop.create_future() y asyncio.Future() radica en quién crea y gestiona el objeto Future.

  • loop.create_future(): Este método se llama en un objeto EventLoop para crear una instancia de Future. Cada EventLoop es responsable de administrar sus propios futuros. Puedes usar esta función si estás dentro de un contexto de bucle de eventos y deseas crear un futuro asociado a ese bucle específico.
  • asyncio.Future(): Esta es la clase Future base proporcionada por el módulo asyncio. Puedes usarla para crear instancias de futuros independientemente del bucle de eventos en el que te encuentres. No está vinculada a ningún bucle en particular, lo que significa que puedes utilizarla en cualquier parte de tu código asincrónico sin preocuparte por el contexto del bucle.

En resumen, si estás dentro de un bucle de eventos y deseas crear un futuro asociado a ese bucle en particular, es mejor usar loop.create_future(). Si estás creando futuros fuera del contexto del bucle o deseas un mayor grado de independencia, puedes usar asyncio.Future().

Para finalizar con los futuros cabe mencionar que tanto loop.create_future() como asyncio.Future() puedes usar algunos métodos similares a los de los task los cuales te ayudaran a manejar el estado de un futuro, establecer un resultado, cancelar un futuro y tener callbacks adicionales:

  • Obtener el estado: future.done(): Devuelve True si el futuro ha sido completado o cancelado.
  • Establecer un resultado: future.set_result(result): Establece el resultado del futuro con el valor result. Esto marca el futuro como completado.
  • Cancelar un futuro: future.cancel(): Intenta cancelar el futuro. Si ya se ha completado o cancelado previamente, no tiene ningún efecto.
  • Callbacks adicionales: Tendras callbacks adicionales los cuales se ejecutaran cuando se complete el futuro

En resumen, un Task es una abstracción más completa que representa la ejecución de una corrutina y ofrece más funcionalidad para administrar tareas asincrónicas. Por otro lado, un Future es más simple y se utiliza principalmente para encapsular resultados futuros de operaciones asincrónicas. En muchos casos, los Task son preferibles debido a su mayor funcionalidad y facilidad de uso.


Manejadores de contexto asincronos: async with

async with es un gestor de contexto asincrónico que se utiliza para administrar la entrada y salida de recursos asincrónicos, como conexiones de red o archivos, de manera segura y eficiente en un entorno asincrónico.

Un ejemplo de uso de async with sería al trabajar con un archivo en un entorno asincrónico:

import asyncio

async def main():
    async with open("archivo.txt", "r") as archivo:
        contenido = await archivo.read()
        print("Contenido del archivo:", contenido)

asyncio.run(main())

En este ejemplo, estamos utilizando async with para asegurarnos de que el archivo se cierre correctamente después de que hayamos leído su contenido. Esto es útil para manejar automáticamente la apertura y cierre adecuados del archivo en un entorno asincrónico.

Iteradores asincronos: Async for

async for es similar a for, pero se utiliza en el contexto de la programación asincrónica para iterar sobre secuencias asíncronas de elementos, como por ejemplo lecturas de bases de datos, solicitudes de red u otras operaciones bloqueantes.

Un ejemplo de como usar async for seria suponiendo que tienes una lista de URLs y deseas hacer solicitudes HTTP a cada una de ellas de manera asincrona utilizando asyncio:

import asyncio
import aiohttp

async def fetch_url(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

async def main():
    urls = ['https://example.com', 'https://google.com', 'https://openai.com']
    tasks = [fetch_url(url) for url in urls]

    async for html_content in asyncio.as_completed(tasks):
        print("Tamaño de respuesta:", len(html_content))

asyncio.run(main())

En este ejemplo, la función fetch_url() realiza una solicitud HTTP asincrónica a una URL utilizando la biblioteca aiohttp y despues en la función main(), creamos una lista de tareas tasks que representan las solicitudes a las URLs y por ultimo usamos async for junto con asyncio.as_completed() para iterar a medida que las tareas se completan y luego imprimimos el tamaño de las respuestas recibidas.

Async Comprehensions

Las "async comprehensions" (comprensiones asíncronas) son una característica en Python que te permite crear estructuras de datos, como listas, conjuntos y diccionarios, utilizando expresiones asíncronas.

import asyncio

async def fetch_data(url):
    await asyncio.sleep(1)
    return f"Data from {url}"

async def main():
    urls = ["url1", "url2", "url3"]

    # Async Comprehension con Listas
    data_list = [await fetch_data(url) for url in urls]

    # Async Comprehension con Diccionarios
    data_dict = {url: await fetch_data(url) for url in urls}

    print("Lista de datos:")
    print(data_list)

    print("\nDiccionario de datos:")
    print(data_dict)

asyncio.run(main())

En este ejemplo, primero se utiliza una "Async Comprehension" con listas para crear la lista data_list y luego se utiliza otra "Async Comprehension" con diccionarios para crear el diccionario data_dict.

Bibliotecas útiles para crear aplicaciones asincronas

A continuación se mencionaran algunas bibliotecas útiles que se deben tomar en cuenta para desarrollar aplicaciones asincronas con python

  • Twisted: Twisted es un framework de programación asíncrona en Python que permite crear aplicaciones de red de alto rendimiento y escalables. Proporciona herramientas y abstracciones para trabajar con I/O no bloqueante y eventos de hecho asyncio esta altamente influenciado por twisted.
  • The Janus Queue: The Janus Queue es una biblioteca de Python que ofrece una cola concurrente que permite tanto la comunicación como la sincronización entre hilos y corrutinas, adaptándose al paradigma de programación asíncrona y concurrente.
  • aiohttp: aiohttp es una biblioteca asincrónica en Python que proporciona manejo de solicitudes HTTP de manera eficiente y permite la escritura de servidores y clientes web utilizando el estilo de programación asíncrona para un mejor rendimiento y escalabilidad.
  • asyncpg: asyncpg es una biblioteca de Python diseñada para interactuar con bases de datos PostgreSQL utilizando programación asincrónica.
  • Sanic: Sanic es un marco web de Python que se enfoca en la programación asincrónica para construir aplicaciones web de alto rendimiento. Utiliza la concurrencia asincrónica de Python para manejar múltiples solicitudes de manera simultánea y eficiente, lo que resulta en un rendimiento mejorado para aplicaciones que necesitan manejar muchas solicitudes concurrentes.

Con todos los conceptos explicados y los ejemplos mostrados anteriormente ya deberías ser capaz de escribir algo de código asincrono utilizando asyncio.