backend / python
Concurrence : threading, multiprocessing et le GIL
Explication
Ce que vous allez apprendre
- Comprendre ce qu'est le GIL et pourquoi il empêche le vrai parallélisme CPU en threading
- Distinguer une tâche CPU-bound d'une tâche I/O-bound pour choisir le bon outil
- Utiliser
threadingpour paralléliser des attentes (réseau, disque) efficacement - Utiliser
multiprocessingpour du vrai calcul parallèle en contournant le GIL - Protéger un état partagé entre threads avec un
Lockpour éviter une race condition
Dans quel contexte ?
Un développeur écrit un script qui télécharge 100 images depuis des URLs différentes puis se demande pourquoi lancer 100 threads ne rend pas le script 100 fois plus rapide sur un calcul de compression d'image qui suit le téléchargement. La réponse tient en un mot : le GIL. Le téléchargement (I/O-bound) profite pleinement du threading, mais la compression (CPU-bound) ne peut pas s'exécuter en parallèle sur plusieurs threads dans CPython — il faudrait multiprocessing pour cette seconde partie.
La particularité qui distingue CPython
CPython, l'implémentation de référence de Python, possède un verrou global appelé GIL (Global Interpreter Lock) qui garantit qu'un seul bytecode Python s'exécute à la fois, même si le programme utilise plusieurs threads. C'est une source de confusion fréquente pour qui vient d'un langage comme Java ou C++ : lancer plusieurs threads en Python n'accélère pas un calcul pur, contrairement à l'intuition.
Deux familles de tâches, deux solutions différentes
Il faut distinguer les tâches limitées par le CPU (des calculs intensifs) des tâches limitées par l'attente (réseau, disque). Le GIL est libéré pendant une opération d'attente comme time.sleep() ou une requête réseau, donc threading reste très efficace pour exécuter plusieurs attentes en parallèle. En revanche, pour du vrai calcul intensif, il faut multiprocessing, qui lance de véritables processus séparés, chacun avec son propre interpréteur et donc son propre GIL, contournant complètement la limitation.
| Type de tâche | Exemple | Outil recommandé |
|---|---|---|
| I/O-bound | Appels API, lecture de fichiers, requêtes réseau | threading ou asyncio |
| CPU-bound | Calcul scientifique, compression, chiffrement | multiprocessing |
Piège fréquent
Paralléliser un calcul pur (total += i ** 2 dans une boucle) avec threading.Thread ne l'accélère pas : le GIL garantit qu'un seul thread exécute du bytecode Python à la fois. Seul multiprocessing, avec de vrais processus séparés, permet d'exploiter plusieurs cœurs pour ce type de calcul.
Le prix à payer pour de vrais processus
Contrairement aux threads, les processus ne partagent pas leur mémoire par défaut : toute communication entre eux passe par une sérialisation explicite (Queue, Pipe), ce qui a un coût. C'est un compromis fondamental : plus de parallélisme réel, mais moins de partage d'état simple.
Le danger classique du partage d'état
Quand plusieurs threads modifient la même variable sans synchronisation, on obtient une race condition : le résultat final dépend de l'ordre imprévisible d'exécution. Un verrou (Lock) protège une section critique en garantissant qu'un seul thread à la fois peut la traverser. concurrent.futures unifie ensuite ces deux approches sous une API commune et plus simple à manier au quotidien.
Commandes & code
Concurrence : threading, multiprocessing et le GIL
Comprendre le Global Interpreter Lock et choisir le bon outil de parallélisme.
import threading
import multiprocessing
import time
import concurrent.futures
# --- Le GIL (Global Interpreter Lock) ---
# CPython n'execute qu'UN SEUL bytecode Python a la fois, meme avec plusieurs threads.
# Consequence : threading n'accelere PAS le code CPU-bound (calculs purs),
# mais reste tres efficace pour le code I/O-bound (reseau, disque, attente).
def tache_cpu_intensive(n):
# calcul pur : le GIL empeche un vrai parallelisme ici
total = 0
for i in range(n):
total += i ** 2
return total
def tache_io_intensive():
# simulateur d'attente reseau/disque : le GIL est LIBERE pendant l'I/O
time.sleep(1)
return "termine"
# --- threading : efficace pour l'I/O-bound ---
debut = time.perf_counter()
threads = [threading.Thread(target=tache_io_intensive) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"5 taches I/O en parallele : {time.perf_counter() - debut:.2f}s") # ~1s, pas 5s
# Race condition : probleme classique du multi-threading sur un etat partage
compteur = 0
verrou = threading.Lock()
def incrementer_sans_verrou():
global compteur
for _ in range(100_000):
compteur += 1 # operation NON atomique : lecture puis ecriture
def incrementer_avec_verrou():
global compteur
for _ in range(100_000):
with verrou: # section critique protegee
compteur += 1
threads = [threading.Thread(target=incrementer_avec_verrou) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(compteur) # 400000 garanti grace au verrou
# Autres primitives de synchronisation
evenement = threading.Event() # signal on/off entre threads
semaphore = threading.Semaphore(3) # limite le nombre d'acces concurrents
condition = threading.Condition() # attente/notification entre threads
# --- multiprocessing : contourne le GIL avec de vrais processus separes ---
def calcul_lourd(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
debut = time.perf_counter()
with multiprocessing.Pool(processes=4) as pool:
resultats = pool.map(calcul_lourd, [10_000_000] * 4)
print(f"Multiprocessing (4 coeurs) : {time.perf_counter() - debut:.2f}s")
# Communication inter-processus : Queue, Pipe (la memoire n'est PAS partagee par defaut)
queue = multiprocessing.Queue()
def worker(q):
q.put("resultat du processus enfant")
p = multiprocessing.Process(target=worker, args=(queue,))
p.start()
p.join()
print(queue.get())
# Memoire partagee explicite si necessaire (couteux, a limiter)
valeur_partagee = multiprocessing.Value("i", 0)
tableau_partage = multiprocessing.Array("d", [0.0] * 5)
# --- concurrent.futures : API haut-niveau unifiee ---
def traiter_element(x):
return x ** 2
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
# ideal pour de l'I/O-bound (appels API, lecture fichiers)
resultats = list(executor.map(traiter_element, range(10)))
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
# ideal pour du CPU-bound (calculs lourds)
futures = [executor.submit(calcul_lourd, n) for n in [1_000_000, 2_000_000]]
for future in concurrent.futures.as_completed(futures):
print(future.result())| Cas d'usage | Outil recommandé |
|---|---|
| Appels réseau, I/O, attente | threading ou asyncio |
| Calcul CPU pur (data science, crypto) | multiprocessing |
| API simple et unifiée | concurrent.futures |
| Milliers de connexions concurrentes | asyncio |
Résumé
- Le GIL empêche le vrai parallélisme CPU en Python :
threadingn'accélère que l'I/O-bound. multiprocessinglance de vrais processus séparés (mémoire non partagée) pour le CPU-bound.- Une race condition survient quand plusieurs threads modifient un état partagé sans verrou (
Lock). concurrent.futuresunifieThreadPoolExecutor/ProcessPoolExecutorsous une même API.
Exercices pratiques
Mission : expliquer pourquoi 8 threads n'accélèrent pas un calcul de hash
Objectif : Diagnostiquer pourquoi le threading n'accélère pas une tâche CPU-bound à cause du GIL, corriger avec multiprocessing, et sécuriser un compteur partagé avec un Lock.
Contexte
Un développeur lance 8 threading.Thread pour calculer le hash de 8 gros fichiers en parallèle, espérant diviser le temps total par 8. Le script prend exactement le même temps qu'avec un seul thread, à quelques millisecondes près, ce qui le rend perplexe puisque sa machine a bien 8 cœurs disponibles.
Tu dois expliquer ce résultat surprenant, corriger le script avec le bon outil de parallélisme, puis sécuriser un compteur partagé entre threads contre une race condition.