Asincronía con Python - Parte 1

Python un lenguaje de programación multiparadigma y aunque tiene una

Asincronía con Python - Parte 1
Photo by Hitesh Choudhary / Unsplash

En éstos últimos años hemos escuchado mucho sobre los beneficios de usar asincronía para el desarrollo de nuestros servicios o aplicaciones Web. En este campo Node.js ha ganado mucho terreno al ser nativamente asíncrono y es muy conocido entre los desarrolladores. Sin embargo, lenguajes como Python han ido adoptándola y junto con las funciones, simplicidad y código limpio que ya nos brinda Python considero que sería interesante aprender las funciones que nos brinda el paquete asyncio para el desarrollo asíncrono y considerarlo para nuestros futuros proyectos.

En Python podemos encontrar desde frameworks maduros como AIOHTTP, hasta nuevos frameworks como SANIC y Vibora, lo cuales nos brindan un conjunto de herramientas, utilizando el paquete asyncio, y que pueden ayudarnos en el desarrollo de nuestros proyectos.

Pero antes de entrar a detalle sobre cómo utilizar el paquete asyncio, veamos un poco de teoría para poder entender algunos términos y cómo funciona la asincronía, esto con el fin de no solo usar el módulo por usar, si no también entender como funciona. Empecemos con dos términos que se suelen escuchar Paralelismo y Concurrencia los cuales ya deberíamos conocer.

El paralelismo consiste en realizar múltiples operaciones al mismo tiempo. En Python solemos utilizar el multiprocesamiento (multiprocessing) como medio para lograr el paralelismo, lo cual implica crear varios procesos sobre las unidades centrales de procesamiento (CPU)  o en los núcleos. Como un tip, tratemos de utilizar paralelismo cuando trabajemos con tareas CPU-bound (veremos mas adelante de que tratan), como ejemplo, cuando se hacen cálculos matemáticos. Para finalizar podemos decir que el multiprocesamiento es una forma de paralelismo, siendo el paralelismo un tipo específico (subconjunto) de concurrencia, pero ¿qué es concurrencia? veamos de qué trata a continuación.

En muchas ocasiones he escuchado a desarrolladores confundir el término paralelismo con concurrencia. Sin embargo, cuando hablamos de concurrencia nos referimos a la capacidad de ejecutar múltiples tareas de manera superpuesta. En Python solemos utilizar los Threading (Hilos) por lo que múltiples Threads van tomando turnos para ejecutar las tareas. Tengamos en cuenta que la concurrencia no implica que deban haber múltiples Threads, ya que un Thread podría estar ejecutándo tareas que han sido divididas en tareas mas pequeñas, tal como lo haría un sistema operativo utilizando un solo core. Otro punto a tener en cuenta es que un proceso puede tener múltiples Threads, la comunicación entre threads es mas sencilla, ya que comparten espacios de memoria, y son ligeros en comparación con los procesos ya que los procesos son mas pesados de crear y ocupan mas memoria. Debemos recordar que en Python (CPython) se tiene un mecanismo para impedir que múltiples threads modifiquen los objetos de Python a la vez en una aplicación multi hilo llamada GIL (si quieren aprender más sobre éste mecanismo pueden ver el video Understanding the Python GIL. Como sugerencia los Threads trabajan mejor con tareas I/O-bound (veremos más adelante de que trata).

Creo que hasta aquí tenemos mucho que procesar, sin embargo Rob Pike explica la diferencia de una forma mas simple: La concurrencia se trata de lidiar con muchas cosas a la vez. El paralelismo se trata de hacer muchas cosas a la vez.

Ahora veamos otros dos términos de mucho interés para entender la asincronía: CPU-bound y I/O-bound.

Los CPU-bound son operaciones que son limitadas por la CPU, por lo que el tiempo para ser completadas es determinado por la velocidad de la CPU. Como ejemplo de este tipo de operaciones tenemos: Algoritmos de búsqueda, algoritmos de conversión, compresión de video/audio/contenido, video streaming, procesamiento o renderizado de gráficos como juegos, videos, y cálculos matemáticos pesados como la multiplicación de matrices.

Los I/O-bound se refieren a las operaciones que son atendidas o se ejecutan fuera del dominio de nuestra aplicación ya que se disparan operaciones que son atendidas por un subsistema de entrada/salida y se dependerá de su velocidad para ejecutar la tarea y completarla (tendríamos una fase de espera) y lo que se quiera hacer con la respuesta (fase de ejecución). Por ejemplo, el acceder a una base de datos, realizar una petición a otro servicio mediante la red, leer un fichero en disco, etc. En este caso si quisiéramos mejorar el desempeño, tendríamos que tener una red más rápida, un disco duro más veloz, etc.

Otros términos que podríamos encontrar son los de bloqueante, no bloqueante, síncrono y asíncrono. Veamos de que tratan ya que los estaremos escuchando mucho cuando hablamos de asincronía.

Bloqueante. Una operación de éste tipo no devuelve el control a nuestra aplicación sino que bloquea el Thread en el que se ejecuta hasta que la operación se haya completado.

No Bloqueante. Una operación de éste tipo no bloquea el Thread ya que devuelve inmediatamente el control a nuestra aplicación independientemente del resultado y puede devolver un resultado o un error. Debemos tener en cuenta que para que esto funcione se tiene un tipo de sondeo cada determinado tiempo, o una operación de consulta (polling en inglés).

Ambos hacen referencia a la fase de espera que afecta a nuestro programa (recordar lo que vimos cuando se habló de operaciones I/O-bound). Si recordamos también hay una fase de ejecución y es ahí en donde entran los términos síncrono y al que queremos llegar desde un inicio, asíncrono, haciendo referencia a cuándo se tendrá lugar la respuesta.

Síncrono. Cuando debemos esperar a que se complete de forma secuencial toda la operación para poder procesar el resultado, por lo tanto podríamos decir que tiene un comportamiento de tipo bloqueante. Veamos un ejemplo:

Estas en una fila en el cine para comprar tu boleto. No puedes comprarlo hasta que todos los que están enfrente de ti compren o adquieran los de ellos, y lo mismo aplica para la gente que está detrás de ti.

Asíncrono. Su comportamiento es no bloqueante. La operación se ejecuta y no se espera hasta que finalice la tarea de E/S, ya que ésta se señalizará más tarde mediante algún mecanismo como un callback, promesa o evento, por lo que la llamada devuelve el control a la aplicación y de esta forma podría seguir ejecutando otras operaciones. Veamos un ejemplo:

Podríamos estar en un restaurante de comida rápida por ejemplo haciendo fila, por lo que ordenamos nuestra comida, sin embargo las otras personas que están detrás no deben esperar a que terminen de cocinar nuestra comida y nos entreguen el pedido, ellos pueden ordenar mientras los cocineros están preparando nuestro pedido y el que atiende la caja puede seguir recibiendo órdenes, y los conocineros de igual forma pordrían estar preparando diferentes comidas al mismo tiempo.

Otro ejemplo que he leído y me ha ayudado a entender mejor como funciona la asincronía, además de que soy aficionado al ajedrez, es el siguiente:

La GM Judit Polgár realiza una exhibición de partidas de ajedrez simultáneas contra jugadores amateurs. Por lo que ella tiene dos formas de realizar dicha exhibición, de forma síncrona o de forma asíncrona.

Supongamos que tiene 24 oponentes, y que realiza un movimiento cada 5 segundos, y los oponentes realizan su movimiento cada 55 segundos, el promedio de movimientos en cada juego es de 30 por jugador (60 en total).

Si Judit juega de forma:

Síncrona: Judit juega un juego a la vez, esto es no pasa a jugar contra otro oponente hasta terminar una partida. Con la suposición que hicimos tenemos que un juego toma (55 + 5) * 30 = 1800 segundos, esto es 30 minutos por cada juego por lo que la exhibición tardará 24*30 = 720 minutos, o 12 horas :S.

Asíncrona: Judit va realizando movimientos de tablero en tablero, lo que quiere decir que realiza un movimiento y pasa contra otro oponente para realizar un movimiento en lo que el oponente anterior realiza su movimiento. Entonces a Judit le tomará hacer un movimiento contra cada oponente (en todos los tableros) 24*5=120 segundos, o 2 minutos. Por lo que la exhibición entera ahora tomaría 120*30=3600 segundos, por lo que el truco de la asincronía podría reducir la exhibición a 1 hora.

Espero que los ejemplos que se han platicado nos ayuden a entender más como trabaja la asincronía y darnos una idea del por qué es tan utilizada para el desarrollo de servicios web ya que éstos realizan muchas operaciones de tipo I/O.

El Modelo Asíncrono de Python

Como hemos venido mencionando, Python cuenta con el paquete asyncio (introducido a partir de la versión 3.4 de Python) como una biblioteca para escribir código concurrente. Sin embargo, cuando hablamos de entradas/salidas asíncronas (async I/O) no estamos hablando de múltiples Threads, ni de multiprocesamiento. ¿Entonces?

Resulta que asyncio trabaja con un único hilo en un proceso único, pero usa la multitarea cooperativa (recordar cuando platicamos que la concurrencia puede funcionar en un solo Thread). Entonces podemos decir que la asincronía es un tipo de programación concurrente, pero no es paralelismo y está mas ligado al Threading que al multiprocesamiento, sin embargo es distinto de ambos, y es otra forma de hacer concurrencia. Recordemos el ejemplo de Judit Polgár, ella tiene solo dos manos y hace un movimiento a la vez. Pero cuando realiza la exhibición de forma asíncrona reduce el tiempo de dicha exhibición a 1 una hora y esto es debido a que aprovecha los tiempos muertos (mientras los oponentes piensan su jugada) para ir realizando sus movimientos.

Entonces, la multitarea cooperativa es una forma elegante de decir que el ciclo de eventos de un programa se comunica con múltiples tareas para permitir que cada uno se turne en el momento óptimo.

El paquete asyncio en Python, utiliza un modelo asíncrono y no bloqueante, con un loop de eventos implementado en un único Thread (debemos tener mucho cuidado de no bloquear el Thread) para sus interfaces de entrada/salida. Por lo que, asyncio, está diseñado para usar corrutinas (coroutines) y futures para simplificar el código asíncrono y hacerlo casi tan legible como si fuera código síncrono, ya que no se usan los llamados callbacks que suelen o solían utilizarse en Node.js.

Pero ¿qué son el event loop, las corutinas y los futures?

Event loops. Se encargan de manejar y distribuir la ejecución de las diferentes tareas. Utilizan la programación cooperativa: un bucle de eventos ejecuta una tarea a la vez. Mientras una Task espera la finalización de un Future, el bucle de eventos ejecuta otras Tasks, devoluciones de llamada o realiza operaciones de Entrada/Salida.

Corutinas. Son funciones especiales que funcionan de forma muy similar a los generadores en Python, para declararlas se utiliza la palabra clave async antepuesta a def. Cuando se usa la palabra clave await (solo puede utilizarse dentro de corutinas) el control de flujo es liberado de vuelta al event loop. Una corutina debe programarse para que se pueda ejecutar en el event loop, una vez programadas, las corutinas se envuelven en Tareas (Tasks), que es un tipo de Future.

Tasks. Son usadas para programar o calendarizar la ejecución de las corutinas concurrentemente dentro del event loop.

Futures. Son objetos que representan un resultado eventual de una operación asíncrona. Con ellos se puede hacer uso de la palabra clave await ya que son objetos "esperables", entonces podemos esperar hasta que tengan un resultado o un conjunto de excepciones, o igual podrían ser canceladas. Para los que suelen programar en Node.js o JS podríamos decir que son parecidas a las promesas.

Entonces la forma en que funciona todo es:

  • El event loop corre en un thread y cuenta con una cola de tareas
  • Recibe tareas desde la cola (queue)
  • Cada tarea llama al siguiente paso de una corutina
  • Si una corutina llama a otra corutina (await <corutina>), la corutina actual se suspende y ocurre el cambio de contexto. Por lo que el contexto de la corrutina actual (variables, estados) se guarda y el contexto de la corutina a la que se llamó se carga
  • Si la corutina se encuentra o topa con un código tipo entrada/salida, sleep, etc. la corutina actual se suspende y el control es pasado de vuelta al event loop
  • El event loop obtiene la siguiente tarea de la cola, realiza el proceso que se ha explicado y luego la siguiente y así, si tenemos n tareas en cola, entonces se ejecutarían 2..n.
  • Entonces el event loop va de regreso a la tarea en donde se quedó (tarea 1), recuerden que es un loop.

Ahora sí veamos un poco de código...

Referencias:

https://realpython.com/async-io-python/

https://lemoncode.net/lemoncode-blog/2018/1/29/javascript-asincrono

https://djangostars.com/blog/asynchronous-programming-in-python-asyncio/