Python parallèle : multiprocessing, threads et le GIL

Accélérez une boucle lente comme il faut — distinguez le CPU-bound de l’I/O-bound, comprenez pourquoi le GIL laisse les threads aider à l’attente mais pas au calcul, et servez-vous de concurrent.futures pour répartir le travail sur des threads ou des processus.

Exécutez une boucle Python lente en parallèle comme il faut : distinguez le travail CPU-bound du travail I/O-bound, comprenez pourquoi le Global Interpreter Lock (GIL) fait que les threads accélèrent l’attente mais pas le calcul, et servez-vous de concurrent.futures (ThreadPoolExecutor / ProcessPoolExecutor) et de multiprocessing.Pool — avec le garde-fou if name == “main”, le nombre de workers et les vrais pièges.

Date de publication

17 juillet 2026

Modifié

18 juillet 2026

AstucePoints clés
  • Première question : la boucle calcule-t-elle ou attend-elle ? Le travail CPU-bound (calculs, parsing, compression) est limité par votre processeur ; le travail I/O-bound (appels réseau, lectures disque, requêtes en base) passe son temps à attendre. La réponse décide de l’outil qui l’accélère — trompez-vous et le parallélisme ne sert à rien.
  • Le GIL (Global Interpreter Lock) ne laisse qu’un seul thread exécuter du bytecode Python à la fois. Les threads n’accélèrent donc pas le travail CPU-bound — mais ils aident le travail I/O-bound, car le verrou est relâché pendant qu’un thread attend le réseau ou le disque, ce qui laisse les autres s’exécuter.
  • CPU-bound → processus ; I/O-bound → threads (ou async). Plusieurs processus ont chacun leur propre interpréteur et leur propre GIL, donc ils calculent sur plusieurs cœurs à la fois. Les threads partagent un seul interpréteur — parfait pour recouvrir des attentes.
  • concurrent.futures est l’API propre et moderne pour les deux. ThreadPoolExecutor et ProcessPoolExecutor partagent une seule interface — .map() pour un map parallèle, .submit() + as_completed() pour les résultats au fil de leur achèvement. Passez de threads ↔︎ processus en changeant un seul nom de classe.
  • Le code par processus a besoin d’un garde-fou if __name__ == "__main__": et d’un script. Les workers lancés réimportent votre module, donc le code par processus doit vivre dans un fichier .py derrière ce garde-fou — sinon il se relance lui-même à l’infini. C’est pourquoi les exemples par processus ci-dessous sont enregistrés et exécutés en tant que scripts, pas dans un notebook.

Introduction

Vous avez une boucle trop lente. Peut-être qu’elle appelle une API 200 fois ; peut-être qu’elle hache un répertoire de fichiers ; peut-être qu’elle exécute le même calcul lourd sur une liste d’entrées. L’instinct dit « lance-la en parallèle » — mais comment le faire dépend entièrement du pourquoi de sa lenteur, et il y a deux réponses très différentes.

Il y a deux sortes de lenteur. Le travail CPU-bound occupe le processeur en permanence : calculs, analyse de texte, redimensionnement d’images, compression de données. Il est lent parce qu’il y a réellement beaucoup à calculer. Le travail I/O-bound est lent parce qu’il attend : une requête réseau, une lecture disque, une requête en base, un appel à un autre service. Le CPU est presque inactif — il est juste bloqué jusqu’au retour des données. Distinguer les deux est la décision la plus importante de cette leçon, car chacun est accéléré par un outil différent, et se tromper d’outil ne vous rapporte rien.

Cette leçon vous donne la vraie décision et le vrai code. D’abord le GIL — le seul rouage interne de Python que vous devez comprendre, car c’est lui qui fait que les threads aident un type de travail et pas l’autre. Ensuite concurrent.futures, l’API propre et de haut niveau pour les threads comme pour les processus, avec une démo I/O exécutable. Ensuite multiprocessing directement, pour le travail CPU-bound sur plusieurs cœurs. Puis un simple tableau de décision et les précautions du monde réel (combien de workers, et pourquoi plus n’est pas toujours mieux).

Un mot sur la façon dont le code s’exécute. Les exemples de threads ci-dessous s’exécutent en direct à la compilation, donc leurs mesures de temps sont réelles. Les exemples de processus sont présentés comme de courts scripts que vous enregistrez et exécutez vous-même — non parce qu’ils sont hypothétiques (leur sortie est indiquée et a été vérifiée), mais parce que le code par processus ne peut réellement pas s’exécuter dans un notebook ou une session interactive : les processus workers lancés réimportent le module d’où ils viennent, ce qui exige un vrai fichier de script et un garde-fou if __name__ == "__main__":. Cette contrainte est une leçon centrale ici, pas un désagrément — nous l’abordons donc de front. Cette leçon accompagne les itérateurs et générateurs en Python (un générateur alimente un pool de workers un élément à la fois) et est suivie dans cette série par la programmation asynchrone, le troisième outil pour le travail I/O-bound.

Le GIL : pourquoi les threads n’aident pas toujours

Python a une contrainte célèbre que vous devez comprendre avant toute chose : le GIL — le Global Interpreter Lock. C’est un unique verrou au sein de CPython (l’interpréteur Python standard) qui n’autorise qu’un seul thread à exécuter du bytecode Python à la fois. Même si vous démarrez dix threads sur une machine à dix cœurs, un seul d’entre eux exécute du code Python à un instant donné ; les autres attendent leur tour pour le verrou.

On dirait que cela rend les threads inutiles. Ce n’est pas le cas — il faut simplement savoir quand ils aident :

  • Travail CPU-bound : les threads n’aident pas. Si chaque thread veut faire des calculs, ils ont tous besoin du GIL pour exécuter du bytecode, donc ils se relaient et vous n’obtenez aucun gain — parfois même un léger ralentissement dû au surcoût du passage du verrou. Pour utiliser plusieurs cœurs pour du calcul, il faut des processus séparés, chacun avec son propre interpréteur et son propre GIL.
  • Travail I/O-bound : les threads aident beaucoup. Voici le détail clé : un thread relâche le GIL pendant qu’il attend sur une I/O (un socket, un fichier, un time.sleep). Ainsi, pendant que le thread A est bloqué à attendre une réponse réseau, les threads B, C et D peuvent saisir le verrou et faire leur travail. Les attentes se recouvrent au lieu de s’empiler bout à bout. Dix requêtes qui attendent chacune 200 ms se terminent en gros en une seule attente de 200 ms, pas en deux secondes.

La règle découle donc directement du GIL : utilisez les threads pour recouvrir l’attente (I/O-bound), utilisez les processus pour recouvrir le calcul (CPU-bound). Gardez cette phrase à l’esprit pour le reste de la leçon — tout le reste n’est que l’API pour l’exprimer.

Note

CPython récent propose une build expérimentale free-threaded (« no-GIL »), et les versions successives desserrent peu à peu l’emprise du verrou. Mais l’interpréteur par défaut que vous exécutez presque certainement a encore le GIL, donc la règle CPU-bound → processus est celle, sûre et portable, à apprendre et sur laquelle compter aujourd’hui.

concurrent.futures : l’API propre

La façon moderne et de haut niveau d’exécuter du travail en parallèle est le module concurrent.futures de la bibliothèque standard. Il vous donne deux « executors » interchangeables derrière une interface identique :

  • ThreadPoolExecutor — un pool de threads, pour le travail I/O-bound.
  • ProcessPoolExecutor — un pool de processus, pour le travail CPU-bound.

Comme ils partagent les mêmes méthodes, vous choisissez votre outil en choisissant la classe et ne changez rien d’autre. Les deux méthodes que vous utiliserez le plus sont .map() (exécuter une fonction sur un itérable, comme le map intégré, mais en parallèle) et .submit() + as_completed() (lancer des tâches individuelles et collecter les résultats à l’instant où chacune se termine).

Les threads pour le travail I/O-bound

Simulons le cas I/O classique : huit tâches qui attendent chacune 0.2 seconde — tenant lieu d’un appel réseau ou disque. Nous allons les exécuter en série, puis via un ThreadPoolExecutor à quatre threads, et chronométrer les deux. Comme un thread en attente relâche le GIL, les quatre threads devraient recouvrir leurs attentes et se terminer en gros en un quart du temps.

import time
from concurrent.futures import ThreadPoolExecutor

def fetch(task_id):
    """Pretend to call a slow API: the work here is WAITING, not computing."""
    time.sleep(0.2)                 # the GIL is released during this wait
    return task_id, task_id * task_id

tasks = range(8)

# Serial: each 0.2s wait happens one after another.
start = time.perf_counter()
serial = [fetch(t) for t in tasks]
serial_time = time.perf_counter() - start

# Threaded: 4 waits overlap, because a waiting thread frees the GIL.
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
    threaded = list(pool.map(fetch, tasks))
threaded_time = time.perf_counter() - start

print(f"serial:   {serial_time:.2f} s")
print(f"threaded: {threaded_time:.2f} s")
print(f"speedup:  {serial_time / threaded_time:.1f}x")
print("same result:", serial == threaded)
serial:   1.65 s
threaded: 0.41 s
speedup:  4.0x
same result: True

L’exécution en série a pris environ 1.6 s — huit attentes de 0.2 seconde empilées bout à bout (8 × 0.2 = 1.6). Avec quatre threads, elle est tombée à environ 0.4 s : les quatre attentes se sont déroulées en même temps, donc huit tâches ont coûté environ deux tours d’attente au lieu de huit. C’est une accélération d’environ 4× — parfaitement en phase avec les quatre workers — et pool.map a renvoyé les résultats dans le même ordre que l’entrée, donc serial == threaded vaut True. (Les temps exacts dépendent de votre machine et de l’ordonnanceur de l’OS ; c’est la forme — un gain quasi linéaire jusqu’au nombre de workers — qui compte.) Point crucial, cela ne fonctionne que parce que les tâches attendent ; si fetch faisait des calculs au lieu de dormir, le GIL les sérialiserait et le temps « threaded » ne serait pas meilleur que le temps en série.

ThreadPoolExecutor(max_workers=4) a créé un pool de quatre threads réutilisables ; .map(fetch, tasks) a confié chaque tâche à un thread libre et a rendu les résultats dans l’ordre d’entrée ; et le bloc with a fermé proprement le pool une fois terminé (il attend que tout soit fini). Voilà tout le schéma pour le travail I/O-bound.

submit + as_completed : les résultats à mesure qu’ils arrivent

.map() est parfait quand vous voulez les résultats dans l’ordre. Parfois, vous préféreriez traiter chaque résultat à l’instant où il est prêt, quel que soit l’ordre — par exemple pour afficher la progression à mesure que les téléchargements arrivent. Pour cela, utilisez .submit() pour planifier chaque tâche (elle renvoie un Future, une poignée vers un résultat pas encore prêt) et as_completed() pour livrer ces futures au fur et à mesure de leur achèvement. Ici, trois « téléchargements » de tailles différentes prennent des temps différents, donc le plus petit se termine en premier :

from concurrent.futures import ThreadPoolExecutor, as_completed

def download(name, seconds):
    time.sleep(seconds)             # bigger files "take" longer
    return name, seconds

jobs = [("small.csv", 0.1), ("medium.csv", 0.2), ("large.csv", 0.3)]

with ThreadPoolExecutor(max_workers=3) as pool:
    futures = [pool.submit(download, name, secs) for name, secs in jobs]
    for future in as_completed(futures):        # yields each as it finishes
        name, secs = future.result()            # .result() unwraps the return
        print(f"done: {name} (took {secs}s)")
done: small.csv (took 0.1s)
done: medium.csv (took 0.2s)
done: large.csv (took 0.3s)

Les trois tâches ont toutes démarré en même temps (trois workers, trois tâches), donc elles se sont terminées dans l’ordre de duréesmall.csv, puis medium.csv, puis large.csv — et non dans l’ordre où nous les avions soumises. as_completed nous a livré chaque Future à l’instant où son travail était fini, et future.result() a extrait la valeur renvoyée (et relèverait toute exception rencontrée par la tâche, ce qui est la façon d’attraper les échecs). Optez pour submit + as_completed chaque fois que vous voulez réagir aux résultats à mesure qu’ils arrivent ; optez pour .map quand des résultats ordonnés sont plus simples.

Les processus pour le travail CPU-bound

Passons à l’autre moitié. Quand le travail calcule, au lieu d’attendre, les threads ne peuvent pas aider — il vous faut ProcessPoolExecutor, qui exécute votre fonction dans des processus séparés, chacun avec son propre interpréteur et son propre GIL, si bien que plusieurs cœurs calculent en même temps. L’API est identique à celle du pool de threads ; vous échangez le nom de la classe.

Mais le code par processus a une exigence stricte : parce que chaque processus worker réimporte votre module pour récupérer la fonction, le code de lancement doit se trouver derrière un garde-fou if __name__ == "__main__":, dans un vrai fichier .py. Sans lui, chaque enfant relancerait le code de lancement à l’import et engendrerait ses propres enfants — une explosion infinie de processus. C’est exactement pourquoi cet exemple est un script que vous enregistrez et exécutez, pas un bloc en direct. Enregistrez-le sous cpu_pool.py et lancez python cpu_pool.py :

# cpu_pool.py  —  save this to a file and run:  python cpu_pool.py
import time
from concurrent.futures import ProcessPoolExecutor

def slow_square(n):
    """CPU-BOUND: a deliberately heavy calculation (no waiting)."""
    total = 0
    for i in range(10_000_000):
        total += (n * i) % 7
    return n, total

if __name__ == "__main__":                 # REQUIRED: workers re-import this file
    nums = [1, 2, 3, 4, 5, 6, 7, 8]

    start = time.perf_counter()
    serial = [slow_square(n) for n in nums]
    serial_time = time.perf_counter() - start

    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        parallel = list(pool.map(slow_square, nums))
    parallel_time = time.perf_counter() - start

    print(f"serial:   {serial_time:.2f} s")
    print(f"parallel: {parallel_time:.2f} s")
    print(f"speedup:  {serial_time / parallel_time:.1f}x")
    print("same result:", serial == parallel)

# Output on an 8-task run, 4 workers (verified on a 10-core machine):
#   serial:   2.00 s
#   parallel: 0.60 s
#   speedup:  3.3x
#   same result: True

En l’exécutant en local, la version en série a pris 2.00 s et le pool à quatre processus 0.60 s — une accélération d’environ 3.3×, répartissant huit calculs lourds sur quatre cœurs. (Vous n’obtiendrez pas un 4× parfait : démarrer des processus et leur transférer des données a un coût, donc les gains réels restent un peu en deçà du nombre de workers — plus de détails ci-dessous.) Le garde-fou if __name__ == "__main__": n’est pas optionnel ici : c’est la ligne qui permet à un worker qui réimporte de charger slow_square sans réexécuter le bloc de lancement. Remplacez ProcessPoolExecutor par ThreadPoolExecutor dans ce code exact et le gain s’évanouit — le GIL sérialiserait les calculs. Ce contraste d’une ligne est la règle CPU-bound vs I/O-bound rendue concrète.

multiprocessing directement

ProcessPoolExecutor est en réalité une surcouche conviviale du module multiprocessing, plus ancien et de plus bas niveau. Vous croiserez encore multiprocessing directement dans du code existant et quand vous avez besoin d’un contrôle plus fin, donc il vaut la peine d’en connaître les deux piliers : Pool (un pool de processus, comme l’executor) et les primitives pour passer des données entre processus.

Pool.map est l’équivalent direct de ProcessPoolExecutor.map — la même idée d’« exécuter cette fonction sur cette liste, à travers des processus ». La même règle du garde-fou __main__ s’applique, donc c’est encore un script que vous exécutez :

# pool_map.py  —  run:  python pool_map.py
from multiprocessing import Pool

def cube(n):
    return n ** 3

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        results = pool.map(cube, [1, 2, 3, 4, 5])
    print(results)

# Output (verified):
#   [1, 8, 27, 64, 125]

Pool(processes=4) a ouvert quatre processus workers ; pool.map(cube, [...]) a réparti la liste entre eux et a rassemblé les résultats dans l’ordre — affichant [1, 8, 27, 64, 125]. Pour une tâche massivement parallèle (chaque élément indépendant), Pool.map suffit souvent.

Les processus séparés ne partagent pas la mémoire, donc pour faire entrer et sortir les données, vous les passez explicitement. Le canal le plus simple est une Queue — un tuyau sûr entre processus dans lequel un processus dépose des éléments et un autre les récupère :

# queue_demo.py  —  run:  python queue_demo.py
from multiprocessing import Process, Queue

def worker(items, out):
    for x in items:
        out.put(x * 10)             # push results back through the queue

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=([1, 2, 3], q))
    p.start()                       # launch the child process
    p.join()                        # wait for it to finish
    print([q.get() for _ in range(3)])

# Output (verified):
#   [10, 20, 30]

Le Process enfant a exécuté worker, poussant 10, 20, 30 dans la Queue partagée ; le parent a fait join (l’a attendu) puis a fait get pour récupérer les trois résultats — [10, 20, 30]. Queue est la façon standard de faire remonter les résultats des workers ; un Pipe fait la même chose pour un simple aller-retour entre deux processus. Le piège à retenir : tout ce que vous envoyez entre processus doit être picklable (sérialisable), car c’est sérialisé pour franchir la frontière du processus — une lambda, un descripteur de fichier ouvert ou une connexion de base de données ne peuvent pas faire le voyage (voir Problèmes fréquents).

Quel outil ? CPU-bound ou I/O-bound

Voici toute la décision sur un seul écran. Commencez par demander ce que le travail lent fait réellement, puis lisez de gauche à droite :

Votre travail est… Exemple Goulot d’étranglement Utilisez Pourquoi
CPU-bound Calcul intensif, parsing, traitement d’images/de vidéos, compression, calcul de features ML Le processeur ProcessusProcessPoolExecutor ou multiprocessing.Pool Chaque processus a son propre GIL, donc les cœurs calculent en parallèle
I/O-bound, nombre de tâches modéré Quelques dizaines d’appels d’API, de lectures de fichiers, de requêtes en base Attente sur l’I/O ThreadsThreadPoolExecutor Un thread en attente relâche le GIL, donc les attentes se recouvrent
I/O-bound, très nombreuses tâches Des milliers de requêtes réseau simultanées Attente sur l’I/O async (asyncio) Un seul thread jongle avec des milliers d’attentes avec bien moins de surcoût qu’un thread par tâche
Map massivement parallèle (chaque élément indépendant) Appliquer f à chaque élément d’une grande liste L’un ou l’autre executor.map(f, items) Une ligne ; choisit threads ou processus selon l’executor que vous choisissez

Deux lectures rapides du tableau. Si vous n’êtes pas sûr qu’une tâche soit CPU- ou I/O-bound, demandez : pendant qu’elle s’exécute, le CPU est-il à fond, ou surtout inactif ? À fond → CPU-bound → processus. Inactif et en attente → I/O-bound → threads ou async. Et pour le cas I/O à très forte concurrence (des milliers de connexions simultanées), les threads commencent à coûter trop de mémoire et de surcoût d’ordonnancement — c’est là que la programmation asynchrone prend le relais, le troisième outil du trio de la concurrence de cette série, traité dans la prochaine leçon.

Dans le monde réel

Quelques éléments décident si le parallélisme est réellement rentable :

  • Plus de workers n’est pas toujours plus rapide. Chaque processus supplémentaire coûte de la mémoire et du temps de démarrage, et chaque élément expédié à un processus doit être sérialisé et envoyé. Passé un certain point — généralement autour de votre nombre de cœurs pour le travail CPU-bound — ajouter des workers ne fait qu’ajouter du surcoût et ralentit les choses. Pour le travail I/O-bound, vous pouvez utilement lancer plus de threads que de cœurs (ils attendent surtout), mais même là, des milliers de threads s’entrechoquent. Démarrez près de votre nombre de cœurs et mesurez.
  • Dimensionnez votre pool à la machine. Utilisez os.cpu_count() pour découvrir combien de cœurs vous avez et choisir une valeur par défaut sensée, plutôt que de coder en dur un nombre qui sera faux sur la prochaine machine :
import os

cores = os.cpu_count()
print("cores available:", cores)
print("a reasonable CPU-bound pool size:", cores)
cores available: 10
a reasonable CPU-bound pool size: 10

Cette machine indique le compte ci-dessus ; une valeur par défaut courante est un worker par cœur pour le travail CPU-bound (laissez un cœur libre si la machine doit rester réactive). Les pool executors utilisent déjà par défaut une taille adaptée à la machine si vous ne passez pas de max_workers, ce qui est souvent le bon choix.

  • Regroupez les petites tâches. Si chaque tâche est minuscule, le surcoût par tâche de son expédition à un processus peut éclipser le travail lui-même. ProcessPoolExecutor.map comme Pool.map acceptent un argument chunksize qui regroupe de nombreux éléments dans chaque envoi — p. ex. pool.map(f, items, chunksize=100) — réduisant considérablement le coût de coordination pour de gros volumes de tâches peu coûteuses. Pour une poignée de tâches lourdes, laissez la valeur par défaut.
  • Le parallélisme a un seuil d’utilité. Pour une boucle qui s’exécute en millisecondes, le coût de démarrage des threads ou des processus dépassera tout ce que vous économisez. Parallélisez le travail vraiment lent — des secondes, pas des microsecondes — et mesurez toujours avant et après au lieu de supposer un gain.
NoteCette leçon est reproductible

Chaque résultat ci-dessus a été produit par le code montré. Les exemples de threads se sont exécutés à la compilation (s’ils s’affichent, le code fonctionne), donc vous pouvez copier n’importe quel bloc {python} et l’exécuter tel quel. Les exemples de processus sont des scripts complets — enregistrez chacun dans un fichier .py et lancez-le avec python <fichier>.py ; leur sortie indiquée a été vérifiée de la même façon. Il n’y a pas de bac à sable dans le navigateur sur cette page (de vrais threads et processus ont besoin d’un vrai runtime Python), donc le flux de travail est copier-et-exécuter-en-local, pas modifier-et-recompiler.

La même idée en R

Vous travaillez dans les deux langages ? R exprime la même idée de parallélisme CPU-bound à travers son package parallel et la pile moderne future / furrr. Là où Python écrit ProcessPoolExecutor().map(f, xs), R écrit future::plan(multisession) une fois pour lancer des sessions workers, puis furrr::future_map(xs, f) — une version parallèle prête à l’emploi de purrr::map(). Le R de base propose parallel::mclapply() (basé sur le fork, sous macOS/Linux) et parallel::parLapply() (basé sur un cluster, multiplateforme) comme analogues directs d’un pool de processus. Le modèle mental est identique : des tâches indépendantes se déploient vers des processus workers qui exécutent chacun leur propre session R, puis les résultats remontent. R contourne la question du GIL de Python — il n’a pas de verrou unique équivalent — mais paie les mêmes coûts de démarrage de processus et de transfert de données, donc les mêmes précautions « mesurez, et ne surparallélisez pas les tâches minuscules » s’appliquent. Voyez le pilier Programmation pour le côté R.

🟢 Avec un agent IA

Une boucle lente et vous ne savez pas si elle est CPU-bound ou I/O-bound — ni quel pool choisir ? Collez-la et demandez à Prova « cette boucle est-elle CPU-bound ou I/O-bound, et réécris-la avec le bon pool concurrent.futures ». Elle lit ce que la boucle fait réellement, nomme le goulot d’étranglement, et écrit la version ThreadPoolExecutor (attente) ou ProcessPoolExecutor (calcul) — avec le garde-fou if __name__ == "__main__": là où les processus en ont besoin. Puis copiez le code qu’elle vous donne, exécutez-le sur votre propre machine, et chronométrez les deux — car le test honnête de « est-ce vraiment devenu plus rapide ? » est le chronomètre, pas une explication plausible. The runtime is the judge. Demandez à Prova →

Problèmes fréquents

Vous avez oublié if __name__ == "__main__": et les processus se multiplient (ou plantent). C’est le bug multiprocessing classique. Les processus workers lancés réimportent votre module pour trouver la fonction cible ; si votre code de lancement du pool n’est pas gardé, chaque nouvel import le réexécute, engendrant encore plus de workers — une explosion incontrôlée, ou sur certaines plateformes une RuntimeError signalant le démarrage d’un processus avant que le processus courant ait fini son amorçage. Le correctif est le garde-fou : mettez chaque ligne qui démarre des processus (créer le pool, appeler .map) à l’intérieur de if __name__ == "__main__":, dans un vrai script .py. Les simples définitions de fonctions restent en dehors ; seul le code de lancement va dedans. (C’est exactement pourquoi les exemples par processus ici sont des scripts que vous exécutez, pas des cellules de notebook.)

Les threads n’ont pas du tout accéléré votre boucle CPU-bound. Si vous avez enveloppé une boucle de calcul dans un ThreadPoolExecutor et n’avez vu aucune amélioration — ou que c’est devenu légèrement plus lent — c’est le GIL fonctionnant comme prévu : un seul thread exécute du bytecode Python à la fois, donc le calcul pur ne peut pas se recouvrir. Passez à ProcessPoolExecutor (ou multiprocessing.Pool) pour utiliser plusieurs cœurs. Les threads sont pour le travail qui attend (I/O), pas pour le travail qui calcule.

PicklingError / Can't pickle … lors de l’envoi de travail à un pool de processus. Les données qui franchissent une frontière de processus sont sérialisées avec pickle, donc tout ce que vous passez à un worker (ou en renvoyez) doit être picklable. Les lambdas, les fonctions définies localement (imbriquées), les descripteurs de fichiers ouverts, les connexions de base de données et beaucoup d’objets personnalisés ne le sont pas. Le correctif : utilisez une fonction nommée, de niveau supérieur comme cible du worker (pas une lambda), et passez des données simples (nombres, chaînes, listes, dictionnaires, DataFrames) plutôt que des ressources vivantes — ouvrez le fichier ou la connexion à l’intérieur du worker au lieu de les passer.

Vous avez ajouté plus de workers et c’est devenu plus lent. Au-delà de votre nombre de cœurs (pour le travail CPU-bound), les processus supplémentaires ne font qu’ajouter de la pression mémoire, des changements de contexte et du surcoût de transfert de données — il n’y a plus de cœurs pour les exécuter. Dimensionnez le pool à os.cpu_count() (ou un peu moins), utilisez chunksize pour regrouper les tâches minuscules, et mesurez toujours : le parallélisme aide le travail vraiment lent, et peut nuire aux boucles rapides où le coût de mise en place domine.

Questions fréquentes

Le threading exécute plusieurs threads dans un seul processus, partageant la même mémoire et le même interpréteur — et donc le même GIL, si bien qu’un seul thread exécute du bytecode Python à la fois. Cela rend les threads excellents pour le travail I/O-bound (un thread en attente relâche le GIL pour que les autres s’exécutent) mais inutiles pour le travail CPU-bound. Le multiprocessing exécute plusieurs processus séparés, chacun avec sa propre mémoire et son propre interpréteur et GIL, donc ils calculent réellement en parallèle sur plusieurs cœurs — le bon outil pour le travail CPU-bound. Le compromis : les processus coûtent plus cher à démarrer et ne peuvent pas partager la mémoire directement (les données sont sérialisées pour passer entre eux), tandis que les threads sont légers mais ne peuvent pas paralléliser le calcul.

Le GIL est un unique verrou dans CPython (l’interpréteur Python standard) qui ne permet qu’à un seul thread d’exécuter du bytecode Python à la fois. Il existe parce qu’il simplifie la gestion de la mémoire de l’interpréteur et accélère le code mono-thread. Sa conséquence : les threads ne peuvent pas exécuter du calcul Python en parallèle, donc le travail CPU-bound ne voit aucun gain avec le threading. Il ne bloque pas les I/O — un thread relâche le GIL pendant qu’il attend le réseau ou le disque — donc le travail I/O-bound se parallélise toujours bien avec des threads. Pour paralléliser le calcul, vous utilisez plusieurs processus (chacun a son propre GIL). Python récent propose une build free-threaded expérimentale qui retire le GIL, mais l’interpréteur par défaut l’a toujours.

Utilisez multiprocessing (ou ProcessPoolExecutor) quand votre travail est CPU-bound — calculs, parsing, traitement d’images ou de vidéos, compression, calcul lourd de features — et que vous voulez le répartir sur plusieurs cœurs. Comme chaque processus a son propre interpréteur et GIL, ils calculent vraiment en parallèle. N’y recourez pas quand le travail est I/O-bound (attente sur le réseau ou le disque) : là, les threads ou async sont plus légers et tout aussi efficaces, et les processus n’ajoutent que du surcoût de démarrage et de transfert de données. Évitez-le aussi pour les boucles très rapides — le coût de démarrage des processus et de transfert des données peut dépasser le temps que vous économisez.

Ils partagent une interface concurrent.futures identique, donc le choix porte uniquement sur la charge de travail. Utilisez ThreadPoolExecutor pour le travail I/O-bound — appels d’API, lectures de fichiers, requêtes en base — où les tâches passent leur temps à attendre ; les threads laissent ces attentes se recouvrir. Utilisez ProcessPoolExecutor pour le travail CPU-bound — du calcul qui occupe le processeur — car des processus séparés ont chacun leur propre GIL et s’exécutent sur des cœurs différents. Règle empirique : attente → threads, calcul → processus. Comme l’API est la même (.map, .submit, as_completed), vous pouvez souvent passer de l’un à l’autre en changeant un seul nom de classe et en rechronométrant. À noter que ProcessPoolExecutor a besoin du garde-fou if __name__ == "__main__": et d’un contexte de script.

Oui — c’est tout l’intérêt. Le GIL est par interpréteur, et chaque processus lancé par multiprocessing (ou ProcessPoolExecutor) a son propre interpréteur Python et donc son propre GIL. Ainsi plusieurs processus exécutent du bytecode Python réellement en parallèle sur plusieurs cœurs, ce qui est pourquoi le multiprocessing est l’outil pour le travail CPU-bound. Les threads, en revanche, partagent un seul interpréteur et un seul GIL, donc ils ne le peuvent pas. Le prix de ce contournement du GIL est que les processus ne partagent pas la mémoire — les données passées entre eux sont sérialisées (picklées), et il y a un coût de démarrage par processus — donc le multiprocessing est rentable pour du calcul vraiment lourd, pas pour des tâches minuscules.

Testez vos connaissances

Vous avez deux tâches lentes. Pour chacune, décidez si elle est CPU-bound ou I/O-bound, choisissez threads ou processus, et écrivez l’appel concurrent.futures.

Tâche A. check_url(url) envoie une requête HTTP et renvoie le code de statut. Vous avez 50 URL à vérifier et chaque requête prend environ 0.3 s (surtout de l’attente du serveur).

Tâche B. count_primes(n) compte les nombres premiers en dessous de n avec une simple boucle. Vous avez une liste de 8 grandes valeurs de n, et chaque appel occupe le CPU pendant quelques secondes.

Question 1. Pour chaque tâche, nommez le goulot d’étranglement (CPU-bound ou I/O-bound) et le bon executor. Question 2. Écrivez l’appel concurrent.futures pour chacune, en utilisant .map. Rappelez-vous laquelle a besoin d’un garde-fou if __name__ == "__main__":.

Demandez ce que chaque tâche fait pendant qu’elle s’exécute. check_url attend surtout sur le réseau → I/O-bound → threads. count_primes occupe le CPU à fond → CPU-bound → processus. La version threads s’exécute n’importe où ; la version processus doit vivre dans un script derrière le garde-fou __main__ parce que les workers réimportent le module.

Question 1. La tâche A est I/O-bound (elle attend sur le réseau) → ThreadPoolExecutor. La tâche B est CPU-bound (elle calcule) → ProcessPoolExecutor.

Question 2.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# Job A — I/O-bound → threads. Runs fine anywhere.
with ThreadPoolExecutor(max_workers=10) as pool:
    statuses = list(pool.map(check_url, urls))     # 50 URLs, waits overlap

# Job B — CPU-bound → processes. MUST be in a script under the guard:
if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=8) as pool:
        counts = list(pool.map(count_primes, n_values))

La tâche A recouvre 50 attentes réseau sur dix threads (le GIL est relâché pendant chaque attente), transformant ~15 s d’attente en série en quelques secondes. La tâche B répartit huit calculs lourds sur des processus pour que plusieurs cœurs s’exécutent à la fois — et elle a besoin de ProcessPoolExecutor, car les threads seraient sérialisés par le GIL. Échanger la classe d’executor entre les deux tâches casserait les deux : les threads n’accéléreraient pas les nombres premiers, et les processus ajouteraient un surcoût inutile (et du pickling) aux vérifications d’URL.

Vérification rapide. Vous avez une boucle qui calcule des hachages SHA-256 d’un million de petites chaînes — le CPU tourne à fond tout du long. Vous l’enveloppez dans un ThreadPoolExecutor à huit workers et ne voyez aucun gain. Quel outil l’accélère réellement, et pourquoi ?

Les processus — un ProcessPoolExecutor (ou multiprocessing.Pool). Le hachage est CPU-bound : il occupe le processeur sans attente. À cause du GIL, un seul thread peut exécuter du bytecode Python à la fois, donc huit threads se relaient et ne donnent aucun gain. Des processus séparés ont chacun leur propre interpréteur et GIL, donc ils hachent sur plusieurs cœurs en parallèle — le vrai gain. (Les threads n’aideraient que si le travail attendait sur une I/O.)

Conclusion

Paralléliser Python commence par une question : le travail calcule-t-il ou attend-il ? Le travail CPU-bound est limité par le processeur et a besoin de processus (ProcessPoolExecutor ou multiprocessing.Pool) pour utiliser plusieurs cœurs ; le travail I/O-bound est limité par l’attente et est accéléré par les threads (ThreadPoolExecutor) — ou, à très forte concurrence, par async. La raison en est le GIL : un seul thread exécute du bytecode Python à la fois, donc les threads peuvent recouvrir les attentes mais pas le calcul. concurrent.futures vous donne les deux derrière une interface propre — .map pour des résultats ordonnés, .submit + as_completed pour les résultats au fil de leur achèvement — et vous changez d’outil en changeant la classe d’executor. Rappelez-vous les deux règles qui piègent tout le monde : le code par processus a besoin d’un garde-fou if __name__ == "__main__": dans un vrai script (les workers réimportent le module), et tout ce qui franchit une frontière de processus doit être picklable. Et mesurez toujours — le parallélisme récompense le travail vraiment lent, et peut vous coûter sur les boucles rapides.

Leçons associées

  • Pour aller plus loin : les itérateurs et générateurs en Python — un générateur alimente un pool de workers un élément à la fois sans tout charger en mémoire, la source naturelle pour executor.map.
  • Poussez plus loin : la prochaine leçon de cette série Python Intermédiaire traite de la programmation asynchroneasyncio, la boucle d’événements, et async/await — le troisième outil de concurrence, idéal pour le travail I/O-bound à très forte concurrence où un thread par tâche cesse de tenir. Voyez le pilier Programmation pour l’ensemble complet des leçons R et Python.
Cette page vous a-t-elle été utile ?

Prouvez que vous savez le faire. Maîtrisez toute la série Python intermédiaire — suivez votre parcours, construisez des projets et obtenez un certificat.

Commencer gratuitement →

Passez à Pro — Prova illimité sur vos propres données et un certificat vérifiable qui atteste la compétence.

dès 15 $/mois facturé annuellement

Passer à Pro →

✓ Vous êtes Pro — continuez. The runtime is the judge.

Recevez les nouvelles leçons R & Python par e-mail

Pratique, reproductible, sans spam. Désinscription à tout moment.

Double opt-in. Nous ne partageons jamais votre e-mail.

Partager cette pageXLinkedInRedditHN

Réutilisation

Citation

BibTeX
@online{2026,
  author = {},
  title = {Python parallèle : multiprocessing, threads et le GIL},
  date = {2026-07-17},
  url = {https://www.datanovia.com/learn/programming/python-intermediate/parallel-concurrent-python},
  langid = {fr}
}
Veuillez citer ce travail comme suit :
“Python parallèle : multiprocessing, threads et le GIL.” 2026. July 17. https://www.datanovia.com/learn/programming/python-intermediate/parallel-concurrent-python.