Démonstration : introduction à la programmation concurrente en Python
Objectifs Pédagogiques
À la fin de cette démonstration, vous serez capable de :
- Identifier la différence fondamentale entre un problème I/O-bound (limité par les attentes) et un problème CPU-bound (limité par la puissance de calcul).
- Mettre en œuvre une solution de concurrence avec
asynciopour accélérer les tâches I/O-bound. - Mettre en œuvre une solution de parallélisme avec
multiprocessingpour accélérer les tâches CPU-bound. - Comprendre quand utiliser l'un ou l'autre de ces modèles.
- Initiation au débogage via l'IDE PyCharm.
Partie 1 : le problème I/O-Bound (concurrence avec asyncio)
Scénario : nous avons une série de tâches qui passent la majorité de leur temps à attendre une ressource externe (par exemple, une réponse d'API, une requête de base de données, ou une lecture sur un disque lent). Nous allons simuler cette attente avec une pause.
Exercice 1.1 : l'approche séquentielle (le point de départ)
-
Ouvrez le fichier
1_sync_simulation.py. -
Lisez attentivement le code, en particulier la fonction
worker_task. Notez l'utilisation detime.sleep(), une fonction bloquante qui met en pause tout le programme.sequenceDiagram participant main as "Programme Principal" participant task1 as "Tâche 1" participant task2 as "Tâche 2" participant task3 as "Tâche 3 (etc...)" main->>task1: Démarrer note right of task1: "time.sleep(1s) (BLOQUANT)" task1-->>main: Fini main->>task2: Démarrer note right of task2: "time.sleep(1s) (BLOQUANT)" task2-->>main: Fini main->>task3: Démarrer note right of task3: "time.sleep(1s) (BLOQUANT)" task3-->>main: Fini -
Exécutez le script via le bouton Run de PyCharm.
-
Observez le temps d'exécution total. Il devrait être approximativement égal à
NUM_TASKS * SLEEP_DURATION. Prenez note de cette valeur.
Exercice 1.2 : l'approche Asynchrone (à compléter)
Maintenant, nous allons modifier le script pour qu'il exécute ces mêmes tâches d'attente de manière concurrente.
-
Ouvrez le fichier
2_async_simulation.py. Ce fichier est incomplet et contient plusieursTODO. -
Suivez les instructions ci-dessous pour le compléter :
-
TODO 1dans la fonctionworker_task_async: une fonction qui utiliseawaitdoit être déclarée comme une "coroutine".Modifiez la signature de la fonction
worker_task_asyncpour en faire une coroutine native. -
TODO 2dans la fonctionworker_task_async: nous devons remplacer l'attente bloquante par son équivalent non-bloquant du moduleasyncio.Trouvez la fonction adéquate dans le module
asynciopour simuler une attente et utilisezawaitpour l'appeler. -
TODO 3dans la fonctionmain: la puissance deasynciovient de sa capacité à lancer de nombreuses tâches et à les gérer simultanément.Utilisez la fonction
asyncio.gather()pour lancer toutes les coroutines de la listetasksen concurrence. N'oubliez pas d'utiliserawaitpour attendre quegather()se termine.
sequenceDiagram participant main as "Programme Principal" participant eventLoop as "Event Loop (asyncio)" participant task1 as "Tâche 1" participant task2 as "Tâche 2" participant task3 as "Tâche 3" activate eventLoop main->>eventLoop: "asyncio.run(main)" eventLoop->>task1: Lancer (via gather) eventLoop->>task2: Lancer (via gather) eventLoop->>task3: Lancer (via gather) note over task1,task3: Les tâches démarrent quasi-simultanément task1->>eventLoop: "await asyncio.sleep(1s)" (NON-BLOQUANT) note right of task1: Rend le contrôle task2->>eventLoop: "await asyncio.sleep(1s)" (NON-BLOQUANT) note right of task2: Rend le contrôle task3->>eventLoop: "await asyncio.sleep(1s)" (NON-BLOQUANT) note right of task3: Rend le contrôle eventLoop-->>task1: Reprendre (après 1s) task1-->>eventLoop: Fini eventLoop-->>task2: Reprendre (après 1s) task2-->>eventLoop: Fini eventLoop-->>task3: Reprendre (après 1s) task3-->>eventLoop: Fini eventLoop-->>main: Terminé deactivate eventLoop -
-
Exécutez le script via le bouton Run de PyCharm.
-
Comparez le temps d'exécution avec la version synchrone. Que remarquez-vous ? Pourquoi une telle différence ?
Partie 2 : le problème CPU-Bound (parallélisme avec multiprocessing)
Scénario : nous changeons de problème. Nous avons maintenant des tâches qui ne consistent pas à attendre, mais à effectuer des calculs mathématiques très intensifs. Chaque tâche utilise 100% d'un cœur de processeur.
Exercice 2.1 : l'approche séquentielle et parallèle (à compléter)
-
Ouvrez le fichier
3_multiprocessing_demo.py.- La fonction
heavy_analysissimule notre tâche de calcul lourd. - La fonction
run_sequentialest déjà complète. Elle exécute les tâches sur un seul cœur, l'une après l'autre.
graph TD subgraph "Exécution Séquentielle" subgraph "CPU Core 1" A[Tâche 1: Calcul lourd] --> B[Tâche 2: Calcul lourd] --> C[Tâche 3: Calcul lourd] --> D[Fin] end subgraph "Autres Cores" idle1[Inutilisé] idle2[Inutilisé] idle3[Inutilisé] end end- La fonction
run_parallelest incomplète. C'est elle que nous allons modifier.
- La fonction
-
Suivez les instructions pour compléter la fonction
run_parallel:-
TODO 1: pour paralléliser le travail sur plusieurs cœurs, nous avons besoin d'un "pool de processus". La bibliothèqueconcurrent.futuresfournit un gestionnaire de contexte parfait pour cela.Instanciez un
ProcessPoolExecutoren utilisant un blocwith. Vous pouvez laisser le nombre deworkerspar défaut pour qu'il utilise tous vos cœurs CPU. -
TODO 2: une fois le pool de processus créé, il faut lui donner la liste des tâches à exécuter.Utilisez la méthode
.map()de l'exécuteur pour appliquer laworker_functionà chaque élément de la listetasks. Cette méthode distribue le travail et retourne les résultats.
graph TD subgraph "Exécution Parallèle" pool("ProcessPoolExecutor") subgraph "CPU Core 1" T1[Tâche 1: Calcul] end subgraph "CPU Core 2" T2[Tâche 2: Calcul] end subgraph "CPU Core 3" T3[Tâche 3: Calcul] end subgraph "CPU Core N..." TN[Tâche N: Calcul] end pool --> T1 pool --> T2 pool --> T3 pool --> TN T1 --> R[Collecte des résultats] T2 --> R T3 --> R TN --> R end -
-
Exécutez le script via le bouton Run de PyCharm.
-
Le script va d'abord lancer la version séquentielle, puis la version parallèle, et enfin afficher un résumé. Analysez le facteur d'accélération (Speedup). Est-il proche du nombre de cœurs de votre processeur ?
Conclusion et synthèse
Discutez des résultats que vous avez obtenus. Vous devriez maintenant être capable de répondre à la question fondamentale :
"Mon programme est lent. Dois-je utiliser asyncio ou multiprocessing ?"
| Type de Problème | Goulot d'étranglement | Solution | Mots-clés |
|---|---|---|---|
| I/O-Bound | Attente (Réseau, Disque, BDD) | Asynchronisme (asyncio) |
async, await, gather |
| CPU-Bound | Calculs intensifs (CPU à 100%) | Parallélisme (multiprocessing) |
ProcessPoolExecutor, .map |
Dans certains cas, une combinaison des deux approches peut être nécessaire, mais cela sort du cadre de cette introduction.
Vous avez maintenant exploré les deux piliers de la programmation performante en Python. N'hésitez pas à modifier les variables comme NUM_TASKS dans les scripts pour voir comment les temps d'exécution évoluent.
Bonus : débogage avec PyCharm
Sur l'ensemble de la suite d'IDE JetBrains (WebStorm pour Javascript, PyCharm pour Python, etc.), le débogage est un outil puissant pour comprendre le flux d'exécution et inspecter les variables en temps réel.
- Mettez un point d'arrêt (breakpoint) par exemple à la ligne 42 du fichier
1_sync_simulation.py. Pour cela, cliquez dans la marge gauche à côté du numéro de ligne. - Lancez le mode débogage via le bouton Debug de PyCharm.
- Utilisez les commandes de débogage pour exécuter le code pas à pas (Step Into My Code, etc.) et observer les valeurs des variables en temps réel