Le GIL en Python : qu'est-ce que c'est et son impact

1. Tổng quan

Beaucoup de développeurs passant de Java ou Go à Python rencontrent le même problème. Lancer 4 threads sur une machine 8 cores, mais le programme ne s’accélère pas, parfois même ralentit. Le coupable habituel est le GIL (Global Interpreter Lock).

Cet article explique ce qu’est le GIL et pourquoi il existe. Vous allez mesurer l’impact du GIL sur le code CPU-bound et I/O-bound. À la fin, vous saurez quand utiliser threading, quand utiliser multiprocessing, et comment le build free-threaded de Python 3.13+ change les choses.

2. GIL est-ce que c’est ?

Le GIL est un mutex dans CPython, l’implémentation la plus courante de Python. À chaque moment donné, dans un processus, un seul thread exécute le bytecode Python.

Vous pouvez toujours créer plusieurs threads. Ce sont de vrais threads du système d’exploitation. Mais pour exécuter du bytecode, un thread doit tenir le GIL. Les autres threads doivent attendre.

2.1. Pourquoi CPython a-t-il besoin du GIL ?

La raison principale est le mécanisme de gestion mémoire par reference counting. Chaque objet a un compteur du nombre de références le pointant :

import sys

order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))

alias = order
print(sys.getrefcount(order))
2
3

sys.getrefcount retourne le nombre de références. Le résultat est toujours plus grand de 1 car l’appel de fonction lui-même crée une référence temporaire. Quand on assigne alias = order, le compteur augmente de 1.

Si deux threads incrémentent ou décrémentent ce compteur sans verrouillage, le compteur peut être incorrect. La conséquence est une fuite mémoire, ou pire, un objet libéré alors qu’il est toujours utilisé. Placer un verrou global est le moyen le plus simple de protéger tous les objets. Cela rend aussi plus facile l’écriture d’extensions C et rend le code monothread plus rapide.

2.2. Quand un thread libère-t-il le GIL ?

Un thread tenant le GIL le libère dans deux cas :

  1. En attendant I/O : lire un fichier, faire un appel réseau, time.sleep… Ces fonctions libèrent le GIL avant de se bloquer.
  2. À la fin d’un « tour » : l’interpréteur demande périodiquement au thread en cours de libérer le GIL pour que d’autres threads aient une chance de s’exécuter.

Vous pouvez voir la durée d’un tour :

import sys

print(sys.getswitchinterval())
0.005

Par défaut, c’est 5 ms. Vous pouvez le changer avec sys.setswitchinterval(), mais c’est rarement nécessaire.

3. Impact sur le code CPU-bound

Testons cela. La fonction suivante compte les nombres premiers, c’est une pure computation :

import time
from concurrent.futures import ThreadPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


def run_sequential(jobs: list[int]) -> float:
    start = time.perf_counter()
    for job in jobs:
        count_primes(job)
    return time.perf_counter() - start


def run_threads(jobs: list[int]) -> float:
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(count_primes, jobs))
    return time.perf_counter() - start


if __name__ == "__main__":
    jobs = [200_000] * 4
    print(f"Séquentiel : {run_sequential(jobs):.2f}s")
    print(f"4 threads  : {run_threads(jobs):.2f}s")

run_sequential exécute les 4 jobs séquentiellement. run_threads confie les 4 jobs à 4 threads. Résultat sur une machine 8 cores (à titre indicatif) :

Séquentiel : 2.31s
4 threads  : 2.40s

Quatre threads ne sont pas plus rapides. Ils sont même un peu plus lents. À cause du GIL, les threads s’exécutent tour à tour sur un seul core. Le surcoût est dû à la contention du GIL et à la commutation de contexte.

Note : C’est l’erreur la plus courante. Ajouter des threads à du code de pur calcul en Python sur CPython avec GIL ne l’accélère pas.

4. Impact sur le code I/O-bound

Avec du code qui attend principalement I/O, c’est très différent. On simule un appel à un PaymentService distant qui prend 0,5 seconde :

import time
from concurrent.futures import ThreadPoolExecutor


def charge_payment(order_id: int) -> str:
    time.sleep(0.5)  # simuler un appel HTTP vers le service de paiement
    return f"order-{order_id}: OK"


if __name__ == "__main__":
    order_ids = list(range(10))

    start = time.perf_counter()
    for oid in order_ids:
        charge_payment(oid)
    print(f"Séquentiel : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=10) as pool:
        list(pool.map(charge_payment, order_ids))
    print(f"10 threads : {time.perf_counter() - start:.2f}s")
Séquentiel : 5.01s
10 threads : 0.50s

10 fois plus rapide. time.sleep, comme les sockets ou la lecture de fichiers, libère le GIL pendant l’attente. Dix threads attendent en parallèle. Ils n’ont besoin du GIL que brièvement pour traiter le résultat.

Conclusion : les threads restent très utiles en Python pour l’I/O-bound, par exemple les appels API, requêtes base de données ou téléchargements. Pour un I/O très volumétrique, asyncio est aussi une bonne option.

5. Contourner le GIL avec multiprocessing

Pour tirer parti de plusieurs cores pour du calcul, le standard est d’utiliser plusieurs processus. Chaque processus a son propre interpréteur et son propre GIL.

import time
from concurrent.futures import ProcessPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


if __name__ == "__main__":
    jobs = [200_000] * 4
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(count_primes, jobs))
    print(results)
    print(f"4 processus : {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4 processus : 0.68s

Par rapport à 2,31 secondes en exécution séquentielle, on est environ 3,4 fois plus rapide. On n’atteint pas 4 fois car il faut payer le coût de lancement des processus.

Le bloc if __name__ == "__main__": est obligatoire. Sur Windows et macOS, les processus enfants sont créés en réimportant le module. Sans ce bloc, le processus enfant créerait un nouveau pool et le programme échouerait.

Note : Multiprocessing a son prix. Les paramètres et résultats doivent être sérialisés avec pickle pour être transmis entre les processus. Si vous envoyez un grand objet, comme un DataFrame de centaines de MB, pour une petite tâche, le coût de copie peut dépasser le bénéfice. Les lambdas et les fonctions imbriquées ne peuvent pas être pickled non plus.

6. Les extensions C peuvent libérer le GIL

Beaucoup de bibliothèques écrites en C libèrent volontairement le GIL lors de l’exécution de parties lourdes, par exemple NumPy, hashlib ou zlib. Dans ce cas, les threads s’exécutent vraiment en parallèle :

import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor

payloads = [os.urandom(50_000_000) for _ in range(4)]


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


if __name__ == "__main__":
    start = time.perf_counter()
    for p in payloads:
        digest(p)
    print(f"Séquentiel : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(digest, payloads))
    print(f"4 threads  : {time.perf_counter() - start:.2f}s")
Séquentiel : 0.98s
4 threads  : 0.27s

hashlib libère le GIL lors du hachage de gros volumes de données (à partir d’environ 2 KB). Ainsi, 4 threads s’exécutent sur 4 cores. En réalité, le GIL ne bloque que le bytecode Python, pas le code machine des bibliothèques natives.

7. Le GIL n’assure pas la thread-safety de votre code

Beaucoup pensent « avec le GIL, pas besoin de verrous ». C’est faux. Le GIL protège l’état interne de l’interpréteur, pas votre logique. Un thread peut être interrompu entre deux instructions quelconques.

import threading
import time


class BankAccount:
    def __init__(self) -> None:
        self.balance = 0
        self._lock = threading.Lock()

    def deposit_unsafe(self, amount: int) -> None:
        current = self.balance
        time.sleep(0)  # libère le GIL, simule une opération I/O intercalée
        self.balance = current + amount

    def deposit_safe(self, amount: int) -> None:
        with self._lock:
            current = self.balance
            time.sleep(0)
            self.balance = current + amount


def run(method_name: str, threads: int = 10, times: int = 100) -> int:
    account = BankAccount()
    method = getattr(account, method_name)

    def worker() -> None:
        for _ in range(times):
            method(1)

    pool = [threading.Thread(target=worker) for _ in range(threads)]
    for t in pool:
        t.start()
    for t in pool:
        t.join()
    return account.balance


if __name__ == "__main__":
    print("unsafe:", run("deposit_unsafe"))
    print("safe  :", run("deposit_safe"))
unsafe: 112
safe  : 1000

On s’attend à 10 × 100 = 1000. Avec deposit_unsafe, plusieurs threads lisent la même vieille valeur current et se réécrivent dessus, perdant la plupart des dépôts. time.sleep(0) aide à révéler le bug de façon certaine. En code réel, le point de libération du GIL pourrait être un log ou un appel database. deposit_safe utilise threading.Lock pour rendre l’opération lire-modifier-écrire atomique.

On peut écrire un test avec pytest pour fixer ce comportement. Installation :

pip install pytest
# test_bank_account.py
from bank_account import run


def test_safe_deposit_keeps_correct_balance():
    assert run("deposit_safe", threads=8, times=200) == 1600


def test_unsafe_deposit_loses_updates():
    assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
..                                                       [100%]
2 passed in 0.41s

Le deuxième test prouve que la race condition existe même si CPython a le GIL.

8. Python free-threaded : l’avenir sans GIL

À partir de Python 3.13, CPython a un build free-threaded (PEP 703) qui permet de désactiver le GIL. Vous lancez généralement cette version avec la commande python3.13t ou python3.14t. Avec Python 3.14, cette version est officiellement supportée (PEP 779), mais n’est pas encore le défaut.

Vérifiez l’état du GIL au moment de l’exécution :

import sys
import sysconfig

print("Build supporte le free-threading:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL est activé:", sys._is_gil_enabled())

Exécuter avec le build free-threaded :

python3.14t check_gil.py
Build supporte le free-threading: True
GIL est activé: False

Avec cette version, l’exemple count_primes utilisant ThreadPoolExecutor à la section 3 bénéficiera vraiment de plusieurs cores.

Cependant, vous devriez considérer quelques points avant de passer :

  • Le code monothread est légèrement plus lent, car l’interpréteur doit utiliser des mécanismes de synchronisation plus sophistiqués.
  • Les extensions C doivent être compatibles. Si vous importez une extension qui n’a pas déclaré son support du free-threading, CPython réactivera automatiquement le GIL et affichera un avertissement. Vous pouvez forcer avec PYTHON_GIL=0, mais c’est à vos risques et périls.
  • Les race conditions sont plus faciles à causer. L’exemple à la section 7 ne bénéficie plus de la « protection » d’un seul thread s’exécutant à la fois.

9. Quel outil choisir ?

Type de tâche À utiliser
I/O-bound (HTTP, DB, fichier) threading, ThreadPoolExecutor, asyncio
CPU-bound Python pur ProcessPoolExecutor / multiprocessing
CPU-bound sur NumPy, hashlib… Les threads conviennent, la lib libère le GIL
CPU-bound, partage mémoire, lib compatible Envisager Python free-threaded (3.13t/3.14t)

Note : Ne pas optimiser sur un coup de tête. Mesurez d’abord avec time.perf_counter() ou cProfile. Beaucoup de programmes « lents à cause du GIL » sont en réalité lents à cause de l’algorithme ou des requêtes database.

10. Conclusion

Le GIL est le verrou global de CPython. Il assure qu’un seul thread exécute le bytecode Python à la fois, principalement pour protéger le reference counting.

Les conséquences pratiques se résument à quatre points :

  • Les threads n’accélèrent pas le code CPU-bound Python pur.
  • Les threads restent très efficaces pour l’I/O-bound et pour les bibliothèques natives qui libèrent le GIL.
  • Pour tirer parti de plusieurs cores pour du calcul, utilisez multiprocessing, ou essayez le build free-threaded de Python 3.13+.
  • Le GIL ne remplace pas les verrous. Les données partagées entre threads nécessitent toujours une synchronisation appropriée.
pythongilconcurrencymultithreadingperformance