Skip to content

Latest commit

 

History

History
349 lines (264 loc) · 12.2 KB

File metadata and controls

349 lines (264 loc) · 12.2 KB

Asincronía en Python

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.

Conceptos esenciales

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.

Corrutinas: async y await

Las corrutinas son funciones que pueden suspender su ejecución y reanudarla. Hemos visto algo parecido con los generadores:

  1. 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
  2. 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.

  3. 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ón asyncio.sleep() termina. Esto lo hacemos mediante el uso de la palabra reservada await. También eliminamos la necesidad de iterar sobre el generador, en la función main(), la palabra reservada await actúa de forma similar a la iteración sobre los generadores, haciendo progresar la tarea hasta completarse.

  4. 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 de concurrent()) que ejecute la corrutina main().

    Fíjate que la efectividad de la ejecución concurrente no se debe a la sintaxis async/await sino a la función asyncio.sleep() que, al contrario que time.sleep(), es no bloqueante.

El módulo asyncio

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.

  1. 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}')
  2. Su versión asíncrona require una biblioteca externa, construida sobre asyncio. Instala aiohttp en tu entorno virtual:

    $ pip install aiohttp
  3. 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}')

Contextos, iteradores, generadores y comprehensiones asíncronos

Además de las corrutinas, Python permite implementar contextos, iterables y comprehensiones asíncronas.

  1. 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 por session.close(). Hemos hecho lo mismo con la petición.

  2. 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__.

  3. 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.

  4. 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())