Poco más se puede decir de la asincronía en Python que no se haya visto en el tema de concurrencia o en la introducción anterior. Como recapitulación, nos ceñiremos al símil de Miguel Grinberg durante su charla en PyCon 2017:
Chess master Judit Polgár hosts a chess exhibition in which she plays multiple amateur players. She has two ways of conducting the exhibition: synchronously and asynchronously.
Assumptions:
- 24 opponents
- Judit makes each chess move in 5 seconds
- Opponents each take 55 seconds to make a move
- Games average 30 pair-moves (60 moves total)
Synchronous version: Judit plays one game at a time, never two at the same time, until the game is complete. Each game takes (55 + 5) * 30 == 1800 seconds, or 30 minutes. The entire exhibition takes 24 * 30 == 720 minutes, or 12 hours.
Asynchronous version: Judit moves from table to table, making one move at each table. She leaves the table and lets the opponent make their next move during the wait time. One move on all 24 games takes Judit 24 * 5 == 120 seconds, or 2 minutes. The entire exhibition is now cut down to 120 * 30 == 3600 seconds, or just 1 hour.
En Python, la entrada y salida asíncrona ha sido siempre una asignatura
pendiente que lleva rondando desde los albores del lenguaje, en el módulo
asyncore
, basado en callbacks. La solución más reciente, se apoya, en bucles de eventos,
futuros (las promesas de Python) y nueva sintáxis, y en la aparicicón de un
nuevo módulo (asyncio
) que proporciona las bases de la entrada y salida
asíncrona y todo un ecosistema alrededor.
Python no se pensó con la asincronía como centro de su modelo de ejecución
y, por tanto, no tiene bucle de eventos que gestione las tareas en cola que
pudieran estar esperando por una operación IO. Hay que crearlo explícitamente
con la función
asyncio.get_event_loop()
.
El bucle de eventos organiza y ejecuta tareas,
objetos de tipo Task
,
que construye pasandoles una corrutina, el equivalente a las funciones
asíncronas de JavaScript y que veremos a continuación.
Python también soporta futuros, las promesas de JavaScript, que son, junto con las tareas, objetos awaitables, es decir, objetos en los que podemos suspender la ejecución esperando a que el computo al que representan termine.
Las corrutinas son funciones que pueden suspender su ejecución y reanudarla. Hemos visto algo parecido con los generadores:
-
Considera el siguiente código:
def get_url_in_chunks(url): import time time.sleep(1) # emulate a blocking time consuming IO operation print(f'opening {url}') yield time.sleep(1) print(f'{url}: reading some bytes') yield time.sleep(1) print(f'{url}: reading more bytes') yield time.sleep(1) print(f'{url}: reading final bytes') yield for chunk in get_url_in_chunks('example.com'): pass for chunk in get_url_in_chunks('another-example.com'): pass
-
Imagina que ahora queremos intercalar la ejecución de ambos:
def concurrent(*generators): pending_generators = list(generators) while pending_generators: for index, generator in enumerate(pending_generators): try: yield next(generator) except StopIteration: pending_generators.pop(index) gen1 = get_url_in_chunks('example.com') gen2 = get_url_in_chunks('another-example.com') for chunk in concurrent(gen1, gen2): pass
Sin embargo, aquí no hay ahorro ninguno. Sí, hay concurrencia, pero no es efectiva porque
time.sleep()
es una operación bloqueante. Python tiene que esperar a que acabe antes de continuar. -
Veamos cómo sería usando corrutinas y código asíncrono. Para el primer ejemplo:
import asyncio async def get_url_in_chunks(url): await asyncio.sleep(1) # a **non-blocking** time consuming IO operation print(f'opening {url}') await asyncio.sleep(1) print(f'{url}: reading some bytes') await asyncio.sleep(1) print(f'{url}: reading more bytes') await asyncio.sleep(1) print(f'{url}: reading final bytes') async def main(): await get_url_in_chunks('example.com') await get_url_in_chunks('another-example.com') asyncio.run(main())
Como puedes comprobar, hemos eliminado el uso de
yield
. En su lugar, ahora suspendemos la ejecución hasta que la funciónasyncio.sleep()
termina. Esto lo hacemos mediante el uso de la palabra reservadaawait
. También eliminamos la necesidad de iterar sobre el generador, en la funciónmain()
, la palabra reservadaawait
actúa de forma similar a la iteración sobre los generadores, haciendo progresar la tarea hasta completarse. -
El segundo ejemplo, el de ejecución concurrente sería:
async def main(): co1 = get_url_in_chunks('example.com') co2 = get_url_in_chunks('another-example.com') await asyncio.gather(co1, co2) asyncio.run(main())
Ahora hemos eliminado todo el código relativo al control de la ejecución de las tareas. De forma similar al ejemplo con generadores, pasamos las corrutinas a
asyncio.gather()
, que se encarga de componer un objeto que las orqueste, y luego le decimos al bucle de eventos (el equivalente al bucle en el interior deconcurrent()
) que ejecute la corrutinamain()
.Fíjate que la efectividad de la ejecución concurrente no se debe a la sintaxis
async
/await
sino a la funciónasyncio.sleep()
que, al contrario quetime.sleep()
, es no bloqueante.
La potencia de toda la asincronía en Python reside en el módulo asyncio
que
proporciona las primitivas asíncronas necesarias para implementar nuestras
propias corrutinas.
-
Recupera el siguiente ejemplo, de Python de alto rendimiento:
from datetime import datetime from urllib import request def get(url): response = request.urlopen(url) print(f'Body at {url}:\n {response.read()}') def main(): urls = [ 'https://raw.githubusercontent.com/python/cpython/master/README.rst', 'https://raw.githubusercontent.com/rust-lang/rust/master/README.md', 'https://raw.githubusercontent.com/ruby/ruby/master/README.md'] * 80 list(map(get, urls)) if __name__ == '__main__': start_time = datetime.now() main() print(f'Elapsed time: {datetime.now() - start_time}')
-
Su versión asíncrona require una biblioteca externa, construida sobre
asyncio
. Instalaaiohttp
en tu entorno virtual:$ pip install aiohttp
-
Ahora podemos migrar el código bloqueante a uno asíncrono:
import asyncio from datetime import datetime from aiohttp import ClientSession async def get(url): session = ClientSession() response = await session.get(url) await session.close() print(f'Body at {url}:\n {await response.text()}') async def main(): urls = [ 'https://raw.githubusercontent.com/python/cpython/master/README.rst', 'https://raw.githubusercontent.com/rust-lang/rust/master/README.md', 'https://raw.githubusercontent.com/ruby/ruby/master/README.md'] * 80 await asyncio.gather(*list(map(get, urls))) if __name__ == '__main__': start_time = datetime.now() asyncio.run(main()) print(f'Elapsed time: {datetime.now() - start_time}')
- Bibliotecas asíncronas basadas en
asyncio
. - Documentación de aiohttp
- curio y
Trio son alternativas a
asyncio
.
Además de las corrutinas, Python permite implementar contextos, iterables y comprehensiones asíncronas.
-
Un contexto asíncrono se implementa sobreescribiendo los métodos mágicos
__aenter__
y__aexit__
:import asyncio import aiohttp class HtmlContent: def __init__(self, url): self.url = url async def __aenter__(self): async with aiohttp.ClientSession() as client: async with client.get(self.url) as response: return await response.text() async def __aexit__(self, *_): pass async def main(): async with HtmlContent( 'https://raw.githubusercontent.com/ruby/ruby/master/README.md') as html: print(html) asyncio.run(main())
Fíjate que a la hora de crear la sesión de
aiohttp
también hemos usado un contexto asíncrono, el de la sesión, en lugar de esperar porsession.close()
. Hemos hecho lo mismo con la petición. -
También podemos crear un iterador asíncrono con los métodos mágicos
__aiter__
y__anext__
tal y como define el PEP-492:import asyncio import aiohttp class GetContent: def __init__(self, *urls): self.urls = urls def __aiter__(self): self._urls_iter = iter(self.urls) return self async def __anext__(self): try: url = next(self._urls_iter) async with aiohttp.ClientSession() as client: async with client.get(url) as response: return await response.text() except StopIteration: raise StopAsyncIteration async def main(): async for html in GetContent( 'https://raw.githubusercontent.com/ruby/ruby/master/README.md', 'https://raw.githubusercontent.com/rust-lang/rust/master/README.md', 'https://raw.githubusercontent.com/python/cpython/master/README.rst'): print(html) asyncio.run(main())
Fíjate que
__aiter__
no es una corrutina, la corrutina es__anext__
. -
También podemos usar el patrón generador, creando así un generador asíncrono que implementa el protocolo anterior:
import asyncio import aiohttp class GetContent: def __init__(self, *urls): self.urls = urls async def __aiter__(self): for url in self.urls: async with aiohttp.ClientSession() as client: async with client.get(url) as response: yield await response.text() async def main(): async for html in GetContent( 'https://raw.githubusercontent.com/ruby/ruby/master/README.md', 'https://raw.githubusercontent.com/rust-lang/rust/master/README.md', 'https://raw.githubusercontent.com/python/cpython/master/README.rst'): print(html) asyncio.run(main())
Fíjate que ahora
__aiter__
sí es una corrutina. -
Por último, es posible utilizar la sintáxis
async for
/await
en todo tipo de comprehensiones, por ejemplo:async def main(): lengths = [len(html) async for html in GetContent( 'https://raw.githubusercontent.com/ruby/ruby/master/README.md', 'https://raw.githubusercontent.com/rust-lang/rust/master/README.md', 'https://raw.githubusercontent.com/python/cpython/master/README.rst')] print(lengths) asyncio.run(main())