
Programmation asynchrone avec asyncio
Découvrez la programmation asynchrone en Python avec la bibliothèque 'asyncio'. Apprenez à écrire du code concurrent non bloquant, à gérer des coroutines avec 'async' et 'await', et à utiliser une boucle d'événements.
Qu'est-ce que la programmation asynchrone ? Concurrence non bloquante
La programmation asynchrone est un style de programmation concurrente qui permet à un programme d'effectuer plusieurs tâches *apparemment* en parallèle, sans utiliser de threads multiples (et donc sans les limitations du GIL en Python).
La clé de la programmation asynchrone est la notion d'opérations *non bloquantes*. Lorsqu'une opération bloquante est rencontrée (par exemple, une attente de données sur un réseau, une lecture de fichier, etc.), au lieu d'attendre que l'opération se termine, le programme peut passer à une autre tâche en attendant. Lorsque l'opération bloquante est terminée, le programme reprend l'exécution de la tâche initiale.
La programmation asynchrone est particulièrement adaptée aux tâches I/O-bound (limitées par la vitesse des entrées/sorties), où le programme passe une grande partie de son temps à attendre.
En Python, la programmation asynchrone est facilitée par la bibliothèque `asyncio` (introduite en Python 3.4) et les mots-clés `async` et `await` (introduits en Python 3.5).
asyncio : la bibliothèque Python pour la programmation asynchrone
`asyncio` est une bibliothèque Python qui fournit une infrastructure pour écrire du code asynchrone en utilisant des *coroutines*, des *boucles d'événements*, et des *tâches*.
Les éléments clés d'`asyncio` sont :
- Coroutines : Des fonctions spéciales définies avec le mot-clé `async def` qui peuvent être suspendues et reprises. Elles sont le coeur de la programmation asynchrone.
- Boucle d'événements (event loop) : Un mécanisme qui gère l'exécution des coroutines et des tâches. C'est le "moteur" d'une application asynchrone.
- Tâches (tasks) : Des objets qui représentent l'exécution d'une coroutine. Elles permettent de planifier l'exécution des coroutines et d'obtenir leurs résultats.
- Futurs (futures) : Des objets qui représentent un résultat qui n'est pas encore disponible. Ils sont utilisés pour synchroniser les coroutines.
`asyncio` fournit également des outils de haut niveau pour gérer les entrées/sorties asynchrones, les réseaux, les sous-processus, etc.
Coroutines : async def et await
Les coroutines sont des fonctions spéciales définies avec le mot-clé `async def`.
Syntaxe :
async def ma_coroutine(arguments):
# Corps de la coroutine
# ...Une coroutine peut être *suspendue* (mise en pause) pendant son exécution, et *reprise* plus tard. Cela permet à d'autres coroutines de s'exécuter pendant que la première est en pause.
Le mot-clé `await` est utilisé à l'intérieur d'une coroutine pour attendre qu'une autre coroutine (ou un objet "awaitable") se termine.
Syntaxe :
resultat = await autre_coroutine()Lorsque `await` est rencontré :
- L'exécution de la coroutine courante est suspendue.
- Le contrôle est rendu à la boucle d'événements.
- Lorsque `autre_coroutine()` se termine, l'exécution de la coroutine courante reprend, et le résultat de `autre_coroutine()` est affecté à `resultat`.
Exemple :
import asyncio
async def saluer(nom):
print(f"Bonjour, {nom} (avant attente)")
await asyncio.sleep(1) # Attend 1 seconde (opération non bloquante)
print(f"Bonjour, {nom} (après attente)")
async def main():
await saluer("Alice")
await saluer("Bob")
asyncio.run(main()) # Exécute la coroutine main dans une boucle d'événementsDans cet exemple :
- `saluer` est une coroutine qui prend un nom en argument, affiche un message, attend une seconde (de manière non bloquante, en utilisant `asyncio.sleep`), puis affiche un autre message.
- `main` est une autre coroutine qui appelle `saluer` deux fois, en utilisant `await` pour attendre que chaque appel se termine.
- `asyncio.run(main())` exécute la coroutine `main` dans une boucle d'événements.
Notez que vous ne pouvez utiliser `await` qu'à l'intérieur d'une fonction `async def`.
La boucle d'événements : le coeur d'asyncio
La boucle d'événements est le coeur d'une application `asyncio`. C'est elle qui gère l'exécution des coroutines et des tâches.
La boucle d'événements :
- Enregistre les coroutines et les tâches à exécuter.
- Planifie leur exécution.
- Surveille les opérations d'E/S et appelle les coroutines lorsqu'elles sont prêtes.
- Gère les signaux et les exceptions.
Vous n'avez généralement pas besoin d'interagir directement avec la boucle d'événements, sauf pour la démarrer et l'arrêter.
La fonction `asyncio.run()` (introduite en Python 3.7) est le moyen le plus simple de démarrer une boucle d'événements et d'exécuter une coroutine :
asyncio.run(ma_coroutine())Pour des utilisations plus avancées, vous pouvez obtenir la boucle d'événements courante avec `asyncio.get_event_loop()`, créer une nouvelle boucle avec `asyncio.new_event_loop()`, et définir la boucle courante avec `asyncio.set_event_loop()`.
Tâches et futurs : gérer l'exécution des coroutines
Une *tâche* (`asyncio.Task`) est un objet qui représente l'exécution d'une coroutine. Vous pouvez créer une tâche en utilisant `asyncio.create_task()` (Python 3.7+) ou `asyncio.ensure_future()`.
Exemple (création de tâches) :
import asyncio
async def ma_coroutine(n):
print(f"Tâche {n} : démarrage")
await asyncio.sleep(1)
print(f"Tâche {n} : terminée")
return n * 2
async def main():
# Crée deux tâches
tache1 = asyncio.create_task(ma_coroutine(1))
tache2 = asyncio.create_task(ma_coroutine(2))
# Attend que les tâches se terminent et récupère les résultats
resultat1 = await tache1
resultat2 = await tache2
print(f"Résultat 1 : {resultat1}")
print(f"Résultat 2 : {resultat2}")
asyncio.run(main())Dans cet exemple, deux tâches sont créées à partir de la coroutine `ma_coroutine`. Elles s'exécutent de manière concurrente (mais pas en parallèle, car il n'y a qu'un seul thread). `await tache1` attend que la tâche 1 se termine et retourne son résultat.
Un *futur* (`asyncio.Future`) est un objet qui représente un résultat qui n'est pas encore disponible. Les tâches sont des sous-classes de futurs.
Vous utiliserez rarement les futurs directement, sauf dans du code de bas niveau. Les tâches sont généralement plus pratiques.
Vous pouvez utiliser `asyncio.gather()` pour attendre que plusieurs tâches se terminent simultanément :
# ... (reprise de l'exemple précédent) ...
async def main():
tache1 = asyncio.create_task(ma_coroutine(1))
tache2 = asyncio.create_task(ma_coroutine(2))
# Attend que les deux tâches se terminent
resultats = await asyncio.gather(tache1, tache2)
print(f"Résultats : {resultats}") # Affiche : Résultats : [2, 4]`asyncio.gather()` prend un ou plusieurs objets "awaitable" (coroutines, tâches, futurs) et retourne un futur qui se résout lorsque tous les objets "awaitable" sont terminés. Le résultat est une liste des résultats des objets "awaitable".
Exemple : asynchrone vs synchrone
Comparons une version synchrone et une version asynchrone d'un code qui effectue des requêtes HTTP :
Version synchrone (bloquante) :
import requests
import time
def telecharger_page(url):
print(f"Téléchargement de {url}...")
reponse = requests.get(url)
print(f"Téléchargement de {url} terminé.")
return reponse.text
def main():
urls = [
"https://www.example.com",
"https://www.google.com",
"https://www.python.org"
]
debut = time.time()
for url in urls:
contenu = telecharger_page(url)
fin = time.time()
print(f"Temps total (synchrone) : {fin-debut:.2f} secondes")
if __name__ == '__main__':
main()Version asynchrone (non bloquante, utilise la bibliothèque `aiohttp` pour les requêtes HTTP asynchrones) :
import asyncio
import aiohttp
import time
async def telecharger_page(session, url):
print(f"Téléchargement de {url}...")
async with session.get(url) as reponse:
contenu = await reponse.text()
print(f"Téléchargement de {url} terminé.")
return contenu
async def main():
urls = [
"https://www.example.com",
"https://www.google.com",
"https://www.python.org"
]
debut = time.time()
async with aiohttp.ClientSession() as session:
taches = [telecharger_page(session, url) for url in urls]
await asyncio.gather(*taches)
fin = time.time()
print(f"Temps total (asynchrone) : {fin-debut:.2f} secondes")
if __name__ == '__main__':
asyncio.run(main())La version asynchrone sera *beaucoup plus rapide* car les téléchargements peuvent se faire en parallèle, au lieu d'attendre que chaque téléchargement se termine avant de commencer le suivant.
Notez l'utilisation de `aiohttp` au lieu de `requests`. `requests` est une bibliothèque synchrone (bloquante), tandis que `aiohttp` est une bibliothèque asynchrone conçue pour être utilisée avec `asyncio`.