Cours de Python / Choisir entre threads, processus et asyncio
Les threads, les processus et asyncio permettent de faire progresser plusieurs tâches selon des modèles différents. Choisissez en fonction du calcul à effectuer, des attentes d’entrées-sorties et des bibliothèques utilisées, puis mesurez une charge représentative au lieu de supposer que la concurrence accélère automatiquement le programme.
Distinguer concurrence, parallélisme et coûts
La concurrence organise la progression de plusieurs tâches ; le parallélisme exécute effectivement plusieurs travaux en même temps. Une application peut être concurrente sans que son calcul Python s’exécute simultanément sur plusieurs cœurs.
| Méthode | Modèle | Points à contrôler |
|---|---|---|
| Threads | Mémoire partagée dans un processus | Accès à l’état commun, synchronisation et comportement des bibliothèques |
| Processus | Espaces mémoire séparés | Démarrage, sérialisation et transfert des données |
| asyncio | Progression coopérative aux points d’attente | Bibliothèques asynchrones et opérations bloquant la boucle d’événements |
Dans un CPython classique avec GIL activé, le Global Interpreter Lock limite l’exécution simultanée du bytecode Python par plusieurs threads. Multiplier les threads ne parallélise donc pas automatiquement un calcul CPU écrit en Python pur. Les extensions qui libèrent le GIL et les versions construites pour fonctionner sans ce verrou ont d’autres conditions ; le guide officiel des builds free-threaded explique ces différences.
En première analyse, examinez les threads pour des appels d’entrées-sorties bloquants, les processus pour des calculs Python purs assez coûteux, et asyncio pour des entrées-sorties dont les bibliothèques prennent en charge async/await. Ce sont des critères de choix à vérifier, pas des promesses de performance. La référence de concurrent.futures décrit les exécuteurs et les objets Future.
Comparer les interfaces avec un programme complet
Enregistrez le fichier UTF-8 concurrency.py dans un dossier d’exercice. Il utilise uniquement la bibliothèque standard. Lancez-le comme un fichier .py depuis un terminal avec l’environnement du projet sélectionné : les notebooks et sessions interactives n’offrent pas nécessairement les mêmes conditions pour le démarrage des processus.
import asyncio
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def square(value):
return value * value
async def async_square(value):
await asyncio.sleep(0.01)
return square(value)
async def gather_values():
return await asyncio.gather(*(async_square(x) for x in [1, 2, 3]))
def main():
with ThreadPoolExecutor(max_workers=2) as pool:
print(list(pool.map(square, [1, 2, 3])))
with ProcessPoolExecutor(max_workers=2) as pool:
print(list(pool.map(square, [1, 2, 3])))
print(asyncio.run(gather_values()))
if __name__ == "__main__":
main()
Exécutez python concurrency.py, ou adaptez le nom de l’interpréteur en python3 si votre installation l’exige. Le résultat attendu est :
[1, 4, 9]
[1, 4, 9]
[1, 4, 9]
Les trois lignes proviennent respectivement des threads, des processus et des coroutines. Elles montrent trois interfaces qui obtiennent les mêmes carrés, pas trois mesures de vitesse. map restitue les résultats dans l’ordre des entrées même si les tâches se terminent dans un autre ordre ; gather conserve de même l’ordre des arguments dans sa liste de résultats.
La fonction square est définie au niveau du module pour être accessible aux processus travailleurs. La protection if __name__ == "__main__" évite de relancer le programme principal lors de l’import effectué au démarrage de nouveaux processus, notamment sous Windows.
Comprendre l’attente coopérative et confirmer une annulation
asyncio.sleep(0.01) cède l’exécution pendant une attente : ce n’est pas une vraie requête HTTP. Une fonction déclarée async peut encore bloquer la boucle si elle exécute longtemps un calcul CPU ou un appel réseau synchrone sans céder la main. Comprenez d’abord les attentes et erreurs de votre client HTTP avant d’en multiplier les appels.
Enregistrez ce deuxième programme complet dans cancel.py, puis exécutez python cancel.py :
import asyncio
async def worker():
try:
await asyncio.sleep(60)
finally:
print("worker cleanup")
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(0)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancel confirmed")
if __name__ == "__main__":
asyncio.run(main())
Le résultat attendu est :
worker cleanup
cancel confirmed
Le premier await asyncio.sleep(0) laisse au travailleur l’occasion de démarrer avant de demander son annulation. Le programme n’attend donc pas que les 60 secondes se terminent : task.cancel() demande l’annulation de la tâche, le finally affiche son nettoyage et l’appelant observe CancelledError en attendant la tâche. La documentation de l’annulation asyncio précise ce mécanisme coopératif.
Une demande d’annulation n’est pas un bouton qui tue immédiatement toute opération externe. Conservez les nettoyages appropriés et ne supprimez pas une exception d’annulation par inadvertance. Dans un notebook possédant déjà sa boucle d’événements, n’imbriquez pas un nouvel asyncio.run ; la référence de asyncio.run précise cette restriction dans le même thread.
Lire les erreurs des Future et limiter l’attente
Enregistrez le programme complet suivant dans future_failure.py, puis exécutez python future_failure.py :
from concurrent.futures import ThreadPoolExecutor
def calculate(value):
if value < 0:
raise ValueError("negative input")
return value * value
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(calculate, value) for value in [2, -1]]
for future in futures:
try:
print(future.result(timeout=5))
except ValueError as error:
print("failed:", error)
Le résultat attendu est :
4
failed: negative input
submit renvoie un Future, qui représente le résultat à venir. Le simple fait d’avoir soumis une tâche ne prouve pas sa réussite. L’appel result obtient la valeur ou relaie l’exception de la tâche ; ici, le premier calcul donne 4 et le second lève ValueError, traitée par le programme.
timeout=5 limite l’attente de cet appel à result ; il n’arrête pas de force un travail déjà en cours. De même, max_workers=2 borne les travailleurs actifs, pas à lui seul toute la file de tâches soumises. La sortie du contexte d’un exécuteur attend normalement la fin de ses travaux : ne considérez pas un délai d’attente comme une garantie de fermeture instantanée.
Pour de nombreuses entrées, soumettez des lots bornés plutôt que de fabriquer une quantité illimitée de tâches. Avec asyncio, un sémaphore peut limiter les entrées-sorties simultanées ; la quantité totale de données et de tâches conservées doit aussi être maîtrisée. La synchronisation et la gestion de l’échec restent nécessaires pour un état partagé.
Mesurer une charge réelle sans masquer les échecs
Les carrés de trois petits nombres sont trop rapides pour conclure à une supériorité d’un modèle. Le démarrage de processus peut coûter davantage que ces calculs. Pour une comparaison utile, notez l’environnement et la version de Python, les bibliothèques, les données, le nombre de tâches, le nombre de travailleurs et la mémoire consommée, puis mesurez le travail qui ressemble à votre usage.
Faites varier un paramètre à la fois et contrôlez d’abord l’égalité des résultats. Ajoutez des cas d’échec, d’annulation et de fermeture, sans transformer les erreurs en résultats vides. Le projet de collecte API avec stockage illustre pourquoi une phase réseau réussie et une phase d’enregistrement ne suffisent pas, séparément, à déclarer une collecte complète.




