LangueFR
Français
English
Retour au sommaire du cours

Concurrence en Python : GIL, threads, processus, asyncio

C'est le point où l'intuition venant du C ou du calcul parallèle classique induit en erreur. Trois modèles de concurrence coexistent en Python, ils ne servent pas aux mêmes problèmes, et choisir le mauvais peut ralentir un programme au lieu de l'accélérer. Ce module part de zéro : ce qu'est un thread, pourquoi le GIL existe, comment savoir lequel des trois modèles s'applique à votre cas, puis chaque outil avec des exemples complets.

1. Processus, thread, concurrence, parallélisme

Quatre mots qu'on emploie souvent l'un pour l'autre, et qu'il faut séparer une bonne fois.

Un processus est un programme en cours d'exécution, avec sa propre zone de mémoire, isolée de celle des autres. Deux processus ne peuvent pas se marcher dessus : pour échanger des données, ils doivent explicitement se les envoyer.

Un thread (ou fil d'exécution) est une ligne d'exécution à l'intérieur d'un processus. Plusieurs threads d'un même processus partagent la même mémoire : ils voient les mêmes variables. C'est ce qui les rend légers — créer un thread coûte bien moins cher que créer un processus — et c'est aussi ce qui les rend dangereux, puisque deux threads peuvent modifier la même donnée en même temps (section 5).

Une analogie qui tient la route

Un processus, c'est un appartement : sa cuisine, ses affaires, une porte fermée. Un thread, c'est un colocataire dans cet appartement : plusieurs colocataires partagent le même frigo. Deux appartements ne peuvent pas se voler de la nourriture (il faut se la passer par la porte, ce qui prend du temps). Deux colocataires, si — et s'ils ouvrent le frigo en même temps pour compter les œufs, ils peuvent arriver à des conclusions incohérentes.

La concurrence, c'est gérer plusieurs tâches en cours en même temps, sans forcément les exécuter simultanément : un cuisinier qui met l'eau à bouillir, découpe des légumes pendant ce temps, puis revient à sa casserole, est concurrent. Le parallélisme, c'est exécuter réellement plusieurs calculs à la même seconde, ce qui exige plusieurs cœurs : deux cuisiniers. La distinction est décisive : pour une tâche qui passe son temps à attendre, la concurrence suffit ; pour une tâche qui calcule, il faut du parallélisme.

2. Le GIL, énoncé correctement

Ce que c'est

Le Global Interpreter Lock est un verrou interne à CPython, l'implémentation de référence de Python. Sa règle : un seul thread exécute du bytecode Python à un instant donné dans un processus. Les threads existent bien, le système d'exploitation les ordonnance, mais ils se passent ce jeton à tour de rôle au lieu de travailler en même temps.

Pourquoi il existe

Python gère la mémoire par comptage de références : chaque objet sait combien de variables pointent vers lui, et il est détruit quand ce compteur tombe à zéro. Si deux threads modifiaient ce compteur simultanément, il deviendrait incohérent — des objets encore utilisés seraient libérés, d'autres ne le seraient jamais. Le GIL est la solution la plus simple à ce problème : un verrou global plutôt qu'un verrou par objet. Il rend l'interpréteur simple, rapide en mono-thread, et facile à interfacer avec du C. Le prix est celui qu'on connaît.

La conséquence, mesurée

Python — la démonstration en dix lignes
import time
from concurrent.futures import ThreadPoolExecutor

def calcul(n=10_000_000):            # boucle Python pure : CPU-bound
    total = 0
    for i in range(n):
        total += i * i
    return total

debut = time.perf_counter()
[calcul() for _ in range(4)]                       # séquentiel
print(f"séquentiel : {time.perf_counter() - debut:.1f} s")     # ≈ 4,0 s

debut = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as ex:      # 4 threads sur 4 cœurs
    list(ex.map(lambda _: calcul(), range(4)))
print(f"4 threads   : {time.perf_counter() - debut:.1f} s")    # ≈ 4,1 s — AUCUN gain

Quatre threads sur quatre cœurs ne vont pas plus vite qu'un seul, et sont même légèrement plus lents à cause du coût de bascule entre threads. Voilà l'effet du GIL sur du calcul Python pur.

Les deux exceptions, qui changent tout

Le GIL est relâché — c'est-à-dire rendu aux autres threads — dans deux situations :

  • pendant une attente d'entrée/sortie : lecture disque, requête réseau, appel à une base de données. Le thread qui attend une réponse HTTP n'exécute aucun bytecode : il rend le jeton et les autres travaillent.
  • pendant l'exécution de code natif qui le relâche explicitement — et c'est exactement ce que font NumPy, SciPy, scikit-learn, PyTorch et polars dans leurs boucles de calcul, écrites en C ou en Rust.

Autrement dit : un produit matriciel NumPy lancé dans quatre threads utilise réellement quatre cœurs, alors que la boucle Python ci-dessus n'en utilise qu'un. Ce n'est pas « les threads sont inutiles en Python », c'est « les threads sont inutiles pour du bytecode Python ».

Depuis Python 3.13 existe un mode expérimental free-threaded (PEP 703) qui supprime le GIL, mais l'écosystème scientifique n'y est pas encore entièrement porté. En 2026, on continue de raisonner avec le GIL.

3. Diagnostiquer : où passe le temps ?

Avant de choisir un outil, il faut savoir si le programme attend ou s'il calcule. C'est la seule question qui compte, et elle se tranche par la mesure, pas par l'intuition.

Python — savoir dans quel régime on est
import time, cProfile

debut_mur = time.perf_counter()     # temps réellement écoulé (horloge murale)
debut_cpu = time.process_time()     # temps de CPU consommé par le processus

mon_traitement()

mur = time.perf_counter() - debut_mur
cpu = time.process_time() - debut_cpu
print(f"mur={mur:.1f}s  cpu={cpu:.1f}s  ratio={cpu/mur:.0%}")

# ratio proche de 100 % → le CPU travaille : CPU-bound     → processus (section 6)
# ratio faible (10-20 %) → le programme attend : I/O-bound → threads ou asyncio
cProfile.run("mon_traitement()", sort="cumtime")   # où exactement, ligne par ligne

L'arbre de décision

  1. Le programme attend-il des entrées/sorties ? (API, base de données, fichiers, scraping) → asyncio pour des milliers d'attentes simultanées, ThreadPoolExecutor si la bibliothèque utilisée est synchrone et bloquante (c'est le cas de requests, de la plupart des pilotes de bases de données classiques, de open()).
  2. Calcule-t-il en Python pur ? (boucles, parsing, logique métier) → ProcessPoolExecutor ou joblib : un processus par cœur, chacun avec son propre GIL.
  3. Calcule-t-il dans NumPy, PyTorch ou polars ? → c'est déjà parallèle, via les bibliothèques natives sous-jacentes. N'ajoutez rien avant d'avoir mesuré : empiler un second niveau de parallélisme dégrade presque toujours les performances (section 8).

4. Les threads, pour attendre

Le module concurrent.futures fournit une interface uniforme pour les threads comme pour les processus. Le vocabulaire : on soumet des tâches à un pool de workers, et chaque soumission renvoie un Future — un objet qui représente « un résultat qui n'existe pas encore ». On l'interroge quand on en a besoin.

Python — premier exemple, commenté ligne à ligne
from concurrent.futures import ThreadPoolExecutor
import requests

def telecharger(url: str) -> int:
    r = requests.get(url, timeout=10)      # le GIL est relâché pendant l'attente réseau
    return len(r.content)

urls = [f"https://exemple.fr/page/{i}" for i in range(200)]

with ThreadPoolExecutor(max_workers=16) as ex:   # 16 threads ; le with attend la fin
    tailles = list(ex.map(telecharger, urls))    # map : garde l'ORDRE des urls

print(sum(tailles))

Deux cents requêtes séquentielles à 100 ms font 20 secondes ; avec seize threads, environ 1,3 seconde. Le gain vient du fait que les threads passent leur temps bloqués en attente réseau, GIL relâché.

ex.map est le plus simple, mais il renvoie les résultats dans l'ordre des entrées : si la première requête est lente, on attend. Pour traiter chaque résultat dès qu'il arrive, et surtout pour gérer les erreurs proprement, on utilise submit et as_completed :

Python — la version robuste
from concurrent.futures import ThreadPoolExecutor, as_completed

resultats, echecs = {}, {}

with ThreadPoolExecutor(max_workers=16) as ex:
    # on soumet tout, et on garde la correspondance future → url
    futures = {ex.submit(telecharger, url): url for url in urls}

    for fut in as_completed(futures):          # rend la main dès qu'un résultat arrive
        url = futures[fut]
        try:
            resultats[url] = fut.result()      # l'exception éventuelle surgit ICI
        except requests.Timeout:
            echecs[url] = "timeout"
        except Exception as e:                 # ne jamais laisser une tâche tuer la boucle
            echecs[url] = repr(e)

print(f"{len(resultats)} réussites, {len(echecs)} échecs")
Deux réflexes obligatoires. D'abord, toujours un timeout sur un appel réseau : sans lui, un serveur muet immobilise un worker indéfiniment, et le programme finit bloqué sans message d'erreur. Ensuite, une exception levée dans une tâche reste silencieuse jusqu'à l'appel de .result() : si l'on ne consulte jamais le futur, l'erreur disparaît sans laisser de trace.

Combien de threads ? Pour de l'I/O, on peut largement dépasser le nombre de cœurs — quelques dizaines — puisqu'ils attendent la plupart du temps. La limite vient plutôt de ce qu'accepte le service distant : au-delà, on se fait limiter ou bannir. Un ordre de grandeur raisonnable est 8 à 32, en surveillant les codes 429.

5. Partager de l'état entre threads

Puisque les threads partagent la mémoire, ils peuvent modifier les mêmes variables — et c'est là que surgissent les bugs les plus difficiles à reproduire. Une condition de course (race condition) apparaît quand le résultat dépend de l'ordre, imprévisible, dans lequel les threads s'intercalent.

Python — un compteur qui perd des incréments
compteur = 0

def incrementer(n=100_000):
    global compteur
    for _ in range(n):
        compteur += 1        # PAS atomique : lire, ajouter 1, réécrire — 3 étapes

with ThreadPoolExecutor(max_workers=4) as ex:
    list(ex.map(lambda _: incrementer(), range(4)))

print(compteur)              # attendu 400 000 ; obtenu 387 412, 400 000, 392 118...

L'explication : compteur += 1 se décompose en trois opérations. Un thread peut lire la valeur 100, être interrompu, puis un autre lit aussi 100, ajoute 1 et écrit 101 ; le premier reprend, écrit lui aussi 101. Un incrément est perdu. Le GIL ne protège pas de cela : il garantit qu'une seule instruction de bytecode s'exécute à la fois, pas qu'une séquence de trois reste indivisible.

Python — les trois façons correctes
import threading, queue

# 1. un verrou : une seule section critique à la fois
verrou = threading.Lock()
def incrementer_sur(n=100_000):
    global compteur
    for _ in range(n):
        with verrou:          # acquiert, exécute, relâche — même en cas d'exception
            compteur += 1     # correct, mais le verrou coûte cher s'il est très sollicité

# 2. NE RIEN PARTAGER : chaque thread renvoie son résultat, on agrège à la fin
def compter(n):
    total = 0
    for _ in range(n):
        total += 1
    return total              # aucune variable partagée → aucun verrou nécessaire
with ThreadPoolExecutor(4) as ex:
    compteur = sum(ex.map(compter, [100_000] * 4))    # exact, et plus rapide

# 3. une file : la structure conçue pour la communication entre threads
travaux = queue.Queue()       # thread-safe par construction
resultats = queue.Queue()

La deuxième approche est presque toujours la bonne : ne pas partager d'état mutable. Chaque tâche produit un résultat indépendant, et l'agrégation se fait dans le thread principal. Les verrous n'ont leur place que lorsqu'une ressource est intrinsèquement unique — un fichier de log, une connexion. Et il faut savoir qu'ils apportent leurs propres pièges : deux verrous acquis dans des ordres différents par deux threads produisent un interblocage, où chacun attend indéfiniment l'autre.

6. Les processus, pour calculer

Pour du calcul Python pur, la seule échappatoire au GIL consiste à lancer plusieurs processus : chacun a son propre interpréteur, donc son propre GIL. L'interface est identique à celle des threads, ce qui rend le passage de l'un à l'autre presque gratuit — mais les contraintes, elles, sont très différentes.

Python — ProcessPoolExecutor
from concurrent.futures import ProcessPoolExecutor
import random

def simuler(graine: int) -> float:
    """Monte-Carlo en Python pur : CPU-bound, donc les threads seraient inutiles."""
    rng = random.Random(graine)
    return sum(rng.random() ** 2 for _ in range(1_000_000)) / 1_000_000

if __name__ == "__main__":                 # OBLIGATOIRE — explication ci-dessous
    with ProcessPoolExecutor(max_workers=8) as ex:
        resultats = list(ex.map(simuler, range(64), chunksize=4))
    print(sum(resultats) / len(resultats))

Ici, le gain est réel : sur huit cœurs, on approche un facteur sept. Trois contraintes expliquent en revanche la plupart des échecs rencontrés avec les processus.

a. La sérialisation

Les processus ne partagent pas la mémoire : arguments et résultats doivent donc être sérialisés — convertis en octets par le module pickle, transmis, puis reconstruits de l'autre côté. Conséquences concrètes : envoyer un DataFrame de 3 Go à huit workers, c'est le sérialiser huit fois, et le transfert coûte alors bien plus cher que le calcul. Et tout n'est pas sérialisable — les fonctions anonymes (lambda), les fonctions définies dans une autre fonction, les objets tenant un fichier ouvert ou une connexion échouent avec un message souvent obscur.

# ÉCHOUE : une lambda n'est pas picklable
ex.map(lambda x: x ** 2, donnees)

# FONCTIONNE : une fonction définie au niveau du module
def carre(x): return x ** 2
ex.map(carre, donnees)

b. La méthode de démarrage

Deux façons de créer un processus enfant. fork (historique sous Linux) duplique le processus courant : rapide, mais dangereux en présence de threads, car un verrou détenu au moment de la duplication reste verrouillé à jamais dans l'enfant — d'où des blocages inexplicables. spawn (défaut sous Windows et macOS, et sous Linux à partir de Python 3.14) démarre un interpréteur neuf qui réimporte le module.

C'est cette réimportation qui explique le fameux if __name__ == "__main__":. Sans lui, le code de lancement du pool serait réexécuté par chaque enfant, qui créerait à son tour un pool, et ainsi de suite — une bombe à fourche qui sature la machine en quelques secondes.

c. Le coût fixe

Créer un processus coûte quelques dizaines à quelques centaines de millisecondes. Paralléliser des tâches de 5 ms est donc perdant : le temps passé à distribuer dépasse le temps de calcul. C'est le rôle de chunksize, qui regroupe les tâches en paquets pour amortir ce coût. Règle simple : viser des paquets d'au moins une centaine de millisecondes de travail.

Éviter les copies : mémoire partagée et joblib

Python — joblib, le plus pratique en data science
from joblib import Parallel, delayed

# n_jobs=-1 : tous les cœurs. Les grands tableaux NumPy sont mémoire-mappés
# sur disque et partagés en lecture, pas copiés dans chaque worker.
scores = Parallel(n_jobs=-1, backend="loky", verbose=1)(
    delayed(evaluer_pli)(X, y, pli) for pli in plis
)

delayed est un décorateur (module 1) qui transforme un appel en description d'appel : il capture la fonction et ses arguments sans les exécuter, pour que joblib puisse les distribuer. C'est ce backend que scikit-learn utilise sous le capot pour n_jobs.

Pour un contrôle plus fin, multiprocessing.shared_memory permet d'exposer un même bloc mémoire à plusieurs processus sans copie — utile pour un grand tableau en lecture seule partagé par tous les workers.

7. asyncio, pas à pas

L'idée

asyncio ne parallélise rien : il entrelace. Tout se passe dans un seul thread, piloté par une boucle d'événements — un ordonnanceur qui tient la liste des tâches en attente. Chaque tâche s'exécute jusqu'au moment où elle doit attendre quelque chose ; à cet instant, elle le signale, la boucle la met de côté et donne la main à une autre tâche. Quand la réponse arrive, la tâche reprend là où elle s'était arrêtée.

C'est exactement le comportement du cuisinier de la section 1 : il ne va pas plus vite, mais il ne reste jamais planté devant la casserole. Le gain est le même qu'avec des threads pour de l'I/O, sans le coût d'un thread système par tâche — on tient ainsi des dizaines de milliers de connexions simultanées.

Le vocabulaire minimal

Python — trois mots-clés, trois rôles
import asyncio

async def tache(nom, duree):        # "async def" → ce n'est plus une fonction ordinaire,
    print(f"{nom} démarre")         #    mais une COROUTINE
    await asyncio.sleep(duree)      # "await" → point de pause : je rends la main
    print(f"{nom} terminée")        #    la boucle en profite pour faire tourner les autres
    return nom

async def main():
    # gather : lancer plusieurs coroutines ENSEMBLE et attendre qu'elles finissent
    resultats = await asyncio.gather(
        tache("A", 2),
        tache("B", 1),
        tache("C", 3),
    )
    return resultats

asyncio.run(main())    # "asyncio.run" → démarre la boucle d'événements et attend main()

# affiche : A démarre, B démarre, C démarre, B terminée, A terminée, C terminée
# durée totale : 3 s (le maximum), et non 6 s (la somme)

Le point à retenir : appeler tache("A", 2) ne l'exécute pas, cela crée un objet coroutine. Rien ne se passe tant qu'une boucle ne le prend pas en charge, via await, gather ou create_task. C'est d'ailleurs l'origine d'un avertissement classique — coroutine was never awaited — qui signifie simplement qu'on a oublié le await.

Un exemple réaliste

Python — 500 requêtes HTTP, avec limitation et reprise
import asyncio, httpx          # httpx : l'équivalent asynchrone de requests

async def recuperer(client: httpx.AsyncClient, url: str,
                    sem: asyncio.Semaphore) -> dict | None:
    async with sem:                          # au plus N requêtes EN VOL simultanément
        for essai in range(3):               # petite politique de reprise
            try:
                r = await client.get(url, timeout=10)
                r.raise_for_status()
                return r.json()
            except (httpx.TimeoutException, httpx.HTTPStatusError):
                await asyncio.sleep(2 ** essai)    # attente exponentielle : 1s, 2s, 4s
        return None

async def main(urls: list[str]) -> list[dict | None]:
    sem = asyncio.Semaphore(20)              # le garde-fou : 20 requêtes au maximum
    async with httpx.AsyncClient() as client:     # une seule session, connexions réutilisées
        taches = [recuperer(client, u, sem) for u in urls]
        return await asyncio.gather(*taches)

resultats = asyncio.run(main(urls))
print(sum(r is not None for r in resultats), "réponses valides")

Le sémaphore mérite une explication : c'est un compteur de jetons. async with sem prend un jeton, et le rend à la sortie du bloc ; s'il n'en reste plus, la tâche attend. Sans lui, gather lancerait les 500 requêtes d'un coup, ce qui saturerait le serveur distant et provoquerait des bannissements. C'est la forme asynchrone de la politesse.

Le piège n° 1 : un appel bloquant gèle toute la boucle. Puisque tout vit dans un seul thread, un seul appel synchrone long — un requests.get, un time.sleep, un model.predict — bloque toutes les tâches en cours, y compris celles qui n'ont rien à voir. La porte de sortie est asyncio.to_thread, qui délègue l'appel bloquant à un thread séparé :
valeur = await asyncio.to_thread(fonction_bloquante, argument)
C'est exactement le mécanisme qui permet à FastAPI de servir un modèle scikit-learn sans bloquer le serveur (module 6).

Le second inconvénient est la contagion : une fonction async ne peut être appelée que depuis du code async. Introduire asyncio dans une base de code synchrone finit par colorer toute la chaîne d'appels, et exige des bibliothèques asynchrones de bout en bout (httpx, asyncpg, aiofiles). C'est la raison pour laquelle, pour deux cents requêtes ponctuelles dans un script, un ThreadPoolExecutor reste souvent le choix le plus raisonnable.

8. Le piège de la sursouscription

Le diagnostic le plus fréquent, et le plus coûteux. Un cross_val_score(..., n_jobs=8) sur une machine à huit cœurs, où chaque processus lance un BLAS lui-même configuré sur huit threads : soixante-quatre threads se disputent huit cœurs. Les changements de contexte et la contention de cache font que le résultat est souvent plus lent que la version séquentielle, avec des cœurs pourtant à 100 %. Le remède : brider les threads natifs quand on parallélise au niveau des processus.

Une précision de vocabulaire : BLAS (Basic Linear Algebra Subprograms) désigne les bibliothèques d'algèbre linéaire compilées — OpenBLAS, MKL — sur lesquelles reposent NumPy et scikit-learn. Elles sont déjà multi-threadées, et c'est précisément ce qu'on oublie.

bash / Python
# à placer AVANT l'import de numpy : ces variables sont lues au chargement
export OMP_NUM_THREADS=1
export OPENBLAS_NUM_THREADS=1
export MKL_NUM_THREADS=1

# ou, plus finement, sur un bloc de code précis :
from threadpoolctl import threadpool_limits

with threadpool_limits(limits=1, user_api="blas"):
    scores = cross_val_score(modele, X, y, cv=5, n_jobs=8)

# pour vérifier ce qui tourne réellement :
from threadpoolctl import threadpool_info
print(threadpool_info())      # liste les bibliothèques natives et leur nombre de threads

La règle générale : un seul niveau de parallélisme à la fois. Soit on parallélise les plis de validation croisée et le BLAS reste mono-thread, soit on laisse le BLAS travailler et on garde n_jobs=1. Le bon réglage se mesure sur le cas réel, il ne se devine pas.

9. Récapitulatif

quoi utiliser quand
Type de charge                Outil                       Parallélisme réel ?
──────────────────────────────────────────────────────────────────────────────
Requêtes HTTP, base de        asyncio + httpx             oui (entrelacement)
données async, scraping
I/O avec une bibliothèque     ThreadPoolExecutor          oui (GIL relâché)
bloquante (requests, open)
Boucles Python pures,         ProcessPoolExecutor         oui (N interpréteurs)
parsing, simulation
Validation croisée,           joblib / n_jobs             oui (backend loky)
recherche d'hyperparamètres
NumPy, PyTorch, polars        ne rien ajouter             oui (BLAS, Rayon)
Boucle Python + threads       —                           NON : le GIL bloque
Données plus grosses          Dask (module 3)             oui (out-of-core)
que la RAM

Ce qu'il faut retenir du module

  • Processus = mémoire isolée, thread = mémoire partagée ; concurrence ≠ parallélisme.
  • Le GIL empêche deux threads d'exécuter du bytecode Python simultanément, mais il est relâché pendant les entrées/sorties et dans le code natif.
  • Mesurer avant de choisir : ratio temps CPU sur temps écoulé.
  • I/O → threads ou asyncio ; calcul Python pur → processus ; calcul NumPy → déjà parallèle.
  • Entre threads, ne rien partager de mutable ; agréger les résultats dans le thread principal.
  • Avec les processus : arguments picklables, garde if __name__ == "__main__", chunksize pour amortir le coût de lancement.
  • En asyncio, un seul appel bloquant gèle toute la boucle — asyncio.to_thread est la sortie de secours.
  • Ne jamais empiler deux niveaux de parallélisme : brider les threads BLAS quand on distribue des processus.

GILThreadsmultiprocessingasynciojoblibBLAS