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.

Où se placent threading / multiprocessing / subprocess
threadingI/O-boundFichiers / BD / APIsmultiprocessingCPU-boundImage / numériquesubprocessCommandes externesgit / ffmpeg / shellasyncio (précédent)Beaucoup d'I/O en parallèle100 appels Web API
threading pour le travail I/O-bound, multiprocessing pour le calcul CPU intensif, subprocess pour appeler des commandes hors de Python. Aligné avec asyncio (couvert dans l'article précédent pour beaucoup d'I/O concurrentes), les quatre rôles se mettent en place.

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é ».

Processus, threads et coroutines s'imbriquent ainsi
Processus (multiprocessing en crée)
  • 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
Thread (threading en crée)
  • 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
Coroutine (asyncio en exécute)
  • Tourne en basculant à l'intérieur d'un seul thread
  • Encore plus léger ; idéal pour beaucoup d'I/O en parallèle
multiprocessing crée les processus les plus extérieurs, threading crée les threads au milieu, et asyncio fait tourner les coroutines au plus interne. Trois couches différentes — ce que tu veux paralléliser décide laquelle utiliser.
Différences entre processus et thread
Processus(multiprocessing)Mémoire indépendanteCoût de démarrage élevéPas d'interférenceVrai parallélismeThread(threading)Mémoire partagéeDémarrage légerAttention aux coursesContrainte GIL
Processusmémoire indépendante, coût de démarrage élevé en échange de zéro interférence. Threadmémoire partagée, léger, mais la synchronisation (Lock et compagnie) est nécessaire. Le GIL de Python limite le parallélisme des threads sur le travail CPU-bound.

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.

GIL — un seul thread tourne à la fois
Thread ACalculGILDétenu par AThread BEn attenteThread AAttente I/O→ libère le GILGILAcquis par BThread BDémarre le calculpassage
Le GIL est un verrou unique qui contrôle le droit d'exécuter du bytecode Python. Les threads font la queue pour l'acquérir et le libèrent dès qu'ils touchent une attente I/O, ce qui permet à un autre thread d'intervenir pendant cette fenêtre.

À 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.

Comment le GIL affecte les threads
CPU-bound(calcul)threadingFile d'attente sur le GIL→ Pas de parallélismemultiprocessing requisI/O-bound(API / BD / fichiers)threadingGIL libéré sur I/O→ Tourne en concurrence(asyncio est encore plus léger)
Le travail CPU-bound reste séquentiel sous le GIL peu importe combien de threads tu lances. Le travail I/O-bound libère le GIL pendant l'attente, donc threading aide vraiment. Pour un vrai parallélisme CPU-bound, prends multiprocessing.
# 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.

multiprocessing.Pool — vrai parallélisme sur 4 cœurs
Entrée[d1, d2, d3, d4]Process 1Core 1heavy(d1)Process 2Core 2heavy(d2)Process 3Core 3heavy(d3)Process 4Core 4heavy(d4)Résultat[r1, r2, r3, r4]
Quatre processus Python tournent sur quatre cœurs CPU séparés, donc il n'y a pas de contrainte GIL et tu obtiens une vraie exécution parallèle. Le travail CPU-bound accélère d'environ 4x.
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.

subprocess.run — appeler une commande externe depuis Python
Pythonsubprocess.run([...])OSlance un processusCommande externegit / ffmpeg etc.Retourne stdout /returncode à Python
Python demande à l'OS de lancer une commandeun processus OS séparé exécute la commande externe → stdout et le code de retour reviennent dans un objet CompletedProcess. Utile pour confier du travail que Python seul ne peut pas faire.
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.

Flux de décision concurrence / parallélisme
Appeler unecommande externe ?I/O-bound ?(attente dominante)CPU-bound ?(calcul dominant)→ subprocess→ asyncio(ou threading)→ multiprocessingOuiOuiOui
Commande externe → subprocess, I/O-bound → asyncio (ou threading), CPU-bound → multiprocessing. Trois axes pour choisir le bon outil.
Charge de travailChoixPourquoi
Appeler 100 Web APIs en concurrenceasyncioI/O-bound ; léger et facile à écrire
Paralléliser un client HTTP synchrone existantthreading (ThreadPoolExecutor)Si la bibliothèque n'a pas de support async, threading
Paralléliser le traitement d'images sur 4 cœursmultiprocessingLe travail CPU-bound a besoin de processus pour éviter le GIL
Lancer des commandes comme git ou ffmpegsubprocessDédié à appeler des programmes hors de Python
Des millions d'opérations mathématiques simplesNumPy / CythonLa 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.

QUIZ

Vérification des connaissances

Répondez à chaque question une par une.

Question 1À cause du GIL (Global Interpreter Lock) de Python, ajouter plus de threads ne parallélise pas quel type de travail ?

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 ?