8.5. Tiempos de espera y cancelación

Cancelación es el nombre que asyncio da a «deja de ejecutar esta corrutina, lanza una excepción dentro de ella para que tenga oportunidad de limpiar, y elimínala del planificador». Los tiempos de espera son la razón más común para cancelar algo; la otra es un task.cancel() manual.

8.5.1. Cancelar una tarea

Llamar a Task.cancel programa el lanzamiento de asyncio.CancelledError dentro de la corrutina en ejecución en su siguiente await. La corrutina es libre de ignorar la cancelación (capturar la excepción y seguir ejecutándose) o de respetarla – la elección habitual es respetarla tras ejecutar el código de limpieza:

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

Una cláusula finally sencilla es el patrón más limpio: la limpieza se ejecuta tanto si la corrutina salió normalmente, lanzó una excepción no relacionada o fue cancelada. El CancelledError se propaga de vuelta hacia arriba a través del finally, el bucle ve que la tarea ha terminado, y quien llama a await task ve la cancelación.

Capturar CancelledError explícitamente también está bien cuando la aplicación quiere hacer algo específico con él – registrarlo, ceder el recurso de forma limpia, etc. La regla es: vuelve a lanzarlo después de que se ejecute la limpieza. Tragarse el CancelledError mantiene la corrutina viva cuando quien la llama le ha pedido que se detenga, lo cual casi siempre es un error:

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. Tiempos de espera con wait_for

asyncio.wait_for() envuelve un awaitable en un plazo límite. Si el awaitable termina dentro del tiempo de espera, se retorna su resultado. Si no, el awaitable se cancela y quien llama obtiene asyncio.TimeoutError:

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

El argumento de segundos acepta un valor de coma flotante. Para plazos con forma de milisegundos, asyncio.wait_for_ms() toma un recuento entero de milisegundos – una extensión de MicroPython que encaja con los ajustes de temporización de grano de milisegundo del firmware.

Internamente, wait_for hace exactamente lo que haría la cancelación manual: cuando expira el plazo, llama a cancel() sobre la tarea envuelta, se lanza CancelledError dentro de la corrutina, se ejecutan las cláusulas finally, y una vez hecha la limpieza la excepción se traduce en un TimeoutError para quien llama.

Eso significa que una corrutina que captura e ignora CancelledError anulará el tiempo de espera – el plazo expiró, pero la corrutina se negó a detenerse, y wait_for no puede forzarla. La regla anterior aplica aquí también: captura CancelledError solo para ejecutar la limpieza, y luego vuelve a lanzarlo.

8.5.3. Cancelación a través de gather

La cancelación se propaga hacia abajo a través de gather(). Si la tarea que está esperando una llamada a gather se cancela, todos los awaitables que aún se ejecutan dentro del gather también se cancelan – cada uno tiene oportunidad de limpiar a través de sus propias cláusulas finally antes de que la cancelación suba hasta quien llama.

Combinado con tiempos de espera, esta es la forma estándar de poner un plazo a un grupo de operaciones:

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

O bien cada suboperación termina dentro de cinco segundos, o todas se cancelan juntas.

8.5.4. Apagar una aplicación

La cancelación también es la forma en que una aplicación real se detiene de forma limpia. El patrón es consistente entre scripts: main captura los manejadores de las tareas en segundo plano de larga duración que inició, ejecuta su trabajo de nivel superior, y luego cancela cada manejador y lo espera en un bloque 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)

El return_exceptions=True es el truco que impide que el gather vuelva a lanzar el CancelledError que cada tarea hija está a punto de entregar, de modo que la propia razón de salida de la aplicación – lo que snapshot_loop haya lanzado o no – es lo que aflora de main.