Skip to content

    Chapter 47, Advanced

    asyncio

    The `asyncio` runs thousands of tasks that wait on **a single thread**, switching between them at the points where each one needs to wait. It is the right tool for a lot of connections and little computation.

    Code for this chapter: avancado/cap47_assincrono.py

    Coroutines and the event loop

    A function async def creates a coroutine. Calling it does not execute anything: it returns an object that needs to be scheduled. The await marks the point where the coroutine yields control to the event loop so it can run another one. The asyncio.run creates the loop and runs the main coroutine:

    avancado/cap47_assincrono.pylinhas 10 a 22
    import asyncio
    
    
    async def buscar(nome, atraso):
        await asyncio.sleep(atraso)
        return f"{nome} pronto"
    
    
    async def principal():
        print(await buscar("a", 0.1))
    
    
    asyncio.run(principal())
    
    Output
    a pronto
    

    Concurrency is cooperative: there is only task switching in a await. This makes the code easier to reason about than with threads (there are no two pieces running at the same time), but it imposes a strict rule: never block the loop.

    Multiple tasks at the same time

    The asyncio.gather schedules multiple coroutines together and returns the results in the order you passed them. Three waits of 0.3, 0.1, and 0.2 seconds take about 0.3 in total, not 0.6:

    avancado/cap47_assincrono.pylinhas 27 a 36
    async def varios():
        resultados = await asyncio.gather(
            buscar("lento", 0.3),
            buscar("rápido", 0.1),
            buscar("médio", 0.2),
        )
        print(resultados)
    
    
    asyncio.run(varios())
    
    Output
    ['lento pronto', 'rápido pronto', 'médio pronto']
    

    When you want to handle each result as it arrives, use as_completed:

    avancado/cap47_assincrono.pylinhas 38 a 44
    async def por_ordem_de_chegada():
        tarefas = [buscar("lento", 0.3), buscar("rápido", 0.1), buscar("médio", 0.2)]
        for futura in asyncio.as_completed(tarefas):
            print(await futura)
    
    
    asyncio.run(por_ordem_de_chegada())
    
    Output
    rápido pronto
    médio pronto
    lento pronto
    

    TaskGroup and timeout

    Since Python 3.11, the TaskGroup is the recommended way to group tasks: if one fails, the others are canceled, and the block only ends when all finish. The asyncio.timeout imposes a deadline on a section:

    avancado/cap47_assincrono.pylinhas 49 a 65
    async def com_grupo():
        async with asyncio.TaskGroup() as grupo:
            t1 = grupo.create_task(buscar("x", 0.1))
            t2 = grupo.create_task(buscar("y", 0.2))
        print(t1.result(), t2.result())
    
    
    async def com_timeout():
        try:
            async with asyncio.timeout(0.1):
                await buscar("demorado", 1)
        except TimeoutError:
            print("estourou o tempo")
    
    
    asyncio.run(com_grupo())
    asyncio.run(com_timeout())
    
    Output
    x pronto y pronto
    estourou o tempo
    

    When multiple tasks in the group fail at the same time, the error arrives as a ExceptionGroup, and the except* (also from 3.11) handles each type separately:

    avancado/cap47_assincrono.pylinhas 67 a 81
    async def falha(nome):
        await asyncio.sleep(0.05)
        raise ValueError(nome)
    
    
    async def grupo_com_erros():
        try:
            async with asyncio.TaskGroup() as grupo:
                grupo.create_task(falha("a"))
                grupo.create_task(falha("b"))
        except* ValueError as grupo_de_erros:
            print(sorted(str(e) for e in grupo_de_erros.exceptions))
    
    
    asyncio.run(grupo_com_erros())
    
    Output
    ['a', 'b']
    

    Limit concurrency

    Firing ten thousand requests at once crashes the server on the other side (and yours). The Semaphore limits how many tasks pass through the region at the same time:

    avancado/cap47_assincrono.pylinhas 86 a 103
    async def limitado():
        limite = asyncio.Semaphore(2)
        ativos = 0
        maximo = 0
    
        async def trabalho(i):
            nonlocal ativos, maximo
            async with limite:
                ativos += 1
                maximo = max(maximo, ativos)
                await asyncio.sleep(0.05)
                ativos -= 1
    
        await asyncio.gather(*(trabalho(i) for i in range(6)))
        print("máximo simultâneo:", maximo)
    
    
    asyncio.run(limitado())
    
    Output
    máximo simultâneo: 2
    

    The most expensive error: blocking the loop

    A blocking call inside a coroutine (time.sleep, requests.get, a synchronous query) blocks all tasks while it runs. When there is no asynchronous alternative, send the call to a thread with asyncio.to_thread:

    avancado/cap47_assincrono.pylinhas 108 a 121
    import time
    
    
    def pesado():
        time.sleep(0.2)
        return "terminou"
    
    
    async def sem_bloquear():
        resultado, _ = await asyncio.gather(asyncio.to_thread(pesado), asyncio.sleep(0.05))
        print(resultado)
    
    
    asyncio.run(sem_bloquear())
    
    Output
    terminou
    

    Threads or asyncio

    I choose asyncio when there are many simultaneous connections (thousands) and the libraries I need have asynchronous versions (httpx, aiohttp, async database drivers). For a few dozen blocking calls, ThreadPoolExecutor it is simpler and sufficient. Mixing both worlds is costly in complexity, so prefer one per service.

    Exercise 1

    Fetch several with a limit

    Write buscar_todos(nomes, limite=2) that uses Semaphore and return a dictionary nome: resultado.