Aller au contenu
Développement medium

concurrent.futures en Python : ThreadPool et ProcessPool

18 min de lecture

logo python

concurrent.futures est l'API haut niveau de Python pour lancer des tâches en parallèle sans manipuler directement les threads ni les processus. Vous soumettez du travail à un exécuteur (ThreadPoolExecutor ou ProcessPoolExecutor), et il vous rend des objets Future qui représentent un résultat à venir. La même interface couvre deux besoins opposés : les threads pour le code I/O bound (réseau, disque, API) et les processus pour le code CPU bound (calcul intensif). Ce guide couvre submit, map, as_completed, wait, la gestion des exceptions dans un Future et le choix entre les deux exécuteurs.

Il s'adresse aux développeurs déjà à l'aise avec les fonctions et la gestion des exceptions, qui veulent paralléliser proprement sans réinventer une file de tâches. Les exemples ont été exécutés avec Python 3.12.

  • Soumettre des tâches avec submit et récupérer un objet Future.
  • Utiliser map pour appliquer une fonction à une séquence en parallèle.
  • Traiter les résultats au fil de l'eau avec as_completed et wait.
  • Récupérer les exceptions levées dans une tâche via .result().
  • Choisir ThreadPoolExecutor (I/O bound) ou ProcessPoolExecutor (CPU bound), mesures à l'appui.

Pourquoi préférer cette API à threading et multiprocessing bruts

Section intitulée « Pourquoi préférer cette API à threading et multiprocessing bruts »

Les modules threading et multiprocessing sont les briques bas niveau de la concurrence. Ils fonctionnent, mais ils vous laissent gérer à la main la création des threads, leur jonction (join), la collecte des résultats dans une structure partagée et la remontée des erreurs. Chaque programme concurrent finit par réécrire la même plomberie fragile, souvent avec des bugs de synchronisation à la clé.

concurrent.futures remplace toute cette mécanique par un modèle unique : un pool de workers réutilisables et un objet Future par tâche. L'exécuteur recycle les workers au lieu d'en créer un par tâche, il collecte les résultats pour vous et il propage les exceptions au moment où vous lisez le résultat. Vous écrivez la logique métier, pas la tuyauterie.

L'atout décisif est l'uniformité. Passer d'une exécution par threads à une exécution par processus se résume à changer le nom de la classe instanciée : ThreadPoolExecutor devient ProcessPoolExecutor, et le reste du code ne bouge pas. Cette symétrie rend l'expérimentation triviale, là où threading et multiprocessing exposent des API différentes.

Un exécuteur (executor) est un gestionnaire de pool : il maintient un nombre fixe de workers et leur distribue les tâches que vous soumettez. La bibliothèque en fournit deux, qui partagent exactement la même interface mais reposent sur des mécanismes très différents.

Le ThreadPoolExecutor lance des threads dans le processus courant. Les threads partagent la mémoire, se créent vite et sont parfaits pour du travail qui attend beaucoup (réseau, fichiers, base de données). Le ProcessPoolExecutor lance des processus séparés, chacun avec son propre interpréteur : il contourne le GIL (le verrou global de l'interpréteur, qui empêche deux threads d'exécuter du bytecode Python en même temps) et exploite réellement plusieurs cœurs pour du calcul.

Les deux s'utilisent comme gestionnaires de contexte avec with, ce qui garantit un arrêt propre du pool à la sortie du bloc.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
with ThreadPoolExecutor(max_workers=4) as executor:
... # le pool est fermé automatiquement en sortie de bloc
with ProcessPoolExecutor(max_workers=4) as executor:
... # meme code, autre moteur d'execution

La méthode submit place une fonction dans la file du pool et rend immédiatement un objet Future, sans attendre le calcul. Ce Future est une promesse : un conteneur qui recevra plus tard soit un résultat, soit une exception. Vous lisez sa valeur avec .result(), qui bloque jusqu'à ce que la tâche soit terminée.

from concurrent.futures import ThreadPoolExecutor, as_completed
def carre(n: int) -> int:
return n * n
with ThreadPoolExecutor(max_workers=3) as executor:
# submit renvoie un Future tout de suite, sans bloquer
futures = {executor.submit(carre, n): n for n in range(1, 6)}
for future in as_completed(futures):
entree = futures[future]
print(f"carre({entree}) = {future.result()}")

Ce motif du dictionnaire Future vers argument est un idiome central : il permet de retrouver l'entrée associée à chaque résultat, car l'ordre d'arrivée n'est pas garanti. La sortie réelle le montre, les tâches ne reviennent pas dans l'ordre de soumission :

carre(4) = 16
carre(5) = 25
carre(3) = 9
carre(1) = 1
carre(2) = 4

Un objet Future offre plusieurs méthodes utiles au-delà de .result() : .done() indique si la tâche est finie, .running() si elle s'exécute, .cancel() tente de l'annuler tant qu'elle n'a pas démarré, et .exception() renvoie l'erreur éventuelle sans la relever. On les combine rarement au quotidien, mais ils rendent le suivi fin possible.

Quand vous appliquez la même fonction à chaque élément d'une séquence, executor.map est plus direct que submit. Il fonctionne comme le map intégré, mais distribue les appels sur le pool et renvoie les résultats dans l'ordre des entrées, pas dans l'ordre de fin.

with ThreadPoolExecutor(max_workers=5) as executor:
resultats = list(executor.map(recuperer, services))

La différence de contrat entre les deux approches est importante. map préserve l'ordre et relance chaque exception au moment où vous itérez sur le résultat correspondant ; c'est le bon choix quand la correspondance entrée/sortie compte et que vous voulez un code compact. submit plus as_completed traite les résultats dès qu'ils arrivent, ce qui est préférable quand certaines tâches sont bien plus longues que d'autres et que vous ne voulez pas attendre la plus lente pour commencer à travailler.

as_completed : traiter les résultats au fil de l'eau

Section intitulée « as_completed : traiter les résultats au fil de l'eau »

La fonction as_completed prend un ensemble de Future et renvoie un itérateur qui produit chaque Future au moment précis où il se termine. C'est l'outil idéal pour afficher une progression ou réagir aux premiers résultats sans attendre les traînards. On l'a déjà vu à l'œuvre plus haut avec le dictionnaire de correspondance.

Son intérêt est la réactivité. Sur un lot de requêtes où une seule est lente, as_completed vous laisse traiter les neuf réponses rapides pendant que la dixième arrive encore. Combiné au dictionnaire Future vers argument, il conserve le lien entre chaque résultat et sa donnée d'origine malgré le désordre d'arrivée.

Là où as_completed itère sur les tâches finies, wait rend la main une fois selon une condition que vous choisissez, et renvoie deux ensembles : les Future terminés et ceux encore en cours. Le paramètre return_when pilote le comportement : ALL_COMPLETED (par défaut) attend tout le monde, FIRST_COMPLETED rend la main dès la première fin, FIRST_EXCEPTION s'arrête à la première erreur.

import time
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
def tache(nom: str, duree: float) -> str:
time.sleep(duree)
return f"{nom} terminee"
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [
executor.submit(tache, "rapide", 0.2),
executor.submit(tache, "moyenne", 0.5),
executor.submit(tache, "lente", 1.0),
]
termines, en_cours = wait(futures, return_when=FIRST_COMPLETED)
print(f"Termines : {len(termines)}, en cours : {len(en_cours)}")
for f in termines:
print(f.result())

À l'exécution, wait s'est débloqué dès la fin de la tâche la plus courte et a laissé les deux autres tourner :

Termines : 1, en cours : 2
rapide terminee

Ce découpage terminés / en cours sert quand vous devez prendre une décision globale à un instant donné, par exemple appliquer un délai maximal avec le paramètre timeout et annuler ce qui traîne encore. Pour un simple traitement séquentiel des résultats, as_completed reste plus lisible.

Le point le plus souvent mal compris : une exception levée dans une tâche ne remonte pas au moment où elle se produit. Elle est capturée et stockée dans le Future, puis relevée seulement quand vous appelez .result(). C'est un comportement voulu, car la tâche s'exécute dans un autre thread ou un autre processus : Python ne peut pas propager l'erreur ailleurs qu'au point de lecture.

from concurrent.futures import ThreadPoolExecutor
def diviser(n: int) -> float:
return 10 / n
with ThreadPoolExecutor() as executor:
future = executor.submit(diviser, 0)
try:
resultat = future.result() # releve l'exception ici
except ZeroDivisionError as erreur:
print(f"Erreur attrapee : {erreur}")
print("Le programme continue")

L'exécution confirme que l'erreur est bien relevée au .result() et non à la soumission, ce qui permet de l'attraper localement :

Erreur attrapee : division by zero
Le programme continue

ThreadPoolExecutor : accélérer un code I/O bound

Section intitulée « ThreadPoolExecutor : accélérer un code I/O bound »

Le ThreadPoolExecutor brille quand vos tâches passent l'essentiel de leur temps à attendre : appels réseau, requêtes HTTP, lectures disque, interrogations de base de données. Pendant qu'un thread attend une réponse, le GIL est relâché et les autres threads progressent. Les attentes se chevauchent au lieu de s'empiler.

L'exemple simule cinq appels de services, chacun bloquant une seconde. En version séquentielle, on attend cinq secondes ; avec un pool de cinq threads, les attentes se superposent.

import time
from concurrent.futures import ThreadPoolExecutor
def recuperer(nom: str) -> str:
# Simule un appel reseau lent (API, base de donnees...)
time.sleep(1)
return f"{nom} : 200 OK"
services = ["auth", "facturation", "stock", "notifications", "recherche"]
debut = time.perf_counter()
sequentiel = [recuperer(s) for s in services]
print(f"Sequentiel : {time.perf_counter() - debut:.2f} s")
debut = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as executor:
concurrent = list(executor.map(recuperer, services))
print(f"Concurrent : {time.perf_counter() - debut:.2f} s")

Le gain mesuré est net, cinq secondes tombent à une seconde :

Sequentiel : 5.00 s
Concurrent : 1.00 s

Ce facteur cinq correspond exactement au nombre d'attentes menées de front. Sur du vrai trafic réseau, le gain dépend de la latence et du nombre de workers, mais le principe tient : plus vous avez d'attentes indépendantes, plus les threads les recouvrent efficacement. Pour un cas concret, ce motif accélère un client requests qui interroge de nombreux points d'API.

ProcessPoolExecutor : paralléliser un calcul CPU bound

Section intitulée « ProcessPoolExecutor : paralléliser un calcul CPU bound »

Face à un calcul intensif, les threads ne servent à rien : le GIL interdit à deux threads d'exécuter du bytecode Python simultanément, donc un pool de threads sur du CPU pur ne va pas plus vite. Il faut de vrais processus, et c'est le rôle du ProcessPoolExecutor, qui répartit le travail sur plusieurs cœurs.

L'exemple compte les nombres premiers sous un seuil, quatre fois, sur un pool de quatre processus. La fonction est volontairement gourmande en CPU pour rendre l'écart visible.

import time
from concurrent.futures import ProcessPoolExecutor
def compter_premiers(limite: int) -> int:
# Calcul volontairement intensif (CPU bound)
total = 0
for n in range(2, limite):
est_premier = True
for d in range(2, int(n ** 0.5) + 1):
if n % d == 0:
est_premier = False
break
if est_premier:
total += 1
return total
taches = [500_000, 500_000, 500_000, 500_000]
if __name__ == "__main__":
debut = time.perf_counter()
sequentiel = [compter_premiers(n) for n in taches]
print(f"Sequentiel : {time.perf_counter() - debut:.2f} s -> {sequentiel}")
debut = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as executor:
parallele = list(executor.map(compter_premiers, taches))
print(f"Parallele : {time.perf_counter() - debut:.2f} s -> {parallele}")

Sur une machine à plusieurs cœurs, les quatre calculs s'exécutent en même temps et le temps total s'effondre :

Sequentiel : 1.98 s -> [41538, 41538, 41538, 41538]
Parallele : 0.62 s -> [41538, 41538, 41538, 41538]

Le passage de 1,98 s à 0,62 s montre l'accélération réelle permise par quatre processus. Le facteur n'est pas exactement quatre : la création des processus, la sérialisation des arguments et le rapatriement des résultats coûtent un peu. Ce surcoût est le prix des processus, rentable uniquement quand le calcul lui-même est long devant la mise en place.

Choisir : ThreadPoolExecutor ou ProcessPoolExecutor

Section intitulée « Choisir : ThreadPoolExecutor ou ProcessPoolExecutor »

Le choix ne dépend que d'une question : votre programme est-il limité par l'attente ou par le calcul ? La réponse détermine l'exécuteur, et se tromper annule tout le bénéfice de la parallélisation.

Votre tâche est limitée par...ExécuteurPourquoi
des entrées-sorties (réseau, disque, API, base)ThreadPoolExecutorle GIL est relâché pendant l'attente, les threads se recouvrent
un calcul intensif (maths, images, parsing lourd)ProcessPoolExecutordes processus séparés contournent le GIL et saturent les cœurs

En pratique, on essaie d'abord les threads : ils sont plus légers, sans sérialisation ni garde __main__, et couvrent la majorité des besoins réels, largement dominés par les I/O. On ne passe aux processus que lorsqu'un profilage confirme que le programme est CPU bound et que le calcul est assez long pour amortir le surcoût. Pour de la concurrence I/O à très grande échelle sur un seul thread, asyncio reste une alternative complémentaire aux pools de threads.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • concurrent.futures est l'API haut niveau de concurrence : un pool de workers et un Future par tâche, sans plomberie manuelle.
  • submit rend un Future immédiatement ; .result() bloque jusqu'au résultat et relève l'exception éventuelle.
  • map applique une fonction à une séquence, dans l'ordre des entrées ; as_completed livre les résultats dès qu'ils arrivent.
  • wait rend la main selon return_when (ALL_COMPLETED, FIRST_COMPLETED, FIRST_EXCEPTION) et sépare terminés et en cours.
  • Une exception dans une tâche est stockée dans le Future et relevée au .result(), jamais au moment où elle survient.
  • ThreadPoolExecutor pour l'I/O bound : cinq attentes d'une seconde tombent à une seconde.
  • ProcessPoolExecutor pour le CPU bound : quatre calculs passent de 1,98 s à 0,62 s sur quatre processus.
  • Le code ProcessPoolExecutor vit sous if __name__ == "__main__": et ne manipule que des données sérialisables.
  • asyncio en Python : l'approche mono-thread de la concurrence I/O, complémentaire des pools de threads pour des milliers d'attentes.
  • Gérer les exceptions : maîtriser la remontée d'erreurs, indispensable pour lire correctement un Future.
  • Appeler des API avec requests : le cas d'usage I/O bound typique où un ThreadPoolExecutor accélère un lot d'appels.
  • asyncio avancé : l'approche mono-thread pour des milliers d'attentes concurrentes, à comparer aux pools de threads.
  • Profiler son code : mesurer le gain réel d'un pool avant de conclure qu'il accélère votre traitement.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn