Pular para o conteúdo

    Capítulo 47, Avançado

    asyncio

    O `asyncio` roda milhares de tarefas que esperam em **uma única thread**, alternando entre elas nos pontos em que cada uma precisa esperar. É a ferramenta certa para muita conexão e pouco cálculo.

    Código deste capítulo: avancado/cap47_assincrono.py

    Corrotinas e o laço de eventos

    Uma função async def cria uma corrotina. Chamá-la não executa nada: devolve um objeto que precisa ser agendado. O await marca o ponto em que a corrotina cede o controle para o laço de eventos poder rodar outra. O asyncio.run cria o laço e executa a corrotina principal:

    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())
    
    Saída
    a pronto
    

    A concorrência é cooperativa: só existe troca de tarefa em um await. Isso torna o código mais fácil de raciocinar do que com threads (não há dois trechos rodando ao mesmo tempo), mas cobra uma regra rígida: nunca bloqueie o laço.

    Várias tarefas ao mesmo tempo

    O asyncio.gather agenda várias corrotinas juntas e devolve os resultados na ordem em que você as passou. Três esperas de 0,3, 0,1 e 0,2 segundo levam cerca de 0,3 no total, e não 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())
    
    Saída
    ['lento pronto', 'rápido pronto', 'médio pronto']
    

    Quando você quer tratar cada resultado conforme ele chega, 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())
    
    Saída
    rápido pronto
    médio pronto
    lento pronto
    

    TaskGroup e tempo limite

    Desde o Python 3.11, o TaskGroup é a forma recomendada de agrupar tarefas: se uma falha, as outras são canceladas, e o bloco só termina quando todas acabam. O asyncio.timeout impõe um prazo a um trecho:

    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())
    
    Saída
    x pronto y pronto
    estourou o tempo
    

    Quando várias tarefas do grupo falham ao mesmo tempo, o erro chega como um ExceptionGroup, e o except* (também do 3.11) trata cada tipo separadamente:

    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())
    
    Saída
    ['a', 'b']
    

    Limitar a concorrência

    Disparar dez mil requisições de uma vez derruba o servidor do outro lado (e o seu). O Semaphore limita quantas tarefas passam pela região ao mesmo tempo:

    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())
    
    Saída
    máximo simultâneo: 2
    

    O erro mais caro: bloquear o laço

    Uma chamada bloqueante dentro de uma corrotina (time.sleep, requests.get, uma consulta síncrona) para todas as tarefas enquanto ela roda. Quando não há alternativa assíncrona, mande a chamada para uma thread com 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())
    
    Saída
    terminou
    

    Threads ou asyncio

    Eu escolho asyncio quando há muitas conexões simultâneas (milhares) e as bibliotecas que preciso têm versão assíncrona (httpx, aiohttp, drivers async de banco). Para poucas dezenas de chamadas bloqueantes, ThreadPoolExecutor é mais simples e basta. Misturar os dois mundos custa caro em complexidade, então prefira um só por serviço.

    Exercício 1

    Buscar vários com um limite

    Escreva buscar_todos(nomes, limite=2) que use Semaphore e devolva um dicionário nome: resultado.