8.15. Pièges

Les mêmes schémas qui rendent asyncio agréable – pas de préemption, des await explicites – lui donnent son propre lot de situations qui mordent. Cette page est le catalogue de celles qui reviennent assez souvent pour qu’il vaille la peine de les connaître.

8.15.1. Oublier d”await

Appeler une fonction async def renvoie un objet coroutine. Cela n’exécute pas le corps de la fonction. Pour réellement l’exécuter, la coroutine doit être attendue (await) ou enveloppée dans une tâche:

async def main():
    send_request()              # bug: returns the coroutine, does nothing
    await send_request()        # right: run it to completion
    asyncio.create_task(send_request())  # right: run it concurrently

Le bogue est silencieux – l’objet coroutine est créé, abandonné et jamais exécuté. L’application poursuit comme si tout avait fonctionné. MicroPython journalisera parfois un avertissement signalant qu’une coroutine n’a jamais été attendue ; parfois non. Vérifiez l’absence de await à chaque site d’appel qui ressemble à un appel de fonction.

8.15.2. Boucles serrées sans await

Une coroutine qui s’exécute en boucle et n”await jamais monopolise la boucle d’événements. Aucune autre tâche ne progresse tant que la boucle ne se termine pas ou ne cède pas la main:

async def counter():
    n = 0
    while True:
        n += 1               # bug: starves the loop

Le correctif est un yield à l’intérieur de la boucle – généralement await asyncio.sleep_ms(0) – afin que les autres tâches prêtes aient une chance de s’exécuter. Les traitements gourmands en calcul relèvent aussi de cette forme : une boucle de traitement d’image qui dure des centaines de millisecondes par itération devrait céder la main au moins une fois par itération pour que le reste du programme ne se bloque pas.

8.15.3. Avaler CancelledError

La page sur l’annulation a déjà traité ce point en détail. On le répète ici car c’est la cause la plus fréquente du « mon application refuse de s’arrêter » : une coroutine intercepte asyncio.CancelledError à des fins de nettoyage et oublie de la relever. La tâche continue de s’exécuter ; l’appelant qui a demandé l’annulation reste suspendu indéfiniment à attendre qu’elle se termine. Relevez toujours l’exception après le nettoyage, ou utilisez un bloc try/finally plutôt qu’un except explicite.

8.15.4. Muter un état partagé à travers des await

L’ordonnancement coopératif garantit qu’une coroutine a le CPU pour elle seule entre les await, mais dès qu’elle await, une autre coroutine peut s’exécuter. Si deux coroutines modifient la même structure de données par des étapes qui incluent un await, leurs opérations peuvent s’entrelacer de manière à corrompre la structure:

# bug: two tasks running do_work simultaneously can
# interleave around the await and corrupt items
async def do_work():
    n = len(items)
    await asyncio.sleep_ms(0)
    items.append(some_work(n))

Pour un état qui n’est muté qu’entre les await à l’intérieur d’une seule coroutine, aucune synchronisation n’est nécessaire. Pour un état muté à travers des await et accédé depuis plus d’une coroutine, enveloppez la section critique dans un Lock.

8.15.5. await au niveau du module

await n’est valide qu’à l’intérieur d’un corps async def. L’écrire au niveau du module – en dehors de toute coroutine – est une erreur de syntaxe:

# bug: not inside an async def
result = await fetch()

Le correctif consiste à placer le travail dans une coroutine et à l’appeler depuis le point d’entrée asyncio.run() du programme.

8.15.6. Plusieurs appels à asyncio.run

MicroPython possède une seule boucle d’événements. Appeler asyncio.run() deux fois de suite – une fois pour la configuration, une fois pour le travail principal – utilise quand même la même boucle. L’appeler depuis l’intérieur d’une coroutine en cours d’exécution est une erreur : la boucle tourne déjà. Les deux cas surviennent le plus souvent lorsqu’un script grandit organiquement et que l’auteur tente de l’étendre en ajoutant d’autres appels à run() au lieu d’intégrer le nouveau travail dans le main existant.

8.15.7. Utilisation de Event depuis une interruption

asyncio.Event.set() ne peut être appelé en toute sécurité que depuis l’intérieur de la boucle d’événements. L’appeler depuis un gestionnaire d’interruption GPIO présente un risque de corruption. Pour réveiller une tâche depuis une interruption, utilisez plutôt ThreadSafeFlag – la page qui lui est consacrée en décrit la forme.

8.15.8. Appels synchrones longs

Une coroutine peut attendre les primitives d’attente propres à asyncio ; tout le reste de ce qu’elle appelle s’exécute de manière synchrone et bloque la boucle jusqu’à son retour. Un time.sleep() bloquant de 200 ms, une écriture sur carte SD qui met 80 ms à se vider, une grosse compression JPEG, un appel à csi.CSI.snapshot() – chacun d’eux retient la boucle d’événements pendant toute sa durée. Le correctif dépend de l’appel :

  • Pour time.sleep : remplacez-le par await asyncio.sleep ou await asyncio.sleep_ms.

  • Pour csi.CSI.snapshot : utilisez le wrapper de capture asynchrone que construit la page sur la capture.

  • Pour les longs calculs (traitement d’image, encodage JPEG) : acceptez le coût ou découpez le travail en morceaux qui await entre les itérations.

Asyncio ne peut pas rendre un appel synchrone non bloquant. Il peut seulement laisser d’autres coroutines s’exécuter pendant qu’autre chose est en attente.