Cours
Le module asyncio a été ajouté à Python en version 3.4 en tant que package provisoire. Cela signifie qu’il peut recevoir des modifications rétro-incompatibles, voire être retiré dans une future version de Python.
Selon la documentation, asyncio « fournit l’infrastructure pour écrire du code concurrent monothread à l’aide de coroutines, multiplexer l’accès E/S sur des sockets et d’autres ressources, exécuter des clients et serveurs réseau, et d’autres primitives associées ». Ce chapitre n’a pas vocation à couvrir tout ce que vous pouvez faire avec asyncio, mais vous apprendrez à utiliser le module et en quoi il est utile.
Si vous avez besoin d’un équivalent d’asyncio sur une version plus ancienne de Python, jetez un œil à Twisted ou gevent.
Définitions
Le module asyncio propose un cadre qui s’articule autour de la boucle d’événements. Une boucle d’événements attend qu’un événement survienne puis réagit. Elle gère notamment les E/S et les événements système.
asyncio propose en réalité plusieurs implémentations de boucle. Par défaut, le module choisit celle qui a le plus de chances d’être la plus efficace pour le système d’exploitation sur lequel il s’exécute ; vous pouvez toutefois sélectionner explicitement la boucle d’événements si vous le souhaitez. Une boucle d’événements, c’est en substance « quand l’événement A arrive, appelle la fonction B ».
Pensez à un serveur qui attend qu’un utilisateur demande une ressource, par exemple une page web. Si le site est peu fréquenté, le serveur restera longtemps inactif. Mais lorsqu’une requête arrive, le serveur doit réagir. Cette réaction correspond à la gestion d’événements. Quand un utilisateur charge la page, le serveur vérifie et appelle un ou plusieurs gestionnaires d’événements. Une fois terminés, ces gestionnaires doivent rendre la main à la boucle d’événements. En Python, asyncio utilise pour cela des coroutines.
Une coroutine est une fonction spéciale qui peut rendre le contrôle à son appelant sans perdre son état. C’est un consommateur et une extension d’un générateur. L’un de leurs grands avantages par rapport aux threads est leur faible empreinte mémoire à l’exécution. Notez que lorsque vous appelez une fonction coroutine, elle ne s’exécute pas immédiatement : elle renvoie un objet coroutine que vous pouvez transmettre à la boucle d’événements pour l’exécuter tout de suite ou plus tard.
Un autre terme que vous rencontrerez probablement avec le module asyncio est « future ». Un future représente le résultat d’un travail qui n’est pas encore terminé. Votre boucle d’événements peut surveiller des futures et attendre leur achèvement. Lorsqu’un future est terminé, il passe à l’état « done ». Asyncio prend également en charge les verrous (locks) et les sémaphores.
Dernier élément à mentionner : la tâche (Task). Une Task est un wrapper autour d’une coroutine et une sous-classe de Future. Vous pouvez même planifier une Task via la boucle d’événements.
async et await
Les mots-clés async et await ont été ajoutés en Python 3.5 pour définir des coroutines natives et en faire un type distinct des coroutines basées sur des générateurs. Pour une description détaillée de async et await, consultez la PEP 492.
En Python 3.4, vous créeriez une coroutine ainsi :
Ce décorateur fonctionne toujours en Python 3.5, mais le module types a été enrichi d’une fonction permettant d’indiquer si l’élément manipulé est une coroutine native ou non. À partir de Python 3.5, vous pouvez utiliser async def pour définir syntaxiquement une fonction coroutine.
La fonction ci-dessus deviendrait donc :
Quand vous définissez une coroutine de cette manière, vous ne pouvez pas utiliser yield dans la fonction coroutine. Elle doit contenir une instruction return ou await pour renvoyer des valeurs à l’appelant. Notez que le mot-clé await ne peut être utilisé qu’à l’intérieur d’une fonction async def.
On peut considérer les mots-clés async/await comme une API destinée à la programmation asynchrone. Le module asyncio n’est qu’un framework qui utilise async/await pour programmer de façon asynchrone. Il existe d’ailleurs un projet nommé curio qui le démontre : une autre implémentation de boucle d’événements qui s’appuie sur async/await sous le capot.
Un mauvais exemple de coroutine
Même s’il est utile de comprendre le fonctionnement global, il est parfois plus parlant de voir des exemples pour se familiariser avec la syntaxe et l’assemblage des pièces.
Partons donc sur un exemple simple !
Une tâche assez courante consiste à télécharger un fichier depuis une ressource interne ou depuis Internet. En pratique, vous voudrez souvent en télécharger plusieurs.
Créons donc une paire de coroutines capables de faire cela :
import asyncio
import os
import urllib.request
async def download_coroutine(url):
#"A coroutine to download the specified url"
request = urllib.request.urlopen(url)
filename = os.path.basename(url)
with open(filename, 'wb') as file_handle:
while True:
chunk = request.read(1024)
if not chunk:
break
file_handle.write(chunk)
msg = 'Finished downloading {filename}'.format(filename=filename)
return msg
async def main(urls):
"""
Creates a group of coroutines and waits for them to finish
"""
coroutines = [download_coroutine(url) for url in urls]
completed, pending = await asyncio.wait(coroutines)
for item in completed:
print(item.result())
if __name__ == '__main__':
urls = ["http://www.irs.gov/pub/irs-pdf/f1040.pdf",
"http://www.irs.gov/pub/irs-pdf/f1040a.pdf",
"http://www.irs.gov/pub/irs-pdf/f1040ez.pdf",
"http://www.irs.gov/pub/irs-pdf/f1040es.pdf",
"http://www.irs.gov/pub/irs-pdf/f1040sb.pdf"]
event_loop = asyncio.get_event_loop()
try:
event_loop.run_until_complete(main(urls))
finally:
event_loop.close()
Ici, nous importons les modules nécessaires puis créons une première coroutine avec la syntaxe async. Cette coroutine, download_coroutine, utilise urllib de Python pour télécharger l’URL transmise. Une fois terminée, elle renvoie un message l’indiquant.
L’autre coroutine est la coroutine principale. Elle prend une liste d’URL, les aligne dans une file et utilise la fonction wait d’asyncio pour attendre la fin des coroutines. Évidemment, pour démarrer réellement les coroutines, il faut les ajouter à la boucle d’événements. Nous le faisons à la fin : nous récupérons une boucle d’événements puis appelons sa méthode run_until_complete. Notez que nous lui passons la coroutine principale. Celle-ci s’exécute, met en file la seconde coroutine et la lance. C’est ce qu’on appelle une coroutine chaînée.
Le problème de cet exemple, c’est qu’il n’est pas vraiment asynchrone. En effet, la fonction download_coroutine n’est pas asynchrone : urllib ne l’est pas, et nous n’utilisons ni await ni yield. Une meilleure approche consiste à utiliser le package aiohttp. Voyons cela.
Un meilleur exemple de coroutine
Le package aiohttp est conçu pour créer des clients et serveurs HTTP asynchrones. Vous pouvez l’installer avec pip :
pip install aiohttp
Une fois installé, mettons à jour notre code pour utiliser aiohttp afin de télécharger les fichiers :
Vous remarquerez l’import de deux éléments : aiohttp et async_timeout. Ce dernier est l’une des dépendances d’aiohttp et permet de créer un gestionnaire de contexte de temporisation (timeout). Partons du bas du code : dans le bloc conditionnel final, nous lançons la boucle d’événements asynchrone et appelons main.
Dans main, nous créons un objet ClientSession que nous passons à notre coroutine de téléchargement pour chacune des URL à récupérer. Dans download_coroutine, nous ouvrons un gestionnaire de contexte async_timeout.timeout() qui fixe un compte à rebours de X secondes. À l’expiration, le contexte se termine ; ici, le délai est de 10 secondes. Nous appelons ensuite la méthode get() de la session, qui renvoie un objet réponse. Vient alors la partie un peu « magique » : l’attribut content de la réponse renvoie une instance de aiohttp.StreamReader qui permet de télécharger le fichier par blocs de la taille souhaitée. Au fil de la lecture, nous écrivons sur le disque local. Enfin, nous appelons release() sur la réponse pour terminer le traitement.
D’après la documentation d’aiohttp, comme l’objet réponse est créé dans un gestionnaire de contexte, release() est appelé implicitement. Mais en Python, l’explicite est généralement préférable, et la documentation précise de ne pas compter sur la fermeture implicite de la connexion ; il est donc plus sûr d’appeler release() ici.
Il reste une partie bloquante : l’écriture sur le disque. Pendant l’écriture du fichier, on bloque toujours. Une autre bibliothèque, aiofiles, peut aider à rendre cette écriture asynchrone ; je vous laisse explorer cette amélioration.
Planifier des appels
Vous pouvez aussi planifier l’appel de fonctions classiques via la boucle d’événements d’asyncio. La première méthode à connaître est call_soon. Elle appellera votre callback ou gestionnaire d’événements dès que possible. Elle fonctionne comme une file FIFO : si certains callbacks sont longs, les suivants attendront que les précédents se terminent.
Voyons un exemple :
La majorité des fonctions d’asyncio n’acceptent pas d’arguments nommés ; si vous devez en passer à votre gestionnaire d’événements, utilisez le module functools. Notre fonction classique écrit un texte sur stdout à chaque appel. Si vous mettez son argument stop à True, elle arrêtera aussi la boucle d’événements.
Au premier appel, nous n’arrêtons pas la boucle. Au second, nous l’arrêtons. La raison : nous lui avons demandé de run_forever, ce qui la ferait tourner indéfiniment. Une fois stoppée, nous pouvons la fermer.
Si vous exécutez ce code, vous devriez obtenir :
starting event loop
Event handler called
Event handler called
stopping the loop
closing event loop
Il existe une variante, call_soon_threadsafe. Comme son nom l’indique, elle fonctionne comme call_soon, mais de façon thread-safe. Si vous souhaitez retarder un appel, utilisez call_later. Dans notre cas, nous pourrions remplacer call_soon par :
loop.call_later(1, event_handler, loop)
Cela retardera l’appel de notre gestionnaire d’une seconde, puis l’appellera en lui passant la boucle en premier paramètre. Si vous voulez planifier un instant précis, récupérez l’horloge de la boucle plutôt que l’heure système :
current_time = loop.time()
Ensuite, utilisez call_at avec l’instant souhaité. Par exemple, pour appeler notre gestionnaire dans cinq minutes :
loop.call_at(current_time + 300, event_handler, loop)
Ici, nous ajoutons 300 secondes (cinq minutes) à l’heure courante de la boucle. Résultat : l’appel est différé de cinq minutes. Astucieux !
Tasks
Les Tasks sont des sous-classes de Future et des wrappers autour de coroutines. Elles permettent de savoir quand le traitement est terminé. Comme elles héritent de Future, d’autres coroutines peuvent attendre une Task, et vous pouvez récupérer son résultat une fois achevée.
Voyons un exemple simple :
Ici, nous créons une fonction asynchrone qui accepte le nombre de secondes nécessaires à son exécution, pour simuler un traitement long. Nous créons ensuite la boucle d’événements et une Task via la méthode create_task de la boucle, à laquelle nous passons la coroutine à transformer en tâche. Enfin, nous demandons à la boucle de tourner jusqu’à l’achèvement de la tâche. À la fin, nous récupérons le résultat de la tâche.
Les tâches peuvent aussi être annulées très simplement avec leur méthode cancel. Si une tâche est annulée alors qu’elle attend une autre opération, elle lèvera une CancelledError.
Pour conclure
À ce stade, vous avez de quoi commencer à travailler avec la bibliothèque asyncio par vous-même. Puissante, elle permet de réaliser de nombreuses tâches intéressantes. La bibliothèque asyncio a été pensée pour les sockets réseau.
Avant asyncio, une excellente bibliothèque pour la programmation de sockets asynchrones est le framework Twisted. Un autre projet intéressant est Dask, une bibliothèque flexible de calcul parallèle pour l’analytique. Plongez dans la documentation d’asyncio et laissez vos idées germer !