8.5. Timeouts e cancelamento

Cancelamento é o nome que o asyncio dá para “pare de executar esta corrotina, lance uma exceção dentro dela para que ela tenha a chance de fazer a limpeza e remova-a do agendador”. Os timeouts são o motivo mais comum para cancelar algo; o task.cancel() manual é o outro.

8.5.1. Cancelando uma tarefa

Chamar Task.cancel agenda o lançamento de asyncio.CancelledError dentro da corrotina em execução em seu próximo await. A corrotina é livre para ignorar o cancelamento (capturar a exceção e continuar executando) ou para honrá-lo – a escolha habitual é honrá-lo após executar o código de limpeza:

async def network_worker(stream):
    try:
        while True:
            data = await stream.read(64)
            # ...
    finally:
        stream.close()

Uma cláusula finally simples é o padrão mais limpo: a limpeza é executada quer a corrotina tenha saído normalmente, lançado uma exceção não relacionada ou sido cancelada. O CancelledError se propaga de volta através do finally, o laço vê que a tarefa terminou, e o chamador de await task vê o cancelamento.

Capturar CancelledError explicitamente também é adequado quando a aplicação quer fazer algo específico com ele – registrá-lo, repassar o recurso de forma limpa etc. A regra é: relance-o depois que a limpeza for executada. Engolir o CancelledError mantém a corrotina viva quando seu chamador pediu para ela parar, o que quase sempre é um bug:

async def worker():
    try:
        await long_running_thing()
    except asyncio.CancelledError:
        log("worker cancelled, cleaning up")
        close_resources()
        raise          # << this line is the important one

8.5.2. Timeouts com wait_for

asyncio.wait_for() envolve um awaitable em um prazo. Se o awaitable terminar dentro do timeout, seu resultado é retornado. Caso contrário, o awaitable é cancelado e o chamador recebe asyncio.TimeoutError

try:
    frame = await asyncio.wait_for(grab_frame(), timeout=2)
except asyncio.TimeoutError:
    print("camera took too long")

O argumento de segundos aceita um float. Para prazos no formato de milissegundos, asyncio.wait_for_ms() recebe uma contagem inteira de milissegundos – uma extensão do MicroPython que se alinha com os controles de temporização do firmware na granularidade de milissegundos.

Internamente, wait_for faz exatamente o que o cancelamento manual faria: quando o prazo expira, ele chama cancel() na tarefa envolvida, o CancelledError é lançado dentro da corrotina, as cláusulas finally são executadas e, uma vez concluída a limpeza, a exceção é traduzida em um TimeoutError para o chamador.

Isso significa que uma corrotina que captura e ignora o CancelledError vai derrotar o timeout – o prazo expirou, mas a corrotina se recusou a parar, e o wait_for não pode forçá-la. A regra anterior também se aplica aqui: capture o CancelledError apenas para executar a limpeza e, em seguida, relance-o.

8.5.3. Cancelamento através do gather

O cancelamento se propaga para baixo através de gather(). Se a tarefa que está aguardando uma chamada de gather for cancelada, todos os awaitables ainda em execução dentro do gather também serão cancelados – cada um tem a chance de fazer a limpeza por meio de suas próprias cláusulas finally antes que o cancelamento suba até o chamador.

Combinado com timeouts, esta é a maneira padrão de colocar um prazo em um grupo de operações:

await asyncio.wait_for(
    asyncio.gather(a(), b(), c()),
    timeout=5,
)

Ou todas as suboperações terminam dentro de cinco segundos, ou todas elas são canceladas juntas.

8.5.4. Desligando uma aplicação

O cancelamento também é como uma aplicação real para de forma limpa. O padrão é consistente entre os scripts: main captura os handles das tarefas de segundo plano de longa duração que iniciou, executa seu trabalho de nível superior e, em seguida, cancela cada handle e o aguarda em um bloco finally

async def main():
    sender = asyncio.create_task(uplink())
    watcher = asyncio.create_task(button_watcher())
    try:
        await snapshot_loop()
    finally:
        sender.cancel()
        watcher.cancel()
        await asyncio.gather(sender, watcher,
                             return_exceptions=True)

O return_exceptions=True é o truque que impede o gather de relançar o CancelledError que cada tarefa filha está prestes a entregar, de modo que o próprio motivo de saída da aplicação – o que quer que snapshot_loop tenha ou não lançado – é o que aflora de main.