Programmation asynchrone : initiation
Utilité de la programmation asynchrone
La recherche de performance dans certains domaines est un point crucial. Comme on l’a vu précédemment, la programmation parallèle, c’est-à-dire l’utilisation de plusieurs tâches ou de plusieurs processus, permet d’améliorer les performances notablement. Mais elles ne sont pas toujours les solutions les plus adaptées et, surtout, elles font intervenir des notions système et augmentent la complexité de l’application. Dans certains cas, lorsque les causes du manque de performance des applications sont dues à des problématiques I/O, il existe une autre solution, laquelle consiste à faire de la programmation asynchrone. La programmation asynchrone permet d’éviter qu’une tâche soit bloquée par une opération lente dépendant d’un facteur externe, telle que la récupération de données depuis le réseau ou le disque dur (lecture de fichiers, requêtes bases de données, requête Internet) ou l’écriture sur le réseau ou le disque dur ou encore l’utilisation d’un périphérique.
L’inconvénient de la programmation asynchrone est que le code n’est plus exécuté de manière parfaitement prédictible, ce qui rend la détection d’erreurs un peu plus...
Introduction à l’asynchrone
1. Notion de coroutine
Pour ce chapitre, le discours sera basé sur la version la plus récente de Python 3, à savoir Python 3.11 au moment de la rédaction de cet article. Nous reviendrons ensuite sur la syntaxe à utiliser pour les versions antérieures.
Pour utiliser la programmation asynchrone, il faut identifier dans son code une partie qui est bloquante, c’est-à-dire qui passe du temps à attendre après des I/O.
Il faut donc sortir cette partie du code linéaire et en faire une coroutine.
Cette notion de coroutine est à la base de la programmation asynchrone.
Voici l’exemple le plus basique, adapté de la documentation officielle :
>>> import asyncio
>>> import time
>>> async def main():
... print(f"hello {time.strftime('%X')}")
... await asyncio.sleep(1)
... print(f"world {time.strftime('%X')}")
...
La première ligne permet d’importer le module asyncio, qui est dédié aux problématiques de programmation asynchrone et la deuxième le module time qui va nous permettre de visualiser le temps pris réellement par les algorithmes.
La troisième ligne ressemble à la définition d’une fonction, sauf que le mot-clé def est précédé du mot-clé async. C’est ce simple détail qui fait de la fonction une coroutine.
Pour être plus précis, dans cet exemple, main est une fonction et l’appel de cette fonction main() va nous donner la coroutine.
>>> main
<function main at 0x7f030738ec80>
>>> main()
<coroutine object main at 0x7f03073e99c8>
Nous allons maintenant essayer une coroutine avec des arguments :
>>> async def say_after(delay, what):
... print(f"before {what} {time.strftime('%X')}")
... await asyncio.sleep(delay)
... print(f"after {what} {time.strftime('%X')}")
...
Cet exemple, également adapté de la documentation...
Éléments de grammaire
1. Gestionnaire de contexte asynchrone
Le gestionnaire de contexte permet de gérer proprement une ressource :
>>> with open("path/to/file") as f:
... content = f.read()
...
En procédant ainsi, on sait que quoi qu’il se passe, notre fichier sera bien fermé. Et ceci est applicable à n’importe quelle ressource, en particulier des ressources réseau.
Il en est de même pour le gestionnaire de contexte asynchrone, sauf que la ressource est traitée de manière asynchrone, par des outils asynchrones.
En d’autres termes, elle nécessite un traitement particulier qui est différent de celui du gestionnaire de contexte classique, mais au final, cela est relativement transparent pour le développeur qui n’a qu’à rajouter le mot-clé async devant le mot-clé with.
Voici un exemple avec le module aiofiles (https://github.com/Tinche/aiofiles) :
>>> async with aiofiles.open("path/to/file") as f:
... content = await f.read()
...
Voici un exemple avec le module aiohttp, qui permet de gérer des ressources HTTP de manière asynchrone, comme son nom l’indique :
>>> import asyncio
>>> import aiohttp
>>> async def download_json():
... loop...
Notions avancées
Ce chapitre est destiné aux lecteurs avancés.
1. Introspection
Voyons dans un premier temps ce que nous pouvons trouver par nous-mêmes pour comprendre ce qu’est une tâche asynchrone :
>>> async def introspect():
... task = asyncio.current_task()
... print(task)
... print(type(task), type.mro(type(task)))
... print(dir(task))
...
Pour récupérer la tâche asynchrone qui est en train d’être exécutée, on peut faire appel à asyncio.current_task. Bien évidemment, ceci nous renverra la coroutine introspect.
On la lance usuellement et on peut constater le premier point : la tâche courante, au sein de notre tâche, est bien notre tâche :
>>> asyncio.run(introspect())
<Task pending coro=<introspect()>
Ensuite, si nous regardons l’arbre d’héritage, nous voyons ceci :
[<class '_asyncio.Task'>, <class '_asyncio.Future'>, <class 'object'>]
Notre coroutine est un objet du type _asyncio.task et elle est un type particulier de futures, un des trois types d’awaitables (avec la tâche et la coroutine).
Voyons maintenant quels sont les attributs de la classe :
['__await__', '__class__', '__del__', '__delattr__', '__dir__', '__doc__',
'__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__',
'__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__ne__',
'__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', '_asyncio_future_blocking',
'_callbacks', '_coro', '_exception', '_fut_waiter', '_log_destroy_pending',
'_log_traceback', '_loop', '_must_cancel', '_repr_info', '_result',
'_source_traceback', '_state', 'add_done_callback', 'all_tasks'...
Boucle événementielle asynchrone
1. Gestion de la boucle
Toute tâche asynchrone est exécutée par ce que l’on nomme la boucle événementielle. Si la programmation parallèle repose sur la gestion des fils d’exécution ou des processus réalisée par la machine virtuelle et par le système, la programmation asynchrone repose sur la boucle événementielle fournie par asyncio pour gérer de la manière la plus efficace possible la gestion des I/O, des événements système et des changements de contextes applicatifs.
L’application que l’on développe va interagir avec la boucle événementielle pour enregistrer du code à exécuter et laisser prendre les décisions appropriées pour ordonner l’exécution de ce code aux moments les plus appropriés, lorsque les ressources demandées sont accessibles. Chaque tâche, une fois qu’on lui a donné la main, va pouvoir avancer puis rendre la main à la boucle dès qu’elle se met en attente.
Dans tout ce que l’on a vu jusqu’à présent, la présence de la boucle événementielle est totalement invisible dans le code, mais à chaque fois que l’on utilise le mot-clé await, on retourne dans cette boucle et celle-ci va choisir une nouvelle tâche asynchrone à exécuter.
Pour obtenir la boucle asynchrone courante, on peut faire :
>>> asyncio.get_running_loop()
Attention, la boucle courante n’est disponible que lorsque l’on est dans une fonction asynchrone, exécutée par exemple par asyncio.run.
S’il n’y a pas de boucle asynchrone, alors on obtient une exception :
>>> asyncio.get_running_loop()
Traceback (most recent call last):
File "<input>", line 1, in <module>
asyncio.get_running_loop()
RuntimeError: no running event loop
Cette boucle courante est à utiliser de manière préférentielle, elle est gérée automatiquement par asyncio.
Sachez qu’il est aussi possible d’utiliser :
>>> loop = asyncio.get_event_loop()
Cette dernière commande peut fonctionner en dehors d’une fonction...
Utiliser la boucle événementielle
1. Utilisation des fonctions de retour (callbacks)
Le rôle du retour de fonction (callback en anglais) consiste à permettre de réaliser une opération que l’on souhaite faire avant même que la future n’ait un résultat. On programme donc cette opération avant et elle s’exécute après que la future soit calculée.
Voici un exemple simple où l’on se contente de programmer un affichage. Voici comment on peut écrire un tel callback :
>>> def print_callback(future):
... print('Callback: future done: {}'.format(future.result()))
...
Voici maintenant comment l’utiliser :
>>> async def main():
... loop = asyncio.get_running_loop()
... future = loop.create_future()
... future.add_done_callback(print_callback)
... task = loop.create_task(compute_value(future))
... await future
... exception = task.exception()
... if exception:
... print(f"The coroutine raised an exception: {exception!r}")
... else:
... print(f"The coroutine returned : {future.result()}")
...
>>> asyncio.run(main())
Callback: future done: 42
The coroutine returned : 42
La ligne mise en évidence permet de rajouter ce callback. On voit qu’elle prend place avant l’appel concret de la future, ce qui se fait lors de l’utilisation de await.
Chaque callback peut être ajouté plusieurs fois :
>>> async def main():
... loop = asyncio.get_running_loop()
... future = loop.create_future()
... future.add_done_callback(print_callback)
... future.add_done_callback(print_callback)
... future.add_done_callback(print_callback)
... task = loop.create_task(compute_value(future))
... ...
L’asynchrone suivant les versions de Python
1. Python 3.7
Python 3.7 introduit la fonction asyncio.run que nous utilisons très largement dans ce chapitre (et dans le chapitre Programmation asynchrone : avancée). Il est à noter que cette fonctionnalité a été introduite à titre probatoire. Il est donc possible qu’elle disparaisse ou soit modifiée dans les versions futures. À l’instant où ce paragraphe est écrit, il semblerait qu’elle ne soit pas modifiée pour Python 3.8. Il est à prévoir que cette fonction soit confirmée ou remplacée pour Python 3.9.
Reprenons un exemple du livre, celui-ci :
>>> async def main():
... loop = asyncio.get_running_loop()
... loop.call_soon(function, 42)
... loop.call_soon(partial(function, kwarg="other"), 34)
... loop.call_soon(partial(function, 16, kwarg="another"))
... await asyncio.sleep(1)
...
>>> asyncio.run(main())
Vous pouvez le remplacer par :
>>> async def main(loop):
... loop.call_soon(function, 42)
... loop.call_soon(partial(function, kwarg="other"), 34)
... loop.call_soon(partial(function, 16, kwarg="another"))
... await asyncio.sleep(1)
...
>>> event_loop = asyncio.get_event_loop() ...
Cas concret
1. Exemple de travail
Pour cet exemple, nous allons présenter un extrait de code non asynchrone, puis le transformer en asynchrone. Ce code fait deux actions : télécharger des images et les enregistrer dans un fichier. Ces deux actions utilisent beaucoup d’I/O, mais la première se fait à travers le réseau et subit donc pas mal de temps d’attente tandis que la seconde utilise directement le système de fichiers et a peu de temps d’attente même si c’est de l’I/O. De plus, aujourd’hui, le langage Python ne propose pas de moyen d’écrire ou de lire des fichiers de manière asynchrone.
Le premier module auquel on pourrait penser pour accéder à une ressource web serait l’excellent module requests.
Or, ce dernier n’est pas asynchrone et il ne propose pas de travailler de manière non bloquante, comme la documentation le précise (http://docs.python-requests.org/en/master/user/advanced/#blocking-or-non-blocking). Le programme sera donc bloqué du début du téléchargement de la ressource jusqu’à la fin. Pour travailler en mode non bloquant, la documentation propose cinq alternatives : requests-thread qui, comme son nom l’indique, propose une solution en utilisant la programmation parallèle, grequests qui, comme son nom l’indique plus subtilement, propose d’utiliser gevent, une forme d’utilisation de fils d’exécution légers et vue comme une alternative à l’asynchrone (présentée dans le chapitre Programmation asynchrone : alternatives), requests-future qui utilise la notion de future, mais celle du module concurrent.future et non la notion de asyncio.Future, une autre solution impliquant la programmation parallèle mais également requests-async qui est maintenant englobée dans le projet httpx (https://www.python-httpx.org/async/) ainsi que aiohttp qui aura notre préférence.
Nous verrons donc ce cas d’usage et ses alternatives dans le chapitre sur la programmation parallèle (Qualité), ainsi que dans celui sur les alternatives à l’asynchrone (Génération de contenu).
Cette parenthèse...
Exemple retravaillé en utilisant des générateurs
Quand utiliser des générateurs et quand utiliser l’asynchrone ? L’asynchrone est à réserver lorsque l’on fait des actions impliquant du travail externe (utilisation de nombreuses I/O qui mettent le programme en pause). Tant qu’il s’agit d’un algorithme manipulant des données, si on ne souhaite pas attendre de collecter toutes les données avant de les traiter, la meilleure chose à faire est d’essayer d’utiliser des générateurs.
Et les générateurs ne sont pas exclusifs de la programmation asynchrone : nous pouvons utiliser les deux ensemble. Nous allons détailler comment transformer le code de base pour l’optimiser un peu :
Le processus de récupération des images depuis le code source HTML peut facilement être transformé en générateur :
def get_images_src_from_html(html_doc):
"""Récupère tous les contenus des attributs src des balises img"""
soup = BeautifulSoup(html_doc, "html.parser")
return (img.get('src') for img in soup.find_all('img'))
Il a suffi de remplacer les crochets par des parenthèses.
L’étape suivante, consistant à retrouver les URI absolues, est plus complexe. Il faut donc utiliser cette technique :
def get_uri_from_images_src(base_uri, images_src):
"""Renvoie une à une chaque URI d'image à télécharger"""
parsed_base = urlparse(base_uri)
for src in images_src:
parsed = urlparse(src)
if parsed.netloc == '':
path = parsed.path
if parsed.query:
path += '?' + parsed.query
if path[0] != '/':
...