Module : Introduction aux Coroutines et à asyncio
Objectif
Ce module a pour but de vous initier à la programmation asynchrone en Python. Vous découvrirez les concepts de coroutines, de boucles d'événements et comment utiliser la bibliothèque asyncio pour écrire du code concurrent non bloquant, particulièrement adapté aux opérations d'Entrée/Sortie (I/O).
1. Le Problème : Les Opérations Bloquantes
En programmation traditionnelle (synchrone), lorsqu'une tâche attend une opération longue (comme une requête réseau, une lecture de fichier ou un appel à une base de données), tout le programme est bloqué. Il ne peut rien faire d'autre en attendant la fin de cette opération.
Imaginez un serveur web qui doit gérer plusieurs clients. Si une requête nécessite 2 secondes pour aller chercher des données en base, le serveur est incapable de répondre à d'autres clients pendant ce temps. C'est très inefficace.
La programmation asynchrone résout ce problème en permettant au programme de "mettre en pause" une tâche en attente et de travailler sur une autre tâche pendant ce temps.
2. Concepts Clés de l'Asynchrone
a. Coroutines
Une coroutine est une fonction spéciale qui peut être mise en pause et reprise plus tard. En Python, on les définit avec la syntaxe async def.
import asyncio
async def ma_coroutine():
print("Début de la coroutine")
# Simule une opération I/O longue (ex: requête réseau)
await asyncio.sleep(1)
print("Fin de la coroutine")
# Appeler ma_coroutine() ne l'exécute PAS directement.
# Cela retourne un objet coroutine.
coro_obj = ma_coroutine()
print(coro_obj)
# <coroutine object ma_coroutine at 0x...>
async def: Déclare une fonction comme étant une coroutine.await: Met en pause l'exécution de la coroutine actuelle et rend la main à la boucle d'événements. Le programme peut alors exécuter une autre tâche. L'expression aprèsawaitdoit être un objet "awaitable" (généralement une autre coroutine ou un objet spécial).
b. Boucle d'Événements (Event Loop)
La boucle d'événements est le cœur de asyncio. C'est elle qui gère et distribue l'exécution des différentes tâches. Son rôle est simple :
- Prendre une tâche à exécuter.
- L'exécuter jusqu'à ce qu'elle rencontre un
await. - Mettre cette tâche en pause.
- Chercher une autre tâche prête à être reprise.
- Recommencer.
Pour exécuter une coroutine, on doit la lancer dans une boucle d'événements.
import asyncio
async def ma_coroutine():
print("Début de la coroutine")
await asyncio.sleep(1)
print("Fin de la coroutine")
# asyncio.run() est la manière moderne de démarrer une coroutine.
# Elle crée une boucle d'événements, y exécute la coroutine,
# et ferme la boucle à la fin.
asyncio.run(ma_coroutine())
# Output:
# Début de la coroutine
# (pause de 1 seconde)
# Fin de la coroutine
3. Exécuter Plusieurs Tâches en Concurrence
L'intérêt principal de l'asynchrone est de faire plusieurs choses "en même temps" (de manière concurrente, pas parallèle). Pour cela, on utilise asyncio.gather.
asyncio.gather prend plusieurs coroutines et les exécute de manière concurrente. Il attend que toutes soient terminées.
import asyncio
import time
async def dire_apres(delai, mot):
print(f"Début de '{mot}'")
await asyncio.sleep(delai)
print(mot)
return len(mot)
async def main():
debut = time.time()
# Crée les tâches à exécuter
tache1 = dire_apres(1, "hello")
tache2 = dire_apres(2, "world")
tache3 = dire_apres(0.5, "async")
# Exécute les tâches de manière concurrente
# gather attend que toutes les coroutines soient finies
resultats = await asyncio.gather(tache1, tache2, tache3)
fin = time.time()
print(f"Programme terminé en {fin - debut:.2f} secondes.")
print(f"Résultats : {resultats}")
asyncio.run(main())
Analyse de l'exécution :
maindémarre.gatherlance les 3 coroutinesdire_apres.dire_apres(0.5, "async")est la première à se terminer après 0.5s.dire_apres(1, "hello")se termine ensuite.dire_apres(2, "world")se termine en dernier.
Le temps total d'exécution sera d'environ 2 secondes, soit la durée de la tâche la plus longue. En mode synchrone, cela aurait pris 1 + 2 + 0.5 = 3.5 secondes. C'est là tout le gain de la programmation asynchrone !
Résultat attendu :
Début de 'hello'
Début de 'world'
Début de 'async'
async
hello
world
Programme terminé en 2.00 secondes.
Résultats : [5, 5, 5]
4. Quand utiliser asyncio ?
La programmation asynchrone est particulièrement efficace pour les programmes limités par les I/O (I/O-bound). C'est-à-dire les programmes qui passent beaucoup de temps à attendre :
- Des requêtes réseau (API, web scraping).
- Des réponses de bases de données.
- Des lectures/écritures sur le disque.
- Des communications avec d'autres processus.
Elle n'est pas adaptée pour les programmes limités par le CPU (CPU-bound), qui effectuent des calculs intensifs (ex: traitement d'image, calculs mathématiques lourds). Pour ces cas, le multiprocessing est plus approprié car il utilise plusieurs cœurs de processeur.
5. asyncio.create_task
Une autre façon de lancer des coroutines en arrière-plan sans attendre immédiatement leur résultat est asyncio.create_task. Cela transforme une coroutine en une Task qui est immédiatement planifiée sur la boucle d'événements.
import asyncio
async def operation_longue():
print("La tâche de fond commence...")
await asyncio.sleep(2)
print("La tâche de fond est terminée.")
async def main():
print("Lancement de la tâche de fond.")
# La tâche est lancée mais on n'attend pas ici
task = asyncio.create_task(operation_longue())
# On peut faire autre chose pendant que la tâche s'exécute
print("Le programme principal continue...")
await asyncio.sleep(1)
print("Le programme principal a fait une pause.")
# On peut attendre la fin de la tâche plus tard si besoin
await task
print("Le programme principal a attendu la fin de la tâche.")
asyncio.run(main())
Résultat attendu :
Lancement de la tâche de fond.
La tâche de fond commence...
Le programme principal continue...
(pause de 1s)
Le programme principal a fait une pause.
(pause de 1s)
La tâche de fond est terminée.
Le programme principal a attendu la fin de la tâche.
Conclusion
asyncio est un outil puissant mais complexe. Il change radicalement la manière de penser la structure d'un programme. En maîtrisant async def, await, asyncio.run() et asyncio.gather, vous avez les bases pour écrire des applications I/O-bound très performantes et capables de gérer des milliers d'opérations concurrentes avec une seule thread.
Exercice 01 : Simulation de Téléchargements Concurrents
Objectif
Cet exercice a pour but de vous faire utiliser asyncio et asyncio.gather pour simuler le téléchargement de plusieurs fichiers de manière concurrente. Vous pourrez ainsi constater le gain de temps par rapport à une approche synchrone.
Contexte
Imaginez que vous devez télécharger une liste de fichiers depuis des URLs. Chaque téléchargement prend un temps variable. Si vous les téléchargez les uns après les autres (de manière synchrone), le temps total sera la somme de tous les temps de téléchargement.
Avec asyncio, vous pouvez lancer tous les téléchargements "en même temps". Le temps total sera alors approximativement celui du téléchargement le plus long.
Énoncé
-
Créez un nouveau fichier Python nommé
concurrent_downloads.py. -
Définissez une coroutine
download_file.- Elle doit être définie avec
async def. - Elle prend un argument
file_name(une chaîne de caractères). - Elle simule un temps de téléchargement variable. Pour cela :
- Générez un temps de pause aléatoire entre 0.5 et 3.0 secondes en utilisant
random.uniform(0.5, 3.0). - Affichez un message indiquant le début du téléchargement, par exemple :
f"Downloading {file_name}...". - Utilisez
await asyncio.sleep()avec le temps de pause calculé pour simuler l'opération I/O. - Une fois la pause terminée, affichez un message de fin, par exemple :
f"✓ Finished downloading {file_name} in {delay:.2f}s". - La coroutine doit retourner le nom du fichier téléchargé.
- Générez un temps de pause aléatoire entre 0.5 et 3.0 secondes en utilisant
- Elle doit être définie avec
-
Définissez une coroutine principale
main.- Elle doit être définie avec
async def. - Créez une liste de noms de fichiers à "télécharger", par exemple :
["file1.zip", "file2.img", "file3.iso", "file4.pdf"]. - Enregistrez le temps de début avec
time.time(). - Créez une liste de tâches (coroutines) en appelant
download_filepour chaque nom de fichier dans votre liste. - Utilisez
await asyncio.gather(*tasks)pour exécuter toutes les tâches de manière concurrente. - Enregistrez le temps de fin.
- Affichez le temps total d'exécution.
- Elle doit être définie avec
-
Lancez l'exécution.
- À la fin de votre script, utilisez
asyncio.run(main())pour démarrer le programme. - N'oubliez pas d'importer les modules nécessaires :
asyncio,random, ettime.
- À la fin de votre script, utilisez
Résultat Attendu
L'ordre des messages de début et de fin peut varier, mais le schéma général devrait être le suivant : tous les téléchargements commencent presque en même temps, puis se terminent en fonction de leur délai aléatoire. Le temps total doit être proche du délai le plus long (environ 3 secondes), et non de la somme de tous les délais.
Downloading file1.zip...
Downloading file2.img...
Downloading file3.iso...
Downloading file4.pdf...
✓ Finished downloading fileX.xxx in 0.XXs
✓ Finished downloading fileY.yyy in 1.YYs
✓ Finished downloading fileZ.zzz in 2.ZZs
✓ Finished downloading fileW.www in 2.WWs
---
All files downloaded concurrently.
Total time: 2.98 seconds.
Downloaded files: ['file1.zip', 'file2.img', 'file3.iso', 'file4.pdf']
Cliquez ici pour voir un exemple de code de solution
# concurrent_downloads.py
import asyncio
import random
import time
async def download_file(file_name: str) -> str:
"""Coroutine to simulate a file download with a random delay."""
delay = random.uniform(0.5, 3.0)
print(f"Downloading {file_name}...")
await asyncio.sleep(delay)
print(f"✓ Finished downloading {file_name} in {delay:.2f}s")
return file_name
async def main():
"""Main coroutine to run concurrent downloads."""
files_to_download = [
"file1.zip",
"file2.img",
"file3.iso",
"file4.pdf"
]
start_time = time.time()
# Create a list of coroutine objects (tasks)
tasks = [download_file(file) for file in files_to_download]
# Run all tasks concurrently and wait for them to complete
downloaded_files = await asyncio.gather(*tasks)
end_time = time.time()
print("---")
print("All files downloaded concurrently.")
print(f"Total time: {end_time - start_time:.2f} seconds.")
print(f"Downloaded files: {downloaded_files}")
if __name__ == "__main__":
asyncio.run(main())