Le GIL de CPython : Mécanisme, Limites et Contournements pour Développeurs Expérimentés
Dans CPython, le GIL protège le comptage de références des conditions de concurrence. Sans lui, deux threads pourraient décrémenter simultanément l'ob_refcnt du même objet : les deux verraient une valeur de 1, les deux libéreraient la mémoire — provoquant un segfault.
Termes clés :
- Segfault : Accès à une mémoire libérée, déclenchant SIGSEGV.
- Opcode : Une instruction de bytecode atomique (utilisez dis pour visualiser).
- Comptage de références : Lorsque ob_refcnt tombe à 0, l'objet est détruit.
- Mutex : Un seul thread peut entrer dans une section critique à la fois.
- Condition de concurrence : Le résultat dépend de l'ordre d'exécution des threads.
- Bytecode : Fichier .py compilé en .pyc, exécuté par la VM.
- Changement de contexte : Surcharge d'environ ~microsecondes, perturbe le cache CPU.
Exemple d'une condition de concurrence sans le GIL :
# thread1 : refcount=1 -> free(obj)
# thread2 : refcount=1 -> free(obj) -> segfault
Objectif du GIL dans CPython
Le GIL est un mutex global qui permet à un seul thread d'exécuter du bytecode à la fois. Les threads peuvent travailler en parallèle pendant les appels système, mais pas dans le code Python.
Pourquoi il est nécessaire :
- Protège ob_refcnt des modifications simultanées.
- Simplifie les extensions C sans soucis de sécurité des threads.
- Offre une sécurité des threads peu coûteuse sans synchronisation complexe.
Le GIL est spécifique à CPython. Jython et IronPython ne l'ont pas.
Mécanisme interne du GIL (depuis 3.2+)
Nouvelle implémentation dans Python/ceval_gil.c : la structure _gil_runtime_state.
Composants clés :
locked: Drapeau atomique (0/1).mutex+cond_var: Primitives POSIX pour l'attente.eval_breaker: Vérifié après chaque opcode.gil_drop_request: Demande d'un thread en attente.
Algorithme :
- Un thread détient le GIL et exécute du bytecode.
- Après 5 ms (sys.getswitchinterval()), un autre thread signale.
- Le thread actuel voit le drapeau via eval_breaker et libère le GIL.
- Le thread en attente l'acquiert ; le thread actuel attend sur cond_var.
sys.setswitchinterval() : Par défaut 0,005 seconde. Augmentez à 0,05–0,1 seconde pour les tâches liées au CPU avec NumPy (moins de basculements). Ne l'ajustez pas pour les E/S.
Pourquoi Lock/RLock sont nécessaires malgré le GIL
Le GIL est atomique par opcode, mais counter += 1 implique LOAD_GLOBAL + BINARY_ADD + STORE_GLOBAL.
counter += 1
# Peut être interrompu après LOAD_GLOBAL
# Deux threads : +1 au lieu de +2
Solution :
import threading
counter = 0
lock = threading.Lock()
def safe_increment():
global counter
with lock:
counter += 1
RLock pour la réentrance :
class SafeTree:
def __init__(self):
self._lock = threading.RLock()
def insert(self, value):
with self._lock:
self._rebalance() # pas de blocage
def _rebalance(self):
with self._lock:
pass
Règle : Le GIL protège l'interpréteur ; Lock protège la logique métier.
Tâches liées au CPU vs liées aux E/S
Liées aux E/S (réseau/disque) : Le GIL est libéré pendant les appels système (lecture/écriture). threading/asyncio fournissent de la concurrence.
import threading, requests
def fetch(url):
r = requests.get(url) # GIL libre
return r.status_code
Liées au CPU (calculs) : Le GIL bloque le parallélisme. Utilisez multiprocessing.
from multiprocessing import Pool
def heavy_compute(n):
return sum(i * i for i in range(n))
with Pool(4) as p:
results = p.map(heavy_compute, [10**7]*4) # 4 GILs
Libération du GIL dans les extensions C
NumPy/pandas libèrent le GIL pendant les opérations lourdes :
Py_BEGIN_ALLOW_THREADS
matrix_multiply(a, b, result, n);
Py_END_ALLOW_THREADS
threading + NumPy = vrai parallélisme. Le Python pur dans les threads ne le permet pas.
asyncio et indépendance vis-à-vis du GIL
Multitâche coopératif dans un seul thread. Le changement se produit sur await.
import asyncio
import aiohttp
async def fetch_data(session, url):
async with session.get(url) as resp:
return await resp.json()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch_data(session, url) for url in urls]
results = await asyncio.gather(*tasks)
threading vs asyncio pour les E/S :
- threading : Threads du système d'exploitation, surcharge, conditions de concurrence.
- asyncio : 1 thread, pas de surcharge, contrôle explicite.
Niveaux de sécurité Thread/Processus/Async
| Type | Problème | Mémoire partagée | Outils | Cas d'utilisation |
|------|---------|---------------|-------|----------|
| Thread | Concurrence dans les threads | Oui | Lock, Queue, local() | ThreadPoolExecutor |
| Processus | Concurrence entre processus | Explicitement | multiprocessing.Lock, Manager | Celery, Gunicorn |
| Async | Concurrence entre coroutines | Un seul thread | asyncio.Lock, éviter la mutabilité | FastAPI |
Exemple de concurrence asynchrone :
# PAS sûr
async def transfer(from_acc, to_acc, amount):
balance = await db.get_balance(from_acc)
if balance >= amount: # yield ici
await db.deduct(from_acc, amount)
Utilisez asyncio.Lock ou les transactions de base de données.
Points clés à retenir
- Le GIL protège le comptage de références, pas la logique métier — utilisez toujours Lock.
- Pour les tâches liées au CPU : multiprocessing, pas threading.
- Les extensions C (NumPy) permettent le parallélisme dans les threads.
- asyncio ignore le GIL : multitâche coopératif dans un seul thread.
- sys.setswitchinterval() est rarement nécessaire, seulement pour les tâches intensives avec NumPy.
— Editorial Team
Aucun commentaire pour le moment.