Skip to main content
Niveau : Expert

Chapitre 39 : Gestion de la Mémoire et le GIL en Python

Objectif

Ce module a pour but de démystifier deux aspects fondamentaux et souvent mal compris de l'interpréteur CPython : la gestion de la mémoire, notamment le comptage de références et le ramasse-miettes (garbage collector), et le fameux Verrou Global de l'Interpréteur (Global Interpreter Lock ou GIL).

1. Gestion de la Mémoire en CPython

CPython (l'implémentation standard de Python) utilise deux mécanismes principaux pour gérer la mémoire :

a. Comptage de Références (Reference Counting)

C'est le mécanisme principal. Chaque objet en mémoire possède un compteur qui stocke le nombre de références (variables, éléments de liste, etc.) pointant vers lui.

  • Quand une nouvelle référence est créée, le compteur est incrémenté.
  • Quand une référence est détruite (sortie de scope, del, réassignation), le compteur est décrémenté.
  • Lorsque le compteur d'un objet atteint zéro, cela signifie que plus rien ne l'utilise. L'objet est alors immédiatement détruit et la mémoire qu'il occupait est libérée.
import sys

a = [] # Le compteur de la liste vide est à 1 (référence 'a')
print(f"1. Compteur pour []: {sys.getrefcount(a) - 1}") # -1 car getrefcount crée une réf. temporaire

b = a # Le compteur est à 2 (références 'a' et 'b')
print(f"2. Compteur pour []: {sys.getrefcount(a) - 1}")

b = None # La référence 'b' est détruite, le compteur passe à 1
print(f"3. Compteur pour []: {sys.getrefcount(a) - 1}")

a = None # La référence 'a' est détruite, le compteur passe à 0. L'objet est libéré.

Avantages :

  • Déterministe : Les objets sont libérés dès qu'ils ne sont plus utilisés.
  • Simple et efficace pour la plupart des cas.

Inconvénient majeur :

  • Ne gère pas les références cycliques.

b. Le Ramasse-Miettes (Garbage Collector)

Le comptage de références échoue dans le cas de cycles. Par exemple, si l'objet A référence l'objet B, et que l'objet B référence l'objet A.

import gc

# Création d'une référence cyclique
a = {}
b = {}
a['b_ref'] = b
b['a_ref'] = a

# Obtenons les ID pour les suivre
id_a = id(a)
id_b = id(b)

# Le compteur de chaque objet est au moins à 2 (variable + référence de l'autre objet)
print(f"Compteur de a: {sys.getrefcount(a) - 1}")
print(f"Compteur de b: {sys.getrefcount(b) - 1}")

# Supprimons les seules références externes
del a
del b

# À ce stade, les compteurs de référence sont à 1, mais les objets sont inaccessibles.
# Ils ne seront jamais libérés par le seul comptage de références. C'est une fuite de mémoire.

# Forçons le passage du Garbage Collector
gc.collect()

print("Garbage Collector a tourné.")
# Après le passage du GC, les objets inaccessibles dans des cycles sont libérés.

Pour résoudre ce problème, Python dispose d'un ramasse-miettes générationnel (Generational Garbage Collector).

  • Il s'exécute périodiquement.
  • Il est conçu spécifiquement pour détecter et nettoyer les cycles de références.
  • Il divise les objets en trois "générations". Les nouveaux objets sont dans la génération 0. S'ils survivent à un cycle de collecte, ils sont promus à la génération 1, puis à la 2. Le GC passe plus souvent sur les jeunes générations.

Le module gc permet d'interagir avec le ramasse-miettes (le désactiver, le lancer manuellement, etc.), mais en pratique, on le laisse généralement gérer les choses automatiquement.

2. Le Verrou Global de l'Interpréteur (GIL)

Le GIL est sans doute l'un des aspects les plus controversés de CPython.

Définition : Le GIL est un mutex (un verrou) qui protège l'accès aux objets Python, empêchant plusieurs threads natifs (threads du système d'exploitation) d'exécuter des bytecodes Python en même temps.

En d'autres termes, même sur une machine avec plusieurs cœurs de processeur, un seul thread peut exécuter du code Python à un instant T.

Pourquoi le GIL existe-t-il ?

Le GIL a été introduit pour une raison simple : simplifier la gestion de la mémoire. Le comptage de références n'est pas "thread-safe". Sans le GIL, deux threads pourraient essayer de modifier le compteur d'un même objet en même temps, menant à des conditions de course (race conditions) qui pourraient corrompre le compteur et soit causer des fuites de mémoire, soit libérer prématurément un objet encore utilisé (provoquant un crash).

Le GIL est une solution simple et efficace à ce problème : un seul thread à la fois, donc pas de conflit.

Impact du GIL sur la Concurrence

L'impact du GIL dépend du type de tâche que vos threads effectuent.

a. Tâches "CPU-bound" (limitées par le processeur)

Ce sont des tâches qui effectuent des calculs intensifs (ex: calculs mathématiques, compression, traitement d'image).

Dans ce cas, le GIL est un goulot d'étranglement majeur. Comme un seul thread peut s'exécuter à la fois, utiliser plusieurs threads pour une tâche CPU-bound sur CPython n'apportera aucun gain de performance, et peut même être plus lent à cause du surcoût de la gestion des threads.

# Exemple (pseudo-code) : Tâche CPU-bound
def calcul_intensif():
# Fait des millions de calculs
...

# Lancer 2 threads avec cette fonction sur une machine multi-cœur
# ne sera pas 2x plus rapide. Le temps total sera environ 2x le temps d'un seul thread.

b. Tâches "I/O-bound" (limitées par les entrées/sorties)

Ce sont des tâches qui passent la plupart de leur temps à attendre une opération externe (ex: lire un fichier, faire une requête réseau, interroger une base de données).

Dans ce cas, le GIL est libéré par le thread en attente. Pendant qu'un thread attend une réponse du réseau, le GIL est relâché, et un autre thread peut prendre le relais et exécuter du code Python.

Pour les tâches I/O-bound, le threading est donc très efficace en Python, car il permet de masquer la latence des opérations d'I/O.

import requests
import threading

def telecharger_url(url):
# L'appel requests.get() est une opération I/O-bound.
# Le GIL est libéré pendant l'attente de la réponse réseau.
response = requests.get(url)
print(f"URL {url} téléchargée.")

urls = ["https://www.python.org", "https://www.google.com"]
threads = [threading.Thread(target=telecharger_url, args=(url,)) for url in urls]

for t in threads:
t.start()

for t in threads:
t.join()

# Les deux téléchargements se font "en même temps" (de manière concurrente).

3. Contourner le GIL

Si vous avez besoin de vrai parallélisme pour des tâches CPU-bound, le threading n'est pas la solution en CPython. Voici les alternatives :

  1. Le module multiprocessing : C'est la solution standard en Python. Il contourne le GIL en créant des processus séparés au lieu de threads. Chaque processus a son propre interpréteur Python et son propre GIL, donc ils peuvent s'exécuter en parallèle sur différents cœurs de CPU. La communication entre processus se fait via des mécanismes comme les Queue ou les Pipe.

  2. Utiliser d'autres implémentations de Python : Des implémentations comme Jython (basé sur Java) ou IronPython (basé sur .NET) n'ont pas de GIL. PyPy, une implémentation JIT très rapide, a un GIL mais son fonctionnement est différent.

  3. Écrire des extensions en C/C++/Rust : Pour des sections de code très gourmandes en calcul, on peut les écrire dans un langage compilé. Ces extensions peuvent manuellement libérer le GIL pendant qu'elles exécutent leurs calculs, permettant à d'autres threads Python de tourner. C'est ce que font des bibliothèques comme NumPy ou Pandas.

Conclusion

La gestion de la mémoire en CPython est un duo entre le comptage de références (rapide et déterministe) et un ramasse-miettes (pour gérer les cycles). Le GIL, bien que souvent critiqué, est une conséquence directe de ce modèle de gestion mémoire. Il rend le threading inefficace pour le parallélisme CPU-bound, mais le laisse parfaitement adapté à la concurrence I/O-bound. Pour le vrai parallélisme, le module multiprocessing est la voie à suivre dans l'écosystème CPython standard.

Exercice : Observer l'Effet du GIL sur les Tâches CPU-Bound

Objectif

Cet exercice a pour but de démontrer de manière pratique l'effet du Global Interpreter Lock (GIL) sur les performances des tâches limitées par le CPU (CPU-bound) en utilisant le module threading. Vous comparerez le temps d'exécution d'une tâche de calcul intensif en mode séquentiel et en mode multi-thread.

Contexte

Une tâche CPU-bound est une tâche qui passe la majorité de son temps à effectuer des calculs sur le processeur. Un exemple simple est de compter jusqu'à un très grand nombre.

Si Python permettait un vrai parallélisme avec les threads, exécuter deux tâches de ce type sur deux threads devrait prendre à peu près le même temps qu'en exécuter une seule sur une machine multi-cœur. Cependant, à cause du GIL, un seul thread peut exécuter du code Python à la fois. Cet exercice vise à visualiser ce phénomène.

Énoncé

  1. Créez un nouveau fichier Python nommé gil_demonstration.py.

  2. Importez les modules nécessaires : time et threading.

  3. Définissez la tâche de calcul intensif.

    • Créez une fonction countdown(n) qui prend un grand nombre n en argument.
    • Dans cette fonction, utilisez une boucle while pour décrémenter n jusqu'à ce qu'il atteigne 0.
    • Cette fonction ne doit rien retourner. Son seul but est de consommer du temps CPU.
  4. Définissez une constante pour le nombre à décompter, par exemple COUNT = 100_000_000.

  5. Mesurez le temps d'exécution en mode séquentiel.

    • Enregistrez le temps de début avec time.time().
    • Appelez countdown(COUNT) deux fois, l'une après l'autre.
    • Enregistrez le temps de fin et affichez la durée totale.
  6. Mesurez le temps d'exécution en mode multi-thread.

    • Enregistrez le temps de début.
    • Créez deux threads. Chaque thread doit avoir pour cible la fonction countdown avec COUNT comme argument.
    • Démarrez les deux threads.
    • Utilisez thread.join() pour attendre que les deux threads aient terminé leur exécution.
    • Enregistrez le temps de fin et affichez la durée totale.
  7. Comparez les résultats.

    • Analysez les temps d'exécution. Le temps de la version multi-thread devrait être approximativement le double de celui de la version séquentielle (ou du moins, significativement plus long qu'un seul appel), démontrant que les threads n'ont pas pu s'exécuter en parallèle.

Résultat Attendu

Les temps exacts varieront en fonction de votre machine, mais la conclusion devrait être la même. La version multi-thread n'est pas plus rapide que la version séquentielle, et elle est souvent même un peu plus lente à cause du surcoût lié à la gestion des threads.

--- Exécution Séquentielle ---
Début de l'exécution séquentielle...
Durée de l'exécution séquentielle : 8.52 secondes

--- Exécution Multi-thread ---
Début de l'exécution avec des threads...
Durée de l'exécution avec des threads : 8.61 secondes

Conclusion : La version multi-thread n'apporte aucun gain de performance pour cette tâche CPU-bound à cause du GIL.
Solution
# gil_demonstration.py

import time
import threading

# Un grand nombre pour s'assurer que la tâche prend du temps
COUNT = 100_000_000

def countdown(n):
"""Une fonction simple et gourmande en CPU."""
while n > 0:
n -= 1

# --- 1. Exécution Séquentielle ---
print("--- Exécution Séquentielle ---")
print("Début de l'exécution séquentielle...")
start_time_seq = time.time()

# Exécute la tâche deux fois de suite dans le thread principal
countdown(COUNT)
countdown(COUNT)

end_time_seq = time.time()
duration_seq = end_time_seq - start_time_seq
print(f"Durée de l'exécution séquentielle : {duration_seq:.2f} secondes\n")


# --- 2. Exécution Multi-thread ---
print("--- Exécution Multi-thread ---")
print("Début de l'exécution avec des threads...")
start_time_thread = time.time()

# Crée deux threads pour exécuter la même tâche
thread1 = threading.Thread(target=countdown, args=(COUNT,))
thread2 = threading.Thread(target=countdown, args=(COUNT,))

# Démarre les threads
thread1.start()
thread2.start()

# Attend que les deux threads aient fini
thread1.join()
thread2.join()

end_time_thread = time.time()
duration_thread = end_time_thread - start_time_thread
print(f"Durée de l'exécution avec des threads : {duration_thread:.2f} secondes\n")

# --- 3. Conclusion ---
print("--- Conclusion ---")
if duration_thread >= duration_seq:
print("La version multi-thread n'est pas plus rapide, voire plus lente.")
print("Cela démontre que le GIL empêche les threads d'exécuter le code Python en parallèle sur les cœurs de CPU.")
else:
# Ce cas ne devrait normalement pas se produire de manière significative
print("La version multi-thread est légèrement plus rapide, ce qui peut arriver à cause des subtilités du scheduling de l'OS.")

Note pour l'exécuteur : Si vous exécutez ce code, vous remarquerez peut-être que le temps de la version multi-thread est presque exactement le même que celui de la version séquentielle, et non le double. Cela est dû à la manière dont le GIL est libéré et ré-acquis. Python force un changement de thread après un certain intervalle, donnant l'illusion que les deux threads progressent. Cependant, le temps CPU total utilisé est bien le double, et le temps réel écoulé est au moins égal à la somme des temps d'exécution individuels, prouvant l'absence de parallélisme.