Question 1À cause du GIL (Global Interpreter Lock) de Python, ajouter plus de threads ne parallélise pas quel type de travail ?
threading et multiprocessing — Threads, processus et le GIL
Threads vs processus, pourquoi le GIL empêche la parallélisation CPU-bound, et comment choisir entre ThreadPoolExecutor, multiprocessing.Pool et subprocess selon le type de charge.
La boîte à outils de concurrence et parallélisme de Python — threading (threads), multiprocessing (processus) et subprocess (commandes externes) — alignée côte à côte. Le sandbox Python du navigateur ne peut pas créer de vrais threads ni de vrais processus, donc cet article s'appuie sur des schémas et des blocs de code en lecture seule pour bien fixer les concepts.
Le code ici ne s'exécute pas vraiment
Les blocs code de cet article sont des exemples destinés à un vrai environnement Python. Des appels comme threading.Thread.start() ou multiprocessing.Pool.map() ne fonctionnent pas dans le sandbox du navigateur parce que le runtime ne peut pas créer de threads OS ni de processus OS. À la place, un quiz de vérification des concepts est proposé à la fin.
Processus vs threads
Un processus est une unité d'exécution distribuée par l'OS — il a son propre espace mémoire, et les processus n'interfèrent pas entre eux. Un thread est une unité d'exécution légère à l'intérieur d'un processus, et il partage la mémoire avec ses threads frères. Ce partage rend le passage de données rapide, mais il faut faire attention aux race conditions (plusieurs threads écrivant sur la même variable en même temps et corrompant le résultat).
Le modèle mental simple : « processus = lourd mais indépendant, thread = léger mais partagé ».
- Une unité d'exécution indépendante du point de vue de l'OS
- Mémoire indépendante · Pas de contrainte GIL
- Coût de démarrage élevé ; vrai parallélisme possible
- L'unité qui fait réellement tourner le code dans un processus
- Mémoire partagée · Soumis au GIL
- Rentable pour le travail I/O-bound
- Tourne en basculant à l'intérieur d'un seul thread
- Encore plus léger ; idéal pour beaucoup d'I/O en parallèle
threading et le GIL — la contrainte des threads de Python
Python (CPython) a un mécanisme appelé le GIL (Global Interpreter Lock) qui impose une contrainte : « un seul thread peut exécuter du bytecode Python à la fois ». Pour du travail CPU intensif, l'exécuter en concurrence sur plusieurs threads n'est en réalité pas plus rapide qu'un seul thread.
À l'inverse, pendant du travail I/O-bound (où le temps est dominé par l'attente de réponses externes — réseau, fichiers, BD), Python libère le GIL, donc threading accélère bien le travail I/O-bound. Cela dit, si la charge est I/O-bound, asyncio a généralement moins de surcharge et est plus simple à écrire, donc préfère asyncio pour le nouveau code.
# threading : API bas niveau
import threading
def worker(name):
print(f"{name} started")
# faire quelque chose
print(f"{name} done")
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
t1.start()
t2.start()
t1.join() # attendre la fin
t2.join()
# concurrent.futures : API haut niveau (recommandée)
from concurrent.futures import ThreadPoolExecutor
def fetch(url):
# en vrai code : requests.get(url) ou autre travail I/O-bound
return f"fetched: {url}"
with ThreadPoolExecutor(max_workers=4) as executor:
urls = ["a.com", "b.com", "c.com"]
results = list(executor.map(fetch, urls))
print(results)
Prends ThreadPoolExecutor pour le nouveau code
Utiliser threading.Thread directement rend la gestion du cycle de vie pénible. concurrent.futures.ThreadPoolExecutor est gérable en sécurité avec with et permet de traiter des listes entières avec executor.map(func, iterable) comme une seule API. La limite de threads (max_workers) est aussi pratique pour ne pas inonder un serveur de connexions.
Quand threading est un bon choix
threading / ThreadPoolExecutor brille quand tu veux remplir le temps d'attente avec d'autres tâches :
- Traitement concurrent de plusieurs Web APIs / requêtes BD / I/O fichier
- Paralléliser des bibliothèques synchrones existantes (sans support async)
- Lire la sortie de plusieurs processus lancés via subprocess en concurrence
- Faire tourner du travail en arrière-plan sans bloquer la boucle principale d'un GUI
multiprocessing — vrai parallélisme
multiprocessing est le module pour lancer plusieurs processus Python et les faire tourner en parallèle. Comme les processus sont libres de la contrainte du GIL, tu peux faire tourner du travail CPU-bound en vrai parallèle — sur un CPU 4 cœurs, le traitement d'image ou le calcul numérique devient environ 4 fois plus rapide.
from multiprocessing import Pool
def heavy(n):
return sum(i * i for i in range(n)) # travail CPU-bound
if __name__ == "__main__": # forme requise pour multiprocessing
with Pool(processes=4) as pool:
results = pool.map(heavy, [10**6, 10**6, 10**6, 10**6])
print("sum:", sum(results))
multiprocessing exige `if __name__ == '__main__':`
multiprocessing fonctionne en réexécutant le script parent dans chaque processus enfant, donc appeler Pool(...).map(...) au top level déclenche une récursion infinie et plante. La méthode de démarrage spawn de Windows / macOS est particulièrement stricte là-dessus — mets toujours ton code principal dans un bloc if __name__ == "__main__":.
subprocess — commandes externes
subprocess est le module pour appeler des commandes externes (commandes shell de l'OS) depuis Python — exécuter git status, convertir une vidéo avec ffmpeg, invoquer un script shell, et autres usages « lancer un programme qui n'est pas Python ». Le nom ressemble à multiprocessing, mais c'est un outil complètement différent.
import subprocess
result = subprocess.run(
["git", "status", "--short"],
capture_output=True,
text=True,
check=True, # lève CalledProcessError en cas d'échec
)
print(result.stdout)
print("return code:", result.returncode)
Flux de décision — lequel choisir ?
Choisir entre asyncio / threading / multiprocessing / subprocess se ramène à deux axes : « CPU-bound ou I/O-bound ? » et « À l'intérieur de Python ou commande externe ? ». Le diagramme de flux ci-dessous enlève la plupart des hésitations.
| Charge de travail | Choix | Pourquoi |
|---|---|---|
| Appeler 100 Web APIs en concurrence | asyncio | I/O-bound ; léger et facile à écrire |
| Paralléliser un client HTTP synchrone existant | threading (ThreadPoolExecutor) | Si la bibliothèque n'a pas de support async, threading |
| Paralléliser le traitement d'images sur 4 cœurs | multiprocessing | Le travail CPU-bound a besoin de processus pour éviter le GIL |
Lancer des commandes comme git ou ffmpeg | subprocess | Dédié à appeler des programmes hors de Python |
| Des millions d'opérations mathématiques simples | NumPy / Cython | La vectorisation bat le parallélisme niveau Python |
Le vrai CPU-bound est plus rare qu'on le croit
Beaucoup de code Python qui a l'air bloqué sur du calcul devient en fait 100x plus rapide en vectorisant avec NumPy / Pandas / Cython. Avant de viser 4x avec multiprocessing, vérifie ce que tu devrais faire d'abord : NumPy pour le numérique, Pandas pour les données, regex optimisé pour les chaînes.
Vérification des connaissances
Répondez à chaque question une par une.
Question 2Lequel utilises-tu pour appeler des commandes externes comme git status depuis Python ?
Question 3Lequel convient le mieux pour vraiment paralléliser du calcul numérique lourd sur 4 cœurs CPU ?