Retour au cours

backend / python

Concurrence : threading, multiprocessing et le GIL

Leçon 201 exercice

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 threading pour paralléliser des attentes (réseau, disque) efficacement
  • Utiliser multiprocessing pour du vrai calcul parallèle en contournant le GIL
  • Protéger un état partagé entre threads avec un Lock pour é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âcheExempleOutil recommandé
I/O-boundAppels API, lecture de fichiers, requêtes réseauthreading ou asyncio
CPU-boundCalcul scientifique, compression, chiffrementmultiprocessing

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.

python
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'usageOutil recommandé
Appels réseau, I/O, attentethreading ou asyncio
Calcul CPU pur (data science, crypto)multiprocessing
API simple et unifiéeconcurrent.futures
Milliers de connexions concurrentesasyncio

Résumé

  • Le GIL empêche le vrai parallélisme CPU en Python : threading n'accélère que l'I/O-bound.
  • multiprocessing lance 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.futures unifie ThreadPoolExecutor/ProcessPoolExecutor sous une même API.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →