Fonctionnement de Python asyncio : reconstruction depuis les bases
(jacobpadilla.com)- 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 sleeppeut être implémenté comme un sous-générateur qui faityieldjusqu’à ce qu’un certain temps se soit écoulé, etyield frompermet 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, etcreate_taskcommerunprennent une forme proche de la véritable API asyncio - En remplaçant les appels personnalisés à
jacobioparasyncio, 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
rangeen 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
- Construire
- 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 prochainyield - S’il n’y a plus de
yield, une exceptionStopIterationest levée
yield frompermet à 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
yieldet rend le contrôle à la boucle d’événements
- Dans l’exemple où
task1()ettask2()affichent chacune quelque chose puis fontyield, 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
- Les deux fonctions contiennent une boucle
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 à faireyieldtant 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)ouyield from sleep(5)- Tant que
sleepcontinue à faireyield, la tâche reste suspendue - Une fois le temps écoulé, la boucle
whiledesleepse termine - Comme il n’y a alors plus de
yield,StopIterationest levée, etyield frompoursuit sur la ligne suivante de la fonction tâche
- Tant que
- Dans l’exemple,
task1affiche quelque chose toutes les 1 seconde ettask2toutes les 5 secondes- La sortie prend donc la forme
Task 1,Task 2, puis plusieursTask 1, avant le retour deTask 2
- La sortie prend donc la forme
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
Taskvia des fonctions commeasyncio.create_task- Les objets
Taskhéritent de l’objetFutured’asyncio - L’objet
Futurefournit une méthode__await__
- Les objets
- Appeler une fonction préfixée par
asynccrée un objet coroutine- Comme une fonction génératrice, une coroutine peut être suspendue puis reprise
- On peut voir
awaitcomme une forme deyield fromassortie de règles de validation supplémentairesawait objectrevient à faire produire quelque chose par__await__sur l’instance de l’objet, ou à attendre une autre coroutine
- Dans le code source de
Futured’asyncio, on voit aussi que tant qu’unFutureou uneTaskn’est pas terminé,__await__appelle essentiellementyield
Implémentation simple de Task, create_task et run
- L’implémentation personnalisée utilise une
Queueplutô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
Taskstocke l’objet générateur et son état d’achèvement- Elle stocke l’objet générateur dans
self.iter self.finishedest 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
- Elle stocke l’objet générateur dans
Task.__await__()continue à appeleryield selftant 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 unTask, 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 deasyncio.run()- Il encapsule d’abord
maindans unTasket 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
StopIterationest levée, il définittask.finished = True - En l’absence d’exception, il remet la tâche dans la file de la boucle d’événements
- Il encapsule d’abord
- L’usage de
task.iter.send(None)au lieu denext(task.iter)tient au fonctionnement avec les mots-clésasync/await, même si ici le rôle est le même
sleep compatible async et exemple avec jacobio
- Le
sleepinitial était une fonction génératrice, maisawaitne peut pas être combiné directement avec une fonction génératrice- La cible de
awaitdoit être un objet doté de__await__ou une fonction coroutine
- La cible de
- La logique d’attente réelle est déplacée dans le générateur
_sleep(seconds)_sleepfaityieldjusqu’à ce qu’un certain temps se soit écoulé
async def sleep(seconds)crée une tâche à partir de_sleep(seconds)puis l’attend avecawaitawait taskappelleTask.__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.pycomprend les éléments suivants- la file de la boucle d’événements
_sleepasync sleepTaskcreate_taskrun
- Dans l’exemple d’utilisation, les anciens
yield fromsont remplacés parawait, et les fonctions utilisantawaitreçoivent le mot-cléasynctask1affiche deux fois quelque chose et attendjacobio.sleep(1)à chaque foistask2affiche trois fois quelque chose et attendjacobio.sleep(0)à chaque foismaincrée les deux tâches, lesawaittoutes les deux, puis affichedone
- 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
jacobioparasynciodans 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
awaitséparément, on peut traiter plusieurs tâches avec une fonction commeasyncio.gather() - L’article renvoie aussi vers handling asyncio tasks like a pro
1 commentaires
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.sleepdevient un minuteur durable. Autrement dit, le code peut reprendre sur une autre machine, ce qui permet même de dormir pendant des semainesLa 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 defLes 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
Quand j’ai compris comment ils avaient implémenté la boucle d’événements asyncio, ça a vraiment été un moment de déclic
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
Purchaser.purchasene devrait-il pas êtredo_purchase?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
selectavec un timeoutL’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
selectque tu décris[1] https://simpy.readthedocs.io/en/latest/
[2] https://gitlab.com/team-simpy/simpy.io
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 bouclewhile truequi attend des sockets, et qui se termine sur une condition d’arrêt ou une interruption, ce qui met aussi fin au programmeDu 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
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 magiqueOn 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 fonctionneLe fait que Python ait détourné
defpour l’utiliser afin de créer en réalité des objets qui ne sont pas des fonctions est complètement bizarreIls auraient au moins pu créer un autre mot-clé
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
IterableetAwaitable, plus simplesOn 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
asyncsoit une fonction asynchrone ne m’a jamais embrouillédefpour 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