Retour au cours

backend / python

Profiling mémoire avancé : tracemalloc et détection de fuites

Leçon 351 exercice

Explication

Ce que vous allez apprendre

  • Comprendre comment une fuite mémoire est possible même avec un garbage collector automatique
  • Localiser précisément où la mémoire croît avec tracemalloc et compare_to
  • Suivre une méthode de diagnostic reproductible, du snapshot à l'identification du coupable
  • Découvrir qui retient encore un objet en vie avec gc.get_referrers
  • Utiliser weakref.finalize pour un nettoyage garanti, plus fiable que __del__ seul

Dans quel contexte ?

Un service Python tournant en production consomme de plus en plus de mémoire au fil des jours, jusqu'à devoir être redémarré manuellement toutes les semaines pour éviter un crash. Personne ne sait quelle partie du code accumule des objets jamais libérés — un cache mal borné, des callbacks jamais désabonnés, une liste qui grossit sans limite. Cette leçon donne la méthode exacte pour transformer cette investigation à l'aveugle en un diagnostic mesurable.

Qu'est-ce qu'une fuite mémoire en Python ?

On pourrait croire qu'un langage avec ramasse-miettes (garbage collector) automatique, comme Python, est à l'abri des fuites mémoire. C'est faux : une fuite se produit dès qu'une référence oubliée empêche un objet devenu inutile d'être libéré — une entrée jamais retirée d'un cache, une liste qui grossit indéfiniment, un callback enregistré mais jamais désabonné. Le garbage collector ne peut rien faire tant qu'une référence existe encore quelque part.

Pourquoi ce sujet vient après les internals CPython

Cette leçon s'appuie directement sur la leçon 27 (comptage de références, GC, weakref) : pour diagnostiquer une fuite, il faut d'abord comprendre pourquoi un objet reste vivant. Ici, on apprend à observer concrètement ce qui se passe, plutôt qu'à le déduire en théorie.

tracemalloc : photographier l'état de la mémoire

Le module tracemalloc enregistre, pour chaque allocation, l'endroit exact du code qui l'a provoquée (fichier, ligne, pile d'appels). L'outil le plus utile est compare_to : prendre un "instantané" (snapshot) avant une opération, un autre après, puis comparer les deux pour voir précisément ce qui a été alloué et pas libéré entre les deux points. C'est une méthode de diagnostic, pas de prévention : elle sert à localiser une fuite déjà suspectée, en production ou en test.

OutilRépond à la question
tracemalloc.compare_toOù la mémoire a-t-elle été allouée sans être libérée ?
gc.get_referrers(objet)Qui retient encore cet objet en vie ?
weakref.finalizeComment garantir un nettoyage, même en cas de plantage ?

La méthode de diagnostic, étape par étape

  1. Suspecter une fuite (mémoire qui croît sans jamais redescendre).
  2. Prendre un snapshot à un instant T.
  3. Laisser le programme tourner ou reproduire l'opération suspecte.
  4. Prendre un second snapshot et comparer : les lignes de code qui apparaissent en tête du delta sont les coupables les plus probables.
  5. Filtrer le bruit des bibliothèques tierces pour se concentrer sur son propre code.

Le piège classique

Confondre une consommation mémoire élevée mais stable (normal pour un cache volontairement conservé) avec une fuite réelle (une croissance continue sans limite). Seule l'observation dans le temps permet de trancher.

Piège fréquent

Un cache Python (dict) sans limite de taille est la cause la plus fréquente de fuite mémoire en production : chaque nouvelle clé grossit le cache indéfiniment, sans jamais évincer les entrées anciennes. Bornez toujours la taille d'un cache maison, ou utilisez functools.lru_cache(maxsize=...).

Commandes & code

Profiling mémoire avancé

Localiser précisément une fuite mémoire en production ou en développement.

python
import tracemalloc
import gc
import sys
import weakref

# --- tracemalloc : suivi precis des allocations, ligne par ligne ---
tracemalloc.start(nframes=10)     # conserve jusqu'a 10 niveaux de call stack par allocation

def creer_gros_objets():
    return [bytearray(10_000) for _ in range(1000)]

avant = tracemalloc.take_snapshot()
gros_objets = creer_gros_objets()
apres = tracemalloc.take_snapshot()

# compare_to : LE outil le plus utile pour localiser une fuite entre deux points dans le temps
differences = apres.compare_to(avant, "lineno")
for stat in differences[:5]:
    print(stat)                     # fichier:ligne, delta memoire, delta nombre de blocs

# --- Snapshot filtre : ignorer le bruit des librairies standard/tierces ---
filtres = [
    tracemalloc.Filter(inclure=True, filename_pattern="*mon_projet*"),
]
snapshot_filtre = apres.filter_traces(filtres)

# --- Traquer l'origine exacte d'un objet precis ---
def tracer_allocation(objet):
    for trace in tracemalloc.get_object_traceback(objet) or []:
        print(trace)

# --- Detection de croissance continue : pattern pour un test de non-regression memoire ---
def detecter_fuite(fonction_suspecte, iterations=1000, seuil_octets=1_000_000):
    gc.collect()
    debut = tracemalloc.take_snapshot()
    for _ in range(iterations):
        fonction_suspecte()
    gc.collect()
    fin = tracemalloc.take_snapshot()

    stats = fin.compare_to(debut, "filename")
    croissance_totale = sum(s.size_diff for s in stats)
    if croissance_totale > seuil_octets:
        raise AssertionError(
            f"Fuite suspectee : +{croissance_totale / 1_000_000:.2f} Mo apres {iterations} iterations"
        )

# --- gc : trouver ce qui empeche precisement un objet d'etre collecte ---
class Ressource:
    def __init__(self, nom):
        self.nom = nom

    def __del__(self):
        print(f"Ressource {self.nom} liberee")

def diagnostiquer_retenue(objet):
    referents = gc.get_referrers(objet)     # QUI reference cet objet ?
    for ref in referents:
        if ref is not sys._getframe():        # exclut la frame courante elle-meme
            print(f"Retenu par : {type(ref)} -> {repr(ref)[:80]}")

r = Ressource("cache-principal")
conteneurs = {"actif": r}
diagnostiquer_retenue(r)      # revele que "conteneurs" retient r

# --- Cycles de references : le GC generationnel les detecte, mais __del__ complique tout ---
class Noeud:
    def __init__(self, nom):
        self.nom = nom
        self.suivant = None

    def __del__(self):
        print(f"Noeud {self.nom} collecte")

n1, n2 = Noeud("A"), Noeud("B")
n1.suivant = n2
n2.suivant = n1          # cycle

del n1, n2
gc.collect()      # necessaire pour casser le cycle : le comptage de refs seul ne suffit pas

# --- gc.get_stats() : suivre la pression memoire par generation ---
print(gc.get_stats())
print(f"Objets actuellement traques par le GC : {len(gc.get_objects())}")

# --- Cache mal borne : la fuite la plus frequente en production ---
class ServiceAvecFuite:
    def __init__(self):
        self._cache = {}      # grandit indefiniment, aucune eviction

    def traiter(self, cle, valeur):
        self._cache[cle] = valeur     # jamais purge -- fuite garantie sur une longue duree de vie

class ServiceCorrige:
    def __init__(self, taille_max=10_000):
        self._cache = {}
        self._taille_max = taille_max

    def traiter(self, cle, valeur):
        if len(self._cache) >= self._taille_max:
            # evincer le plus ancien (ordre d'insertion garanti depuis 3.7)
            plus_ancien = next(iter(self._cache))
            del self._cache[plus_ancien]
        self._cache[cle] = valeur

# --- weakref.finalize : nettoyage garanti, plus fiable que __del__ ---
class GestionnaireFichier:
    def __init__(self, chemin):
        self.chemin = chemin
        self._descripteur = open(chemin, "w")
        # finalize s'execute quand l'objet est collecte, MEME si le programme plante avant __del__
        self._finalizer = weakref.finalize(self, self._descripteur.close)

    def fermer_maintenant(self):
        self._finalizer()     # peut aussi etre declenche manuellement

# --- objgraph (librairie tierce) : visualiser les chaines de references (tres utile en debug) ---
# pip install objgraph
'''
import objgraph

objgraph.show_most_common_types(limit=10)       # types d'objets les plus nombreux en memoire
objgraph.show_growth()                            # objets crees depuis le dernier appel (delta)
objgraph.show_backrefs([mon_objet_suspect], max_depth=5, filename="chaine.png")
# genere un graphe visuel des references qui retiennent mon_objet_suspect en vie
'''

tracemalloc.stop()

Résumé

  • tracemalloc.take_snapshot().compare_to(ancien, "lineno") localise précisément où la mémoire croît entre deux points.
  • gc.get_referrers(objet) révèle QUI retient encore un objet, utile pour diagnostiquer une fuite persistante.
  • Un cycle de références nécessite le GC générationnel pour être libéré ; le comptage de références seul ne suffit pas.
  • weakref.finalize garantit un nettoyage même en cas d'erreur, plus fiable que __del__ seul.

Exercices pratiques

1 disponible
1

Mission : localiser la fuite qui force un redémarrage hebdomadaire

Objectif : Diagnostiquer méthodiquement une fuite mémoire avec tracemalloc.compare_to, identifier un cache non borné comme coupable, et le corriger.

Contexte

Un service Python en production doit être redémarré manuellement chaque semaine car sa consommation mémoire ne cesse de croître. Personne n'a de preuve concrète de l'origine du problème, seulement des soupçons sur un ServiceAvecFuite qui maintient un dictionnaire self._cache interne, jamais purgé.

Tu dois appliquer la méthode de diagnostic avec tracemalloc, confirmer le coupable, puis corriger le cache pour qu'il ne grossisse plus indéfiniment.

Résoudre l’exercice →