8.5. Délais et annulation

L’annulation est le nom donné par asyncio à « arrêter l’exécution de cette coroutine, lever une exception à l’intérieur afin qu’elle ait une chance de faire le nettoyage, et la retirer de l’ordonnanceur ». Les délais d’attente sont la raison la plus courante d’annuler quelque chose ; un task.cancel() manuel est l’autre.

8.5.1. Annuler une tâche

L’appel de Task.cancel planifie la levée de asyncio.CancelledError à l’intérieur de la coroutine en cours d’exécution lors de son prochain await. La coroutine est libre d’ignorer l’annulation (attraper l’exception et continuer de s’exécuter) ou de l’honorer – le choix habituel étant de l’honorer après avoir exécuté le code de nettoyage

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

Une simple clause finally est le motif le plus propre : le nettoyage s’exécute que la coroutine se soit terminée normalement, ait levé une exception sans rapport, ou ait été annulée. Le CancelledError remonte à travers le finally, la boucle voit que la tâche est terminée, et l’appelant de await task voit l’annulation.

Attraper explicitement CancelledError convient également lorsque l’application veut en faire quelque chose de spécifique – le consigner, transférer proprement la ressource, etc. La règle est la suivante : le relever après l’exécution du nettoyage. Avaler CancelledError maintient la coroutine en vie alors que son appelant lui a demandé de s’arrêter, ce qui est presque toujours un bogue

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. Délais d’attente avec wait_for

asyncio.wait_for() enveloppe un awaitable dans une échéance. Si l’awaitable se termine dans le délai imparti, son résultat est renvoyé. Sinon, l’awaitable est annulé et l’appelant reçoit asyncio.TimeoutError

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

L’argument en secondes accepte un nombre à virgule flottante. Pour des échéances de l’ordre de la milliseconde, asyncio.wait_for_ms() prend un nombre entier de millisecondes – une extension MicroPython qui s’aligne sur les réglages de temporisation du micrologiciel à granularité de la milliseconde.

En interne, wait_for fait exactement ce que ferait une annulation manuelle : lorsque l’échéance expire, il appelle cancel() sur la tâche enveloppée, CancelledError est levée à l’intérieur de la coroutine, les clauses finally s’exécutent, et une fois le nettoyage terminé, l’exception est traduite en TimeoutError pour l’appelant.

Cela signifie qu’une coroutine qui attrape et ignore CancelledError déjouera le délai d’attente – l’échéance a expiré, mais la coroutine a refusé de s’arrêter, et wait_for ne peut pas l’y contraindre. La règle énoncée précédemment s’applique ici aussi : n’attrapez CancelledError que pour exécuter le nettoyage, puis relevez-la.

8.5.3. Annulation à travers gather

L’annulation se propage vers le bas à travers gather(). Si la tâche qui attend un appel à gather est annulée, tous les awaitables encore en cours d’exécution à l’intérieur du gather sont annulés eux aussi – chacun a une chance de faire son nettoyage à travers ses propres clauses finally avant que l’annulation ne remonte jusqu’à l’appelant.

Combiné aux délais d’attente, c’est la façon standard de poser une échéance sur un groupe d’opérations

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

Soit chaque sous-opération se termine dans les cinq secondes, soit elles sont toutes annulées ensemble.

8.5.4. Arrêter une application

L’annulation est également la manière dont une véritable application s’arrête proprement. Le motif est constant d’un script à l’autre : main capture les handles des tâches d’arrière-plan de longue durée qu’il a démarrées, exécute son travail de premier niveau, puis annule chaque handle et l’attend dans un bloc 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)

Le return_exceptions=True est l’astuce qui empêche le gather de relever le CancelledError que chaque tâche enfant est sur le point de délivrer, de sorte que la propre raison de sortie de l’application – selon que snapshot_loop ait levé ou non une exception – soit ce qui remonte hors de main.