3 points par GN⁺ 2024-05-09 | 1 commentaires | Partager sur WhatsApp
  • asyncio de Python est un outil conçu pour les programmes I/O-bound ; l’article le reconstruit via une implémentation simple reposant sur les générateurs afin d’en montrer le fonctionnement
  • L’idée centrale est qu’une boucle d’événements avec plusieurs tâches exécute chaque tâche, récupère le contrôle au niveau de yield, puis passe à la suivante
  • sleep peut être implémenté comme un sous-générateur qui fait yield jusqu’à ce qu’un certain temps se soit écoulé, et yield from permet de reprendre la tâche d’origine une fois le générateur enfant terminé
  • En passant à async/await, Task.__await__() rend le contrôle à la boucle d’événements tant que l’exécution n’est pas terminée, et create_task comme run prennent une forme proche de la véritable API asyncio
  • En remplaçant les appels personnalisés à jacobio par asyncio, on obtient du code utilisant le vrai package, même si asyncio réel est bien plus complexe que l’exemple et que son flux interne diffère en partie

Structure de base d’asyncio vue à travers les générateurs

  • asyncio sert à traiter les programmes I/O-bound en Python, et l’article le simplifie puis le reconstruit sur une base de générateurs
  • Comme range en Python 3+, un générateur ne stocke pas toute la séquence en mémoire et produit les valeurs une par une au moment voulu
    • Construire range(100_000_000) comme une liste obligerait à stocker 100 millions d’éléments, ce qui est très inefficace en mémoire
    • Un générateur produit les valeurs au fur et à mesure des besoins sans conserver toute la séquence
  • Une fonction génératrice se définit comme une fonction classique, mais avec yield
    • Lorsqu’on l’appelle, son corps ne s’exécute pas immédiatement : elle renvoie un objet générateur
    • Un appel à next(iterator) exécute la fonction jusqu’au prochain yield
    • S’il n’y a plus de yield, une exception StopIteration est levée
  • yield from permet à un générateur d’appeler un sous-générateur ou un objet itérable, ce qui permet de chaîner des générateurs
  • La propriété essentielle ici est qu’on peut interrompre l’exécution d’une fonction puis la reprendre plus tard tout en conservant son état

Simplifier la boucle d’événements en une liste

  • La boucle d’événements est le cœur d’asyncio : elle exécute et gère les tâches en cours
  • Dans asyncio réel, la boucle d’événements est écrite en C, mais dans un modèle simplifié on peut la voir comme une liste contenant les tâches actives
  • Dans l’exemple initial, les tâches sont traitées comme des objets générateurs
    • Le gestionnaire de la boucle d’événements parcourt la liste des tâches
    • Il appelle next(task) sur chaque tâche pour la faire avancer
    • Lorsqu’une tâche doit attendre, comme dans un travail I/O-bound, elle s’interrompt avec yield et rend le contrôle à la boucle d’événements
  • Dans l’exemple où task1() et task2() affichent chacune quelque chose puis font yield, les sorties s’alternent
    • Les deux fonctions contiennent une boucle while True, donc l’exécution se poursuit indéfiniment
    • L’affichage répète un schéma du type Task 1, Task 2, Task 1, Task 2

Créer sleep avec yield from

  • sleep(seconds) est implémenté comme un générateur qui enregistre l’heure de départ, puis continue à faire yield tant que la durée demandée n’est pas écoulée
  • Une fonction tâche délègue alors son exécution au sous-générateur sleep via yield from sleep(1) ou yield from sleep(5)
    • Tant que sleep continue à faire yield, la tâche reste suspendue
    • Une fois le temps écoulé, la boucle while de sleep se termine
    • Comme il n’y a alors plus de yield, StopIteration est levée, et yield from poursuit sur la ligne suivante de la fonction tâche
  • Dans l’exemple, task1 affiche quelque chose toutes les 1 seconde et task2 toutes les 5 secondes
    • La sortie prend donc la forme Task 1, Task 2, puis plusieurs Task 1, avant le retour de Task 2

Passer de yield à await

  • Pour utiliser await, l’objet visé doit posséder une méthode __await__ ou être une coroutine
  • Dans asyncio, on manipule généralement des objets Task via des fonctions comme asyncio.create_task
    • Les objets Task héritent de l’objet Future d’asyncio
    • L’objet Future fournit une méthode __await__
  • Appeler une fonction préfixée par async crée un objet coroutine
    • Comme une fonction génératrice, une coroutine peut être suspendue puis reprise
  • On peut voir await comme une forme de yield from assortie de règles de validation supplémentaires
    • await object revient à faire produire quelque chose par __await__ sur l’instance de l’objet, ou à attendre une autre coroutine
  • Dans le code source de Future d’asyncio, on voit aussi que tant qu’un Future ou une Task n’est pas terminé, __await__ appelle essentiellement yield

Implémentation simple de Task, create_task et run

  • L’implémentation personnalisée utilise une Queue plutôt qu’une liste pour la boucle d’événements
    • L’objectif est de gérer en temps constant l’ajout et le retrait de tâches dans la boucle
  • La classe Task stocke l’objet générateur et son état d’achèvement
    • Elle stocke l’objet générateur dans self.iter
    • self.finished est initialisé à False
    • Si le générateur lève StopIteration, la tâche est considérée comme terminée
    • done() renvoie l’état d’achèvement
  • Task.__await__() continue à appeler yield self tant que la tâche n’est pas finie
    • C’est ce mécanisme qui rend le contrôle à la boucle d’événements
  • create_task(generator) encapsule le générateur dans un Task, l’ajoute à la file de la boucle d’événements, puis le renvoie
    • Son rôle est de planifier la tâche dans la boucle d’événements
  • run(main) démarre la boucle d’événements avec une forme proche de asyncio.run()
    • Il encapsule d’abord main dans un Task et l’ajoute à la file
    • Tant que la file n’est pas vide, il récupère la tâche suivante
    • Il fait avancer la tâche avec task.iter.send(None)
    • Si StopIteration est levée, il définit task.finished = True
    • En l’absence d’exception, il remet la tâche dans la file de la boucle d’événements
  • L’usage de task.iter.send(None) au lieu de next(task.iter) tient au fonctionnement avec les mots-clés async/await, même si ici le rôle est le même

sleep compatible async et exemple avec jacobio

  • Le sleep initial était une fonction génératrice, mais await ne peut pas être combiné directement avec une fonction génératrice
    • La cible de await doit être un objet doté de __await__ ou une fonction coroutine
  • La logique d’attente réelle est déplacée dans le générateur _sleep(seconds)
    • _sleep fait yield jusqu’à ce qu’un certain temps se soit écoulé
  • async def sleep(seconds) crée une tâche à partir de _sleep(seconds) puis l’attend avec await
    • await task appelle Task.__await__()
    • Si la tâche n’est pas terminée, le contrôle est rendu à la boucle d’événements via yield
  • Le fichier personnalisé final jacobio.py comprend les éléments suivants
    • la file de la boucle d’événements
    • _sleep
    • async sleep
    • Task
    • create_task
    • run
  • Dans l’exemple d’utilisation, les anciens yield from sont remplacés par await, et les fonctions utilisant await reçoivent le mot-clé async
    • task1 affiche deux fois quelque chose et attend jacobio.sleep(1) à chaque fois
    • task2 affiche trois fois quelque chose et attend jacobio.sleep(0) à chaque fois
    • main crée les deux tâches, les await toutes les deux, puis affiche done
  • La sortie de l’exemple est, dans l’ordre : Task 1, Task 2, Task 2, Task 2, Task 1, done

Passer au véritable asyncio

  • En remplaçant partout jacobio par asyncio dans l’exemple personnalisé, on obtient un code utilisant le vrai package asyncio
  • Les fonctions correspondantes sont les suivantes
    • jacobio.sleep()asyncio.sleep()
    • jacobio.create_task()asyncio.create_task()
    • jacobio.run()asyncio.run()
  • Dans la réalité, asyncio fait bien davantage de choses en interne
  • Ce gestionnaire de boucle d’événements a été conçu pour rester aussi simple que possible : il montre donc bien les idées de base d’asyncio, mais diffère un peu du flux du code source réel en raison de l’ampleur et de la complexité du package
  • Avec asyncio réel, au lieu de créer deux tâches puis de les await séparément, on peut traiter plusieurs tâches avec une fonction comme asyncio.gather()
  • L’article renvoie aussi vers handling asyncio tasks like a pro

1 commentaires

 
GN⁺ 2024-05-09
Avis de Hacker News
  • Asyncio permet de remplacer l’implémentation de la boucle d’événements par une implémentation faite maison
    Dans Temporal Python, les workflows sont exprimés au moyen d’une boucle d’événements asyncio durable personnalisée, si bien que même asyncio.sleep devient un minuteur durable. Autrement dit, le code peut reprendre sur une autre machine, ce qui permet même de dormir pendant des semaines
    La façon dont c’est implémenté est expliquée dans cet article : https://temporal.io/blog/durable-distributed-asyncio-event-l...
    Le plus gros problème d’asyncio, c’est qu’en Python un appel synchrone peut bloquer le thread asyncio et ainsi figer facilement tout le système. Python aurait grand besoin d’outils d’analyse statique capables de construire le graphe d’appels et de détecter si un appel bloquant pour le thread est invoqué directement ou indirectement depuis un async def

    • C’est peut-être une mauvaise idée, mais je me demande ce que ça donnerait de faire l’inverse de l’approche actuelle, où tous les appels sont synchrones par défaut
      Les opérations de base comme l’arithmétique ou l’accès aux structures de données pourraient être placées sur liste blanche comme appels synchrones rapides, et si la boucle d’événements tourne à une vitesse suspecte, on pourrait peut-être aussi détecter d’autres opérations qui devraient être synchrones
    • Temporal est vraiment génial
      Quand j’ai compris comment ils avaient implémenté la boucle d’événements asyncio, ça a vraiment été un moment de déclic
    • Je me demande pourquoi ne pas utiliser des threads
      J’essaie encore de comprendre si Python est vraiment un langage adapté à la concurrence. Asyncio m’a toujours donné l’impression de tenir à peine debout, et à mes yeux l’implémentation asynchrone de C# est plus propre
    • Dans l’exemple de workflow, Purchaser.purchase ne devrait-il pas être do_purchase ?
    • Ce ne sera pas facile à détecter statiquement
      Même un simple read() peut bloquer ou non selon ce qu’est le descripteur et la façon dont il a été configuré. Comment détecter cela par analyse statique ?
  • Cette implémentation semble faire de l’attente active pendant le sleep. Autrement dit, même s’il n’y a aucune tâche actuellement exécutable, la boucle d’événements continue de tourner
    Je me souviens avoir vu autrefois une autre implémentation jouet où l’heure de prochaine exécution possible des tâches était suivie dans un ordre trié, et où, s’il n’y avait aucune tâche immédiatement exécutable, la boucle d’événements elle-même se mettait en sommeil. C’est ainsi, d’après ce que je comprends, que le véritable asyncio fonctionne aussi
    Ensuite, on peut étendre cela pour que la prochaine condition d’exécution possible ne soit pas seulement une heure d’horloge murale, mais dépende aussi de choses comme des sockets, ce qui permet d’utiliser select avec un timeout

    • Dans asyncio lui-même, on peut aussi utiliser des implémentations de boucle d’événements personnalisées en plus de l’implémentation fournie par défaut
      L’une des implémentations connues est uvloop (https://github.com/MagicStack/uvloop), qui implémente essentiellement la boucle avec libuv, et c’est libuv qui gère les opérations comme le select que tu décris
    • Cela ressemble beaucoup à SimPy [1] / Simpy.io [2], mais SimPy est apparu plusieurs années avant asyncio
      [1] https://simpy.readthedocs.io/en/latest/
      [2] https://gitlab.com/team-simpy/simpy.io
    • D’un certain point de vue, cette approche n’a pas vraiment de problème
      La boucle d’événements n’a pas forcément besoin de boucler : elle peut lancer main, puis se terminer avec lui quand il se termine. Il suffit d’imaginer le démarrage d’un serveur avec une boucle while true qui attend des sockets, et qui se termine sur une condition d’arrêt ou une interruption, ce qui met aussi fin au programme
      Du point de vue de la boucle d’événements, il n’y a ni attente active ni besoin de toucher au sleep ou aux sockets. C’est la différence entre exécuter jusqu’à complétion et exécuter indéfiniment
      Si l’on construit une boucle jouet, je pense qu’il vaut mieux ne pas s’embêter à gérer le cas d’exécution infinie
    • Quel serait le meilleur livre pour apprendre ce genre de choses ?
  • La conférence de David Beazley sur asyncio est excellente
    Je m’en suis servi comme base pour créer un outil de simulation à événements discrets. Le fait de pouvoir implémenter soi-même asyncio et remplacer l’horloge système par un temps simulé est assez génial

  • C’est un très bon article, une explication de haut niveau qui évite bien les parties susceptibles d’ennuyer quelqu’un qui découvre le sujet
    Ce serait encore mieux d’ajouter à la fin d’autres ressources expliquant comment cela fonctionne réellement en interne

  • J’aurais préféré que l’article montre la même chose sans utiliser du tout yield. Honnêtement, c’est là que se trouve la partie qui paraît vraiment magique
    On trouve ici une explication bien plus approfondie des coroutines Python : https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coro...

  • Aucune mention de poll() ? Dans ce cas, cela n’a rien à voir avec la façon dont asyncio fonctionne

  • Le fait que Python ait détourné def pour l’utiliser afin de créer en réalité des objets qui ne sont pas des fonctions est complètement bizarre
    Ils auraient au moins pu créer un autre mot-clé

    • C’est bien une fonction, simplement transformée en une autre fonction dont le type de retour est différent
      Elle renvoie un générateur ou une coroutine, et la signature de type est visible ici [1]. Même sans sucre syntaxique au niveau du langage, on pourrait faire quelque chose de similaire en Python pur avec des décorateurs, par exemple
      [1] https://docs.python.org/3/library/typing.html#typing.Generat...
      Cela dit, je mentionne la signature de type surtout par intérêt académique ; dans les annotations réelles, je préfère les Iterable et Awaitable, plus simples
    • Quel est le vrai problème ? Ou est-ce simplement une question de pureté du langage ?
    • Ce n’est pas quelque chose qu’il faut voir comme une tragédie
      On dirait qu’ils ont fait passer la sémantique de l’expérience développeur avant quelque chose comme la pureté du langage. Le fait qu’une fonction async soit une fonction asynchrone ne m’a jamais embrouillé
    • Le plus gros problème de Python, c’est surtout qu’il faut utiliser def pour les fonctions, ce qui empêche de créer des fonctions inline comme les fonctions fléchées de JS ou les lambdas de C++
      Ce n’est pas un problème de pureté du langage, c’est juste peu pratique
    • Tu parles des décorateurs ? Dans le contexte de Python, il est assez déroutant de comprendre ce que tu veux dire