Skip to main content
Niveau : Avancé

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ès await doit ê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 :

  1. Prendre une tâche à exécuter.
  2. L'exécuter jusqu'à ce qu'elle rencontre un await.
  3. Mettre cette tâche en pause.
  4. Chercher une autre tâche prête à être reprise.
  5. 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 :

  1. main démarre.
  2. gather lance les 3 coroutines dire_apres.
  3. dire_apres(0.5, "async") est la première à se terminer après 0.5s.
  4. dire_apres(1, "hello") se termine ensuite.
  5. 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é

  1. Créez un nouveau fichier Python nommé concurrent_downloads.py.

  2. 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é.
  3. 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_file pour 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.
  4. 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, et time.

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())