Aller au contenu
Développement medium

Le GIL en Python : comprendre le Global Interpreter Lock

17 min de lecture

logo python

Le GIL (Global Interpreter Lock) est un verrou global de l'interpréteur CPython qui autorise un seul thread à exécuter du bytecode Python à un instant donné. Conséquence directe et souvent mal comprise : lancer un calcul CPU-bound sur plusieurs threading.Thread ne le rend pas plus rapide, car les threads se relaient sur un unique cœur logique au lieu de tourner vraiment en parallèle. Ce guide explique pourquoi ce verrou existe (la protection du comptage de références de CPython), prouve son effet par la mesure, détaille les contournements réels (multiprocessing, extensions C qui relâchent le verrou, asyncio pour l'I/O) et fait le point sur le free-threading (PEP 703), le build sans GIL.

Il s'adresse aux développeurs qui profilent un programme, se demandent pourquoi leurs threads ne saturent pas les cœurs, et veulent choisir le bon modèle de concurrence. Les exemples ont été exécutés avec Python 3.12.

  • Définir ce qu'est le GIL et le rôle qu'il joue dans CPython.
  • Comprendre pourquoi il protège le comptage de références.
  • Prouver par la mesure que les threads ne parallélisent pas le CPU-bound.
  • Choisir le bon contournement : processus, extension C ou asyncio.
  • Situer le free-threading (PEP 703) et son statut actuel.

Le GIL, ou Global Interpreter Lock, est un mutex unique protégeant l'ensemble de l'état interne de l'interpréteur CPython, l'implémentation de référence du langage. Un mutex (verrou d'exclusion mutuelle) garantit qu'une seule entité accède à une ressource à la fois. Ici, la ressource est l'interpréteur lui-même : pour exécuter la moindre instruction de bytecode Python, un thread doit d'abord acquérir le GIL, et il le relâche ensuite pour laisser sa chance aux autres.

La nuance capitale est que le GIL est un détail de CPython, pas une propriété du langage Python. D'autres implémentations comme Jython (sur la JVM) ou IronPython (sur .NET) n'ont jamais eu de GIL. Quand on parle du GIL, on parle donc du comportement de l'interpréteur que vous obtenez en tapant python3 sur la quasi-totalité des systèmes, celui distribué par python.org, les distributions Linux et les gestionnaires comme pyenv ou uv.

Pourquoi le GIL existe : le comptage de références

Section intitulée « Pourquoi le GIL existe : le comptage de références »

CPython gère la mémoire par comptage de références (reference counting) : chaque objet garde un compteur du nombre de variables qui le référencent. Quand ce compteur tombe à zéro, l'objet est libéré immédiatement. Vous pouvez inspecter ce compteur avec sys.getrefcount, qui renvoie une valeur volontairement supérieure de un, car l'argument passé à la fonction compte lui-même comme une référence temporaire.

import sys
texte = "concurrence"
print(sys.getrefcount(texte)) # p. ex. 2 : la variable + l'argument temporaire

Le problème surgit dès que deux threads manipulent le même objet en même temps. Incrémenter ou décrémenter un compteur n'est pas une opération atomique au niveau machine : c'est une lecture, un calcul, puis une écriture. Sans protection, deux threads qui décrémentent simultanément peuvent écraser mutuellement leur résultat. Le compteur devient faux, et l'on tombe soit sur une fuite mémoire (objet jamais libéré), soit sur une libération prématurée (objet détruit alors qu'il est encore utilisé, source de corruption et de plantages).

Le GIL est la réponse historique et pragmatique de CPython à ce risque. Plutôt que de poser un verrou fin sur chaque objet, ce qui coûterait cher et compliquerait tout, l'interpréteur pose un seul verrou global. Tant qu'un thread le détient, aucun autre ne touche au comptage de références : la cohérence est garantie par construction. Ce choix rend aussi l'interpréteur mono-thread très rapide et simplifie énormément l'intégration d'extensions C, deux raisons pour lesquelles le GIL a survécu si longtemps.

La preuve par la mesure : les threads ne parallélisent pas le CPU

Section intitulée « La preuve par la mesure : les threads ne parallélisent pas le CPU »

L'affirmation « les threads ne parallélisent pas le CPU-bound » se démontre en quelques lignes. On prend une fonction purement CPU-bound, c'est-à-dire qui ne fait que calculer sans jamais attendre le disque ou le réseau, et on compare deux exécutions séquentielles à deux threads concurrents.

import time
import threading
import sys
def compter(n: int) -> None:
# Boucle purement CPU-bound : aucun accès disque ni reseau
while n > 0:
n -= 1
N = 50_000_000
# 1. Sequentiel : deux appels l'un apres l'autre
debut = time.perf_counter()
compter(N)
compter(N)
sequentiel = time.perf_counter() - debut
print(f"Sequentiel (2x) : {sequentiel:.2f} s")
# 2. Deux threads censes travailler "en parallele"
debut = time.perf_counter()
t1 = threading.Thread(target=compter, args=(N,))
t2 = threading.Thread(target=compter, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
threads = time.perf_counter() - debut
print(f"2 threads : {threads:.2f} s")
print(f"Acceleration threads : x{sequentiel / threads:.2f}")

Voici la sortie obtenue sur une machine à 16 cœurs logiques, avec Python 3.12 :

Sequentiel (2x) : 1.37 s
2 threads : 1.54 s
Acceleration threads : x0.89

Le résultat est sans appel. Avec deux cœurs réellement disponibles, on pourrait espérer une accélération proche de x2. On obtient x0,89, c'est-à-dire un programme légèrement plus lent en threads qu'en séquentiel. Le GIL sérialise les deux boucles sur un unique cœur, et l'on paie en plus le coût des changements de contexte et des allers-retours pour acquérir et relâcher le verrou. La machine a beau posséder seize cœurs, un seul travaille à un instant donné sur du bytecode Python.

Puisque le verrou est par interpréteur, la parade la plus directe consiste à lancer plusieurs interpréteurs, c'est-à-dire plusieurs processus système. Chaque processus possède son propre GIL et sa propre mémoire ; ils tournent donc véritablement en parallèle sur des cœurs distincts. Le module multiprocessing de la bibliothèque standard fournit une API calquée sur threading pour cela.

import time
from multiprocessing import Process
def compter(n: int) -> None:
while n > 0:
n -= 1
if __name__ == "__main__":
N = 50_000_000
debut = time.perf_counter()
p1 = Process(target=compter, args=(N,))
p2 = Process(target=compter, args=(N,))
p1.start(); p2.start()
p1.join(); p2.join()
print(f"2 processus : {time.perf_counter() - debut:.2f} s")

Sur la même machine, le contraste est net : les deux processus bouclent en 0,91 s, contre 1,37 s pour la version séquentielle.

2 processus : 0.91 s

L'accélération est réelle parce que chaque processus calcule sur son cœur sans se disputer un verrou commun. Ce gain a un prix qu'il faut connaître. Créer un processus est plus lourd qu'un thread, et surtout les processus ne partagent pas la mémoire : tout objet échangé doit être sérialisé (avec pickle) puis transmis, ce qui coûte du temps et interdit de passer des objets non picklables. Pour des tâches CPU nombreuses et indépendantes, on préfère en pratique un pool via concurrent.futures.ProcessPoolExecutor, qui recycle un nombre fixe de processus au lieu d'en créer un par tâche.

Contourner le GIL dans les extensions C et les bibliothèques numériques

Section intitulée « Contourner le GIL dans les extensions C et les bibliothèques numériques »

Il existe une seconde issue, souvent invisible et pourtant décisive : une extension C peut relâcher volontairement le GIL pendant qu'elle exécute du code natif qui ne touche pas aux objets Python. Le code C encadre alors sa section de calcul par les macros Py_BEGIN_ALLOW_THREADS et Py_END_ALLOW_THREADS. Pendant cet intervalle, d'autres threads Python progressent librement, et le calcul natif s'exécute réellement en parallèle.

C'est exactement ce que font les grandes bibliothèques de calcul scientifique. NumPy, SciPy, pandas ou PyTorch délèguent leurs opérations lourdes à des routines compilées (souvent en C, C++ ou Fortran, via des back-ends comme BLAS) qui relâchent le GIL le temps du calcul. Un produit matriciel NumPy lancé sur plusieurs threads peut donc bel et bien saturer plusieurs cœurs, alors même que le GIL existe. Le verrou ne bloque que l'exécution de bytecode Python, pas le code natif sous-jacent.

Cette réalité change la lecture du problème. Si votre goulot d'étranglement est déjà vectorisé dans NumPy ou délégué à une bibliothèque native, le GIL n'est peut-être pas votre ennemi : le travail intensif se fait déjà hors de son emprise. Le GIL mord surtout sur du calcul écrit en Python pur, boucle par boucle, comme dans notre démonstration.

Quand le programme passe l'essentiel de son temps à attendre (réponses réseau, requêtes de base de données, fichiers), le vrai besoin n'est pas le parallélisme mais la concurrence : occuper le temps d'attente utilement. C'est le terrain d'asyncio, le modèle asynchrone de la bibliothèque standard bâti sur une boucle d'événements et les mots-clés async et await.

Contrairement aux threads, asyncio fait tourner toutes les tâches sur un seul thread, donc sans lutte autour du GIL et sans changement de contexte système. À chaque await, une tâche qui attend rend la main à la boucle, qui fait avancer une autre tâche prête. Pour des milliers de connexions I/O simultanées, ce modèle est plus léger en mémoire et souvent plus prévisible que des milliers de threads.

Attention toutefois au piège symétrique : asyncio ne résout pas le CPU-bound. Une tâche qui calcule longuement sans await bloque toute la boucle d'événements, gelant les autres coroutines. Pour un calcul lourd au sein d'un programme asynchrone, on le déporte vers un pool de processus avec loop.run_in_executor. Le tableau ci-dessous résume quel outil viser selon la nature du travail.

Nature du travailOutil adaptéLe GIL est-il un frein ?
CPU-bound en Python purmultiprocessing, ProcessPoolExecutorOui, contourné par des processus
CPU-bound vectorisé (NumPy, PyTorch)Threads + extension nativeNon, relâché par le code natif
I/O-bound (réseau, disque, base)threading ou asyncioNon, relâché pendant l'attente
I/O massif (milliers de connexions)asyncioNon, un seul thread suffit

Le chantier le plus marquant de l'écosystème est la suppression optionnelle du GIL, formalisée par la PEP 703 (« Making the Global Interpreter Lock Optional in CPython »). L'idée est de proposer un build free-threaded, dit « no-GIL », où plusieurs threads exécutent réellement du bytecode Python en parallèle, en remplaçant le verrou global par des mécanismes de synchronisation plus fins sur les objets et le ramasse-miettes.

Techniquement, il ne s'agit pas du même exécutable que le Python habituel. Le build free-threaded est séparé : son exécutable porte un suffixe t (par exemple python3.13t), et l'on peut vérifier l'état du verrou à l'exécution avec sys._is_gil_enabled(), qui renvoie False quand le GIL est désactivé. Cette fonction n'apparaît qu'avec Python 3.13 : sur un build classique 3.13 ou plus récent elle renvoie True, et sous Python 3.12, notre machine de test, l'appel échoue avec une AttributeError puisqu'elle n'existe pas encore. Le build free-threaded accepte aussi de réactiver le GIL au démarrage via la variable d'environnement PYTHON_GIL ou l'option -X gil.

Le premier piège est de blâmer le GIL trop vite. Beaucoup de programmes « lents » ne sont pas CPU-bound : ils attendent une base de données ou un réseau. Là, le GIL n'est pas en cause, et passer à multiprocessing n'apporte rien, voire dégrade les choses par le coût de sérialisation. Profilez d'abord pour savoir si vous êtes I/O-bound ou CPU-bound avant de choisir une parade.

Le deuxième piège est de croire que le GIL rend le code automatiquement thread-safe. C'est faux. Le GIL protège l'état interne de l'interpréteur, pas la logique de votre programme. Une opération apparemment simple comme compteur += 1 sur une variable partagée reste non atomique au niveau du bytecode et peut se corrompre entre threads. Il faut toujours poser vos propres verrous (threading.Lock) sur les données partagées mutables. Le GIL et le free-threading ne changent rien à cette exigence de conception.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Le GIL est un verrou global de CPython : un seul thread exécute du bytecode Python à la fois.
  • Il existe pour protéger le comptage de références des objets contre les accès concurrents.
  • Conséquence : les threads ne parallélisent pas le CPU-bound en Python pur (mesuré à x0,89 sur deux threads).
  • Le GIL est relâché pendant l'I/O et dans les extensions C (NumPy, PyTorch), qui parallélisent réellement.
  • Pour du CPU-bound en Python pur, on passe par des processus (multiprocessing, ProcessPoolExecutor).
  • Pour de l'I/O massif, asyncio tient des milliers de connexions sur un seul thread.
  • Le free-threading (PEP 703, build python3.13t) supprime le GIL ; vérifiez son statut pour votre version avant d'y bâtir dessus.
  • Le GIL ne rend pas votre code thread-safe : verrouillez vous-même les données partagées mutables.
  • asyncio en Python : le modèle asynchrone pour occuper les temps d'attente sans multiplier les threads.
  • FastAPI : un framework web bâti sur asyncio où le choix concurrence / parallélisme se pose concrètement.
  • Type hints en Python : annoter proprement les fonctions de vos workers avant de les paralléliser.
  • Le multiprocessing : contourner le verrou en répartissant le calcul sur plusieurs processus indépendants.
  • Cython : relâcher le GIL avec nogil pour paralléliser une boucle chaude dans une extension native.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn